Compare commits

...
54 Commits
Author SHA1 Message Date
DenozordecandCursor ee9804f1bb feat(traffic-flow): enhance flow analytics and IP handling
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m29s
Docker images / frontend-image (push) Successful in 3m20s
Docker images / updater-image (push) Successful in 51s
Docker images / backend-image (push) Successful in 2m40s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 13s
- Introduced `canonicalIp` function to standardize IP address formats across the application, improving consistency in flow processing.
- Updated traffic flow analytics to utilize new endpoint resolution logic, enhancing accuracy in traffic classification.
- Enhanced tests for traffic flow analytics and IP handling, ensuring comprehensive coverage for new functionalities.
- Improved traffic flow destination resolution with additional test cases for various internet services, including Google and Fastly.

Co-authored-by: Cursor <[email protected]>
2026-09-12 01:51:23 +07:00
DenozordecandCursor 4ce6169d14 feat(traffic-flow): enhance traffic classification and map display
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 2m16s
Docker images / frontend-image (push) Successful in 2m42s
Docker images / updater-image (push) Successful in 43s
Docker images / backend-image (push) Successful in 2m30s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 13s
- Added a new state variable `mapAsnLoaded` to track ASN loading status in the NetworkMapPage.
- Updated the page to conditionally display a message when ASN data is not loaded, improving user feedback.
- Enhanced traffic classification tests to include additional scenarios for Google and YouTube services, ensuring comprehensive coverage.
- Introduced a new function `pickMapInternetDest` to improve destination selection logic in traffic flow processing.
- Updated the traffic flow destination classification to handle new conditions and improve accuracy.

Co-authored-by: Cursor <[email protected]>
2026-09-12 01:07:26 +07:00
DenozordecandCursor 60a0970d73 feat(traffic-flow): enhance traffic flow analytics and overlay configuration
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 2m42s
Docker images / frontend-image (push) Successful in 3m22s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 2m55s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 12s
- Updated the traffic flow analytics to include new state variables for named bytes, total bytes, and window seconds, improving data granularity.
- Modified the NetworkMapPage to display classified traffic data, including a new section for showing classified and total bytes.
- Enhanced the applyFlowOverlay function to support a new option for disabling GRE fast path, allowing for more flexible traffic flow configurations.
- Added a toggle in the FlowOverlaySheet component to enable or disable the GRE fast path setting, improving user control over traffic processing.
- Updated tests to validate the new functionalities and ensure accurate traffic flow analytics.

Co-authored-by: Cursor <[email protected]>
2026-09-12 00:03:13 +07:00
DenozordecandCursor fc29dcede7 feat(traffic-flow): add NAT fields to flow processing and analytics
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m22s
Docker images / frontend-image (push) Successful in 3m30s
Docker images / updater-image (push) Successful in 45s
Docker images / backend-image (push) Successful in 2m51s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 9s
- Introduced new fields for NAT source and destination IPs, as well as their respective ports, in the flow data model.
- Updated database schema and migration scripts to accommodate the new NAT fields in the `flow_buckets` table.
- Enhanced flow analytics and processing functions to utilize the new NAT fields, improving accuracy in traffic flow analysis.
- Added tests to validate the handling of NAT data in various scenarios, ensuring robustness in flow processing.

Co-authored-by: Cursor <[email protected]>
2026-09-11 23:01:48 +07:00
DenozordecandCursor 5f774ce26e feat(gre): enhance GRE tunnel status handling and IPsec configuration
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m18s
Docker images / frontend-image (push) Successful in 3m1s
Docker images / updater-image (push) Successful in 40s
Docker images / backend-image (push) Successful in 2m31s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
- Introduced a new `greStatusMeta` function to streamline status retrieval and default handling.
- Updated GRE tunnel command generation to improve IPsec configuration handling, including optional fields for encryption and authentication algorithms.
- Enhanced the GRE tunnels data grid to display IPsec settings more clearly, including handling cases where certain parameters may be undefined.
- Refactored status display logic in the GRE page to utilize the new status meta function for consistency.

Co-authored-by: Cursor <[email protected]>
2026-09-11 15:39:32 +07:00
DenozordecandCursor 25b82997b6 feat(config-revisions): implement configuration history for Firewall, GRE, and WireGuard
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 2m20s
Docker images / frontend-image (push) Successful in 2m51s
Docker images / updater-image (push) Successful in 44s
Docker images / backend-image (push) Successful in 2m49s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 12s
- Added configuration history management for Firewall, GRE, and WireGuard pages, enabling users to view and restore previous configurations.
- Introduced new components for displaying configuration history and integrated them into the respective pages.
- Enhanced API routes to support fetching and restoring configuration revisions, ensuring data consistency across the application.
- Updated state management to handle loading and restoring states effectively, improving user experience during data operations.
- Enhanced tests to cover new functionalities and ensure reliability.

Co-authored-by: Cursor <[email protected]>
2026-09-11 14:03:00 +07:00
DenozordecandCursor b4a3c3a925 feat(filters, recursive-routes): enhance configuration history and live data handling
- Introduced configuration history management in Filters and Recursive Routes pages, allowing users to view and restore previous configurations.
- Updated state management to handle live data loading and error states more effectively, improving user experience during data fetching.
- Added new components for displaying configuration history and integrated them into existing pages.
- Enhanced API interactions to support fetching and applying configuration revisions, ensuring data consistency across the application.
- Updated tests to cover new functionalities and ensure reliability.

Co-authored-by: Cursor <[email protected]>
2026-09-11 12:15:36 +07:00
DenozordecandCursor 9c0ee7940e feat(bgp, vxlan, ospf): enhance server data handling and introduce new routes
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-test (push) Successful in 1m40s
Docker images / frontend-image (push) Successful in 2m50s
Docker images / updater-image (push) Successful in 42s
Docker images / backend-image (push) Successful in 2m40s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
- Added support for fetching and displaying server data in BGP, VXLAN, and OSPF pages, improving the overall user experience.
- Introduced new backend routes for OSPF and VXLAN, allowing for better data management and retrieval.
- Implemented mapping functions for backend server data to frontend types, ensuring consistency across components.
- Enhanced the sidebar to display counts for BGP sessions, VXLAN tunnels, and containers, providing users with quick insights into their network status.
- Updated tests to cover new functionalities and ensure reliability.

Co-authored-by: Cursor <[email protected]>
2026-09-11 11:18:04 +07:00
DenozordecandCursor 5750590b68 feat(traffic-flow): add rebuild facts endpoint and enhance traffic flow analytics
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m6s
Docker images / frontend-image (push) Successful in 3m15s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 2m50s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 9s
- Introduced a new endpoint `/traffic/flow/rebuild-facts` to rebuild flow facts from buckets, improving data accuracy and management.
- Updated traffic flow analytics to utilize the new `resolveInternetDest` function for better destination resolution.
- Enhanced tests for traffic flow IP handling and added new utility functions for managing internet destinations.

Co-authored-by: Cursor <[email protected]>
2026-09-11 10:32:51 +07:00
DenozordecandCursor 3c42c114f5 refactor(statistics): improve component structure and enhance state management
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Successful in 2m17s
Docker images / frontend-image (push) Successful in 4m11s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 2m18s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 8s
- Introduced Suspense for lazy loading in StatisticsPage to optimize rendering.
- Refactored StatisticsPage to separate inner logic into StatisticsPageInner for better readability.
- Updated PeriodSelector to utilize useCallback for handling state changes, improving performance and clarity.

Co-authored-by: Cursor <[email protected]>
2026-09-11 09:38:31 +07:00
DenozordecandCursor 5aef419582 fix(statistics): считать уникальный payload без дублей hops
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m10s
Docker images / frontend-image (push) Successful in 3m13s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 2m52s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
Co-authored-by: Cursor <[email protected]>
2026-09-11 01:27:40 +07:00
DenozordecandCursor 0c0dfa1df7 fix(statistics): не двоить overlay и транзит в кубе
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m18s
Docker images / frontend-image (push) Successful in 3m25s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 3m9s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 14s
Co-authored-by: Cursor <[email protected]>
2026-09-11 00:46:25 +07:00
DenozordecandCursor c162a41bc0 fix(network-map): подписать unbound-пути парой серверов
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-test (push) Successful in 2m22s
Docker images / frontend-image (push) Successful in 3m29s
Docker images / updater-image (push) Successful in 52s
Docker images / backend-image (push) Successful in 2m41s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 12s
Co-authored-by: Cursor <[email protected]>
2026-09-10 22:19:05 +07:00
DenozordecandCursor 97e43b2335 feat(statistics): enhance interface handling and data aggregation
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m29s
Docker images / frontend-image (push) Successful in 3m24s
Docker images / updater-image (push) Successful in 44s
Docker images / backend-image (push) Successful in 2m39s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 15s
Updated the statistics aggregation service to improve interface resolution and data handling. Introduced new functions for managing interface aliases and collapsing server interface rows, ensuring accurate data representation. Enhanced test coverage for interface resolution and added checks for new functionality.

- Implemented `factIfaceAliases` and `collapseServerIfaceRows` for better interface data management.
- Updated `resolveIfaceName` to handle additional cases for interface indexing.
- Enhanced tests for interface resolution and aggregation logic.

Co-authored-by: Cursor <[email protected]>
2026-09-10 21:49:50 +07:00
DenozordecandCursor db21e1217c feat(network-map): enhance service selection logic and UI updates
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m19s
Docker images / frontend-image (push) Successful in 2m52s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 2m20s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 11s
Refactor the service selection mechanism to utilize a live reference for selected services, ensuring accurate display of service details. Update UI components to reflect changes in service selection, including dynamic updates for service metrics and paths. Additionally, introduce new utility functions for deduplicating flow rows across exporters in the backend, improving data handling in traffic flow analysis.

- Updated service selection logic in NetworkMapPage component.
- Enhanced UI to display live service data.
- Added deduplication functions in traffic-flow-dedup module for improved data integrity.

Co-authored-by: Cursor <[email protected]>
2026-09-10 19:54:16 +07:00
DenozordecandCursor 5bb9066be8 feat(statistics): добавить BI-разрез и сводную матрицу
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m23s
Docker images / frontend-image (push) Successful in 3m30s
Docker images / updater-image (push) Successful in 52s
Docker images / backend-image (push) Successful in 3m8s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 14s
Сопоставить клиентов с ifIndex как на карте трафика, чтобы KPI пользователей не обнулялся. На экране — разрез остальных измерений и сводная матрица.

Co-authored-by: Cursor <[email protected]>
2026-09-10 16:07:25 +07:00
DenozordecandCursor b1fd259f10 fix(statistics): убрать параметры хранения с родительских партиций
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m25s
Docker images / frontend-image (push) Successful in 3m26s
Docker images / updater-image (push) Successful in 45s
Docker images / backend-image (push) Successful in 2m32s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 13s
PostgreSQL 42809: FILLFACTOR и autovacuum нельзя задавать на partitioned parent — только на листьях.

Co-authored-by: Cursor <[email protected]>
2026-09-10 13:36:35 +07:00
DenozordecandCursor 3687bb8fa2 feat(statistics): добавить раздел статистики трафика
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m19s
Docker images / frontend-image (push) Successful in 4m47s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 2m53s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 13s
Куб IPFIX час+день с AND-слайсами и экраном отчётности /statistics, живой /traffic не меняем.

Co-authored-by: Cursor <[email protected]>
2026-09-10 11:57:47 +07:00
Denozordec a80caf5676 feat(netflow): локальные GeoLite2-базы для стран и ASN потоков
Docker images / prepare-release (push) Successful in 13s
Docker images / backend-test (push) Successful in 2m13s
Docker images / frontend-image (push) Successful in 4m31s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 3m1s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 15s
Lookup страны/ASN при ingest теперь идёт сначала по локальным mmdb
MaxMind GeoLite2 (Country + ASN, зеркало P3TERX, без ключей), с
мгновенным синхронным ответом для IPv4/IPv6; RIPEstat остаётся
fallback до первой загрузки баз и при промахе.

- backend/src/services/traffic-flow-geoip.ts: ридеры maxmind, фасад
  resolveFlowIp (geoip-first → RIPE-кэш), статус ридеров
- geoip-update-collector.ts: conditional GET по ETag, валидация пробоем
  8.8.8.8 (US / AS15169), атомарная подмена с .prev-откатом
- джоба планировщика geoip_update (по умолчанию раз в 7 дней),
  настройки geoip_settings + миграция 0004, API /api/geoip
  (GET/PUT/update), контракты @mmapp/contracts/geoip
- engine/analytics/map-hops переведены на resolveFlowIp
- секция «GeoIP-базы» в настройках NetFlow, метки джобы на странице
  сбора данных, тесты test:geoip
2026-09-09 23:33:31 +07:00
DenozordecandCursor d39e3454aa feat(network-map): показывать ASN-бренды по доле среди сервисов
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Successful in 2m5s
Docker images / frontend-image (push) Successful in 3m15s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 2m44s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 15s
Долю считать среди именованных брендов, а не от всего окна. На карте всегда оставлять топ-8 и добавить иконки новых брендов.

Co-authored-by: Cursor <[email protected]>
2026-09-09 16:53:06 +07:00
DenozordecandCursor a8f2055c77 feat(netflow): расширить каталог ASN популярных сервисов
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Successful in 2m5s
Docker images / frontend-image (push) Successful in 3m14s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 4m2s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 11s
В аналитике потока видны сети со своим AS (Apple, Steam, VK, Epic и другие), а не только Cloudflare/AWS. Holder и порты Steam не перебивают Cloudflare CIDR.

Co-authored-by: Cursor <[email protected]>
2026-09-09 13:18:58 +07:00
DenozordecandCursor cdeb97d841 feat(certificates): добавить отключение автообновления на странице сертификатов
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m7s
Docker images / frontend-image (push) Successful in 3m19s
Docker images / updater-image (push) Successful in 49s
Docker images / backend-image (push) Successful in 2m45s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 15s
Co-authored-by: Cursor <[email protected]>
2026-09-08 16:21:50 +07:00
DenozordecandCursor 36c5305db7 feat(dashboard): собрать ops-дашборд на ReUI Frame с живыми данными
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m13s
Docker images / frontend-image (push) Successful in 3m14s
Docker images / updater-image (push) Successful in 47s
Docker images / backend-image (push) Successful in 2m44s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 14s
Co-authored-by: Cursor <[email protected]>
2026-09-08 15:13:27 +07:00
Denozordec 5f2b4e2d40 feat(backups): replace button with checkbox for server selection in backup settings
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Successful in 2m4s
Docker images / frontend-image (push) Successful in 3m10s
Docker images / updater-image (push) Successful in 42s
Docker images / backend-image (push) Successful in 2m29s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
Updated the BackupCreateSheet and BackupsSettings components to use checkboxes for selecting servers instead of buttons. This improves user interaction by allowing for easier selection and toggling of server states. The Checkbox component is now integrated for better accessibility and functionality.
2026-09-08 14:47:44 +07:00
DenozordecandCursor cff26813b9 feat(backups): добавить S3-хранилище и обновить экран бэкапов
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m18s
Docker images / frontend-image (push) Successful in 4m37s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 3m6s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 16s
Сохранять снимки в S3-compatible бакет, скачивать и удалять их вместе с локальными файлами.
Привести /backups к DNA /servers: Frame, KPI, фильтры и реальное восстановление.

Co-authored-by: Cursor <[email protected]>
2026-09-08 14:06:27 +07:00
DenozordecandCursor 39c8ec4a02 fix(netflow): починить live-аналитику и снизить запись в PostgreSQL
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Successful in 2m9s
Docker images / frontend-image (push) Successful in 3m20s
Docker images / updater-image (push) Successful in 49s
Docker images / backend-image (push) Successful in 2m44s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
Коллектор снова отдаёт назначения в живом потоке. Ошибки записи видны в статусе, лишние перезаписи минутных агрегатов убраны.

Co-authored-by: Cursor <[email protected]>
2026-09-08 12:46:37 +07:00
DenozordecandCursor c6c859a495 perf(db): сжать схему PostgreSQL 18 и повторно загрузить SQLite
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m2s
Docker images / frontend-image (push) Successful in 3m23s
Docker images / updater-image (push) Successful in 44s
Docker images / backend-image (push) Successful in 2m47s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 11s
При первом рестарте wipe всех таблиц и импорт из SQLite в компактную схему. Retention через DROP PARTITION, lz4 и AIO worker.

Co-authored-by: Cursor <[email protected]>
2026-09-08 10:54:36 +07:00
DenozordecandCursor bf5cfff12c ci(backend): убрать конфликт контейнера PostgreSQL в тестах
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m4s
Docker images / frontend-image (push) Successful in 3m16s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 3m0s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 13s
На общем Docker-раннере имя mm-pg и порт 5432 сталкивались с прошлым запуском. Контейнер и порт теперь уникальны, старый mm-pg снимается.

Co-authored-by: Cursor <[email protected]>
2026-09-08 09:34:29 +07:00
DenozordecandCursor 30a8ec4420 fix(db): исправить импорт jsonb из sqlite
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Failing after 12s
Docker images / backend-image (push) Skipped
Docker images / frontend-image (push) Successful in 3m14s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 47s
Docker images / publish-release (push) Skipped
JSON-колонки передаются строкой с приведением ::jsonb, иначе node-pg кодирует массивы как PostgreSQL array и ETL падает. Повтор импорта идёт, пока нет маркера. Том PG18 монтируется в /var/lib/postgresql.

Co-authored-by: Cursor <[email protected]>
2026-09-08 09:28:03 +07:00
DenozordecandCursor ec43591a99 feat(db): перевести хранилище с SQLite на PostgreSQL
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 4m9s
Docker images / frontend-image (push) Successful in 4m28s
Docker images / updater-image (push) Successful in 58s
Docker images / backend-image (push) Successful in 2m49s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 11s
При старте backend накатывает схему PostgreSQL 18 и, если база пустая, один раз импортирует mikrotik.db с тома. Повторный старт не копирует данные. Бэкап в UI идёт через pg_dump.

Co-authored-by: Cursor <[email protected]>
2026-09-08 01:36:48 +07:00
DenozordecandCursor 0e5fb065e2 fix(backend): уменьшить лишние записи SQLite
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-image (push) Successful in 2m8s
Docker images / frontend-image (push) Successful in 3m14s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 47s
Docker images / publish-release (push) Successful in 12s
Убрать WAL TRUNCATE с hot path NetFlow, не писать неизменённые UPDATE
и служебные INSERT, кэшировать карты topology/analytics.

Co-authored-by: Cursor <[email protected]>
2026-09-07 20:22:20 +07:00
DenozordecandCursor 5188b2aff2 fix(network-map): обрезать пунктир до сервиса и показать пути клиентов
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m58s
Docker images / frontend-image (push) Successful in 3m17s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 49s
Docker images / publish-release (push) Successful in 13s
Линия от обода EN до рамки сервиса; таблица клиент, узел и сервис с подсветкой на карте.

Co-authored-by: Cursor <[email protected]>
2026-09-07 17:39:18 +07:00
DenozordecandCursor cd1fd2c9d3 fix(network-map): рисовать пунктир и скорость до сервиса
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m41s
Docker images / frontend-image (push) Successful in 2m55s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 43s
Docker images / publish-release (push) Successful in 9s
Якорь hop на EN даже без nextHop; линия ARN\to Google с NetflowRateBadge.

Co-authored-by: Cursor <[email protected]>
2026-09-07 17:02:28 +07:00
DenozordecandCursor 77425cca32 fix(network-map): якорить сервисы на выходную ноду
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m46s
Docker images / frontend-image (push) Successful in 2m56s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 45s
Docker images / publish-release (push) Successful in 16s
Пунктир от EN, а не от JH; перетаскивание узлов сервисов; иконка Google без foreignObject.

Co-authored-by: Cursor <[email protected]>
2026-09-07 16:10:39 +07:00
DenozordecandCursor 29d245cde3 feat(traffic): enhance flow analytics and brand classification
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 2m12s
Docker images / frontend-image (push) Successful in 4m3s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 48s
Docker images / publish-release (push) Successful in 12s
- Introduced `pickInternetPeer` function to improve IP classification by selecting the appropriate public IP from source and destination.
- Updated `buildFlowAnalytics` and related functions to utilize the new peer selection logic, enhancing accuracy in flow analytics.
- Added tests for new classifications and ensured existing tests cover new scenarios for Google and Cloudflare.
- Refactored traffic flow brand mappings to include additional CIDR ranges for Google and Cloudflare.

Co-authored-by: Cursor <[email protected]>
2026-09-07 15:35:52 +07:00
DenozordecandCursor 7a491a325d fix(network-map): убрать тяжёлую классификацию из poll карты
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m53s
Docker images / frontend-image (push) Successful in 3m11s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 49s
Docker images / publish-release (push) Successful in 12s
Не сканировать RIPE и каталог по каждой строке окна; сервисы по уникальным dst и кэшу ASN. Порог доли на карте настраиваемый и отключаемый.

Co-authored-by: Cursor <[email protected]>
2026-09-07 14:17:04 +07:00
DenozordecandCursor db64621122 feat(network-map): показать конечные сервисы на карте сети
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 2m0s
Docker images / frontend-image (push) Successful in 3m15s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 45s
Docker images / publish-release (push) Successful in 12s
NetFlow ≥ 5% окна, узлы с логотипом бренда справа от EN, скорость потока на рёбрах к сервисам.

Co-authored-by: Cursor <[email protected]>
2026-09-07 13:30:45 +07:00
DenozordecandCursor 6332d83a12 feat(traffic): показать поток NetFlow на карте сети
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-image (push) Successful in 2m14s
Docker images / frontend-image (push) Successful in 3m14s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 46s
Docker images / publish-release (push) Successful in 11s
Скорость между узлами считается как в Трафике (5 мин, без overlay/mesh), отдельно от ёмкости WAN и BT.

Co-authored-by: Cursor <[email protected]>
2026-09-07 12:37:47 +07:00
DenozordecandCursor 3834c40aa8 fix(traffic): починить сборку tsc для частичных IPFIX-фикстур
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m50s
Docker images / frontend-image (push) Successful in 3m15s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 47s
Docker images / publish-release (push) Successful in 11s
Тесты не должны попадать в Docker tsc; ingest принимает ParsedFlowInput и нормализует поля.

Co-authored-by: Cursor <[email protected]>
2026-09-07 11:45:22 +07:00
DenozordecandCursor 90c8c393e5 feat(traffic): разделить плоскости IPFIX и показать путь JH→EN
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Failing after 1m31s
Docker images / frontend-image (push) Successful in 2m36s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 42s
Docker images / publish-release (push) Skipped
Считать payload отдельно от overlay GRE/ESP и mesh; вкладка Пути и KPI Wire из счётчиков iface.

Co-authored-by: Cursor <[email protected]>
2026-09-07 11:39:14 +07:00
DenozordecandCursor cb799da13a fix(netflow): изолировать коллектор IPFIX и срезать раздувание базы
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 2m23s
Docker images / frontend-image (push) Successful in 3m16s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 57s
Docker images / publish-release (push) Successful in 12s
Co-authored-by: Cursor <[email protected]>
2026-09-07 09:50:24 +07:00
DenozordecandCursor e0ddb17539 feat(traffic): классифицировать потоки по брендам и разгрузить ingest
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-image (push) Successful in 1m48s
Docker images / frontend-image (push) Successful in 2m53s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 41s
Docker images / publish-release (push) Successful in 8s
Починить ISO-страну Cloudflare, не держать holder как сервис, клик по срезу открывает Сессии. Не вызывать REST /interface с UDP, flush SQLite пачкой — uptime не должен ловить timeout 10s.

Co-authored-by: Cursor <[email protected]>
2026-09-07 03:01:14 +07:00
DenozordecandCursor 2820683cba feat(traffic): добавить карту и срезы IPFIX по странам и сессиям
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 2m6s
Docker images / frontend-image (push) Successful in 3m23s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 49s
Docker images / publish-release (push) Successful in 14s
Имена ifIndex пишутся по обоим ключам REST, дедуп 5-tuple по max байт, RIPEstat только из prefix-кэша, карта откуда-куда в Frame.

Co-authored-by: Cursor <[email protected]>
2026-09-07 02:13:21 +07:00
DenozordecandCursor 37167f78e3 feat(traffic): показать аналитику IPFIX по серверам и интерфейсам
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-image (push) Successful in 2m10s
Docker images / frontend-image (push) Successful in 2m53s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 48s
Docker images / publish-release (push) Successful in 11s
Резолвить ifIndex в имена RouterOS, дать вкладке Потоки ту же оболочку сервер/клиент/iface, что у обычного трафика, и обновлять срезы live без перезагрузки.

Co-authored-by: Cursor <[email protected]>
2026-09-07 01:21:12 +07:00
DenozordecandCursor cf68b59b3f fix(traffic): сбрасывать src-address Traffic Flow в авто
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 2m2s
Docker images / frontend-image (push) Successful in 3m11s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 47s
Docker images / publish-release (push) Successful in 12s
Target с адресом wg-flow помечался invalid, IPFIX не уходил на коллектор.

Co-authored-by: Cursor <[email protected]>
2026-09-07 00:37:12 +07:00
DenozordecandCursor 5e512407e5 fix(traffic): записывать потоки при подмене UDP-источника в Docker
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 2m11s
Docker images / frontend-image (push) Successful in 2m57s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 46s
Docker images / publish-release (push) Successful in 11s
Пакеты IPFIX доходили, но разговоры отбрасывались, если Docker подменял адрес jump-host на 172.x.

Co-authored-by: Cursor <[email protected]>
2026-09-07 00:02:50 +07:00
DenozordecandCursor 13889005f8 refactor(traffic): update traffic flow host file generation and configuration scripts
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m39s
Docker images / frontend-image (push) Successful in 3m2s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 43s
Docker images / publish-release (push) Successful in 9s
- Renamed and refactored functions for better clarity, including `buildHostComposeSnippet` to `buildHostComposeOverride` and `buildHostNftSnippet` to `buildHostLinuxInstallSh`.
- Introduced a new script for Linux installation of WireGuard, enhancing the setup process for Docker hosts.
- Updated the `generateNativeConf` function to conditionally include the listen port in the configuration.
- Adjusted the `FlowOverlaySheet` component to default to the new Linux tab and improved the user interface for selecting jump-hosts.

Co-authored-by: Cursor <[email protected]>
2026-09-06 23:20:12 +07:00
DenozordecandCursor f0dc5acfd3 fix(traffic): сделать JH сервером WG и патчить traffic-flow по id
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m52s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 45s
Docker images / publish-release (push) Successful in 9s
RouterOS не принимает PATCH без .id у singleton. Пир на jump-host без endpoint: клиент — хост MM с Endpoint JH:13232.

Co-authored-by: Cursor <[email protected]>
2026-09-06 22:58:31 +07:00
DenozordecandCursor 63bed28251 fix(ci): импортировать TrafficFlowHostFile в клиенте overlay
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 56s
Docker images / frontend-image (push) Successful in 3m6s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 49s
Docker images / publish-release (push) Successful in 16s
Co-authored-by: Cursor <[email protected]>
2026-09-06 22:24:44 +07:00
DenozordecandCursor 95dcd3df58 fix(traffic): спрашивать endpoint MM в sheet подключения JH
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m31s
Docker images / frontend-image (push) Failing after 2m38s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 46s
Docker images / publish-release (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-09-06 22:16:41 +07:00
DenozordecandCursor 1e9312acbd feat(traffic): добавить приём Traffic Flow с jump-host
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 2m1s
Docker images / frontend-image (push) Successful in 3m56s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 46s
Docker images / publish-release (push) Successful in 12s
Чтобы видеть «кто с кем», а не только объём порта: IPFIX внутри WG на хосте Docker MM, REST-счётчики не трогаем.

Co-authored-by: Cursor <[email protected]>
2026-09-06 21:54:30 +07:00
Denozordec 5884bd8873 feat(traffic, users): enhance interface and peer management features
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 1m37s
Docker images / frontend-image (push) Successful in 2m44s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 40s
Docker images / publish-release (push) Successful in 10s
Added support for managing peer information in interface bindings, including new fields for peerPublicKey and peerName in the BoundIfaceTraffic interface. Updated the database schema to include these fields in user_interface_bindings and traffic_samples tables. Enhanced the UI components to display peer details alongside interface names, improving user experience and clarity in the traffic management system. Updated relevant functions and services to handle peer-specific logic, ensuring robust integration across the application.
2026-09-06 20:45:37 +07:00
Denozordec fc161506e7 feat(users): enhance interface type mapping and parsing logic
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m30s
Docker images / frontend-image (push) Successful in 2m11s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 40s
Docker images / publish-release (push) Successful in 8s
Updated the mapRosInterfaceType function to support additional interface types, including "gre-tunnel" and improved handling for various input formats. Enhanced the parseRawInterfaces function to accommodate new interface types in the test cases, ensuring accurate parsing and type assignment. Added corresponding test cases to validate the new functionality and ensure robustness in interface type handling.
2026-09-06 19:35:01 +07:00
Denozordec b3e50a1f5f feat(users): implement user management features and database schema
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-image (push) Successful in 1m57s
Docker images / frontend-image (push) Successful in 4m8s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 44s
Docker images / publish-release (push) Successful in 11s
Added user management functionality, including the creation of app_users and user_interface_bindings tables in the database. Implemented API routes for user data retrieval and permissions handling. Enhanced the traffic monitoring system to include user traffic statistics and interface bindings. Updated relevant components and services to support the new user features, improving overall application functionality and user experience.
2026-09-06 19:20:05 +07:00
293 changed files with 37586 additions and 5951 deletions
+51 -1
View File
@@ -38,8 +38,58 @@ jobs:
node .ci/scripts/compute-release.mjs node .ci/scripts/compute-release.mjs
test -f .ci/release/release-manifest.json test -f .ci/release/release-manifest.json
backend-test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: 22
- name: Start PostgreSQL 18
shell: bash
run: |
set -euo pipefail
NAME="mm-pg-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
docker rm -f "$NAME" mm-pg 2>/dev/null || true
HOST_PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')
docker run -d --name "$NAME" --rm \
-e POSTGRES_USER=mmapp \
-e POSTGRES_PASSWORD=mmapp \
-e POSTGRES_DB=mmapp \
-p "127.0.0.1:${HOST_PORT}:5432" \
postgres:18-alpine
echo "PG_CONTAINER=$NAME" >> "${GITHUB_ENV}"
echo "DATABASE_URL=postgres://mmapp:[email protected]:${HOST_PORT}/mmapp" >> "${GITHUB_ENV}"
for i in $(seq 1 40); do
if docker exec "$NAME" pg_isready -U mmapp -d mmapp; then
exit 0
fi
sleep 1
done
echo "PostgreSQL не поднялся"
docker logs "$NAME" || true
exit 1
- name: Install and test backend
shell: bash
run: |
set -euo pipefail
npm ci
npm run test --prefix backend
- name: Stop PostgreSQL
if: always()
shell: bash
run: docker rm -f "${PG_CONTAINER:-}" mm-pg 2>/dev/null || true
backend-image: backend-image:
needs: prepare-release needs:
- prepare-release
- backend-test
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
@@ -0,0 +1,63 @@
# Локальные GeoLite2-базы (Country + ASN) для потоков по странам и ASN
## Контекст
Сейчас страна и ASN для netflow-потоков резолвятся через внешний RIPEstat API (`backend/src/services/traffic-flow-ripe.ts`): лимит 30 новых префиксов/мин, очередь на 90, кэш в PG `flow_ip_meta`. Новые IP «дозревают» с задержкой, IPv6 не покрывается (кэш индексируется только по IPv4). Локальные mmdb-базы дают мгновенный синхронный lookup всех IP без внешних вызовов.
Решения (подтверждены):
- Источник — **MaxMind GeoLite2 через P3TERX-зеркало**: `https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb` и `.../GeoLite2-ASN.mmdb`. Без регистрации, ключей и tar-распаковки. Точность по стране у GeoLite2 и IPinfo паритетная (<1% ошибок у обоих, arXiv 2026); выбран P3TERX за надёжность зеркала (5.2k звёзд) и преемственность: текущий RIPE-путь и так читает GeoLite (`maxmind-geo-lite`), история в кэше остаётся консистентной.
- **RIPEstat остаётся fallback** (до первой загрузки баз / если lookup не дал результата).
- City-базу не качаем (lat/lng фронтенд не использует).
## Изменения
### 1. Зависимость
- `npm install -w mikrotik-manager-backend maxmind` — sync-чтение mmdb, встроенные TS-типы, без транзитивных зависимостей, Node 22 ок.
### 2. Новый сервис `backend/src/services/traffic-flow-geoip.ts`
(по конвенциям окружения traffic-flow-*: контракт → маршрут → сервис, без БД-логики в маршрутах)
- Каталог: `backend/storage/geoip/` (конвенция `storage/backups`), файлы `GeoLite2-Country.mmdb`, `GeoLite2-ASN.mmdb`.
- `initGeoip()` — открыть ридеры best-effort при старте (из `index.ts` рядом с `startTrafficFlowListener`), независимо от настроек автообновления: файлы есть — работают.
- `lookupGeoip(ip): FlowIpMeta | null` — синхронно: `country.iso_code` (fallback `registered_country.iso_code`) с валидацией `isIsoCountry`, ASN = `autonomous_system_number`, holder = `autonomous_system_organization`; приватные IP → negative-запись как в RIPE (`isNonPublicIp`); в PG не пишем (lookup и так быстрый). IPv6 поддержан ридером.
- `resolveFlowIp(ip)` — фасад: `lookupGeoip(ip) ?? lookupRipeCached(ip)`; главный экспорт для потребителей.
- `geoipStatus()` — loaded, даты сборки баз (метаданные mmdb). Тест-хук `setGeoipReadersForTests`. Смена ридеров после обновления — атомарная замена ссылок.
### 3. Коллектор `backend/src/services/geoip-update-collector.ts`
`collectGeoipUpdateOnce()` по образцу `certificate-renew-collector.ts`:
1. Conditional GET с ETag/If-None-Match из настроек → 304 = skip (фолбэк-сравнение: размер/содержимое).
2. Скачивание в `*.tmp` через глобальный `fetch` + AbortController с таймаутом (внешний HTTP из service-слоя — по правилу fastify-backend-drizzle).
3. Валидация: открыть ридер из tmp-файла, пробой 8.8.8.8 (страна US, ASN 15169).
4. `fs.rename` атомарная подмена, старый файл → `*.prev` (откат, если новый ридер не открылся).
5. Перезагрузка ридеров, статус в настройках; snapshot для `scheduler_runs` (checked/downloaded/skipped/bytes/error).
### 4. Планировщик (`backend/src/services/scheduler.ts`)
- `JOB_KEYS` += `geoip_update`; case в `runSchedulerJobBody`; блок в `refreshScheduler()` по образцу `certificates_renew`: интервал `Math.max(6ч, updateIntervalSec*1000)`, по умолчанию 7 дней (upstream обновляется еженедельно) + немедленный первый запуск при включённой настройке.
### 5. Схема и миграция
- `backend/src/db/schema.ts`: singleton `geoip_settings``enabled` (default true), `updateIntervalSec` (default 604800), `lastCheckAt`, `lastSuccessAt`, `lastError`, `countryBuildAt`, `asnBuildAt`, `etagsJson` (jsonb), `createdAt/updatedAt`.
- Миграция: `npm run db:generate` → файл в `backend/drizzle/`.
### 6. API + контракты
- `packages/contracts/src/geoip.ts`: zod-схемы настроек/статуса (все входы — Zod, по правилам проекта).
- Новый `backend/src/routes/geoip.ts`, регистрация в `index.ts` с prefix `/api`:
- `GET /api/geoip` — настройки + статус (ready, даты сборки, последняя проверка/ошибка);
- `PUT /api/geoip` — сохранить настройки, затем `refreshScheduler()`;
- `POST /api/geoip/update` — запустить загрузку сейчас (409, если уже идёт; флаг-гард как в коллекторах).
### 7. Интеграция в пайплайн (geoip-first, RIPE-fallback)
- `traffic-flow-engine.ts` (`queueParsedFlows`, ~строка 336): `lookupRipeCached``resolveFlowIp`. Логика misses не меняется: при готовом mmdb публичные IP (v4+v6) резолвятся сразу, очередь RIPE пустеет; до скачивания баз — прежнее поведение.
- Остальные вызовы `lookupRipeCached``resolveFlowIp` (grep: как минимум `traffic-flow-analytics.ts` ~258271).
- `classifyFlowDst`/бренды не трогаем: holder из mmdb (org name) встаёт в существующие `HOLDER_BRANDS`-регулярки как есть.
### 8. Frontend (по next-shadcn-production / ui-guardian: только переиспользование)
- Секция «GeoIP-базы (GeoLite2)» внутри существующей `components/traffic/netflow-settings-panel.tsx`: статус (готово/не скачано, даты сборки Country/ASN, последняя проверка, ошибка), тумблер автообновления, интервал, кнопка «Обновить сейчас» с индикатором. Только уже используемые в панели примитивы (Switch/Button/поля) — никаких новых визуальных паттернов и Card-shell. API-клиент через существующие http-хелперы.
### 9. Хаускипинг, тесты, проверки
- `backend/.gitignore`: `storage/geoip/`.
- Тесты `backend/src/services/traffic-flow-geoip.test.ts` + скрипт `test:geoip` (по образцу `test:traffic-flow`): приоритет фасада (geoip hit → RIPE не зовётся; miss → fallback), negative на приватных IP, фильтрация EU/ZZ через `isIsoCountry`, коллектор с мокнутым fetch (304-skip, битый файл → подмены нет, `.prev` сохранён), dims по стране/ASN с засеянным ридером.
- Проверки после реализации (обязательно по правилам): типы/сборка бэка (`npm run build -w mikrotik-manager-backend`), типы фронта при правке UI (`npx tsc --noEmit`), `npm run test:geoip` и `test:traffic-flow`; предупреждения не игнорировать.
- Коммит: `feat(netflow): <subject по-русски>` — новая пользовательская фича (мгновенные страна/ASN в потоках), по commit-messages-ru.
- README: короткий раздел о GeoIP; примечание, что в Docker `storage/geoip` ephemeral без тома — базы перекачаются после пересоздания контейнера (~17 МБ); при желании смонтировать volume.
## Что это даёт
Страна и ASN появляются у потока мгновенно при ingest (включая IPv6), без ограничения скорости RIPE; dims `country`/`asn` в `flow_daily_dims`, аналитика (карта, топы, monthly) становятся полными сразу. Внешняя зависимость от stat.ripe.net остаётся только как fallback до первой загрузки баз.
+38 -21
View File
@@ -19,7 +19,7 @@
| Компонент | Путь | Стек | Порт (runtime) | Docker-образ | | Компонент | Путь | Стек | Порт (runtime) | Docker-образ |
|-----------|------|------|----------------|--------------| |-----------|------|------|----------------|--------------|
| Frontend | корень (`app/`, `components/`, `lib/`, …) | Next.js 16.2.4 (App Router), React 19 | 3000 | `…-frontend` | | Frontend | корень (`app/`, `components/`, `lib/`, …) | Next.js 16.2.4 (App Router), React 19 | 3000 | `…-frontend` |
| Backend | `backend/` | Fastify 5, TypeScript ESM, SQLite, Drizzle | 8000 | `…-backend` | | Backend | `backend/` | Fastify 5, TypeScript ESM, PostgreSQL 18, Drizzle | 8000 | `…-backend` |
| Контракты | `packages/contracts/` | Zod 4, `@mmapp/contracts` | — | встраиваются в frontend/backend | | Контракты | `packages/contracts/` | Zod 4, `@mmapp/contracts` | — | встраиваются в frontend/backend |
| Updater | `deploy/updater/` | bash, `docker:27-cli`, curl, jq | — | `…-updater` | | Updater | `deploy/updater/` | bash, `docker:27-cli`, curl, jq | — | `…-updater` |
| Деплой | `deploy/docker-compose.yml` | Docker Compose | — | — | | Деплой | `deploy/docker-compose.yml` | Docker Compose | — | — |
@@ -43,11 +43,11 @@ flowchart TB
Browser["Браузер"] Browser["Браузер"]
NextDev["Next.js :3000"] NextDev["Next.js :3000"]
FastifyDev["Fastify :8000"] FastifyDev["Fastify :8000"]
SqliteDev["SQLite файл"] PostgresDev["PostgreSQL 18"]
Contracts["@mmapp/contracts"] Contracts["@mmapp/contracts"]
Browser --> NextDev Browser --> NextDev
Browser -->|"fetch /api, CORS"| FastifyDev Browser -->|"fetch /api, CORS"| FastifyDev
FastifyDev --> SqliteDev FastifyDev --> PostgresDev
Contracts --> NextDev Contracts --> NextDev
Contracts --> FastifyDev Contracts --> FastifyDev
end end
@@ -60,7 +60,8 @@ flowchart TB
subgraph prod [Прод-сервер] subgraph prod [Прод-сервер]
FE["mmapp-frontend :3000"] FE["mmapp-frontend :3000"]
BE["mmapp-backend :8000"] BE["mmapp-backend :8000"]
DBVol["volume backend-data /app/data"] PG["mmapp-postgres"]
DBVol["volume sqlite ETL /app/data"]
UPD["mmapp-updater"] UPD["mmapp-updater"]
Sock["/var/run/docker.sock"] Sock["/var/run/docker.sock"]
Reg --> FE Reg --> FE
@@ -69,6 +70,7 @@ flowchart TB
UPD --> Sock UPD --> Sock
UPD --> FE UPD --> FE
UPD --> BE UPD --> BE
BE --> PG
BE --> DBVol BE --> DBVol
BrowserProd["Клиент"] --> FE BrowserProd["Клиент"] --> FE
BrowserProd -->|"live + backendUrl"| BE BrowserProd -->|"live + backendUrl"| BE
@@ -117,7 +119,7 @@ sequenceDiagram
``` ```
- Триггер workflow: `push` в ветку `main`, ручной `workflow_dispatch`. - Триггер workflow: `push` в ветку `main`, ручной `workflow_dispatch`.
- Параллельные jobs: `backend-image`, `frontend-image`, `updater-image`; опционально `notify-webhook` после backend и frontend, если задан секрет `DEPLOY_WEBHOOK_URL`. - Параллельные jobs: `backend-test` (PostgreSQL 18), `backend-image`, `frontend-image`, `updater-image`; опционально `notify-webhook` после backend и frontend, если задан секрет `DEPLOY_WEBHOOK_URL`.
- Теги на каждый успешный push: **`:latest`** и **`:<commit-sha>`**; платформа **linux/amd64**. - Теги на каждый успешный push: **`:latest`** и **`:<commit-sha>`**; платформа **linux/amd64**.
- На сервере образы подтягиваются вручную (`docker compose pull`) и/или через **updater** (сравнение digest у тега из `targets.json`). Webhook CI **не заменяет** updater. - На сервере образы подтягиваются вручную (`docker compose pull`) и/или через **updater** (сравнение digest у тега из `targets.json`). Webhook CI **не заменяет** updater.
@@ -125,7 +127,8 @@ sequenceDiagram
| Зависимость | Реализация | | Зависимость | Реализация |
|-------------|------------| |-------------|------------|
| SQLite | Не отдельный контейнер. Файл `mikrotik.db` в томе `backend-data``/app/data` (`DATABASE_PATH=/app/data/mikrotik.db` в образе backend). | | PostgreSQL | Контейнер `mmapp-postgres` (`postgres:18-alpine`). `DATABASE_URL=postgres://mmapp:…@postgres:5432/mmapp`. |
| SQLite (ETL) | Файл `mikrotik.db` на томе `/app/data` (`DATABASE_PATH=/app/data/mikrotik.db`). При старте, пока нет маркера `data_migration.sqlite_imported_at`, backend импортирует sqlite в PG (`ON CONFLICT` / upsert). После успешного импорта повтор не копирует заново. Том PG18: `mmapp-pgdata:/var/lib/postgresql` (не `.../data`). |
| Docker socket | Только у контейнера updater: `/var/run/docker.sock` — доступ к Docker API хоста (управление контейнерами, pull). | | Docker socket | Только у контейнера updater: `/var/run/docker.sock` — доступ к Docker API хоста (управление контейнерами, pull). |
## Локальная разработка ## Локальная разработка
@@ -134,7 +137,8 @@ sequenceDiagram
- **Node.js 22** (как в `Dockerfile.frontend` и `backend/Dockerfile`). - **Node.js 22** (как в `Dockerfile.frontend` и `backend/Dockerfile`).
- **npm** с workspaces; установка из корня: `npm ci` или `npm install`. - **npm** с workspaces; установка из корня: `npm ci` или `npm install`.
- Для нативной сборки `better-sqlite3` на Linux может понадобиться toolchain (`python3`, `make`, `g++`); в Docker-образе backend они уже ставятся. - Для нативной сборки `better-sqlite3` на Linux может понадобиться toolchain (`python3`, `make`, `g++`); в Docker-образе backend они уже ставятся (нужен только для одноразового boot-ETL из `mikrotik.db`).
- Локально нужен PostgreSQL 18 (`deploy/docker-compose.postgres.yml` или `DATABASE_URL`).
- Backend Docker-образ ставит только workspaces `backend` + `contracts` (без корневых Next/React deps); в production логи — JSON без `pino-pretty`. - Backend Docker-образ ставит только workspaces `backend` + `contracts` (без корневых Next/React deps); в production логи — JSON без `pino-pretty`.
### Запуск ### Запуск
@@ -161,7 +165,8 @@ Backend — скопировать `backend/.env.example` в `backend/.env`:
| Переменная | По умолчанию | Назначение | | Переменная | По умолчанию | Назначение |
|------------|--------------|------------| |------------|--------------|------------|
| `DATABASE_PATH` | `./mikrotik.db` | путь к файлу SQLite | | `DATABASE_URL` | `postgres://mmapp:[email protected]:5432/mmapp` | подключение к PostgreSQL |
| `DATABASE_PATH` | `./mikrotik.db` | SQLite только для boot-ETL, если PG пустой |
| `PORT` | `8000` | порт Fastify | | `PORT` | `8000` | порт Fastify |
| `CORS_ORIGIN` | `http://localhost:3000` | origin фронтенда для CORS | | `CORS_ORIGIN` | `http://localhost:3000` | origin фронтенда для CORS |
@@ -179,14 +184,20 @@ Frontend — в репозитории нет корневого `.env.example`.
npm run build -w @mmapp/contracts npm run build -w @mmapp/contracts
``` ```
После изменения схем Drizzle: Схема PostgreSQL накатывается при старте backend из [`backend/drizzle/0000_postgresql.sql`](backend/drizzle/0000_postgresql.sql). Опциональный ETL:
```bash ```bash
npm --prefix backend run db:generate npm --prefix backend run db:migrate-from-sqlite
npm --prefix backend run db:migrate
npm --prefix backend run db:studio
``` ```
### GeoIP-базы GeoLite2 (страны и ASN для NetFlow)
Backend держит локальные mmdb-базы MaxMind GeoLite2 (Country + ASN) в `backend/storage/geoip/` и скачивает их с зеркала [P3TERX/GeoLite.mmdb](https://github.com/P3TERX/GeoLite.mmdb) — без регистрации и ключей. Lookup страны/ASN потока при ingest становится мгновенным (включая IPv6) и не упирается в лимиты RIPEstat; пока базы не скачаны или lookup промахнулся, работает прежний RIPE-fallback.
Управление — секция «GeoIP-базы (GeoLite2)» в настройках NetFlow (страница «Сбор данных»): автообновление (по умолчанию проверка раз в 7 дней, upstream обновляется еженедельно), статус сборки баз и кнопка «Обновить сейчас». Джоба планировщика — `geoip_update`. Атрибуция: данные MaxMind GeoLite2, CC BY-SA 4.0.
Примечание для Docker: каталог `storage/geoip` внутри контейнера ephemeral — без смонтированного volume базы (~17 МБ) перекачаются после пересоздания контейнера. Каталог переопределяется переменной `GEOIP_DIR`.
## CI/CD (Gitea Actions) ## CI/CD (Gitea Actions)
Файл: `.gitea/workflows/docker.yml` (имя workflow: **Docker images**). Файл: `.gitea/workflows/docker.yml` (имя workflow: **Docker images**).
@@ -281,7 +292,8 @@ SSO auth-portal: [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md
| Сервис | `container_name` | Образ (пример) | Порты host:container | Тома | `restart` | | Сервис | `container_name` | Образ (пример) | Порты host:container | Тома | `restart` |
|--------|------------------|----------------|----------------------|------|-----------| |--------|------------------|----------------|----------------------|------|-----------|
| backend | `mmapp-backend` | `git.shx.one/denozord/mikrotikmanager-backend:latest` | `8000:8000` | `backend-data` `/app/data` | `unless-stopped` | | postgres | `mmapp-postgres` | `postgres:18-alpine` | нет (внутренняя сеть) | `mmapp-pgdata` | `unless-stopped` |
| backend | `mmapp-backend` | `git.shx.one/denozord/mikrotikmanager-backend:latest` | `8000:8000` | sqlite ETL → `/app/data` | `unless-stopped` |
| frontend | `mmapp-frontend` | `git.shx.one/denozord/mikrotikmanager-frontend:latest` | `3000:3000` | — | `unless-stopped` | | frontend | `mmapp-frontend` | `git.shx.one/denozord/mikrotikmanager-frontend:latest` | `3000:3000` | — | `unless-stopped` |
| updater | `mmapp-updater` | `git.shx.one/denozord/mikrotikmanager-updater:latest` | не публикуются | docker.sock, `updater-state``/state`, `targets.json``/etc/updater/targets.json:ro` | `unless-stopped` | | updater | `mmapp-updater` | `git.shx.one/denozord/mikrotikmanager-updater:latest` | не публикуются | docker.sock, `updater-state``/state`, `targets.json``/etc/updater/targets.json:ro` | `unless-stopped` |
@@ -297,11 +309,14 @@ SSO auth-portal: [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md
### Переменные окружения (прод) ### Переменные окружения (прод)
**Backend** (в образе заданы `NODE_ENV=production`, `PORT=8000`, `DATABASE_PATH=/app/data/mikrotik.db`; в compose обычно переопределяют только CORS): **Backend** (в образе заданы `NODE_ENV=production`, `PORT=8000`; в compose задают `DATABASE_URL`, `DATABASE_PATH` для ETL и CORS):
| Переменная | Источник в compose | Назначение | | Переменная | Источник в compose | Назначение |
|------------|-------------------|------------| |------------|-------------------|------------|
| `CORS_ORIGIN` | `${CORS_ORIGIN:-http://localhost:3000}` | origin UI, с которого браузер вызывает API | | `CORS_ORIGIN` | `${CORS_ORIGIN:-http://localhost:3000}` | origin UI, с которого браузер вызывает API |
| `DATABASE_URL` | `postgres://mmapp:${POSTGRES_PASSWORD}@postgres:5432/mmapp` | PostgreSQL |
| `DATABASE_PATH` | `/app/data/mikrotik.db` | SQLite на томе для одноразового ETL |
| `POSTGRES_PASSWORD` | обязателен | пароль пользователя `mmapp` |
**Frontend** (в образе: `PORT=3000`, `HOSTNAME=0.0.0.0`). URL API в образ **не** зашит — задаётся в браузере (режим live + URL бэкенда) вместе с `CORS_ORIGIN` на backend. **Frontend** (в образе: `PORT=3000`, `HOSTNAME=0.0.0.0`). URL API в образ **не** зашит — задаётся в браузере (режим live + URL бэкенда) вместе с `CORS_ORIGIN` на backend.
@@ -312,7 +327,7 @@ SSO auth-portal: [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md
| `TARGETS_FILE` | `/etc/updater/targets.json` | список целей | | `TARGETS_FILE` | `/etc/updater/targets.json` | список целей |
| `STATE_FILE` | `/state/updater-state.json` | digest и ошибки | | `STATE_FILE` | `/state/updater-state.json` | digest и ошибки |
| `POLL_INTERVAL_SECONDS` | `300` | пауза между циклами опроса | | `POLL_INTERVAL_SECONDS` | `300` | пауза между циклами опроса |
| `HEALTH_TIMEOUT_SECONDS` | `120` | ожидание HTTP health | | `HEALTH_TIMEOUT_SECONDS` | `600` | ожидание HTTP health (первый boot с ETL может занять минуты) |
| `STOP_TIMEOUT_SECONDS` | `30` | `docker stop -t` | | `STOP_TIMEOUT_SECONDS` | `30` | `docker stop -t` |
| `REGISTRY` | `git.shx.one` | registry для `docker login` | | `REGISTRY` | `git.shx.one` | registry для `docker login` |
| `REGISTRY_USERNAME` | пусто | логин (если заданы оба с паролем) | | `REGISTRY_USERNAME` | пусто | логин (если заданы оба с паролем) |
@@ -516,18 +531,20 @@ docker compose up -d --force-recreate
### Откат ### Откат
- **Автоматически:** updater при failed health после обновления — контейнер на `previous_digest`. - **Автоматически:** updater при failed health после обновления — контейнер на `previous_digest`.
- **Вручную:** остановить контейнер, запустить образ с нужным тегом или digest из registry, сохранив те же volume и labels. Откат **схемы SQLite** updater не выполняет — нужен отдельный backup тома `backend-data` / файла `mikrotik.db`. - **Вручную:** остановить контейнер, запустить образ с нужным тегом или digest из registry, сохранив те же volume и labels. Откат **схемы PostgreSQL** updater не выполняет — нужен `pg_dump` / restore или том `mmapp-pgdata`.
### Резервное копирование SQLite ### Резервное копирование PostgreSQL
Том `backend-data` (или `mmapp-backend-data` при явном `docker volume create`). Пример остановки backend для консистентной копии: Том `mmapp-pgdata` и (до cleanup) sqlite `mikrotik.db` на `/app/data`. Из UI: Настройки → «Скачать бэкап» (`pg_dump -Fc`). С хоста:
```bash ```bash
docker stop mmapp-backend docker exec mmapp-postgres pg_dump -Fc -U mmapp mmapp > mikrotik-manager.dump
docker run --rm -v backend-data:/data -v $(pwd):/backup alpine tar czf /backup/mikrotik-db-backup.tar.gz -C /data .
docker start mmapp-backend
``` ```
Восстановление: UI «Восстановить из файла» или `pg_restore --clean --if-exists -d …`.
Том sqlite после успешного ETL и smoke **не удаляйте сразу** — оставьте как fallback, затем уберите `mikrotik.db`, когда маркер `data_migration.sqlite_imported_at` подтверждён.
### Отладка ### Отладка
| Действие | Команда | | Действие | Команда |
+1 -1
View File
@@ -2846,7 +2846,7 @@ export default function AlertsPage() {
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="px-3 pb-3 text-[11px] text-muted-foreground leading-relaxed space-y-2 border-t border-border/60 pt-2"> <CollapsibleContent className="px-3 pb-3 text-[11px] text-muted-foreground leading-relaxed space-y-2 border-t border-border/60 pt-2">
<p> <p>
Движок читает последние сэмплы из SQLite (uptime ресурсы, REST-пинг, пробы, GRE/BGP). Условие Движок читает последние сэмплы из PostgreSQL (uptime ресурсы, REST-пинг, пробы, GRE/BGP). Условие
срабатывает только если цепочка состояний совпадает с текстом правила. срабатывает только если цепочка состояний совпадает с текстом правила.
</p> </p>
<ul className="list-disc pl-4 space-y-1.5"> <ul className="list-disc pl-4 space-y-1.5">
File diff suppressed because it is too large Load Diff
+130 -42
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { Fragment, useState, useMemo, useEffect } from "react" import { useState, useMemo, useEffect } from "react"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { DataPageToolbar } from "@/components/data-page-toolbar" import { DataPageToolbar } from "@/components/data-page-toolbar"
import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid" import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid"
@@ -25,6 +25,9 @@ import {
} from "lucide-react" } from "lucide-react"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client" import { requestJson } from "@/shared/api/http-client"
import { servers as mockServers, type Server } from "@/lib/data"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
// ─── types ──────────────────────────────────────────────────────────────────── // ─── types ────────────────────────────────────────────────────────────────────
@@ -233,6 +236,37 @@ interface BackendBgpSession {
capabilities: string[]; lastError: string | null capabilities: string[]; lastError: string | null
} }
interface BackendServer {
id: number
name: string
host: string
type?: Server["type"]
site?: string
country: string
asn?: string
enabled: boolean
status?: Server["status"]
latency?: number | null
}
function mapBackendServer(s: BackendServer): Server {
return {
id: String(s.id),
name: s.name || s.host,
host: s.host,
model: "—",
os: "—",
site: s.site ?? "",
country: s.country || "UN",
asn: s.asn ?? "",
type: s.type ?? "exit-node",
enabled: s.enabled,
status: s.status ?? "online",
latency: s.latency ?? null,
sessions: 0,
}
}
function backendToFrontend(b: BackendBgpSession): BgpSession { function backendToFrontend(b: BackendBgpSession): BgpSession {
return { return {
id: `${b.serverId}-${b.id}`, id: `${b.serverId}-${b.id}`,
@@ -623,27 +657,40 @@ const TABS: Array<{ id: BgpTab; label: string; icon: React.ReactNode }> = [
export default function BgpPage() { export default function BgpPage() {
const [activeTab, setActiveTab] = useState<BgpTab>("sessions") const [activeTab, setActiveTab] = useState<BgpTab>("sessions")
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const { mode, backendUrl } = useDataSource() const { mode, backendUrl } = useDataSource()
const isLive = mode === "live" const isLive = mode === "live"
const [liveSessions, setLiveSessions] = useState<BgpSession[]>([]) const [liveSessions, setLiveSessions] = useState<BgpSession[]>([])
const [liveServers, setLiveServers] = useState<Server[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [fetchedAt, setFetchedAt] = useState<Date | null>(null) const [fetchedAt, setFetchedAt] = useState<Date | null>(null)
const [liveError, setLiveError] = useState<string | null>(null) const [liveError, setLiveError] = useState<string | null>(null)
const [fetchTick, setFetchTick] = useState(0) const [fetchTick, setFetchTick] = useState(0)
useEffect(() => { useEffect(() => {
if (!isLive) return if (!isLive) {
queueMicrotask(() => {
setLiveSessions([])
setLiveServers([])
setLiveError(null)
})
return
}
let cancelled = false let cancelled = false
queueMicrotask(() => { queueMicrotask(() => {
if (cancelled) return if (cancelled) return
setLoading(true) setLoading(true)
setLiveError(null) setLiveError(null)
void requestJson<BackendBgpSession[]>(backendUrl, "/api/bgp/sessions") void Promise.all([
.then(data => { requestJson<BackendBgpSession[]>(backendUrl, "/api/bgp/sessions"),
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
])
.then(([data, servers]) => {
if (cancelled) return if (cancelled) return
setLiveSessions(data.map(backendToFrontend)) setLiveSessions(data.map(backendToFrontend))
setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer))
setFetchedAt(new Date()) setFetchedAt(new Date())
setLoading(false) setLoading(false)
}) })
@@ -656,50 +703,87 @@ export default function BgpPage() {
return () => { cancelled = true } return () => { cancelled = true }
}, [isLive, backendUrl, fetchTick]) }, [isLive, backendUrl, fetchTick])
// Use live or mock data for all tabs and KPI const allSessions = isLive ? liveSessions : SESSIONS
const sessions = isLive ? liveSessions : SESSIONS const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
const effectiveServerId =
selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId)
? selectedServerId
: ALL_SERVERS_ID
const sessions = useMemo(() => {
if (effectiveServerId === ALL_SERVERS_ID) return allSessions
return allSessions.filter((s) => s.serverId === effectiveServerId)
}, [allSessions, effectiveServerId])
const railItems = useMemo<ServerTileItem[]>(() => {
const counts = new Map<string, number>()
for (const s of allSessions) {
counts.set(s.serverId, (counts.get(s.serverId) ?? 0) + 1)
}
return displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
type: s.type,
count: counts.get(s.id) ?? 0,
enabled: s.enabled,
title: [s.name, s.host, s.asn].filter(Boolean).join(" · "),
}))
}, [displayServers, allSessions])
const established = sessions.filter(s => s.state === "Established").length const established = sessions.filter(s => s.state === "Established").length
const notEstab = sessions.length - established const notEstab = sessions.length - established
const totalRx = sessions.reduce((a, s) => a + s.prefixesRx, 0) const totalRx = sessions.reduce((a, s) => a + s.prefixesRx, 0)
const serverCount = useMemo( const serverCount = useMemo(
() => new Set(liveSessions.map(s => s.serverId)).size, () => new Set(sessions.map(s => s.serverId)).size,
[liveSessions], [sessions],
) )
return ( return (
<div className="flex flex-col h-full"> <ServerRailLayout
<PageHeader items={railItems}
crumbs={[{ label: "Управление" }, { label: "BGP" }]} selectedId={effectiveServerId}
actions={ onSelect={setSelectedServerId}
<> showAll
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}> allCount={displayServers.length}
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} /> loading={isLive && loading && displayServers.length === 0}
Обновить header={
</Button> <PageHeader
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button> crumbs={[{ label: "Управление" }, { label: "BGP" }]}
</> actions={
} <>
/> <ServerRailMobileButton />
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
{/* tab bar */} <RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
<div className="border-b bg-background shrink-0"> Обновить
<div className="flex items-center px-6"> </Button>
{TABS.map(t => ( <Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
<button key={t.id} onClick={() => setActiveTab(t.id)} </>
className={cn( }
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px", />
activeTab === t.id }
? "border-primary text-foreground" banner={
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border", <div className="border-b bg-background shrink-0">
)}> <div className="flex items-center px-6">
{t.icon}{t.label} {TABS.map(t => (
</button> <button key={t.id} onClick={() => setActiveTab(t.id)}
))} className={cn(
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === t.id
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
)}>
{t.icon}{t.label}
</button>
))}
</div>
</div> </div>
</div> }
>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{/* data source banner */} {/* data source banner */}
@@ -729,11 +813,16 @@ export default function BgpPage() {
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription> <AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
</Alert> </Alert>
)} )}
{isLive && !loading && liveSessions.length === 0 && !liveError && fetchedAt && ( {isLive && !loading && allSessions.length === 0 && !liveError && fetchedAt && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground"> <div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
BGP не настроен ни на одном сервере BGP не настроен ни на одном сервере
</div> </div>
)} )}
{isLive && !loading && allSessions.length > 0 && sessions.length === 0 && !liveError && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
На выбранном сервере нет BGP-сессий
</div>
)}
{mode === "mock" && ( {mode === "mock" && (
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground"> <span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
Моковые данные Моковые данные
@@ -794,7 +883,6 @@ export default function BgpPage() {
{activeTab === "analytics" && <AnalyticsTab sessions={sessions} />} {activeTab === "analytics" && <AnalyticsTab sessions={sessions} />}
</div> </div>
</div> </ServerRailLayout>
</div>
) )
} }
+4 -1
View File
@@ -11,6 +11,7 @@ import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card" import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar" import { DataPageToolbar } from "@/components/data-page-toolbar"
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid" import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
import { CertificateRenewSettingsPanel } from "@/components/certificates/certificate-renew-settings"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail" import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
@@ -243,7 +244,7 @@ function CertPartReference() {
return ( return (
<OpsPanel <OpsPanel
title="RouterOS 7 · /certificate — справка CLI" title="RouterOS 7 · /certificate — справка CLI"
description="RouterOS 7.22+ · публичные LE для Cloudflare через backend DNS-01, не через /certificate add-acme на устройстве." description="RouterOS 7 умеет обновлять Let's Encrypt сам. Этот CLI — справка; автообновление MM включается панелью выше."
contentClassName="px-5 py-4" contentClassName="px-5 py-4"
> >
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
@@ -715,6 +716,8 @@ export default function CertificatesPage() {
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} /> <CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} />
<CertificateRenewSettingsPanel backendUrl={backendUrl} liveReady={liveReady} />
{liveReady && ( {liveReady && (
<CertPartAcmeSettings <CertPartAcmeSettings
acmeDirectoryUrl={acmeDirectoryUrl} acmeDirectoryUrl={acmeDirectoryUrl}
+253 -70
View File
@@ -1,15 +1,17 @@
"use client" "use client"
import { useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { routerContainers, servers } from "@/lib/data" import { routerContainers as mockContainers, servers as mockServers } from "@/lib/data"
import type { RouterContainer } from "@/lib/data" import type { RouterContainer, Server } from "@/lib/data"
import { Flag } from "@/components/flag" import { Flag } from "@/components/flag"
import { Frame, FramePanel } from "@/components/reui/frame" import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { toast } from "sonner"
import { import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuItem, DropdownMenuSeparator,
@@ -18,18 +20,47 @@ import {
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon, BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon, MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
CodeXmlIcon, ActivityIcon, ServerIcon, CodeXmlIcon, ActivityIcon, ServerIcon,
TerminalIcon, AlertCircleIcon, TerminalIcon, AlertCircleIcon, RefreshCwIcon,
} from "lucide-react" } from "lucide-react"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
// ─── helpers ────────────────────────────────────────────────────────────────── interface BackendServer {
id: number
name: string
host: string
type?: Server["type"]
site?: string
country: string
asn?: string
enabled: boolean
status?: Server["status"]
latency?: number | null
}
function serverFor(id: string) { interface ContainersApiResponse {
return servers.find((s) => s.id === id) containers: RouterContainer[]
}
function mapBackendServer(s: BackendServer): Server {
return {
id: String(s.id),
name: s.name || s.host,
host: s.host,
model: "—",
os: "—",
site: s.site ?? "",
country: s.country || "UN",
asn: s.asn ?? "",
type: s.type ?? "exit-node",
enabled: s.enabled,
status: s.status ?? "online",
latency: s.latency ?? null,
sessions: 0,
}
} }
function statusConfig(status: RouterContainer["status"]) { function statusConfig(status: RouterContainer["status"]) {
@@ -52,10 +83,8 @@ function statusConfig(status: RouterContainer["status"]) {
}[status] }[status]
} }
// ─── RSC generator ──────────────────────────────────────────────────────────── function generateContainerRsc(c: RouterContainer, serverById: Record<string, Server>): string {
const srv = serverById[c.serverId]
function generateContainerRsc(c: RouterContainer): string {
const srv = serverFor(c.serverId)
const lines: string[] = [] const lines: string[] = []
lines.push(`# RouterOS Container — ${c.name}`) lines.push(`# RouterOS Container — ${c.name}`)
if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`) if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`)
@@ -63,13 +92,11 @@ function generateContainerRsc(c: RouterContainer): string {
lines.push(`# RouterOS 7.4+ · /container`) lines.push(`# RouterOS 7.4+ · /container`)
lines.push(``) lines.push(``)
// interface
for (const iface of c.interfaces) { for (const iface of c.interfaces) {
lines.push(`/interface/veth/add name=${iface} address=172.17.0.2/24 gateway=172.17.0.1`) lines.push(`/interface/veth/add name=${iface} address=172.17.0.2/24 gateway=172.17.0.1`)
} }
lines.push(``) lines.push(``)
// envs
if (c.envs.length > 0) { if (c.envs.length > 0) {
lines.push(`/container/envs/add name=${c.name}-envs \\`) lines.push(`/container/envs/add name=${c.name}-envs \\`)
for (const { key, value } of c.envs) { for (const { key, value } of c.envs) {
@@ -78,7 +105,6 @@ function generateContainerRsc(c: RouterContainer): string {
lines.push(``) lines.push(``)
} }
// mounts
for (const m of c.mounts) { for (const m of c.mounts) {
lines.push(`/container/mounts/add name=${c.name}-mount-${m.dst.replace(/\//g, "-").slice(1)} \\`) lines.push(`/container/mounts/add name=${c.name}-mount-${m.dst.replace(/\//g, "-").slice(1)} \\`)
if (m.src) lines.push(` src=${m.src} \\`) if (m.src) lines.push(` src=${m.src} \\`)
@@ -86,7 +112,6 @@ function generateContainerRsc(c: RouterContainer): string {
lines.push(``) lines.push(``)
} }
// container
lines.push(`/container/add \\`) lines.push(`/container/add \\`)
lines.push(` remote-image=${c.image}:${c.tag} \\`) lines.push(` remote-image=${c.image}:${c.tag} \\`)
lines.push(` interface=${c.interfaces[0] ?? "veth-container"} \\`) lines.push(` interface=${c.interfaces[0] ?? "veth-container"} \\`)
@@ -100,12 +125,18 @@ function generateContainerRsc(c: RouterContainer): string {
return lines.join("\n") return lines.join("\n")
} }
// ─── Export Sheet ───────────────────────────────────────────────────────────── function ExportSheet({
open, container, onClose, serverById,
function ExportSheet({ open, container, onClose }: { }: {
open: boolean; container: RouterContainer | null; onClose: () => void open: boolean
container: RouterContainer | null
onClose: () => void
serverById: Record<string, Server>
}) { }) {
const code = useMemo(() => container ? generateContainerRsc(container) : "", [container]) const code = useMemo(
() => (container ? generateContainerRsc(container, serverById) : ""),
[container, serverById],
)
return ( return (
<CodeExportSheet <CodeExportSheet
@@ -125,17 +156,29 @@ function ExportSheet({ open, container, onClose }: {
) )
} }
// ─── Container card ───────────────────────────────────────────────────────────
function ContainerCard({ function ContainerCard({
container, container,
server,
live,
busy,
onExport, onExport,
onStart,
onStop,
onRestart,
onRemove,
}: { }: {
container: RouterContainer container: RouterContainer
server?: Server
live: boolean
busy: boolean
onExport: () => void onExport: () => void
onStart: () => void
onStop: () => void
onRestart: () => void
onRemove: () => void
}) { }) {
const srv = serverFor(container.serverId)
const cfg = statusConfig(container.status) const cfg = statusConfig(container.status)
const canMutate = live && Boolean(container.rosId)
return ( return (
<Frame dense className="w-full overflow-hidden"> <Frame dense className="w-full overflow-hidden">
@@ -150,29 +193,36 @@ function ContainerCard({
</div> </div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger render={ <DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7 shrink-0"> <Button variant="ghost" size="icon" className="size-7 shrink-0" disabled={busy}>
<MoreHorizontalIcon className="size-4" /> <MoreHorizontalIcon className="size-4" />
</Button> </Button>
} /> } />
<DropdownMenuContent side="bottom" align="end"> <DropdownMenuContent side="bottom" align="end">
{container.status === "running" ? ( {container.status === "running" ? (
<DropdownMenuItem><StopCircleIcon className="size-4 text-amber-500" />Остановить</DropdownMenuItem> <DropdownMenuItem disabled={!canMutate} onClick={onStop}>
<StopCircleIcon className="size-4 text-amber-500" />Остановить
</DropdownMenuItem>
) : ( ) : (
<DropdownMenuItem><PlayIcon className="size-4 text-emerald-500" />Запустить</DropdownMenuItem> <DropdownMenuItem disabled={!canMutate} onClick={onStart}>
<PlayIcon className="size-4 text-emerald-500" />Запустить
</DropdownMenuItem>
)} )}
<DropdownMenuItem><TerminalIcon className="size-4" />Логи</DropdownMenuItem> <DropdownMenuItem disabled><TerminalIcon className="size-4" />Логи</DropdownMenuItem>
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem> <DropdownMenuItem disabled><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
<DropdownMenuItem onClick={onExport}><CodeXmlIcon className="size-4" />Экспорт .rsc</DropdownMenuItem> <DropdownMenuItem onClick={onExport}><CodeXmlIcon className="size-4" />Экспорт .rsc</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem><PowerIcon className="size-4" />Перезапустить</DropdownMenuItem> <DropdownMenuItem disabled={!canMutate} onClick={onRestart}>
<PowerIcon className="size-4" />Перезапустить
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem> <DropdownMenuItem variant="destructive" disabled={!canMutate} onClick={onRemove}>
<Trash2Icon className="size-4" />Удалить
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
<div className="px-4 py-3 flex flex-col gap-3"> <div className="px-4 py-3 flex flex-col gap-3">
{/* image */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<BoxIcon className="size-3.5 text-muted-foreground shrink-0" /> <BoxIcon className="size-3.5 text-muted-foreground shrink-0" />
<span className="font-mono text-xs text-foreground/80"> <span className="font-mono text-xs text-foreground/80">
@@ -180,17 +230,15 @@ function ContainerCard({
</span> </span>
</div> </div>
{/* server */} {server && (
{srv && (
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<ServerIcon className="size-3.5 shrink-0" /> <ServerIcon className="size-3.5 shrink-0" />
<Flag code={srv.country} size={12} /> <Flag code={server.country} size={12} />
<span className="font-mono">{srv.name}</span> <span className="font-mono">{server.name}</span>
</div> </div>
)} )}
{/* uptime + stats */} {container.status === "running" && (container.uptime || container.cpu !== undefined || container.memMb !== undefined) && (
{container.status === "running" && (
<div className="flex items-center gap-4 text-xs text-muted-foreground border-t pt-2.5"> <div className="flex items-center gap-4 text-xs text-muted-foreground border-t pt-2.5">
{container.uptime && ( {container.uptime && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -216,7 +264,6 @@ function ContainerCard({
</div> </div>
)} )}
{/* interfaces */}
{container.interfaces.length > 0 && ( {container.interfaces.length > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{container.interfaces.map((i) => ( {container.interfaces.map((i) => (
@@ -227,7 +274,6 @@ function ContainerCard({
</div> </div>
)} )}
{/* mounts */}
{container.mounts.length > 0 && ( {container.mounts.length > 0 && (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{container.mounts.map((m, idx) => ( {container.mounts.map((m, idx) => (
@@ -249,50 +295,183 @@ function ContainerCard({
) )
} }
// ════════════════════════════════════════════════════════════════════════════
export default function ContainersPage() { export default function ContainersPage() {
const [search, setSearch] = useState("") const { mode, backendUrl } = useDataSource()
const [statusFilter, setStatusFilter] = useState<RouterContainer["status"] | "all">("all") const isLive = mode === "live"
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<RouterContainer["status"] | "all">("all")
const [exportContainer, setExportContainer] = useState<RouterContainer | null>(null) const [exportContainer, setExportContainer] = useState<RouterContainer | null>(null)
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const [liveContainers, setLiveContainers] = useState<RouterContainer[]>([])
const [liveServers, setLiveServers] = useState<Server[]>([])
const [loading, setLoading] = useState(false)
const [busyId, setBusyId] = useState<string | null>(null)
const [liveError, setLiveError] = useState<string | null>(null)
const loadLive = useCallback(async () => {
if (!isLive) return
setLoading(true)
setLiveError(null)
try {
const [cRes, sRes] = await Promise.all([
requestJson<ContainersApiResponse>(backendUrl, "/api/containers"),
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
])
setLiveContainers(cRes.containers ?? [])
setLiveServers(sRes.filter((s) => s.enabled).map(mapBackendServer))
} catch (e) {
setLiveError(e instanceof Error ? e.message : "Ошибка загрузки")
setLiveContainers([])
} finally {
setLoading(false)
}
}, [isLive, backendUrl])
useEffect(() => {
if (!isLive) {
queueMicrotask(() => {
setLiveContainers([])
setLiveServers([])
setLiveError(null)
})
return
}
queueMicrotask(() => {
void loadLive()
})
}, [isLive, loadLive])
const displayContainers = isLive ? liveContainers : mockContainers
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
const effectiveServerId =
selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId)
? selectedServerId
: ALL_SERVERS_ID
const scoped = useMemo(() => {
if (effectiveServerId === ALL_SERVERS_ID) return displayContainers
return displayContainers.filter((c) => c.serverId === effectiveServerId)
}, [displayContainers, effectiveServerId])
const serverById = useMemo(
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
[displayServers],
)
const railItems = useMemo<ServerTileItem[]>(() => (
displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
type: s.type,
enabled: s.enabled,
meta: String(displayContainers.filter((c) => c.serverId === s.id).length),
}))
), [displayServers, displayContainers])
const filtered = useMemo(() => { const filtered = useMemo(() => {
return routerContainers.filter((c) => { return scoped.filter((c) => {
if (statusFilter !== "all" && c.status !== statusFilter) return false if (statusFilter !== "all" && c.status !== statusFilter) return false
if (!search) return true if (!search) return true
const q = search.toLowerCase() const q = search.toLowerCase()
return ( return (
c.name.toLowerCase().includes(q) || c.name.toLowerCase().includes(q) ||
c.image.toLowerCase().includes(q) || c.image.toLowerCase().includes(q) ||
(serverFor(c.serverId)?.name.toLowerCase().includes(q) ?? false) (serverById[c.serverId]?.name.toLowerCase().includes(q) ?? false)
) )
}) })
}, [search, statusFilter]) }, [search, statusFilter, scoped, serverById])
const running = routerContainers.filter((c) => c.status === "running").length const running = scoped.filter((c) => c.status === "running").length
const stopped = routerContainers.filter((c) => c.status === "stopped").length const stopped = scoped.filter((c) => c.status === "stopped").length
const errors = routerContainers.filter((c) => c.status === "error").length const errors = scoped.filter((c) => c.status === "error").length
async function mutate(c: RouterContainer, action: "start" | "stop" | "restart" | "remove") {
if (!isLive || !c.rosId) {
toast.info("Действие доступно только в live-режиме")
return
}
if (action === "remove" && !window.confirm(`Удалить контейнер ${c.name}?`)) return
setBusyId(c.id)
try {
await requestJson(backendUrl, `/api/servers/${c.serverId}/containers/${action}`, {
method: "POST",
body: JSON.stringify({ rosId: c.rosId }),
})
const labels = { start: "запущен", stop: "остановлен", restart: "перезапущен", remove: "удалён" }
toast.success(`${c.name}: ${labels[action]}`)
await loadLive()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка RouterOS")
} finally {
setBusyId(null)
}
}
return ( return (
<div className="flex flex-col h-full"> <>
<PageHeader <ServerRailLayout
crumbs={[{ label: "Управление" }, { label: "Контейнеры" }]} items={railItems}
actions={ selectedId={effectiveServerId}
<Button size="sm"> onSelect={setSelectedServerId}
<BoxIcon className="size-4" />Новый контейнер showAll
</Button> allCount={displayServers.length}
} loading={isLive && loading && displayServers.length === 0}
/> header={
<PageHeader
<div className="flex-1 overflow-y-auto p-6"> crumbs={[{ label: "Управление" }, { label: "Контейнеры" }]}
actions={
<>
<ServerRailMobileButton />
<Button
variant="outline"
size="sm"
onClick={() => { void loadLive() }}
disabled={!isLive || loading}
>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
Обновить
</Button>
<Button size="sm">
<BoxIcon className="size-4" />Новый контейнер
</Button>
</>
}
/>
}
>
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{isLive && liveError && (
<Alert variant="warning" className="py-2">
<AlertCircleIcon />
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
</Alert>
)}
{isLive && !loading && displayContainers.length === 0 && !liveError && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
Контейнеры не найдены. Нужен пакет container (RouterOS 7.4+).
</div>
)}
{mode === "mock" && (
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
Моковые данные
</span>
)}
<KpiStatGrid <KpiStatGrid
aria-label="Сводка контейнеров" aria-label="Сводка контейнеров"
items={[ items={[
{ {
id: "all", id: "all",
label: "Всего", label: "Всего",
value: routerContainers.length, value: scoped.length,
icon: <BoxIcon className="size-4" />, icon: <BoxIcon className="size-4" />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
}, },
@@ -321,7 +500,6 @@ export default function ContainersPage() {
]} ]}
/> />
{/* Info banner */}
<div className="flex items-start gap-3 rounded-lg bg-violet-500/5 border border-violet-500/20 px-4 py-3 text-sm"> <div className="flex items-start gap-3 rounded-lg bg-violet-500/5 border border-violet-500/20 px-4 py-3 text-sm">
<BoxIcon className="size-5 text-violet-500 shrink-0 mt-0.5" /> <BoxIcon className="size-5 text-violet-500 shrink-0 mt-0.5" />
<div> <div>
@@ -333,7 +511,6 @@ export default function ContainersPage() {
</div> </div>
</div> </div>
{/* Toolbar */}
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]"> <div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" /> <SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
@@ -363,7 +540,6 @@ export default function ContainersPage() {
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} контейнеров</span> <span className="text-sm text-muted-foreground ml-auto">{filtered.length} контейнеров</span>
</div> </div>
{/* Grid */}
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground"> <div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
<BoxIcon className="size-10 mb-3 opacity-20" /> <BoxIcon className="size-10 mb-3 opacity-20" />
@@ -376,13 +552,19 @@ export default function ContainersPage() {
<ContainerCard <ContainerCard
key={c.id} key={c.id}
container={c} container={c}
server={serverById[c.serverId]}
live={isLive}
busy={busyId === c.id}
onExport={() => setExportContainer(c)} onExport={() => setExportContainer(c)}
onStart={() => { void mutate(c, "start") }}
onStop={() => { void mutate(c, "stop") }}
onRestart={() => { void mutate(c, "restart") }}
onRemove={() => { void mutate(c, "remove") }}
/> />
))} ))}
</div> </div>
)} )}
{/* RouterOS reference */}
<OpsPanel title="RouterOS 7.4+ · /container — быстрые команды" contentClassName="px-5 py-4"> <OpsPanel title="RouterOS 7.4+ · /container — быстрые команды" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[ {[
@@ -442,13 +624,14 @@ export default function ContainersPage() {
</OpsPanel> </OpsPanel>
</div> </div>
</div> </ServerRailLayout>
<ExportSheet <ExportSheet
open={!!exportContainer} open={!!exportContainer}
container={exportContainer} container={exportContainer}
onClose={() => setExportContainer(null)} onClose={() => setExportContainer(null)}
serverById={serverById}
/> />
</div> </>
) )
} }
File diff suppressed because it is too large Load Diff
+46 -3
View File
@@ -34,6 +34,7 @@ import {
type InternetPathRunSnapshot, type InternetPathRunSnapshot,
type CertificatesRenewRunSnapshot, type CertificatesRenewRunSnapshot,
type BackupsRunSnapshot, type BackupsRunSnapshot,
type GeoipUpdateRunSnapshot,
type PingRunSnapshot, type PingRunSnapshot,
type ResourcesRunSnapshot, type ResourcesRunSnapshot,
type SchedulerRunSnapshot, type SchedulerRunSnapshot,
@@ -54,6 +55,7 @@ import {
type SchedulerJobGridRow, type SchedulerJobGridRow,
} from "@/components/data-grids/data-collection-scheduler-data-grid" } from "@/components/data-grids/data-collection-scheduler-data-grid"
import { DataPageCard } from "@/components/data-page-card" import { DataPageCard } from "@/components/data-page-card"
import { NetflowSettingsPanel } from "@/components/traffic/netflow-settings-panel"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { import {
AlertCircleIcon, AlertCircleIcon,
@@ -219,7 +221,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</Alert> </Alert>
) : null} ) : null}
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Запись в SQLite на{" "} Запись в PostgreSQL на{" "}
<span className="font-mono tabular-nums">{new Date(g.sampledAt).toLocaleString("ru-RU")}</span> <span className="font-mono tabular-nums">{new Date(g.sampledAt).toLocaleString("ru-RU")}</span>
{" "} строки GRE и BGP для движка оповещений. {" "} строки GRE и BGP для движка оповещений.
</p> </p>
@@ -339,6 +341,41 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</div> </div>
) )
} }
if (snap.job === "geoip_update") {
const g = snap as GeoipUpdateRunSnapshot
return (
<div className="flex flex-col gap-3">
{g.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: обновление уже выполнялось или задача отключена.</p>
) : null}
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
<div>
<dt className="text-muted-foreground">Баз проверено</dt>
<dd className="font-mono font-medium">{g.checked}</dd>
</div>
<div>
<dt className="text-muted-foreground">Скачано</dt>
<dd className="font-mono font-medium">{g.downloaded}</dd>
</div>
<div>
<dt className="text-muted-foreground">Без изменений</dt>
<dd className="font-mono font-medium">{g.skippedUnchanged}</dd>
</div>
<div>
<dt className="text-muted-foreground">Объём, МБ</dt>
<dd className="font-mono font-medium">{(g.bytes / 1024 / 1024).toFixed(1)}</dd>
</div>
</dl>
{g.errors.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
{g.errors.map((e, i) => (
<p key={i} className="break-words">{e}</p>
))}
</div>
) : null}
</div>
)
}
if (snap.job === "alert_engine") { if (snap.job === "alert_engine") {
const a = snap as AlertEngineRunSnapshot const a = snap as AlertEngineRunSnapshot
return ( return (
@@ -346,7 +383,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Снимок на{" "} Снимок на{" "}
<span className="font-mono tabular-nums">{new Date(a.sampledAt).toLocaleString("ru-RU")}</span> <span className="font-mono tabular-nums">{new Date(a.sampledAt).toLocaleString("ru-RU")}</span>
. Данные для правил берутся из SQLite после джоб сбора (трафик, uptime, REST, GRE+BGP и т.д.). . Данные для правил берутся из PostgreSQL после джоб сбора (трафик, uptime, REST, GRE+BGP и т.д.).
</p> </p>
<dl className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs"> <dl className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
<div> <div>
@@ -1126,7 +1163,11 @@ export default function DataCollectionPage() {
onChange={(e) => setRenewBeforeDaysDraft(e.target.value)} onChange={(e) => setRenewBeforeDaysDraft(e.target.value)}
className="h-8 text-sm" className="h-8 text-sm"
inputMode="numeric" inputMode="numeric"
disabled={!draftCertRenewEnabled}
/> />
<p className="text-[11px] text-muted-foreground mt-1">
Вкл/выкл автообновления MM также на странице «Сертификаты». Не включайте вместе со встроенным ACME RouterOS.
</p>
</div> </div>
</div> </div>
<p className="text-[11px] text-muted-foreground leading-snug"> <p className="text-[11px] text-muted-foreground leading-snug">
@@ -1210,9 +1251,11 @@ export default function DataCollectionPage() {
</div> </div>
</DataPageCard> </DataPageCard>
{isLive ? <NetflowSettingsPanel backendUrl={backendUrl} enabled={isLive} /> : null}
<OpsPanel <OpsPanel
title="Журнал прогонов" title="Журнал прогонов"
description="SQLite `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки." description="Таблица `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки."
headerRight={ headerRight={
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<label className="text-xs text-muted-foreground whitespace-nowrap" htmlFor="run-filter"> <label className="text-xs text-muted-foreground whitespace-nowrap" htmlFor="run-filter">
+172 -138
View File
@@ -27,7 +27,7 @@ import {
StarIcon, ArrowUpDownIcon, ArrowUpIcon, ArrowDownIcon, StarIcon, ArrowUpDownIcon, ArrowUpIcon, ArrowDownIcon,
FileCodeIcon, CopyIcon, NetworkIcon, TagIcon, FileCodeIcon, CopyIcon, NetworkIcon, TagIcon,
ArrowRightIcon, AlertTriangleIcon, LoaderCircleIcon, RouteIcon, ArrowRightIcon, AlertTriangleIcon, LoaderCircleIcon, RouteIcon,
CheckCircle2Icon, XCircleIcon, CircleDashedIcon, RefreshCwIcon, RefreshCwIcon, HistoryIcon,
} from "lucide-react" } from "lucide-react"
import { import {
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter, Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter,
@@ -36,13 +36,13 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { toast } from "sonner" import { toast } from "sonner"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
import { ConfigHistorySheet } from "@/components/config-history-sheet"
import type { ConfigRevisionDto } from "@/lib/config-revisions"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { type ServerTileItem } from "@/components/server-tile-rail" import { type ServerTileItem } from "@/components/server-tile-rail"
// ── helpers ──────────────────────────────────────────────────────────────────── // ── helpers ────────────────────────────────────────────────────────────────────
type FilterRouterSyncStatus = "synced" | "drift" | "missing"
function newId() { return `r${Date.now()}-${Math.random().toString(36).slice(2, 6)}` } function newId() { return `r${Date.now()}-${Math.random().toString(36).slice(2, 6)}` }
function innerIpToGateway(ip: string) { return ip.split("/")[0] } function innerIpToGateway(ip: string) { return ip.split("/")[0] }
@@ -1239,6 +1239,9 @@ interface BackendServer {
interface LiveFiltersResponse { interface LiveFiltersResponse {
rulesets: ServerFilterRuleset[] rulesets: ServerFilterRuleset[]
greTunnels: GreTunnel[] greTunnels: GreTunnel[]
live?: boolean
stale?: boolean
error?: string
} }
function buildRulesets(serverList: Server[], sourceRulesets: ServerFilterRuleset[]): ServerFilterRuleset[] { function buildRulesets(serverList: Server[], sourceRulesets: ServerFilterRuleset[]): ServerFilterRuleset[] {
@@ -1325,18 +1328,14 @@ export default function FiltersPage() {
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create") const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
const [sheetInitial, setSheetInitial]= useState<RuleForm>(emptyForm()) const [sheetInitial, setSheetInitial]= useState<RuleForm>(emptyForm())
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
const [previewOpen, setPreviewOpen] = useState(false) const [previewOpen, setPreviewOpen] = useState(false)
const [copyOpen, setCopyOpen] = useState(false) const [copyOpen, setCopyOpen] = useState(false)
const [syncBusy, setSyncBusy] = useState<"from" | "to" | null>(null) const [applyBusy, setApplyBusy] = useState(false)
const [routerCompare, setRouterCompare] = useState<{ const [liveStale, setLiveStale] = useState(false)
serverId: string const [historyOpen, setHistoryOpen] = useState(false)
byCommunity: Record<string, FilterRouterSyncStatus> const [historyLoading, setHistoryLoading] = useState(false)
} | null>(null) const [historyRestoring, setHistoryRestoring] = useState(false)
const [routerCompareLoading, setRouterCompareLoading] = useState(false) const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
useEffect(() => {
setRouterCompare(null)
}, [selectedServerId])
useEffect(() => { useEffect(() => {
if (!isLive) { if (!isLive) {
@@ -1347,6 +1346,7 @@ export default function FiltersPage() {
setRulesets(buildRulesets(servers, serverFilterRulesets)) setRulesets(buildRulesets(servers, serverFilterRulesets))
setSelectedServerId(servers[0]?.id ?? "") setSelectedServerId(servers[0]?.id ?? "")
setLiveLoadState("idle") setLiveLoadState("idle")
setLiveStale(false)
}) })
return return
} }
@@ -1390,18 +1390,43 @@ export default function FiltersPage() {
}) })
}, [isLive, apiFetch]) }, [isLive, apiFetch])
const loadLiveRules = useCallback(async (serverId: string) => {
if (!isLive || !serverId) return
try {
const fresh = await apiFetch<LiveFiltersResponse>(
`/api/filters/rules?serverId=${encodeURIComponent(serverId)}`,
)
const liveRules = fresh.rulesets.find((r) => r.serverId === serverId)?.rules ?? fresh.rulesets[0]?.rules ?? []
setRulesets((prev) => {
const has = prev.some((rs) => rs.serverId === serverId)
if (!has) return [...prev, { serverId, rules: liveRules }]
return prev.map((rs) => rs.serverId === serverId ? { ...rs, rules: liveRules } : rs)
})
setLiveStale(Boolean(fresh.stale))
if (fresh.greTunnels?.length) {
setGreByServer((prev) => ({ ...prev, [serverId]: fresh.greTunnels }))
}
} catch (err) {
setLiveStale(true)
toast.error("Не удалось прочитать правила с роутера", { description: String(err) })
}
}, [isLive, apiFetch])
useEffect(() => { useEffect(() => {
if (!isLive || !selectedServerId) return if (!isLive || !selectedServerId || liveLoadState !== "idle") return
if (!liveServers.some((s) => s.id === selectedServerId)) return
void Promise.all([ void Promise.all([
loadLiveRules(selectedServerId),
ensureGreTunnels(selectedServerId), ensureGreTunnels(selectedServerId),
ensureRecursiveRoutes(selectedServerId), ensureRecursiveRoutes(selectedServerId),
]) ])
}, [isLive, selectedServerId, ensureGreTunnels, ensureRecursiveRoutes]) }, [isLive, selectedServerId, liveLoadState, liveServers, ensureGreTunnels, ensureRecursiveRoutes, loadLiveRules])
const allServers = isLive ? liveServers : servers const allServers = !isLive || liveLoadState === "error" ? servers : liveServers
const allTunnels = isLive ? (greByServer[selectedServerId] ?? []) : greTunnels const allTunnels = !isLive || liveLoadState === "error" ? greTunnels : (greByServer[selectedServerId] ?? [])
const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[0] const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[0]
const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 0) const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 0)
const mutationsLocked = isLive && (applyBusy || liveStale || liveLoadState === "error")
const filterRailItems = useMemo<ServerTileItem[]>(() => ( const filterRailItems = useMemo<ServerTileItem[]>(() => (
allServers.map((s) => ({ allServers.map((s) => ({
@@ -1427,21 +1452,6 @@ export default function FiltersPage() {
[rulesets, selectedServerId], [rulesets, selectedServerId],
) )
const fetchRouterCompare = useCallback(async () => {
if (!isLive || !selectedServerId) return
setRouterCompareLoading(true)
try {
const d = await apiFetch<{ byCommunity: Record<string, FilterRouterSyncStatus> }>(
`/api/filters/router-compare?serverId=${encodeURIComponent(selectedServerId)}`,
)
setRouterCompare({ serverId: selectedServerId, byCommunity: d.byCommunity })
} catch {
setRouterCompare(null)
} finally {
setRouterCompareLoading(false)
}
}, [isLive, selectedServerId, apiFetch])
const filteredRules = useMemo(() => { const filteredRules = useMemo(() => {
const q = search.toLowerCase() const q = search.toLowerCase()
if (!q) return currentRules if (!q) return currentRules
@@ -1453,68 +1463,94 @@ export default function FiltersPage() {
) )
}, [currentRules, search, communityNameMap]) }, [currentRules, search, communityNameMap])
const updateRules = useCallback((serverId: string, updater: (rules: FilterRule[]) => FilterRule[]) => { const applyRules = useCallback(async (
setRouterCompare(rc => (rc && rc.serverId === serverId ? null : rc)) serverId: string,
setRulesets(prev => { nextRules: FilterRule[],
const next = prev.map(rs => source: "apply" | "copy" = "apply",
rs.serverId === serverId ? { ...rs, rules: updater(rs.rules) } : rs ) => {
) const prev = rulesets
if (isLive) { setRulesets((p) => p.map((rs) => rs.serverId === serverId ? { ...rs, rules: nextRules } : rs))
void apiFetch<{ ok: boolean }>("/api/filters/rules", { if (!isLive) return
method: "PUT", if (liveStale) {
body: JSON.stringify({ rulesets: next }), setRulesets(prev)
}).catch(() => {}) toast.error("Роутер недоступен — изменения заблокированы")
} return
return next }
}) setApplyBusy(true)
}, [isLive, apiFetch])
const syncFromRouter = useCallback(async () => {
if (!isLive || syncBusy) return
setSyncBusy("from")
try { try {
await apiFetch<{ ok: boolean }>("/api/filters/sync/from-router", { const res = await apiFetch<{ ok: boolean; rules?: FilterRule[] }>("/api/filters/rules", {
method: "POST", method: "PUT",
body: JSON.stringify({ serverId: selectedServerId }), body: JSON.stringify({ serverId, rules: nextRules, source }),
}) })
const fresh = await apiFetch<LiveFiltersResponse>("/api/filters/rules") if (res.rules) {
setRulesets(buildRulesets(allServers, fresh.rulesets)) setRulesets((p) => p.map((rs) => rs.serverId === serverId ? { ...rs, rules: res.rules ?? nextRules } : rs))
}
toast.success("Правила применены на роутер")
} catch (err) {
setRulesets(prev)
toast.error("Не удалось применить правила на роутер", { description: String(err) })
} finally {
setApplyBusy(false)
}
}, [isLive, apiFetch, rulesets, liveStale])
const refreshFromRouter = useCallback(async () => {
if (!isLive || !selectedServerId || applyBusy) return
setApplyBusy(true)
try {
await loadLiveRules(selectedServerId)
await Promise.all([ await Promise.all([
ensureGreTunnels(selectedServerId), ensureGreTunnels(selectedServerId),
ensureRecursiveRoutes(selectedServerId), ensureRecursiveRoutes(selectedServerId),
]) ])
await fetchRouterCompare()
} finally { } finally {
setSyncBusy(null) setApplyBusy(false)
} }
}, [isLive, syncBusy, apiFetch, allServers, selectedServerId, ensureGreTunnels, ensureRecursiveRoutes, fetchRouterCompare]) }, [isLive, selectedServerId, applyBusy, loadLiveRules, ensureGreTunnels, ensureRecursiveRoutes])
const syncToRouter = useCallback(async () => { const loadRevisions = useCallback(async () => {
if (!isLive || syncBusy || !selectedServerId) return if (!isLive || !selectedServerId) return
setSyncBusy("to") setHistoryLoading(true)
try { try {
const res = await apiFetch<{ const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>(
ok: boolean `/api/filters/revisions?serverId=${encodeURIComponent(selectedServerId)}`,
updatedServers: number )
pushedRules: number setRevisions(res.revisions)
errors?: Array<{ serverId: number; error: string }>
}>("/api/filters/sync/to-router", {
method: "POST",
body: JSON.stringify({ serverId: selectedServerId }),
})
if (res.ok) {
toast.success(`Загружено правил на роутер: ${res.pushedRules}`)
} else {
const detail = res.errors?.[0]?.error ?? "неизвестная ошибка"
toast.error("Не удалось загрузить правила на роутер", { description: detail })
}
await fetchRouterCompare()
} catch (err) { } catch (err) {
toast.error("Не удалось загрузить правила на роутер", { description: String(err) }) toast.error("Не удалось загрузить историю", { description: String(err) })
setRevisions([])
} finally { } finally {
setSyncBusy(null) setHistoryLoading(false)
} }
}, [isLive, syncBusy, selectedServerId, apiFetch, fetchRouterCompare]) }, [isLive, selectedServerId, apiFetch])
const restoreRevision = useCallback(async (id: string) => {
if (!isLive || !selectedServerId) return
setHistoryRestoring(true)
try {
const res = await apiFetch<{ ok: boolean; rules?: FilterRule[] }>(
`/api/filters/revisions/${encodeURIComponent(id)}/restore`,
{ method: "POST", body: JSON.stringify({ serverId: selectedServerId }) },
)
if (res.rules) {
setRulesets((p) => p.map((rs) => rs.serverId === selectedServerId ? { ...rs, rules: res.rules ?? [] } : rs))
} else {
await loadLiveRules(selectedServerId)
}
setLiveStale(false)
toast.success("Версия применена на роутер")
await loadRevisions()
} catch (err) {
toast.error("Не удалось откатить", { description: String(err) })
} finally {
setHistoryRestoring(false)
}
}, [isLive, selectedServerId, apiFetch, loadLiveRules, loadRevisions])
const updateRules = useCallback((serverId: string, updater: (rules: FilterRule[]) => FilterRule[]) => {
const current = rulesets.find((rs) => rs.serverId === serverId)?.rules ?? []
void applyRules(serverId, updater(current))
}, [rulesets, applyRules])
const openCreate = () => { const openCreate = () => {
setSheetInitial(emptyForm()); setSheetMode("create"); setEditingId(null); setSheetOpen(true) setSheetInitial(emptyForm()); setSheetMode("create"); setEditingId(null); setSheetOpen(true)
@@ -1532,6 +1568,7 @@ export default function FiltersPage() {
} }
const handleSave = (form: RuleForm) => { const handleSave = (form: RuleForm) => {
if (mutationsLocked) return
const { gatewayKind: _gk, ...payload } = form const { gatewayKind: _gk, ...payload } = form
if (sheetMode === "create") { if (sheetMode === "create") {
updateRules(selectedServerId, rules => [ updateRules(selectedServerId, rules => [
@@ -1549,34 +1586,30 @@ export default function FiltersPage() {
setSheetOpen(false) setSheetOpen(false)
} }
const handleDelete = (id: string) => updateRules(selectedServerId, rules => rules.filter(r => r.id !== id)) const handleDelete = (id: string) => {
if (mutationsLocked) return
updateRules(selectedServerId, rules => rules.filter(r => r.id !== id))
}
const handleCopyRules = useCallback((targetServerId: string, rules: FilterRule[], mode: CopyMode) => { const handleCopyRules = useCallback((targetServerId: string, rules: FilterRule[], mode: CopyMode) => {
updateRules(targetServerId, existing => const existing = rulesets.find((rs) => rs.serverId === targetServerId)?.rules ?? []
mode === "replace" ? rules : [...existing, ...rules] const next = mode === "replace" ? rules : [...existing, ...rules]
) void applyRules(targetServerId, next, "copy")
}, [updateRules]) }, [rulesets, applyRules])
const handleMoveUp = (index: number) => { const handleMoveUp = (index: number) => {
if (index === 0) return if (mutationsLocked || index === 0) return
updateRules(selectedServerId, rules => { updateRules(selectedServerId, rules => {
const n = [...rules]; [n[index - 1], n[index]] = [n[index], n[index - 1]]; return n const n = [...rules]; [n[index - 1], n[index]] = [n[index], n[index - 1]]; return n
}) })
} }
const handleMoveDown = (index: number) => { const handleMoveDown = (index: number) => {
if (mutationsLocked) return
updateRules(selectedServerId, rules => { updateRules(selectedServerId, rules => {
if (index >= rules.length - 1) return rules if (index >= rules.length - 1) return rules
const n = [...rules]; [n[index], n[index + 1]] = [n[index + 1], n[index]]; return n const n = [...rules]; [n[index], n[index + 1]] = [n[index + 1], n[index]]; return n
}) })
} }
if (!selectedServer) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Нет доступных серверов
</div>
)
}
if (isLive && liveLoadState === "loading") { if (isLive && liveLoadState === "loading") {
return ( return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm text-muted-foreground"> <div className="flex h-full flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
@@ -1586,6 +1619,14 @@ export default function FiltersPage() {
) )
} }
if (!selectedServer) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Нет доступных серверов
</div>
)
}
return ( return (
<> <>
<ServerRailLayout <ServerRailLayout
@@ -1604,35 +1645,25 @@ export default function FiltersPage() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={syncFromRouter} onClick={() => void refreshFromRouter()}
disabled={syncBusy !== null} disabled={applyBusy}
title="Синхронизация Router → БД" title="Прочитать актуальные правила с роутера"
> >
{syncBusy === "from" ? "Синк Router → DB…" : "Router → DB"} <RefreshCwIcon className={cn("size-4", applyBusy && "animate-spin")} />
Обновить
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={syncToRouter} onClick={() => {
disabled={syncBusy !== null} setHistoryOpen(true)
title="Синхронизация БД → Router" void loadRevisions()
}}
disabled={applyBusy}
title="История версий и откат на CHR"
> >
{syncBusy === "to" ? "Синк DB → Router…" : "DB → Router"} <HistoryIcon className="size-4" />
</Button> История
<Button
variant="outline"
size="sm"
onClick={() => void fetchRouterCompare()}
disabled={syncBusy !== null || routerCompareLoading}
title="Сравнить правила в БД с цепочкой bgp-in на MikroTik"
className="gap-1.5"
>
{routerCompareLoading ? (
<LoaderCircleIcon className="size-4 animate-spin" />
) : (
<RefreshCwIcon className="size-4" />
)}
Сверить
</Button> </Button>
</> </>
)} )}
@@ -1642,12 +1673,12 @@ export default function FiltersPage() {
<Button <Button
variant="outline" size="sm" variant="outline" size="sm"
onClick={() => setCopyOpen(true)} onClick={() => setCopyOpen(true)}
disabled={currentRules.length === 0} disabled={currentRules.length === 0 || mutationsLocked}
title="Копировать правила на другой сервер" title="Копировать правила на другой сервер"
> >
<CopyIcon className="size-4" />Копировать <CopyIcon className="size-4" />Копировать
</Button> </Button>
<Button size="sm" onClick={openCreate}> <Button size="sm" onClick={openCreate} disabled={mutationsLocked}>
<PlusIcon className="size-4" />Новое правило <PlusIcon className="size-4" />Новое правило
</Button> </Button>
</> </>
@@ -1662,6 +1693,12 @@ export default function FiltersPage() {
Бекенд недоступен показаны демо-данные из lib/data. Проверьте URL бекенда в настройках. Бекенд недоступен показаны демо-данные из lib/data. Проверьте URL бекенда в настройках.
</div> </div>
)} )}
{isLive && liveStale && liveLoadState !== "error" && (
<div className="shrink-0 border-b border-amber-500/30 bg-amber-500/10 px-6 py-2.5 text-xs text-amber-700 dark:text-amber-400 flex items-center gap-2">
<AlertTriangleIcon className="size-3.5 shrink-0" />
Роутер недоступен показан кэш. Изменения заблокированы, пока не удастся прочитать CHR.
</div>
)}
<div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6"> <div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6">
<div className="relative min-w-[200px] max-w-xs flex-1"> <div className="relative min-w-[200px] max-w-xs flex-1">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" /> <SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
@@ -1745,15 +1782,7 @@ export default function FiltersPage() {
)}>{selectedServer.latency}мс</span> )}>{selectedServer.latency}мс</span>
)} )}
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground flex-wrap justify-end"> <div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground flex-wrap justify-end">
{isLive && routerCompare?.serverId === selectedServerId && currentRules.length > 0 && ( {applyBusy && <span>Применение на роутер</span>}
<span className="font-mono tabular-nums">
роутер:{" "}
<span className="text-emerald-600 dark:text-emerald-500">
{Object.values(routerCompare.byCommunity).filter(s => s === "synced").length}
</span>
/{currentRules.length} совпало
</span>
)}
<span>{currentRules.length} правил</span> <span>{currentRules.length} правил</span>
</div> </div>
</div> </div>
@@ -1790,12 +1819,6 @@ export default function FiltersPage() {
serversList={allServers} serversList={allServers}
communityNameMap={communityNameMap} communityNameMap={communityNameMap}
recursiveRoutes={recRoutesByServer[selectedServerId] ?? []} recursiveRoutes={recRoutesByServer[selectedServerId] ?? []}
routerSyncByCommunity={
!isLive || !routerCompare || routerCompare.serverId !== selectedServerId
? null
: routerCompare.byCommunity
}
isLive={isLive}
enableSorting={!!search} enableSorting={!!search}
onEdit={openEdit} onEdit={openEdit}
onDelete={handleDelete} onDelete={handleDelete}
@@ -1805,7 +1828,7 @@ export default function FiltersPage() {
)} )}
{/* add rule shortcut */} {/* add rule shortcut */}
<button onClick={openCreate} <button onClick={openCreate} disabled={mutationsLocked}
className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t"> className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
<PlusIcon className="size-3.5" /> <PlusIcon className="size-3.5" />
Добавить правило для {selectedServer.name} Добавить правило для {selectedServer.name}
@@ -1854,6 +1877,17 @@ export default function FiltersPage() {
recRoutesByServer={recRoutesByServer} recRoutesByServer={recRoutesByServer}
ensureRecursiveFor={ensureRecursiveRoutes} ensureRecursiveFor={ensureRecursiveRoutes}
/> />
<ConfigHistorySheet
open={historyOpen}
onOpenChange={setHistoryOpen}
title="История фильтров"
itemLabel="правил"
revisions={revisions}
loading={historyLoading}
restoring={historyRestoring}
onRestore={restoreRevision}
/>
</> </>
) )
} }
+67 -1
View File
@@ -49,12 +49,14 @@ import {
PowerIcon, CheckCircleIcon, PowerIcon, CheckCircleIcon,
PlayIcon, SquareIcon, RotateCcwIcon, ZapIcon, PlayIcon, SquareIcon, RotateCcwIcon, ZapIcon,
CheckCircle2Icon, XCircleIcon, MinusCircleIcon, SkipForwardIcon, CheckCircle2Icon, XCircleIcon, MinusCircleIcon, SkipForwardIcon,
SlidersHorizontalIcon, RefreshCwIcon, SlidersHorizontalIcon, RefreshCwIcon, HistoryIcon,
} from "lucide-react" } from "lucide-react"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail" import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
import { toast } from "sonner" import { toast } from "sonner"
import { ConfigHistorySheet } from "@/components/config-history-sheet"
import type { ConfigRevisionDto } from "@/lib/config-revisions"
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -1947,6 +1949,10 @@ function FirewallPageInner() {
const [exportOpen, setExportOpen] = useState(false) const [exportOpen, setExportOpen] = useState(false)
const [editingAddr, setEditingAddr] = useState<Partial<AddressListEntry> | null>(null) const [editingAddr, setEditingAddr] = useState<Partial<AddressListEntry> | null>(null)
const [addrSheetOpen, setAddrSheetOpen] = useState(false) const [addrSheetOpen, setAddrSheetOpen] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false)
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
const [historyLoading, setHistoryLoading] = useState(false)
const [historyRestoring, setHistoryRestoring] = useState(false)
const loadLive = useCallback(async () => { const loadLive = useCallback(async () => {
if (!isLive) return if (!isLive) return
@@ -1970,6 +1976,42 @@ function FirewallPageInner() {
} }
}, [isLive, apiFetch]) }, [isLive, apiFetch])
const historyServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId
const loadRevisions = useCallback(async () => {
if (!isLive || !historyServerId) return
setHistoryLoading(true)
try {
const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>(
`/api/firewall/revisions?serverId=${encodeURIComponent(historyServerId)}`,
)
setRevisions(res.revisions)
} catch (err) {
toast.error("Не удалось загрузить историю", { description: String(err) })
setRevisions([])
} finally {
setHistoryLoading(false)
}
}, [isLive, historyServerId, apiFetch])
const restoreRevision = useCallback(async (id: string) => {
if (!isLive || !historyServerId) return
setHistoryRestoring(true)
try {
await apiFetch(
`/api/firewall/revisions/${encodeURIComponent(id)}/restore`,
{ method: "POST", body: JSON.stringify({ serverId: historyServerId }) },
)
toast.success("Версия применена на роутер")
await loadLive()
await loadRevisions()
} catch (err) {
toast.error("Не удалось откатить", { description: String(err) })
} finally {
setHistoryRestoring(false)
}
}, [isLive, historyServerId, apiFetch, loadLive, loadRevisions])
useEffect(() => { useEffect(() => {
if (!isLive) { if (!isLive) {
queueMicrotask(() => { queueMicrotask(() => {
@@ -2373,6 +2415,19 @@ function FirewallPageInner() {
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} /> <RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
Обновить Обновить
</Button> </Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setHistoryOpen(true)
void loadRevisions()
}}
disabled={!isLive || !historyServerId || dataLoading}
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат на CHR"}
>
<HistoryIcon className="size-4" />
История
</Button>
<Button variant="outline" size="sm" onClick={() => setExportOpen(true)}> <Button variant="outline" size="sm" onClick={() => setExportOpen(true)}>
<CodeXmlIcon className="size-4" />Экспорт .rsc <CodeXmlIcon className="size-4" />Экспорт .rsc
</Button> </Button>
@@ -2586,6 +2641,17 @@ function FirewallPageInner() {
onClose={() => setExportOpen(false)} onClose={() => setExportOpen(false)}
rules={familyRules} rules={familyRules}
/> />
<ConfigHistorySheet
open={historyOpen}
onOpenChange={setHistoryOpen}
title="История Firewall"
itemLabel="объектов"
revisions={revisions}
loading={historyLoading}
restoring={historyRestoring}
onRestore={restoreRevision}
/>
</> </>
) )
} }
+289 -58
View File
@@ -13,11 +13,23 @@ import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client" import { requestJson } from "@/shared/api/http-client"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { toast } from "sonner" import { toast } from "sonner"
import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { ConfigHistorySheet } from "@/components/config-history-sheet"
import type { ConfigRevisionDto } from "@/lib/config-revisions"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { import {
Sheet, SheetContent, SheetHeader, SheetTitle, Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose, SheetDescription, SheetFooter, SheetClose,
@@ -31,7 +43,7 @@ import {
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon, LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon, EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon,
DatabaseIcon, DatabaseIcon, HistoryIcon, TriangleAlertIcon,
} from "lucide-react" } from "lucide-react"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
@@ -56,6 +68,10 @@ const STATUS_MAP: Record<GreStatus, { label: string; dot: string }> = {
down: { label: "Down", dot: "bg-red-500" }, down: { label: "Down", dot: "bg-red-500" },
} }
function greStatusMeta(status: GreStatus | undefined) {
return STATUS_MAP[status ?? "degraded"] ?? STATUS_MAP.degraded
}
// ─── RouterOS code generator ───────────────────────────────────────────────── // ─── RouterOS code generator ─────────────────────────────────────────────────
function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>): string { function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>): string {
@@ -91,10 +107,10 @@ function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>):
lines.push(` address=${t.localInnerIp} \\`) lines.push(` address=${t.localInnerIp} \\`)
lines.push(` interface=${t.name}`) lines.push(` interface=${t.name}`)
// IPsec manual equivalent // IPsec: live CHR имеет только ipsec-secret; proposal — у моков/формы
if (t.ipsec) { if (t.ipsec?.encAlg && t.ipsec.authAlg) {
const ikeMode = t.ipsec.ikeVersion === "ikev2" ? "ike2" : "ike1" const ikeMode = t.ipsec.ikeVersion === "ikev1" ? "ike1" : "ike2"
const pfsGroup = t.ipsec.pfs ? t.ipsec.dhGroup : "none" const pfsGroup = t.ipsec.pfs ? (t.ipsec.dhGroup ?? "none") : "none"
lines.push("") lines.push("")
lines.push("# ── IPsec (авто через ipsec-secret; ручной эквивалент) ───────") lines.push("# ── IPsec (авто через ipsec-secret; ручной эквивалент) ───────")
@@ -111,13 +127,16 @@ function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>):
lines.push(` enc-algorithms=${ENC_ROS[t.ipsec.encAlg]} \\`) lines.push(` enc-algorithms=${ENC_ROS[t.ipsec.encAlg]} \\`)
lines.push(` auth-algorithms=${AUTH_ROS[t.ipsec.authAlg]} \\`) lines.push(` auth-algorithms=${AUTH_ROS[t.ipsec.authAlg]} \\`)
lines.push(` pfs-group=${pfsGroup} \\`) lines.push(` pfs-group=${pfsGroup} \\`)
lines.push(` lifetime=${t.ipsec.lifetime}`) lines.push(` lifetime=${t.ipsec.lifetime ?? "1d"}`)
lines.push("") lines.push("")
lines.push(`/ip ipsec policy add \\`) lines.push(`/ip ipsec policy add \\`)
lines.push(` src-address=${t.localAddress !== "0.0.0.0" ? t.localAddress + "/32" : "0.0.0.0/0"} \\`) lines.push(` src-address=${t.localAddress !== "0.0.0.0" ? t.localAddress + "/32" : "0.0.0.0/0"} \\`)
lines.push(` dst-address=${t.remoteAddress}/32 \\`) lines.push(` dst-address=${t.remoteAddress}/32 \\`)
lines.push(` proposal=${t.name} \\`) lines.push(` proposal=${t.name} \\`)
lines.push(` tunnel=yes`) lines.push(` tunnel=yes`)
} else if (t.ipsec) {
lines.push("")
lines.push("# IPsec: peer/policy создаёт RouterOS по ipsec-secret")
} }
return lines.join("\n") return lines.join("\n")
@@ -126,7 +145,7 @@ function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>):
// ─── small ui helpers ──────────────────────────────────────────────────────── // ─── small ui helpers ────────────────────────────────────────────────────────
function TunnelStatus({ status }: { status: GreStatus }) { function TunnelStatus({ status }: { status: GreStatus }) {
const s = STATUS_MAP[status] const s = greStatusMeta(status)
return ( return (
<span className="inline-flex items-center gap-1.5 text-sm"> <span className="inline-flex items-center gap-1.5 text-sm">
<span className={`size-1.5 rounded-full ${s.dot}`} /> <span className={`size-1.5 rounded-full ${s.dot}`} />
@@ -164,6 +183,7 @@ interface BackendServer {
interface GreTunnelsApiResponse { interface GreTunnelsApiResponse {
tunnels: GreTunnel[] tunnels: GreTunnel[]
failures?: Array<{ serverId: string; serverName?: string; error: string }>
} }
function makeApiFetch(backendUrl: string) { function makeApiFetch(backendUrl: string) {
@@ -237,7 +257,6 @@ export default function GrePage() {
const [liveTunnels, setLiveTunnels] = useState<GreTunnel[]>([]) const [liveTunnels, setLiveTunnels] = useState<GreTunnel[]>([])
const [dataLoading, setDataLoading] = useState(false) const [dataLoading, setDataLoading] = useState(false)
const [dataError, setDataError] = useState<string | null>(null) const [dataError, setDataError] = useState<string | null>(null)
const [syncJhBusy, setSyncJhBusy] = useState(false)
const [pageTab, setPageTab] = useState<PageTab>("tunnels") const [pageTab, setPageTab] = useState<PageTab>("tunnels")
const [tabFilter, setTabFilter] = useState<TabFilter>("all") const [tabFilter, setTabFilter] = useState<TabFilter>("all")
@@ -245,6 +264,15 @@ export default function GrePage() {
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID) const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const [tunnelOpen, setTunnelOpen] = useState(false) const [tunnelOpen, setTunnelOpen] = useState(false)
const [tunnelMode, setTunnelMode] = useState<"create" | "edit">("create")
const [editingTunnel, setEditingTunnel] = useState<GreTunnel | null>(null)
const [pendingDelete, setPendingDelete] = useState<GreTunnel | null>(null)
const [mutateBusy, setMutateBusy] = useState(false)
const [liveStale, setLiveStale] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false)
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
const [historyLoading, setHistoryLoading] = useState(false)
const [historyRestoring, setHistoryRestoring] = useState(false)
const [poolOpen, setPoolOpen] = useState(false) const [poolOpen, setPoolOpen] = useState(false)
const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null) const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null)
@@ -261,14 +289,19 @@ export default function GrePage() {
try { try {
const [backendServers, greRes] = await Promise.all([ const [backendServers, greRes] = await Promise.all([
apiFetch<BackendServer[]>("/api/servers"), apiFetch<BackendServer[]>("/api/servers"),
apiFetch<GreTunnelsApiResponse>("/api/filters/gre-tunnels"), apiFetch<GreTunnelsApiResponse>("/api/gre/tunnels"),
]) ])
setLiveServers(backendServers.map(mapBackendToServer)) setLiveServers(backendServers.map(mapBackendToServer))
setLiveTunnels(greRes.tunnels) setLiveTunnels(greRes.tunnels)
setLiveStale(false)
if (greRes.failures?.length) {
toast.warning(
`Не удалось опросить: ${greRes.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`,
)
}
} catch (e) { } catch (e) {
setDataError(e instanceof Error ? e.message : "Ошибка загрузки") setDataError(e instanceof Error ? e.message : "Ошибка загрузки")
setLiveServers([]) setLiveStale(true)
setLiveTunnels([])
} finally { } finally {
setDataLoading(false) setDataLoading(false)
} }
@@ -280,6 +313,7 @@ export default function GrePage() {
setLiveServers([]) setLiveServers([])
setLiveTunnels([]) setLiveTunnels([])
setDataError(null) setDataError(null)
setLiveStale(false)
}) })
return return
} }
@@ -323,39 +357,186 @@ export default function GrePage() {
[displayPools], [displayPools],
) )
const syncJhToDb = useCallback(async () => { const historyServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId
if (!isLive || syncJhBusy) return const mutationsLocked = isLive && (mutateBusy || liveStale || historyRestoring)
const jh = displayServers.filter((s) => s.type === "jump-host" && s.enabled)
if (jh.length === 0) { const loadRevisions = useCallback(async () => {
toast.info("Нет включённых Jump Host в списке серверов") if (!isLive || !historyServerId) return
setHistoryLoading(true)
try {
const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>(
`/api/gre/revisions?serverId=${encodeURIComponent(historyServerId)}`,
)
setRevisions(res.revisions)
} catch (err) {
toast.error("Не удалось загрузить историю", { description: String(err) })
setRevisions([])
} finally {
setHistoryLoading(false)
}
}, [isLive, historyServerId, apiFetch])
const restoreRevision = useCallback(async (id: string) => {
if (!isLive || !historyServerId) return
setHistoryRestoring(true)
try {
await apiFetch(
`/api/gre/revisions/${encodeURIComponent(id)}/restore`,
{ method: "POST", body: JSON.stringify({ serverId: historyServerId }) },
)
toast.success("Версия применена на роутер")
await loadLive()
await loadRevisions()
} catch (err) {
toast.error("Не удалось откатить", { description: String(err) })
} finally {
setHistoryRestoring(false)
}
}, [isLive, historyServerId, apiFetch, loadLive, loadRevisions])
function tunnelWriteBody(form: typeof defaultTunnelForm) {
return {
serverId: form.serverId,
name: form.name.trim(),
localAddress: form.localAddress.trim() || undefined,
remoteAddress: form.remoteAddress.trim(),
localInnerIp: form.localInnerIp.trim() || undefined,
remoteInnerIp: form.remoteInnerIp.trim() || undefined,
comment: form.comment || undefined,
enabled: form.enabled,
mtu: form.mtu,
keepaliveInterval: form.keepaliveInterval,
keepaliveRetries: form.keepaliveRetries,
dscp: form.dscp,
clampTcpMss: form.clampTcpMss,
allowFastPath: form.allowFastPath,
ipsecSecret: form.ipsecEnabled ? form.ipsecSecret : undefined,
}
}
async function submitTunnel() {
if (!isLive) {
toast.info("Создание на роутер доступно только в live-режиме")
return return
} }
setSyncJhBusy(true) if (liveStale) {
const errors: string[] = [] toast.error("Роутер недоступен — изменения заблокированы")
try { return
for (const s of jh) {
try {
await apiFetch<{ ok: boolean }>("/api/filters/sync/from-router", {
method: "POST",
body: JSON.stringify({ serverId: s.id }),
})
} catch (e) {
errors.push(`${s.name}: ${e instanceof Error ? e.message : "ошибка"}`)
}
}
const fresh = await apiFetch<GreTunnelsApiResponse>("/api/filters/gre-tunnels")
setLiveTunnels(fresh.tunnels)
if (errors.length) {
toast.warning(`Синхронизировано JH: ${jh.length - errors.length}/${jh.length}. Ошибки: ${errors.join("; ")}`)
} else {
toast.success(`Правила с ${jh.length} JH записаны в БД, список GRE обновлён.`)
}
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка после синхронизации")
} finally {
setSyncJhBusy(false)
} }
}, [isLive, syncJhBusy, apiFetch, displayServers]) if (!tForm.name.trim() || !tForm.serverId || !tForm.remoteAddress.trim()) {
toast.error("Заполните имя, сервер и удалённый адрес")
return
}
if (tForm.ipsecEnabled && tForm.ipsecSecret.trim().length < 8) {
toast.error("Для IPsec нужен PSK не короче 8 символов")
return
}
setMutateBusy(true)
try {
if (tunnelMode === "edit" && editingTunnel) {
await apiFetch("/api/gre/tunnels", {
method: "PATCH",
body: JSON.stringify({
...tunnelWriteBody(tForm),
rosId: editingTunnel.id,
name: editingTunnel.name,
}),
})
toast.success(`Туннель ${tForm.name} обновлён`)
} else {
await apiFetch("/api/gre/tunnels", {
method: "POST",
body: JSON.stringify(tunnelWriteBody(tForm)),
})
toast.success(`Туннель ${tForm.name} создан`)
}
setTunnelOpen(false)
setEditingTunnel(null)
await loadLive()
} catch (err) {
toast.error("Не удалось сохранить туннель", { description: String(err) })
} finally {
setMutateBusy(false)
}
}
async function toggleTunnel(t: GreTunnel) {
if (!isLive || mutationsLocked) return
setMutateBusy(true)
try {
await apiFetch("/api/gre/tunnels", {
method: "PATCH",
body: JSON.stringify({
serverId: t.serverId,
rosId: t.id,
name: t.name,
enabled: !t.enabled,
remoteAddress: t.remoteAddress,
}),
})
toast.success(t.enabled ? `Выключен ${t.name}` : `Включён ${t.name}`)
await loadLive()
} catch (err) {
toast.error("Не удалось изменить туннель", { description: String(err) })
} finally {
setMutateBusy(false)
}
}
async function confirmDeleteTunnel() {
const t = pendingDelete
if (!t || !isLive) return
setMutateBusy(true)
try {
await apiFetch("/api/gre/tunnels", {
method: "DELETE",
body: JSON.stringify({ serverId: t.serverId, rosId: t.id, name: t.name }),
})
toast.success(`Удалён ${t.name}`)
setPendingDelete(null)
await loadLive()
} catch (err) {
toast.error("Не удалось удалить туннель", { description: String(err) })
} finally {
setMutateBusy(false)
}
}
function openCreateTunnel() {
setTunnelMode("create")
setEditingTunnel(null)
setTForm({
...defaultTunnelForm,
serverId: selectedServerId === ALL_SERVERS_ID ? "" : selectedServerId,
})
setTunnelOpen(true)
}
function openEditTunnel(t: GreTunnel) {
setTunnelMode("edit")
setEditingTunnel(t)
setTForm({
...defaultTunnelForm,
name: t.name,
serverId: t.serverId,
localAddress: t.localAddress === "0.0.0.0" ? "" : t.localAddress,
remoteAddress: t.remoteAddress,
poolId: t.poolId === "live" ? "" : t.poolId,
localInnerIp: t.localInnerIp,
remoteInnerIp: t.remoteInnerIp,
comment: t.comment,
enabled: t.enabled,
ipsecEnabled: !!t.ipsec,
ipsecSecret: t.ipsec?.secret ?? "",
mtu: t.mtu,
keepaliveInterval: t.keepaliveInterval,
keepaliveRetries: t.keepaliveRetries,
dscp: String(t.dscp),
clampTcpMss: t.clampTcpMss,
allowFastPath: t.allowFastPath,
})
setTunnelOpen(true)
}
useEffect(() => { useEffect(() => {
if (dataError) toast.error(dataError) if (dataError) toast.error(dataError)
@@ -403,6 +584,14 @@ export default function GrePage() {
showAll showAll
allCount={displayServers.length} allCount={displayServers.length}
loading={isLive && dataLoading && displayServers.length === 0} loading={isLive && dataLoading && displayServers.length === 0}
banner={
isLive && liveStale ? (
<div className="shrink-0 border-b border-amber-500/30 bg-amber-500/10 px-6 py-2.5 text-xs text-amber-700 dark:text-amber-400 flex items-center gap-2">
<TriangleAlertIcon className="size-3.5 shrink-0" />
Роутер недоступен показан кэш. Изменения заблокированы, пока не удастся прочитать CHR.
</div>
) : null
}
header={ header={
<PageHeader <PageHeader
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]} crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
@@ -422,14 +611,17 @@ export default function GrePage() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { void syncJhToDb() }} onClick={() => {
disabled={!isLive || syncJhBusy || dataLoading} setHistoryOpen(true)
title="Загрузить правила фильтрации с каждого Jump Host в БД и обновить опрос GRE" void loadRevisions()
}}
disabled={!isLive || !historyServerId || dataLoading}
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат на CHR"}
> >
<DatabaseIcon className={cn("size-4", syncJhBusy && "animate-pulse")} /> <HistoryIcon className="size-4" />
JH БД История
</Button> </Button>
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}> <Button size="sm" onClick={openCreateTunnel} disabled={mutateBusy}>
<PlusIcon className="size-4" />Добавить туннель <PlusIcon className="size-4" />Добавить туннель
</Button> </Button>
</> </>
@@ -521,6 +713,10 @@ export default function GrePage() {
servers={displayServers} servers={displayServers}
pools={displayPools} pools={displayPools}
onCodePreview={setCodePreviewTunnel} onCodePreview={setCodePreviewTunnel}
onEdit={openEditTunnel}
onToggle={(t) => { void toggleTunnel(t) }}
onDelete={setPendingDelete}
mutationsLocked={mutationsLocked}
/> />
</DataPageCard> </DataPageCard>
)} )}
@@ -548,7 +744,7 @@ export default function GrePage() {
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{poolTunnels.map((t, tunnelIndex) => ( {poolTunnels.map((t, tunnelIndex) => (
<div key={`${t.id}:${t.serverId}:${t.name}:${tunnelIndex}`} className="flex items-center gap-2 border border-border rounded-md px-3 py-1.5 bg-muted/30 text-xs"> <div key={`${t.id}:${t.serverId}:${t.name}:${tunnelIndex}`} className="flex items-center gap-2 border border-border rounded-md px-3 py-1.5 bg-muted/30 text-xs">
<span className={`size-1.5 rounded-full ${STATUS_MAP[t.status].dot}`} /> <span className={`size-1.5 rounded-full ${greStatusMeta(t.status).dot}`} />
<span className="font-mono font-medium">{t.name}</span> <span className="font-mono font-medium">{t.name}</span>
<span className="text-muted-foreground">{t.localInnerIp} {t.remoteInnerIp}</span> <span className="text-muted-foreground">{t.localInnerIp} {t.remoteInnerIp}</span>
{t.ipsec && <LockIcon className="size-3 text-emerald-400" />} {t.ipsec && <LockIcon className="size-3 text-emerald-400" />}
@@ -609,8 +805,8 @@ export default function GrePage() {
codePreviewTunnel ? ( codePreviewTunnel ? (
<div className="flex flex-wrap gap-3 text-xs shrink-0"> <div className="flex flex-wrap gap-3 text-xs shrink-0">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} /> <span className={`size-1.5 rounded-full ${greStatusMeta(codePreviewTunnel.status).dot}`} />
{STATUS_MAP[codePreviewTunnel.status].label} {greStatusMeta(codePreviewTunnel.status).label}
</span> </span>
<span className="text-muted-foreground">·</span> <span className="text-muted-foreground">·</span>
<span>{serverById[codePreviewTunnel.serverId]?.name}</span> <span>{serverById[codePreviewTunnel.serverId]?.name}</span>
@@ -625,7 +821,7 @@ export default function GrePage() {
<span className="text-muted-foreground">·</span> <span className="text-muted-foreground">·</span>
<span className="flex items-center gap-1 text-success"> <span className="flex items-center gap-1 text-success">
<LockIcon className="size-3" /> <LockIcon className="size-3" />
IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]} IPsec {codePreviewTunnel.ipsec.ikeVersion ? IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion] : "PSK"}
</span> </span>
</> </>
) : null} ) : null}
@@ -638,18 +834,18 @@ export default function GrePage() {
<Sheet open={tunnelOpen} onOpenChange={setTunnelOpen}> <Sheet open={tunnelOpen} onOpenChange={setTunnelOpen}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0"> <SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0"> <SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Новый GRE-туннель</SheetTitle> <SheetTitle>{tunnelMode === "edit" ? "Редактировать GRE-туннель" : "Новый GRE-туннель"}</SheetTitle>
<SheetDescription>RouterOS 7.20+ · /interface gre add</SheetDescription> <SheetDescription>RouterOS 7.20+ · /interface gre {tunnelMode === "edit" ? "set" : "add"}</SheetDescription>
</SheetHeader> </SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5"> <div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle> <SectionTitle>Основные</SectionTitle>
<FormField label="Имя интерфейса" required hint="Только латиница, цифры и дефис, например gre-msk-spb"> <FormField label="Имя интерфейса" required hint="Только латиница, цифры и дефис, например gre-msk-spb">
<Input className="font-mono" placeholder="gre-msk-spb" value={tForm.name} onChange={(e) => setT("name", e.target.value)} /> <Input className="font-mono" placeholder="gre-msk-spb" value={tForm.name} disabled={tunnelMode === "edit"} onChange={(e) => setT("name", e.target.value)} />
</FormField> </FormField>
<FormField label="Сервер (MikroTik)" required> <FormField label="Сервер (MikroTik)" required>
<select value={tForm.serverId} onChange={(e) => setT("serverId", e.target.value)} <select value={tForm.serverId} onChange={(e) => setT("serverId", e.target.value)} disabled={tunnelMode === "edit"}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"> className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
<option value="" disabled>Выбрать сервер</option> <option value="" disabled>Выбрать сервер</option>
{displayServers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)} {displayServers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)}
@@ -676,7 +872,7 @@ export default function GrePage() {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<SectionTitle>Внутренний IP</SectionTitle> <SectionTitle>Внутренний IP</SectionTitle>
<FormField label="IP-пул" required hint="Из какого пула выделяется /30-блок"> <FormField label="IP-пул" hint="Необязательно — внутренний IP можно указать вручную">
<select value={tForm.poolId} onChange={(e) => setT("poolId", e.target.value)} <select value={tForm.poolId} onChange={(e) => setT("poolId", e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"> className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
<option value="" disabled>Выбрать пул</option> <option value="" disabled>Выбрать пул</option>
@@ -798,7 +994,9 @@ export default function GrePage() {
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2"> <SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose> <SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
<Button className="flex-1" onClick={() => setTunnelOpen(false)}>Создать туннель</Button> <Button className="flex-1" onClick={() => void submitTunnel()} disabled={mutateBusy}>
{tunnelMode === "edit" ? "Сохранить" : "Создать туннель"}
</Button>
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
@@ -845,6 +1043,39 @@ export default function GrePage() {
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
<ConfigHistorySheet
open={historyOpen}
onOpenChange={setHistoryOpen}
title="История GRE"
itemLabel="туннелей"
revisions={revisions}
loading={historyLoading}
restoring={historyRestoring}
onRestore={restoreRevision}
/>
<AlertDialog open={!!pendingDelete} onOpenChange={(v) => { if (!v) setPendingDelete(null) }}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<Trash2Icon />
</AlertDialogMedia>
<AlertDialogTitle>Удалить GRE-туннель?</AlertDialogTitle>
<AlertDialogDescription>
{pendingDelete
? `${pendingDelete.name} на сервере ${serverById[pendingDelete.serverId]?.name ?? pendingDelete.serverId}. Будут удалены интерфейс и связанный /ip/address.`
: null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setPendingDelete(null)}>Отмена</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={() => void confirmDeleteTunnel()}>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</> </>
) )
} }
File diff suppressed because it is too large Load Diff
+29 -3
View File
@@ -122,11 +122,17 @@ interface BackendBfdSession {
packetsRx: number; packetsTx: number; stateChanges: number packetsRx: number; packetsTx: number; stateChanges: number
} }
interface BackendOspfRoute {
id: string; serverId: number; serverName: string; serverSite: string
destination: string; type: OspfRoute["type"]; cost: number; nextHop: string; via: string; area: string
}
interface BackendOspfAll { interface BackendOspfAll {
neighbors: BackendNeighbor[] neighbors: BackendNeighbor[]
interfaces: BackendInterface[] interfaces: BackendInterface[]
instances: BackendInstance[] instances: BackendInstance[]
bfdSessions: BackendBfdSession[] bfdSessions: BackendBfdSession[]
routes?: BackendOspfRoute[]
} }
function isRefInterfaceName(name: string): boolean { function isRefInterfaceName(name: string): boolean {
@@ -213,6 +219,22 @@ function backendToBfdSession(b: BackendBfdSession): BfdSession {
} }
} }
function backendToRoute(b: BackendOspfRoute): OspfRoute {
const allowed: OspfRoute["type"][] = ["O", "O IA", "O E1", "O E2"]
const type = allowed.includes(b.type) ? b.type : "O"
return {
id: `${b.serverId}-${b.id}`,
destination: b.destination,
type,
cost: b.cost,
nextHop: b.nextHop,
via: b.via,
serverId: String(b.serverId),
serverLabel: b.serverName,
area: b.area || "—",
}
}
// ─── mock data ──────────────────────────────────────────────────────────────── // ─── mock data ────────────────────────────────────────────────────────────────
const COST_STEP = 10 const COST_STEP = 10
@@ -1172,7 +1194,7 @@ export default function OspfPage() {
}, [isLive, backendUrl, fetchTick]) }, [isLive, backendUrl, fetchTick])
// Derive frontend types from backend data or use mocks // Derive frontend types from backend data or use mocks
const { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions } = useMemo(() => { const { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions, routes } = useMemo(() => {
if (isLive && liveData) { if (isLive && liveData) {
// Build interface→cost map for neighbor cost lookup // Build interface→cost map for neighbor cost lookup
const ifaceMap = new Map<string, number>() const ifaceMap = new Map<string, number>()
@@ -1185,6 +1207,7 @@ export default function OspfPage() {
.filter((item) => !isRefInterfaceName(item.interfaceName)) .filter((item) => !isRefInterfaceName(item.interfaceName))
const neighbors = liveData.neighbors.map(b => backendToNeighbor(b, ifaceMap)) const neighbors = liveData.neighbors.map(b => backendToNeighbor(b, ifaceMap))
const bfdSessions = (liveData.bfdSessions ?? []).map(backendToBfdSession) const bfdSessions = (liveData.bfdSessions ?? []).map(backendToBfdSession)
const routes = (liveData.routes ?? []).map(backendToRoute)
// Build routerIds from instances // Build routerIds from instances
const routerIds: Record<string, string> = {} const routerIds: Record<string, string> = {}
@@ -1195,7 +1218,7 @@ export default function OspfPage() {
} }
const { nodes: graphNodes, edges: graphEdges } = buildLiveGraph(neighbors) const { nodes: graphNodes, edges: graphEdges } = buildLiveGraph(neighbors)
return { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions } return { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions, routes }
} }
if (isLive) { if (isLive) {
return { return {
@@ -1205,6 +1228,7 @@ export default function OspfPage() {
graphEdges: [], graphEdges: [],
routerIds: {}, routerIds: {},
bfdSessions: [], bfdSessions: [],
routes: [],
} }
} }
return { return {
@@ -1214,6 +1238,7 @@ export default function OspfPage() {
graphEdges: MOCK_GRAPH_EDGES, graphEdges: MOCK_GRAPH_EDGES,
routerIds: MOCK_ROUTER_IDS, routerIds: MOCK_ROUTER_IDS,
bfdSessions: MOCK_BFD, bfdSessions: MOCK_BFD,
routes: MOCK_ROUTES,
} }
}, [isLive, liveData]) }, [isLive, liveData])
@@ -1257,6 +1282,7 @@ export default function OspfPage() {
const displayItems = filterServerId === ALL_SERVERS_ID ? items : items.filter(i => i.routerKey === filterServerId) const displayItems = filterServerId === ALL_SERVERS_ID ? items : items.filter(i => i.routerKey === filterServerId)
const displayNeighbors = filterServerId === ALL_SERVERS_ID ? neighbors : neighbors.filter(n => n.localRouter === filterServerId) const displayNeighbors = filterServerId === ALL_SERVERS_ID ? neighbors : neighbors.filter(n => n.localRouter === filterServerId)
const displayBfdSessions = filterServerId === ALL_SERVERS_ID ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId) const displayBfdSessions = filterServerId === ALL_SERVERS_ID ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId)
const displayRoutes = filterServerId === ALL_SERVERS_ID ? routes : routes.filter(r => r.serverId === filterServerId)
const ospfRailItems = useMemo<ServerTileItem[]>(() => ( const ospfRailItems = useMemo<ServerTileItem[]>(() => (
ospfServers.map((s) => { ospfServers.map((s) => {
@@ -1398,7 +1424,7 @@ export default function OspfPage() {
routerIds={routerIds} routerIds={routerIds}
/> />
)} )}
{activeTab === "routes" && <RoutesTab routes={isLive ? [] : MOCK_ROUTES} />} {activeTab === "routes" && <RoutesTab routes={displayRoutes} />}
{activeTab === "bfd" && <BfdTab sessions={displayBfdSessions} />} {activeTab === "bfd" && <BfdTab sessions={displayBfdSessions} />}
</div> </div>
+119 -58
View File
@@ -18,9 +18,12 @@ import { Flag } from "@/components/flag"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { servers as mockServers, type Server } from "@/lib/data" import { servers as mockServers, type Server } from "@/lib/data"
import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, CheckIcon, AlertCircleIcon } from "lucide-react" import { PlusIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, CheckIcon, AlertCircleIcon, RefreshCwIcon, HistoryIcon, AlertTriangleIcon } from "lucide-react"
import { toast } from "sonner"
import { requestJson } from "@/shared/api/http-client" import { requestJson } from "@/shared/api/http-client"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ConfigHistorySheet } from "@/components/config-history-sheet"
import type { ConfigRevisionDto } from "@/lib/config-revisions"
import { type ServerTileItem } from "@/components/server-tile-rail" import { type ServerTileItem } from "@/components/server-tile-rail"
interface BackendServer { interface BackendServer {
@@ -364,7 +367,7 @@ export default function RecursiveRoutesPage() {
const [servers, setServers] = useState<Server[]>([]) const [servers, setServers] = useState<Server[]>([])
const [selectedServerId, setSelectedServerId] = useState<string>("") const [selectedServerId, setSelectedServerId] = useState<string>("")
const [rows, setRows] = useState<RecursiveRouteRow[]>([]) const [rows, setRows] = useState<RecursiveRouteRow[]>([])
const [busy, setBusy] = useState<"load" | "save" | "from" | "to" | null>(null) const [busy, setBusy] = useState<"load" | "apply" | null>(null)
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [sheetOpen, setSheetOpen] = useState(false) const [sheetOpen, setSheetOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create") const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
@@ -373,6 +376,11 @@ export default function RecursiveRoutesPage() {
const [gatewayOptions, setGatewayOptions] = useState<GatewayOption[]>([]) const [gatewayOptions, setGatewayOptions] = useState<GatewayOption[]>([])
const [expandedGroupKey, setExpandedGroupKey] = useState<string | null>(null) const [expandedGroupKey, setExpandedGroupKey] = useState<string | null>(null)
const [opError, setOpError] = useState<string | null>(null) const [opError, setOpError] = useState<string | null>(null)
const [liveStale, setLiveStale] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false)
const [historyLoading, setHistoryLoading] = useState(false)
const [historyRestoring, setHistoryRestoring] = useState(false)
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
/** В live не дергаем API с id мока (srv1…) пока не подтянули /api/servers */ /** В live не дергаем API с id мока (srv1…) пока не подтянули /api/servers */
const [liveServerListReady, setLiveServerListReady] = useState(false) const [liveServerListReady, setLiveServerListReady] = useState(false)
@@ -407,10 +415,13 @@ export default function RecursiveRoutesPage() {
setOpError(null) setOpError(null)
setBusy("load") setBusy("load")
try { try {
const res = await apiFetch<{ routes: RecursiveRouteRow[] }>(`/api/recursive-routes?serverId=${selectedServerId}`) const res = await apiFetch<{ routes: RecursiveRouteRow[]; stale?: boolean }>(
`/api/recursive-routes?serverId=${selectedServerId}`,
)
setRows(res.routes) setRows(res.routes)
setLiveStale(Boolean(res.stale))
} catch (e) { } catch (e) {
setRows([]) setLiveStale(true)
setOpError(e instanceof Error ? e.message : "Не удалось загрузить маршруты") setOpError(e instanceof Error ? e.message : "Не удалось загрузить маршруты")
} finally { } finally {
setBusy(null) setBusy(null)
@@ -437,56 +448,65 @@ export default function RecursiveRoutesPage() {
void loadGateways() void loadGateways()
}, [loadGateways]) }, [loadGateways])
const saveToDb = useCallback(async () => { const applyRoutes = useCallback(async (next: RecursiveRouteRow[]) => {
if (!isLive || !selectedServerId) return if (!isLive || !selectedServerId || liveStale) return
const prev = rows
setRows(next)
setOpError(null) setOpError(null)
setBusy("save") setBusy("apply")
try { try {
await apiFetch<{ ok: boolean }>("/api/recursive-routes", { const res = await apiFetch<{ ok: boolean; routes?: RecursiveRouteRow[] }>("/api/recursive-routes", {
method: "PUT", method: "PUT",
body: JSON.stringify({ serverId: selectedServerId, routes: rows }), body: JSON.stringify({ serverId: selectedServerId, routes: next }),
}) })
await loadRoutes() setRows(res.routes ?? next)
setLiveStale(false)
toast.success("Маршруты применены на роутер")
} catch (e) { } catch (e) {
setOpError(e instanceof Error ? e.message : "Не удалось сохранить маршруты в БД") setRows(prev)
const msg = e instanceof Error ? e.message : "Не удалось применить маршруты на роутер"
setOpError(msg)
toast.error(msg)
} finally { } finally {
setBusy(null) setBusy(null)
} }
}, [isLive, selectedServerId, rows, apiFetch, loadRoutes]) }, [isLive, selectedServerId, liveStale, rows, apiFetch])
const syncFromRouter = useCallback(async () => { const loadRevisions = useCallback(async () => {
if (!isLive || !selectedServerId) return if (!isLive || !selectedServerId) return
setOpError(null) setHistoryLoading(true)
setBusy("from")
try { try {
await apiFetch<{ ok: boolean }>("/api/recursive-routes/sync/from-router", { const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>(
method: "POST", `/api/recursive-routes/revisions?serverId=${encodeURIComponent(selectedServerId)}`,
body: JSON.stringify({ serverId: selectedServerId }), )
}) setRevisions(res.revisions)
await loadRoutes()
} catch (e) { } catch (e) {
setOpError(e instanceof Error ? e.message : "Не удалось синхронизировать маршруты с роутера") toast.error(e instanceof Error ? e.message : "Не удалось загрузить историю")
setRevisions([])
} finally { } finally {
setBusy(null) setHistoryLoading(false)
}
}, [isLive, selectedServerId, apiFetch, loadRoutes])
const syncToRouter = useCallback(async () => {
if (!isLive || !selectedServerId) return
setOpError(null)
setBusy("to")
try {
await apiFetch<{ ok: boolean }>("/api/recursive-routes/sync/to-router", {
method: "POST",
body: JSON.stringify({ serverId: selectedServerId }),
})
} catch (e) {
setOpError(e instanceof Error ? e.message : "Не удалось применить маршруты на роутер")
} finally {
setBusy(null)
} }
}, [isLive, selectedServerId, apiFetch]) }, [isLive, selectedServerId, apiFetch])
const restoreRevision = useCallback(async (id: string) => {
if (!isLive || !selectedServerId) return
setHistoryRestoring(true)
try {
const res = await apiFetch<{ ok: boolean; routes?: RecursiveRouteRow[] }>(
`/api/recursive-routes/revisions/${encodeURIComponent(id)}/restore`,
{ method: "POST", body: JSON.stringify({ serverId: selectedServerId }) },
)
setRows(res.routes ?? [])
setLiveStale(false)
toast.success("Версия применена на роутер")
await loadRevisions()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось откатить")
} finally {
setHistoryRestoring(false)
}
}, [isLive, selectedServerId, apiFetch, loadRevisions])
function groupKeyOf(row: RecursiveRouteRow): string { function groupKeyOf(row: RecursiveRouteRow): string {
return row.dstAddress.trim().toLowerCase() return row.dstAddress.trim().toLowerCase()
} }
@@ -529,22 +549,26 @@ export default function RecursiveRoutesPage() {
disabled: false, disabled: false,
country: ep.country || inferCountry(ep.gateway) || "", country: ep.country || inferCountry(ep.gateway) || "",
}) })
let next: RecursiveRouteRow[]
if (sheetMode === "create") { if (sheetMode === "create") {
const base = `new-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` const base = `new-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`)) const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
setRows(prev => [...prev, ...expanded]) next = [...rows, ...expanded]
} else if (editingGroupKey) { } else if (editingGroupKey) {
setRows(prev => { const kept = rows.filter(r => groupKeyOf(r) !== editingGroupKey)
const kept = prev.filter(r => groupKeyOf(r) !== editingGroupKey) const base = `edit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const base = `edit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`)) next = [...kept, ...expanded]
return [...kept, ...expanded] } else {
}) setSheetOpen(false)
return
} }
setSheetOpen(false) setSheetOpen(false)
void applyRoutes(next)
} }
const currentServer = servers.find(s => s.id === selectedServerId) const currentServer = servers.find(s => s.id === selectedServerId)
const mutationsLocked = !isLive || busy !== null || liveStale
const rrRailItems = useMemo<ServerTileItem[]>(() => ( const rrRailItems = useMemo<ServerTileItem[]>(() => (
servers.map((s) => ({ servers.map((s) => ({
id: s.id, id: s.id,
@@ -602,16 +626,33 @@ export default function RecursiveRoutesPage() {
actions={ actions={
<> <>
<ServerRailMobileButton /> <ServerRailMobileButton />
<Button variant="outline" size="sm" onClick={syncFromRouter} disabled={!isLive || busy !== null}> {isLive && (
{busy === "from" ? "Синхронизация..." : "Router => DB"} <>
</Button> <Button
<Button variant="outline" size="sm" onClick={syncToRouter} disabled={!isLive || busy !== null}> variant="outline"
{busy === "to" ? "Применение..." : "DB => Router"} size="sm"
</Button> onClick={() => void loadRoutes()}
<Button variant="outline" size="sm" onClick={saveToDb} disabled={!isLive || busy !== null}> disabled={busy !== null}
<SaveIcon className="size-4" />Сохранить в БД title="Прочитать маршруты с роутера"
</Button> >
<Button size="sm" onClick={openCreate} disabled={!isLive || busy !== null}> <RefreshCwIcon className={cn("size-4", busy === "load" && "animate-spin")} />
Обновить
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setHistoryOpen(true)
void loadRevisions()
}}
disabled={busy !== null}
>
<HistoryIcon className="size-4" />
История
</Button>
</>
)}
<Button size="sm" onClick={openCreate} disabled={mutationsLocked}>
<PlusIcon className="size-4" />Добавить <PlusIcon className="size-4" />Добавить
</Button> </Button>
</> </>
@@ -643,6 +684,12 @@ export default function RecursiveRoutesPage() {
{opError} {opError}
</div> </div>
)} )}
{liveStale && (
<div className="w-full text-xs text-amber-700 dark:text-amber-400 bg-amber-500/10 border border-amber-500/20 rounded-md px-3 py-2 flex items-center gap-2">
<AlertTriangleIcon className="size-3.5 shrink-0" />
Роутер недоступен показан кэш. Изменения заблокированы.
</div>
)}
</div> </div>
} }
> >
@@ -679,10 +726,13 @@ export default function RecursiveRoutesPage() {
expandedKey={expandedGroupKey} expandedKey={expandedGroupKey}
onExpandedChange={setExpandedGroupKey} onExpandedChange={setExpandedGroupKey}
onEdit={openEdit} onEdit={openEdit}
onDelete={(g) => setRows((prev) => prev.filter((r) => groupKeyOf(r) !== g.key))} onDelete={(g) => {
if (mutationsLocked) return
void applyRoutes(rows.filter((r) => groupKeyOf(r) !== g.key))
}}
/> />
<button onClick={openCreate} <button onClick={openCreate} disabled={mutationsLocked}
className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t"> className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t disabled:opacity-50">
<PlusIcon className="size-3.5" /> <PlusIcon className="size-3.5" />
Добавить маршрут Добавить маршрут
</button> </button>
@@ -698,6 +748,17 @@ export default function RecursiveRoutesPage() {
onClose={() => setSheetOpen(false)} onClose={() => setSheetOpen(false)}
gateways={gatewayOptions} gateways={gatewayOptions}
/> />
<ConfigHistorySheet
open={historyOpen}
onOpenChange={setHistoryOpen}
title="История маршрутов"
itemLabel="маршрутов"
revisions={revisions}
loading={historyLoading}
restoring={historyRestoring}
onRestore={restoreRevision}
/>
</> </>
) )
} }
File diff suppressed because it is too large Load Diff
+558
View File
@@ -0,0 +1,558 @@
"use client"
import { Suspense, useCallback, useEffect, useMemo, useState } from "react"
import { useSearchParams } from "next/navigation"
import {
ActivityIcon,
DatabaseIcon,
GaugeIcon,
ServerIcon,
UsersIcon,
} from "lucide-react"
import { PageHeader } from "@/components/page-header"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { SegmentedControl } from "@/components/form-kit"
import { EmptyState } from "@/components/empty-state"
import { PeriodSelector, rangeForPreset, type DateRangeYmd } from "@/components/statistics/period-selector"
import { StatisticsVolumeChart } from "@/components/statistics/statistics-volume-chart"
import { DimensionSelect, PivotDimSelect } from "@/components/statistics/dimension-select"
import { SliceChips } from "@/components/statistics/slice-chips"
import { BreakdownDashboard } from "@/components/statistics/breakdown-dashboard"
import { StatisticsPivotGrid } from "@/components/statistics/statistics-pivot-grid"
import {
StatisticsBreakdownDataGrid,
type StatisticsSliceKind,
} from "@/components/data-grids/statistics-breakdown-data-grid"
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
import type { Filter } from "@/components/reui/filters"
import { STATISTICS_FILTER_FIELDS } from "@/lib/data-filters/statistics-filter-fields"
import {
isStatisticsPivotDim,
isStatisticsSliceKind,
STATISTICS_DIMS,
} from "@/lib/statistics-dims"
import { useDataSource } from "@/lib/data-source"
import { fmtBps, formatBytes } from "@/lib/fmt-rate"
import {
getStatistics,
getStatisticsPivot,
STATISTICS_UNBOUND_USER_ID,
type StatisticsDto,
type StatisticsPivotDto,
type StatisticsQuery,
} from "@/shared/api/statistics"
import type { StatisticsBreakdownRow, StatisticsPivotDim } from "@mmapp/contracts/statistics"
/**
* BI-куб трафика: критерий остальные разрезы + pivot.
* Preview: https://reui.io/preview/base/dashboard-1 · https://reui.io/preview/base/stats-12
* · https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/preview/base/solution-analytics-8
* · https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/data-grid
*/
const EMPTY: StatisticsDto = {
from: "",
to: "",
grain: "day",
kpis: {
bytes: 0,
packets: 0,
avgBps: 0,
users: 0,
servers: 0,
ifaces: 0,
topCountry: "",
topService: "",
},
series: [],
users: [],
servers: [],
interfaces: [],
countries: [],
services: [],
asns: [],
}
const EMPTY_PIVOT: StatisticsPivotDto = {
rowDim: "country",
colDim: "service",
metric: "bytes",
columns: [],
rows: [],
otherBytes: 0,
}
interface CubeSlices {
country?: string
service?: string
asn?: string
serverId?: string
userId?: string
iface?: string
}
const SLICE_KEYS = ["country", "service", "asn", "serverId", "userId", "iface"] as const
function readRange(sp: URLSearchParams): DateRangeYmd {
const from = sp.get("from")
const to = sp.get("to")
if (from && to && from <= to) return { from, to }
return rangeForPreset("7d")
}
function readDim(sp: URLSearchParams): StatisticsSliceKind {
const t = sp.get("dim") ?? sp.get("tab")
return t && isStatisticsSliceKind(t) ? t : "users"
}
function readView(sp: URLSearchParams): "explore" | "pivot" {
return sp.get("view") === "pivot" ? "pivot" : "explore"
}
function readPlanes(sp: URLSearchParams): "unique" | "all" {
return sp.get("planes") === "all" ? "all" : "unique"
}
function readPivotDim(sp: URLSearchParams, key: string, fallback: StatisticsPivotDim): StatisticsPivotDim {
const v = sp.get(key)
return v && isStatisticsPivotDim(v) ? v : fallback
}
function readSlices(sp: URLSearchParams): CubeSlices {
const next: CubeSlices = {}
for (const key of SLICE_KEYS) {
const v = sp.get(key)?.trim()
if (v) next[key] = v
}
return next
}
function slicesToFilters(slices: CubeSlices): Filter[] {
return SLICE_KEYS.flatMap((key) => {
const val = slices[key]
if (!val) return []
return [{ id: key, field: key, operator: "is", values: [val] }]
})
}
function filtersToSlices(filters: Filter[]): CubeSlices {
const next: CubeSlices = {}
for (const f of filters) {
const raw = String(f.values[0] ?? "").trim()
if (!raw) continue
if (f.field === "country") next.country = raw.toUpperCase().slice(0, 2)
else if (f.field === "service") next.service = raw
else if (f.field === "asn") next.asn = raw.replace(/[^\d]/g, "")
else if (f.field === "serverId") next.serverId = raw
else if (f.field === "userId") next.userId = raw
else if (f.field === "iface") next.iface = raw
}
return next
}
function toQuery(range: DateRangeYmd, slices: CubeSlices, planes: "unique" | "all"): StatisticsQuery {
const serverId = slices.serverId ? Number(slices.serverId) : undefined
const asn = slices.asn != null && slices.asn !== "" ? Number(slices.asn) : undefined
return {
from: range.from,
to: range.to,
serverId: Number.isFinite(serverId) && (serverId ?? 0) > 0 ? serverId : undefined,
userId: slices.userId,
iface: slices.iface,
country: slices.country && slices.country.length === 2 ? slices.country : undefined,
service: slices.service,
asn: Number.isFinite(asn) ? asn : undefined,
planes,
}
}
function selectedIdForKind(kind: StatisticsSliceKind, slices: CubeSlices): string | undefined {
if (kind === "users") return slices.userId
if (kind === "servers") return slices.serverId
if (kind === "countries") return slices.country
if (kind === "services") return slices.service
if (kind === "asns") return slices.asn
if (kind === "interfaces" && slices.serverId && slices.iface) {
return `${slices.serverId}:${slices.iface}`
}
if (kind === "interfaces") return slices.iface
return undefined
}
function rowsForKind(data: StatisticsDto, kind: StatisticsSliceKind) {
if (kind === "users") return data.users
if (kind === "servers") return data.servers
if (kind === "interfaces") return data.interfaces
if (kind === "countries") return data.countries
if (kind === "services") return data.services
return data.asns
}
function hasAnySlice(slices: CubeSlices): boolean {
return SLICE_KEYS.some((k) => Boolean(slices[k]))
}
function hiddenKinds(slices: CubeSlices): Set<StatisticsSliceKind> {
const hidden = new Set<StatisticsSliceKind>()
if (slices.userId) hidden.add("users")
if (slices.serverId) hidden.add("servers")
if (slices.iface) hidden.add("interfaces")
if (slices.country) hidden.add("countries")
if (slices.service) hidden.add("services")
if (slices.asn) hidden.add("asns")
return hidden
}
function applyDimValue(slices: CubeSlices, kind: StatisticsSliceKind, rowId: string): CubeSlices {
const next: CubeSlices = { ...slices }
if (kind === "users") {
if (rowId === STATISTICS_UNBOUND_USER_ID) return next
if (next.userId === rowId) delete next.userId
else next.userId = rowId
} else if (kind === "servers") {
if (next.serverId === rowId) delete next.serverId
else next.serverId = rowId
} else if (kind === "countries") {
if (next.country === rowId) delete next.country
else next.country = rowId
} else if (kind === "services") {
if (next.service === rowId) delete next.service
else next.service = rowId
} else if (kind === "asns") {
if (next.asn === rowId) delete next.asn
else next.asn = rowId
} else {
const colon = rowId.indexOf(":")
const sid = colon >= 0 ? rowId.slice(0, colon) : undefined
const iface = colon >= 0 ? rowId.slice(colon + 1) : rowId
if (next.iface === iface && next.serverId === sid) {
delete next.iface
delete next.serverId
} else {
next.iface = iface
if (sid) next.serverId = sid
}
}
return next
}
function applyPivotDim(slices: CubeSlices, dim: StatisticsPivotDim, id: string): CubeSlices {
const kind = STATISTICS_DIMS.find((d) => d.pivot === dim)?.id ?? "users"
return applyDimValue(slices, kind, id)
}
function chipList(slices: CubeSlices): { key: string; label: string }[] {
const chips: { key: string; label: string }[] = []
if (slices.country) chips.push({ key: "country", label: `страна ${slices.country}` })
if (slices.service) chips.push({ key: "service", label: `сервис ${slices.service}` })
if (slices.asn) chips.push({ key: "asn", label: `ASN ${slices.asn}` })
if (slices.serverId) chips.push({ key: "serverId", label: `сервер ${slices.serverId}` })
if (slices.userId) chips.push({ key: "userId", label: `пользователь ${slices.userId}` })
if (slices.iface) chips.push({ key: "iface", label: `iface ${slices.iface}` })
return chips
}
function StatisticsPageInner() {
const searchParams = useSearchParams()
const { mode, backendUrl, prefsHydrated } = useDataSource()
const isLive = mode === "live"
const range = useMemo(() => readRange(searchParams), [searchParams])
const slices = useMemo(() => readSlices(searchParams), [searchParams])
const filters = useMemo(() => slicesToFilters(slices), [slices])
const dim = useMemo(() => readDim(searchParams), [searchParams])
const view = useMemo(() => readView(searchParams), [searchParams])
const planes = useMemo(() => readPlanes(searchParams), [searchParams])
const pivotRow = useMemo(() => readPivotDim(searchParams, "pivotRow", "country"), [searchParams])
const pivotCol = useMemo(() => readPivotDim(searchParams, "pivotCol", "service"), [searchParams])
const [data, setData] = useState<StatisticsDto>(EMPTY)
const [pivot, setPivot] = useState<StatisticsPivotDto>(EMPTY_PIVOT)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const replaceParams = useCallback(
(patch: Record<string, string | undefined>) => {
const sp = new URLSearchParams(searchParams.toString())
for (const [k, v] of Object.entries(patch)) {
if (v) sp.set(k, v)
else sp.delete(k)
}
const qs = sp.toString()
if (qs === searchParams.toString()) return
window.history.replaceState(null, "", qs ? `/statistics?${qs}` : "/statistics")
},
[searchParams],
)
const setRange = useCallback(
(next: DateRangeYmd) => {
replaceParams({ from: next.from, to: next.to })
},
[replaceParams],
)
const setSlices = useCallback(
(next: CubeSlices) => {
replaceParams({
country: next.country,
service: next.service,
asn: next.asn,
serverId: next.serverId,
userId: next.userId,
iface: next.iface,
})
},
[replaceParams],
)
useEffect(() => {
if (!prefsHydrated || !isLive) return
let cancelled = false
void (async () => {
setLoading(true)
setError(null)
try {
const query = toQuery(range, slices, planes)
const dto = await getStatistics(backendUrl, query)
if (!cancelled) setData(dto)
if (view === "pivot" && pivotRow !== pivotCol) {
const matrix = await getStatisticsPivot(backendUrl, {
...query,
row: pivotRow,
col: pivotCol,
metric: "bytes",
})
if (!cancelled) setPivot(matrix)
}
} catch (e: unknown) {
if (!cancelled) {
setData(EMPTY)
setPivot(EMPTY_PIVOT)
setError(e instanceof Error ? e.message : "Не удалось загрузить статистику")
}
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [backendUrl, isLive, prefsHydrated, range, slices, view, pivotRow, pivotCol, planes])
const viewData = isLive ? data : EMPTY
const sliced = hasAnySlice(slices)
const emptyCube = !isLive || (!loading && viewData.kpis.bytes === 0 && viewData.interfaces.length === 0)
function handleRowClick(kind: StatisticsSliceKind, row: StatisticsBreakdownRow) {
if (kind === "users" && row.id === STATISTICS_UNBOUND_USER_ID) return
if (kind === "interfaces" && row.label.includes("· дубль")) return
setSlices(applyDimValue(slices, kind, row.id))
}
function handlePivotCell(rowId: string, colId: string) {
if (rowId === "__other__" || colId === "__other__") return
let next = applyPivotDim(slices, pivotRow, rowId)
next = applyPivotDim(next, pivotCol, colId)
replaceParams({
country: next.country,
service: next.service,
asn: next.asn,
serverId: next.serverId,
userId: next.userId,
iface: next.iface,
view: "explore",
})
}
const kpis = viewData.kpis
const chips = chipList(slices)
const countLabel =
view === "pivot"
? `${pivot.rows.length} × ${pivot.columns.length}`
: sliced
? `${STATISTICS_DIMS.filter((d) => !hiddenKinds(slices).has(d.id)).length} разрезов`
: `${rowsForKind(viewData, dim).length} строк`
return (
<div className="flex h-full flex-col">
<PageHeader
crumbs={[{ label: "Обзор", href: "/dashboard" }, { label: "Статистика" }]}
actions={<PeriodSelector range={range} onChange={setRange} />}
/>
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4 md:gap-6 md:px-6 md:py-5">
{!isLive ? (
<Alert>
<AlertTitle>Живые данные выключены</AlertTitle>
<AlertDescription>
Куб статистики строится из IPFIX. Переключитесь на живой источник, чтобы увидеть отчёт.
</AlertDescription>
</Alert>
) : null}
{error ? (
<Alert variant="destructive">
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{!slices.serverId && isLive && !emptyCube ? (
<Alert>
<AlertTitle>Уникальный объём</AlertTitle>
<AlertDescription>
Объём трафик клиентов на GRE/WG, без повторного учёта JHEN и WAN.
</AlertDescription>
</Alert>
) : null}
<KpiStatGrid
aria-label="Сводка трафика"
isLoading={loading}
skeletonCount={5}
items={[
{
id: "bytes",
label: "Объём",
value: formatBytes(kpis.bytes),
hint: "GRE/WG клиентов, без hops",
icon: <DatabaseIcon />,
iconClassName: "text-muted-foreground",
},
{
id: "packets",
label: "Пакеты",
value: kpis.packets.toLocaleString("ru-RU"),
hint: kpis.topService ? `топ: ${kpis.topService}` : undefined,
icon: <ActivityIcon />,
iconClassName: "text-muted-foreground",
},
{
id: "bps",
label: "Средний bitrate",
value: fmtBps(kpis.avgBps),
icon: <GaugeIcon />,
iconClassName: "text-muted-foreground",
},
{
id: "users",
label: "Пользователи",
value: String(kpis.users),
icon: <UsersIcon />,
iconClassName: "text-muted-foreground",
},
{
id: "servers",
label: "Серверы",
value: String(kpis.servers),
hint: slices.serverId
? (kpis.ifaces ? `${kpis.ifaces} iface` : undefined)
: planes === "all"
? "WAN и дубли в списке"
: "без WAN и overlay",
icon: <ServerIcon />,
iconClassName: "text-muted-foreground",
},
]}
/>
<StatisticsVolumeChart series={viewData.series} grain={viewData.grain} />
<DataPageCard>
<DataPageToolbar
leading={
<div className="flex flex-wrap items-center gap-3">
<SegmentedControl
value={view}
onChange={(next) => replaceParams({ view: next === "pivot" ? "pivot" : "explore" })}
options={[
{ value: "explore", label: "Разрез" },
{ value: "pivot", label: "Сводка" },
]}
/>
<SegmentedControl
value={planes}
onChange={(next) => replaceParams({ planes: next === "all" ? "all" : undefined })}
options={[
{ value: "unique", label: "Уникальный" },
{ value: "all", label: "Все плоскости" },
]}
/>
{view === "explore" && !sliced ? (
<DimensionSelect
label="Критерий"
value={dim}
onChange={(next) => replaceParams({ dim: next })}
/>
) : null}
{view === "pivot" ? (
<>
<PivotDimSelect
label="Строки"
value={pivotRow}
exclude={pivotCol}
onChange={(next) => replaceParams({ pivotRow: next })}
/>
<PivotDimSelect
label="Колонки"
value={pivotCol}
exclude={pivotRow}
onChange={(next) => replaceParams({ pivotCol: next })}
/>
</>
) : null}
</div>
}
filters={filters}
onFiltersChange={(next) => setSlices(filtersToSlices(next))}
filterFields={STATISTICS_FILTER_FIELDS}
countLabel={countLabel}
/>
<SliceChips
chips={chips}
onRemove={(key) => {
const next = { ...slices }
delete next[key as keyof CubeSlices]
setSlices(next)
}}
/>
{emptyCube ? (
<EmptyState
title="Нет данных куба"
description="За выбранный период нет IPFIX-фактов. Куб заполняется с момента деплоя, без бэкфилла за год."
/>
) : view === "pivot" ? (
<StatisticsPivotGrid data={isLive ? pivot : EMPTY_PIVOT} onCellClick={handlePivotCell} isLoading={loading} />
) : sliced ? (
<BreakdownDashboard
data={viewData}
hidden={hiddenKinds(slices)}
selectedIdFor={(kind) => selectedIdForKind(kind, slices)}
onRowClick={handleRowClick}
isLoading={loading}
/>
) : (
<StatisticsBreakdownDataGrid
rows={rowsForKind(viewData, dim)}
kind={dim}
selectedId={selectedIdForKind(dim, slices)}
onRowClick={(row) => handleRowClick(dim, row)}
isLoading={loading}
/>
)}
</DataPageCard>
</div>
</div>
)
}
export default function StatisticsPage() {
return (
<Suspense fallback={null}>
<StatisticsPageInner />
</Suspense>
)
}
File diff suppressed because it is too large Load Diff
+297
View File
@@ -0,0 +1,297 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { DataPageCard } from "@/components/data-page-card"
import { UsersDataGrid } from "@/components/data-grids/users-data-grid"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { UserSheet } from "@/components/users/user-sheet"
import { Button } from "@/components/ui/button"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { servers as mockServers } from "@/lib/data"
import { useDataSource } from "@/lib/data-source"
import {
ALL_SECTIONS,
INIT_USERS,
bindingDiffKey,
userInitials,
type AppUser,
type AppUserForm,
type UserServerOption,
} from "@/lib/users"
import { listServers } from "@/shared/api/servers"
import {
createAppUser,
createUserBinding,
deleteAppUser,
deleteUserBinding,
listAppUsers,
updateAppUser,
} from "@/shared/api/users"
import { ApiClientError } from "@/shared/api/http-client"
import { toast } from "sonner"
import {
CableIcon,
PlusIcon,
TrashIcon,
UserCheckIcon,
UsersIcon,
} from "lucide-react"
function toServerOptionsFromMock(): UserServerOption[] {
return mockServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
}))
}
export default function UsersPage() {
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const [users, setUsers] = useState<AppUser[]>(INIT_USERS)
const [serverOptions, setServerOptions] = useState<UserServerOption[]>(toServerOptionsFromMock)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [sheetOpen, setSheetOpen] = useState(false)
const [editUser, setEditUser] = useState<AppUser | null>(null)
const [deleteTarget, setDeleteTarget] = useState<AppUser | null>(null)
const loadLive = useCallback(async () => {
setLoading(true)
try {
const [list, srvs] = await Promise.all([
listAppUsers(backendUrl),
listServers(backendUrl),
])
setUsers(list)
setServerOptions(srvs.map((s) => ({
id: String(s.id),
name: s.name || s.host,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
})))
} catch (err) {
toast.error(err instanceof Error ? err.message : "Не удалось загрузить пользователей")
} finally {
setLoading(false)
}
}, [backendUrl])
useEffect(() => {
if (!isLive) {
setUsers(INIT_USERS)
setServerOptions(toServerOptionsFromMock())
return
}
void loadLive()
}, [isLive, loadLive])
const boundCount = users.reduce((n, u) => n + u.bindings.length, 0)
const activeCount = users.filter((u) => u.active).length
const applyBindingsDiff = async (userId: string, next: AppUserForm["bindings"], prev: AppUser["bindings"]) => {
const nextKeys = new Set(next.map(bindingDiffKey))
const prevKeys = new Map(prev.map((b) => [bindingDiffKey(b), b] as const))
for (const b of prev) {
if (!nextKeys.has(bindingDiffKey(b))) {
await deleteUserBinding(backendUrl, userId, b.id)
}
}
for (const b of next) {
if (!prevKeys.has(bindingDiffKey(b))) {
await createUserBinding(backendUrl, userId, {
serverId: Number(b.serverId),
interfaceName: b.interfaceName,
interfaceType: b.interfaceType,
peerPublicKey: b.peerPublicKey,
peerName: b.peerName,
comment: b.comment,
})
}
}
}
const handleSave = async (form: AppUserForm) => {
if (!isLive) {
if (!editUser) {
const id = `u${Date.now()}`
const created: AppUser = {
id,
...form,
last: "только что",
avatar: userInitials(form.name),
bindings: form.bindings.map((b, i) => ({ ...b, id: `b${id}-${i}`, userId: id })),
}
setUsers((prev) => [...prev, created])
} else {
setUsers((prev) => prev.map((u) => u.id === editUser.id ? { ...u, ...form, avatar: userInitials(form.name) } : u))
}
setSheetOpen(false)
toast.success(editUser ? "Пользователь сохранён" : "Пользователь создан")
return
}
setSaving(true)
try {
if (!editUser) {
const created = await createAppUser(backendUrl, {
name: form.name,
login: form.login,
email: form.email,
role: form.role,
active: form.active,
avatar: userInitials(form.name),
sections: form.sections,
servers: form.servers,
})
await applyBindingsDiff(created.id, form.bindings, [])
await loadLive()
} else {
await updateAppUser(backendUrl, editUser.id, {
name: form.name,
login: form.login,
email: form.email,
role: form.role,
active: form.active,
avatar: userInitials(form.name),
sections: form.sections,
servers: form.servers,
})
await applyBindingsDiff(editUser.id, form.bindings, editUser.bindings)
await loadLive()
}
setSheetOpen(false)
toast.success(editUser ? "Пользователь сохранён" : "Пользователь создан")
} catch (err) {
const msg = err instanceof ApiClientError ? err.message : err instanceof Error ? err.message : "Ошибка сохранения"
toast.error(msg)
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!deleteTarget) return
if (!isLive) {
setUsers((prev) => prev.filter((u) => u.id !== deleteTarget.id))
setDeleteTarget(null)
toast.success("Пользователь удалён")
return
}
try {
await deleteAppUser(backendUrl, deleteTarget.id)
setDeleteTarget(null)
await loadLive()
toast.success("Пользователь удалён")
} catch (err) {
toast.error(err instanceof Error ? err.message : "Не удалось удалить")
}
}
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Пользователи" }]}
actions={
<Button size="sm" onClick={() => { setEditUser(null); setSheetOpen(true) }}>
<PlusIcon className="size-4" />Пригласить
</Button>
}
/>
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-5">
{/* Preview: https://reui.io/preview/base/stats-12 · https://reui.io/docs/components/base/icon-tile */}
<KpiStatGrid
aria-label="Сводка пользователей"
items={[
{
id: "all",
label: "Пользователи",
value: users.length,
icon: <UsersIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "active",
label: "Активные",
value: activeCount,
icon: <UserCheckIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "ifaces",
label: "Привязанные ifaces",
value: boundCount,
icon: <CableIcon className="size-4" />,
iconClassName: "text-info",
},
]}
/>
{/* Preview: https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/docs/components/base/data-grid · https://reui.io/docs/components/base/frame */}
<DataPageCard>
<UsersDataGrid
users={users}
serversCount={serverOptions.length}
allSectionsCount={ALL_SECTIONS.length}
isLoading={loading}
onEdit={(u) => { setEditUser(u); setSheetOpen(true) }}
onDelete={setDeleteTarget}
/>
</DataPageCard>
</div>
<UserSheet
key={sheetOpen ? (editUser?.id ?? "create") : "closed"}
open={sheetOpen}
user={editUser}
users={users}
servers={serverOptions}
isLive={isLive}
backendUrl={backendUrl}
saving={saving}
onSave={(f) => { void handleSave(f) }}
onClose={() => setSheetOpen(false)}
/>
{deleteTarget && (
<AlertDialog open={!!deleteTarget} onOpenChange={(v) => { if (!v) setDeleteTarget(null) }}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<TrashIcon />
</AlertDialogMedia>
<AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget.name} · {deleteTarget.email || deleteTarget.login}. Привязки интерфейсов будут удалены.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setDeleteTarget(null)}>Отмена</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={() => { void handleDelete() }}>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
)
}
+190 -45
View File
@@ -1,30 +1,63 @@
"use client" "use client"
import { useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { vxlanTunnels, servers } from "@/lib/data" import { vxlanTunnels as mockVxlanTunnels, servers as mockServers } from "@/lib/data"
import type { VxlanTunnel } from "@/lib/data" import type { Server, VxlanTunnel } from "@/lib/data"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card" import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar" import { DataPageToolbar } from "@/components/data-page-toolbar"
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid" import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { import {
NetworkIcon, PlusIcon, CodeXmlIcon, LayersIcon, NetworkIcon, PlusIcon, LayersIcon, RefreshCwIcon, AlertCircleIcon,
} from "lucide-react" } from "lucide-react"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import { cn } from "@/lib/utils"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
// ─── helpers ────────────────────────────────────────────────────────────────── interface BackendServer {
id: number
function serverFor(id: string) { name: string
return servers.find((s) => s.id === id) host: string
type?: Server["type"]
site?: string
country: string
asn?: string
enabled: boolean
status?: Server["status"]
latency?: number | null
} }
// ─── RSC generator ─────────────────────────────────────────────────────────── interface VxlanApiResponse {
tunnels: VxlanTunnel[]
}
function generateVxlanRsc(t: VxlanTunnel): string { function mapBackendServer(s: BackendServer): Server {
const srv = serverFor(t.serverId) return {
id: String(s.id),
name: s.name || s.host,
host: s.host,
model: "—",
os: "—",
site: s.site ?? "",
country: s.country || "UN",
asn: s.asn ?? "",
type: s.type ?? "exit-node",
enabled: s.enabled,
status: s.status ?? "online",
latency: s.latency ?? null,
sessions: 0,
}
}
function generateVxlanRsc(t: VxlanTunnel, serverById: Record<string, Server>): string {
const srv = serverById[t.serverId]
const lines: string[] = [] const lines: string[] = []
lines.push(`# VXLAN — ${t.name} · VNI ${t.vni}`) lines.push(`# VXLAN — ${t.name} · VNI ${t.vni}`)
if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`) if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`)
@@ -42,7 +75,6 @@ function generateVxlanRsc(t: VxlanTunnel): string {
if (!t.enabled) lines.push(` disabled=yes \\`) if (!t.enabled) lines.push(` disabled=yes \\`)
lines.push(``) lines.push(``)
// FDB entries for remote VTEPs
for (const vtep of t.remoteVteps) { for (const vtep of t.remoteVteps) {
lines.push(`/interface/vxlan/vteps/add \\`) lines.push(`/interface/vxlan/vteps/add \\`)
lines.push(` interface=${t.name} \\`) lines.push(` interface=${t.name} \\`)
@@ -50,7 +82,6 @@ function generateVxlanRsc(t: VxlanTunnel): string {
lines.push(``) lines.push(``)
} }
// Bridge
lines.push(`# Добавить в bridge:`) lines.push(`# Добавить в bridge:`)
lines.push(`/interface/bridge/port/add \\`) lines.push(`/interface/bridge/port/add \\`)
lines.push(` bridge=bridge-overlay \\`) lines.push(` bridge=bridge-overlay \\`)
@@ -59,12 +90,18 @@ function generateVxlanRsc(t: VxlanTunnel): string {
return lines.join("\n") return lines.join("\n")
} }
// ─── Export Sheet ───────────────────────────────────────────────────────────── function ExportSheet({
open, tunnel, onClose, serverById,
function ExportSheet({ open, tunnel, onClose }: { }: {
open: boolean; tunnel: VxlanTunnel | null; onClose: () => void open: boolean
tunnel: VxlanTunnel | null
onClose: () => void
serverById: Record<string, Server>
}) { }) {
const code = useMemo(() => tunnel ? generateVxlanRsc(tunnel) : "", [tunnel]) const code = useMemo(
() => (tunnel ? generateVxlanRsc(tunnel, serverById) : ""),
[tunnel, serverById],
)
return ( return (
<CodeExportSheet <CodeExportSheet
@@ -84,46 +121,156 @@ function ExportSheet({ open, tunnel, onClose }: {
) )
} }
// ─── Export Sheet ─────────────────────────────────────────────────────────────
export default function VxlanPage() { export default function VxlanPage() {
const [search, setSearch] = useState("") const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const [search, setSearch] = useState("")
const [exportTunnel, setExportTunnel] = useState<VxlanTunnel | null>(null) const [exportTunnel, setExportTunnel] = useState<VxlanTunnel | null>(null)
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const [liveTunnels, setLiveTunnels] = useState<VxlanTunnel[]>([])
const [liveServers, setLiveServers] = useState<Server[]>([])
const [loading, setLoading] = useState(false)
const [liveError, setLiveError] = useState<string | null>(null)
const loadLive = useCallback(async () => {
if (!isLive) return
setLoading(true)
setLiveError(null)
try {
const [tunnelsRes, serversRes] = await Promise.all([
requestJson<VxlanApiResponse>(backendUrl, "/api/vxlan"),
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
])
setLiveTunnels(tunnelsRes.tunnels ?? [])
setLiveServers(serversRes.filter((s) => s.enabled).map(mapBackendServer))
} catch (e) {
setLiveError(e instanceof Error ? e.message : "Ошибка загрузки")
setLiveTunnels([])
} finally {
setLoading(false)
}
}, [isLive, backendUrl])
useEffect(() => {
if (!isLive) {
queueMicrotask(() => {
setLiveTunnels([])
setLiveServers([])
setLiveError(null)
})
return
}
queueMicrotask(() => {
void loadLive()
})
}, [isLive, loadLive])
const displayTunnels = isLive ? liveTunnels : mockVxlanTunnels
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
const effectiveServerId =
selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId)
? selectedServerId
: ALL_SERVERS_ID
const scopedTunnels = useMemo(() => {
if (effectiveServerId === ALL_SERVERS_ID) return displayTunnels
return displayTunnels.filter((t) => t.serverId === effectiveServerId)
}, [displayTunnels, effectiveServerId])
const serverById = useMemo(
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
[displayServers],
)
const railItems = useMemo<ServerTileItem[]>(() => (
displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
type: s.type,
enabled: s.enabled,
meta: String(displayTunnels.filter((t) => t.serverId === s.id).length),
}))
), [displayServers, displayTunnels])
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!search) return vxlanTunnels if (!search) return scopedTunnels
const q = search.toLowerCase() const q = search.toLowerCase()
return vxlanTunnels.filter((t) => return scopedTunnels.filter((t) =>
t.name.includes(q) || t.name.includes(q) ||
String(t.vni).includes(q) || String(t.vni).includes(q) ||
t.vtepIp.includes(q) || t.vtepIp.includes(q) ||
(serverFor(t.serverId)?.name.toLowerCase().includes(q) ?? false) (serverById[t.serverId]?.name.toLowerCase().includes(q) ?? false),
) )
}, [search]) }, [search, scopedTunnels, serverById])
const upCount = vxlanTunnels.filter((t) => t.status === "up").length const upCount = scopedTunnels.filter((t) => t.status === "up").length
const vnis = new Set(vxlanTunnels.map((t) => t.vni)).size const vnis = new Set(scopedTunnels.map((t) => t.vni)).size
return ( return (
<div className="flex flex-col h-full"> <>
<PageHeader <ServerRailLayout
crumbs={[{ label: "Управление" }, { label: "VXLAN" }]} items={railItems}
actions={ selectedId={effectiveServerId}
<Button size="sm"> onSelect={setSelectedServerId}
<PlusIcon className="size-4" />Новый VXLAN showAll
</Button> allCount={displayServers.length}
} loading={isLive && loading && displayServers.length === 0}
/> header={
<PageHeader
<div className="flex-1 overflow-y-auto p-6"> crumbs={[{ label: "Управление" }, { label: "VXLAN" }]}
actions={
<>
<ServerRailMobileButton />
<Button
variant="outline"
size="sm"
onClick={() => { void loadLive() }}
disabled={!isLive || loading}
>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
Обновить
</Button>
<Button size="sm">
<PlusIcon className="size-4" />Новый VXLAN
</Button>
</>
}
/>
}
>
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{isLive && liveError && (
<Alert variant="warning" className="py-2">
<AlertCircleIcon />
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
</Alert>
)}
{isLive && !loading && displayTunnels.length === 0 && !liveError && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
На опрошенных серверах нет VXLAN-интерфейсов
</div>
)}
{mode === "mock" && (
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
Моковые данные
</span>
)}
<KpiStatGrid <KpiStatGrid
aria-label="Сводка VXLAN" aria-label="Сводка VXLAN"
items={[ items={[
{ {
id: "tunnels", id: "tunnels",
label: "Туннелей", label: "Туннелей",
value: vxlanTunnels.length, value: scopedTunnels.length,
icon: <NetworkIcon className="size-4" />, icon: <NetworkIcon className="size-4" />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
}, },
@@ -144,14 +291,13 @@ export default function VxlanPage() {
{ {
id: "servers", id: "servers",
label: "Серверов", label: "Серверов",
value: new Set(vxlanTunnels.map((t) => t.serverId)).size, value: new Set(scopedTunnels.map((t) => t.serverId)).size,
icon: <NetworkIcon className="size-4" />, icon: <NetworkIcon className="size-4" />,
iconClassName: "text-primary", iconClassName: "text-primary",
}, },
]} ]}
/> />
{/* Info banner */}
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm"> <div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
<NetworkIcon className="size-5 text-sky-500 shrink-0 mt-0.5" /> <NetworkIcon className="size-5 text-sky-500 shrink-0 mt-0.5" />
<div> <div>
@@ -163,7 +309,6 @@ export default function VxlanPage() {
</div> </div>
</div> </div>
{/* Table */}
<DataPageCard> <DataPageCard>
<DataPageToolbar <DataPageToolbar
search={search} search={search}
@@ -173,12 +318,11 @@ export default function VxlanPage() {
/> />
<VxlanDataGrid <VxlanDataGrid
tunnels={filtered} tunnels={filtered}
servers={servers} servers={displayServers}
onExport={setExportTunnel} onExport={setExportTunnel}
/> />
</DataPageCard> </DataPageCard>
{/* Reference */}
<OpsPanel title="RouterOS 7 · /interface/vxlan — быстрые команды" contentClassName="px-5 py-4"> <OpsPanel title="RouterOS 7 · /interface/vxlan — быстрые команды" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[ {[
@@ -232,13 +376,14 @@ export default function VxlanPage() {
</OpsPanel> </OpsPanel>
</div> </div>
</div> </ServerRailLayout>
<ExportSheet <ExportSheet
open={!!exportTunnel} open={!!exportTunnel}
tunnel={exportTunnel} tunnel={exportTunnel}
onClose={() => setExportTunnel(null)} onClose={() => setExportTunnel(null)}
serverById={serverById}
/> />
</div> </>
) )
} }
+71 -1
View File
@@ -57,10 +57,12 @@ import {
type ServerTileItem, type ServerTileItem,
} from "@/components/server-tile-rail" } from "@/components/server-tile-rail"
import { toast } from "sonner" import { toast } from "sonner"
import { ConfigHistorySheet } from "@/components/config-history-sheet"
import type { ConfigRevisionDto } from "@/lib/config-revisions"
import { import {
ShieldCheckIcon, PlusIcon, KeyRoundIcon, ShieldCheckIcon, PlusIcon, KeyRoundIcon,
UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon, UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon,
Trash2Icon, CodeXmlIcon, AlertCircleIcon, Trash2Icon, CodeXmlIcon, AlertCircleIcon, HistoryIcon,
} from "lucide-react" } from "lucide-react"
type WgWorkspaceTab = "interfaces" | "peers" | "cli" type WgWorkspaceTab = "interfaces" | "peers" | "cli"
@@ -188,6 +190,10 @@ export default function WireGuardPage() {
const [exportPeerId, setExportPeerId] = useState<string | null>(null) const [exportPeerId, setExportPeerId] = useState<string | null>(null)
const [peerIface, setPeerIface] = useState<WgIfaceWithServer | null>(null) const [peerIface, setPeerIface] = useState<WgIfaceWithServer | null>(null)
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null) const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null)
const [historyOpen, setHistoryOpen] = useState(false)
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
const [historyLoading, setHistoryLoading] = useState(false)
const [historyRestoring, setHistoryRestoring] = useState(false)
const [liveExport, setLiveExport] = useState<{ const [liveExport, setLiveExport] = useState<{
rsc?: string rsc?: string
conf?: string conf?: string
@@ -242,6 +248,44 @@ export default function WireGuardPage() {
? selectedServerId ? selectedServerId
: ALL_SERVERS_ID : ALL_SERVERS_ID
const historyServerId = effectiveServerId === ALL_SERVERS_ID ? null : effectiveServerId
const loadRevisions = useCallback(async () => {
if (!isLive || !historyServerId) return
setHistoryLoading(true)
try {
const res = await requestJson<{ revisions: ConfigRevisionDto[] }>(
backendUrl,
`/api/wireguard/revisions?serverId=${encodeURIComponent(historyServerId)}`,
)
setRevisions(res.revisions)
} catch (err) {
toast.error("Не удалось загрузить историю", { description: String(err) })
setRevisions([])
} finally {
setHistoryLoading(false)
}
}, [isLive, historyServerId, backendUrl])
const restoreRevision = useCallback(async (id: string) => {
if (!isLive || !historyServerId) return
setHistoryRestoring(true)
try {
await requestJson(
backendUrl,
`/api/wireguard/revisions/${encodeURIComponent(id)}/restore`,
{ method: "POST", body: JSON.stringify({ serverId: historyServerId }) },
)
toast.success("Версия применена на роутер")
await loadLive()
await loadRevisions()
} catch (err) {
toast.error("Не удалось откатить", { description: String(err) })
} finally {
setHistoryRestoring(false)
}
}, [isLive, historyServerId, backendUrl, loadLive, loadRevisions])
const scopedIfaces = useMemo(() => { const scopedIfaces = useMemo(() => {
if (effectiveServerId === ALL_SERVERS_ID) return displayIfaces if (effectiveServerId === ALL_SERVERS_ID) return displayIfaces
return displayIfaces.filter((i) => i.serverId === effectiveServerId) return displayIfaces.filter((i) => i.serverId === effectiveServerId)
@@ -544,6 +588,7 @@ export default function WireGuardPage() {
<> <>
<ServerRailMobileButton /> <ServerRailMobileButton />
{isLive && ( {isLive && (
<>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -553,6 +598,20 @@ export default function WireGuardPage() {
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} /> <RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
Обновить Обновить
</Button> </Button>
<Button
size="sm"
variant="outline"
disabled={loading || !historyServerId}
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат на CHR"}
onClick={() => {
setHistoryOpen(true)
void loadRevisions()
}}
>
<HistoryIcon className="size-4" />
История
</Button>
</>
)} )}
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}> <Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" /> <UploadIcon className="size-4" />
@@ -822,6 +881,17 @@ export default function WireGuardPage() {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
<ConfigHistorySheet
open={historyOpen}
onOpenChange={setHistoryOpen}
title="История WireGuard"
itemLabel="интерфейсов"
revisions={revisions}
loading={historyLoading}
restoring={historyRestoring}
onRestore={restoreRevision}
/>
</> </>
) )
} }
+5 -1
View File
@@ -1,5 +1,9 @@
# Path to SQLite database file # PostgreSQL connection string
DATABASE_URL=postgres://mmapp:[email protected]:5432/mmapp
# SQLite file used only for one-shot boot ETL (empty PG + existing mikrotik.db)
DATABASE_PATH=./mikrotik.db DATABASE_PATH=./mikrotik.db
# SQLITE_IMPORT_FULL_HISTORY=true
# Port for the Fastify server # Port for the Fastify server
PORT=8000 PORT=8000
+1
View File
@@ -5,3 +5,4 @@ dist/
*.db-wal *.db-wal
.env .env
storage/backups/ storage/backups/
storage/geoip/
+9 -1
View File
@@ -47,8 +47,16 @@ COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages/contracts ./packages/contracts COPY --from=build /app/packages/contracts ./packages/contracts
COPY --from=build /app/backend/dist ./backend/dist COPY --from=build /app/backend/dist ./backend/dist
COPY --from=build /app/backend/package.json ./backend/package.json COPY --from=build /app/backend/package.json ./backend/package.json
COPY --from=build /app/backend/drizzle ./backend/drizzle
# Nested install from lockfile (dotenv etc.) — ESM resolves from /app/backend/dist → ../node_modules # Nested install from lockfile (dotenv etc.) — ESM resolves from /app/backend/dist → ../node_modules
COPY --from=build /app/backend/node_modules ./backend/node_modules COPY --from=build /app/backend/node_modules ./backend/node_modules
RUN mkdir -p /app/data RUN apt-get update \
&& apt-get install -y --no-install-recommends gnupg wget ca-certificates \
&& wget -qO- https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/pgdg.gpg \
&& echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends postgresql-client-18 \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /app/data
EXPOSE 8000 EXPOSE 8000
CMD ["node", "backend/dist/index.js"] CMD ["node", "backend/dist/index.js"]
+2 -2
View File
@@ -6,9 +6,9 @@ config()
export default defineConfig({ export default defineConfig({
schema: "./src/db/schema.ts", schema: "./src/db/schema.ts",
out: "./drizzle", out: "./drizzle",
dialect: "sqlite", dialect: "postgresql",
dbCredentials: { dbCredentials: {
url: process.env.DATABASE_PATH ?? "./mikrotik.db", url: process.env.DATABASE_URL ?? "postgres://mmapp:[email protected]:5432/mmapp",
}, },
verbose: true, verbose: true,
strict: true, strict: true,
-36
View File
@@ -1,36 +0,0 @@
CREATE TABLE `server_snapshots` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`server_id` integer NOT NULL,
`polled_at` text NOT NULL,
`status` text NOT NULL,
`latency_ms` real,
`ros_version` text,
`board_name` text,
`uptime` text,
`cpu_load` integer,
`free_memory` integer,
`total_memory` integer,
`identity_name` text,
`raw_interfaces` text,
`raw_ip_addresses` text,
FOREIGN KEY (`server_id`) REFERENCES `servers`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `servers` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text DEFAULT '' NOT NULL,
`host` text NOT NULL,
`port` integer DEFAULT 443 NOT NULL,
`username` text DEFAULT 'admin' NOT NULL,
`password` text DEFAULT '' NOT NULL,
`use_ssl` integer DEFAULT true NOT NULL,
`verify_ssl` integer DEFAULT false NOT NULL,
`type` text DEFAULT 'home-router' NOT NULL,
`site` text DEFAULT '' NOT NULL,
`country` text DEFAULT '' NOT NULL,
`asn` text DEFAULT '' NOT NULL,
`comment` text DEFAULT '' NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`created_at` text DEFAULT (datetime('now')) NOT NULL,
`updated_at` text DEFAULT (datetime('now')) NOT NULL
);
+626
View File
@@ -0,0 +1,626 @@
-- MikrotikManager PostgreSQL 18 schema (not 1:1 SQLite).
-- Applied once at backend boot via src/db/migrate.ts
CREATE TABLE IF NOT EXISTS schema_migrations (
id TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS servers (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
host TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 443,
username TEXT NOT NULL DEFAULT 'admin',
password TEXT NOT NULL DEFAULT '',
use_ssl BOOLEAN NOT NULL DEFAULT TRUE,
verify_ssl BOOLEAN NOT NULL DEFAULT FALSE,
type TEXT NOT NULL DEFAULT 'home-router'
CHECK (type IN ('jump-host', 'exit-node', 'home-router')),
site TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
asn TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
lan_subnet TEXT NOT NULL DEFAULT '',
wan_uplinks JSONB NOT NULL DEFAULT '[]'::jsonb,
mgmt_tunnel_ip TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS traffic_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
interval_sec INTEGER NOT NULL DEFAULT 30,
retention_days INTEGER NOT NULL DEFAULT 14,
last_collected_at TIMESTAMPTZ,
last_duration_ms INTEGER,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS servers_api_ping_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT FALSE,
interval_sec INTEGER NOT NULL DEFAULT 120,
last_collected_at TIMESTAMPTZ,
last_duration_ms INTEGER,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS traffic_flow_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT FALSE,
collector_ip TEXT NOT NULL DEFAULT '10.255.254.1',
flow_listen_port INTEGER NOT NULL DEFAULT 4739,
wg_listen_port INTEGER NOT NULL DEFAULT 51821,
prefix TEXT NOT NULL DEFAULT '10.255.254.0/24',
public_endpoint TEXT NOT NULL DEFAULT '',
host_public_key TEXT NOT NULL DEFAULT '',
host_private_key TEXT NOT NULL DEFAULT '',
hub_server_id BIGINT,
retention_hours INTEGER NOT NULL DEFAULT 24,
top_n INTEGER NOT NULL DEFAULT 200,
map_service_min_share_pct DOUBLE PRECISION NOT NULL DEFAULT 5,
last_datagram_at TIMESTAMPTZ,
last_exporter_ip TEXT,
last_error TEXT,
packets_received BIGINT NOT NULL DEFAULT 0,
peers_json JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS uptime_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
resources_enabled BOOLEAN NOT NULL DEFAULT TRUE,
ping_enabled BOOLEAN NOT NULL DEFAULT TRUE,
speed_enabled BOOLEAN NOT NULL DEFAULT TRUE,
interval_sec INTEGER NOT NULL DEFAULT 15,
probe_interval_sec INTEGER NOT NULL DEFAULT 15,
speed_interval_sec INTEGER NOT NULL DEFAULT 60,
retention_days INTEGER NOT NULL DEFAULT 14,
last_collected_at TIMESTAMPTZ,
last_duration_ms INTEGER,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS evobgp_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
base_url TEXT NOT NULL DEFAULT '',
api_key TEXT NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT FALSE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS alert_telegram_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
bot_token TEXT NOT NULL DEFAULT '',
chat_id TEXT NOT NULL DEFAULT '',
message_thread_id INTEGER,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS acme_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
directory_url TEXT NOT NULL DEFAULT 'https://acme-v02.api.letsencrypt.org/directory',
cloudflare_api_token TEXT NOT NULL DEFAULT '',
default_zone_id TEXT NOT NULL DEFAULT '',
account_private_key TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS certificate_renew_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
interval_sec INTEGER NOT NULL DEFAULT 21600,
renew_before_days INTEGER NOT NULL DEFAULT 30,
last_collected_at TIMESTAMPTZ,
last_duration_ms INTEGER,
last_error TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS backup_schedule_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
frequency TEXT NOT NULL DEFAULT 'daily' CHECK (frequency IN ('daily', 'weekly', 'monthly')),
hour INTEGER NOT NULL DEFAULT 3,
minute INTEGER NOT NULL DEFAULT 0,
week_day INTEGER NOT NULL DEFAULT 0,
month_day INTEGER NOT NULL DEFAULT 1,
keep_count INTEGER NOT NULL DEFAULT 7,
format TEXT NOT NULL DEFAULT 'rsc' CHECK (format IN ('rsc', 'backup')),
server_ids_json JSONB NOT NULL DEFAULT '[]'::jsonb,
last_run_at TIMESTAMPTZ,
last_duration_ms INTEGER,
last_error TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS internet_path_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
interval_sec INTEGER NOT NULL DEFAULT 300,
retention_days INTEGER NOT NULL DEFAULT 14,
last_collected_at TIMESTAMPTZ,
last_duration_ms INTEGER,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS alert_engine_cursor (
id BIGINT PRIMARY KEY CHECK (id = 1),
last_source_finished_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS data_migration (
id INTEGER PRIMARY KEY CHECK (id = 1),
sqlite_imported_at TIMESTAMPTZ,
sqlite_path TEXT,
sqlite_sha256 TEXT,
report_json JSONB
);
CREATE TABLE IF NOT EXISTS filter_rules (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
community TEXT NOT NULL,
community_name TEXT,
action TEXT NOT NULL DEFAULT 'route' CHECK (action IN ('route', 'blackhole')),
gateway TEXT NOT NULL DEFAULT '',
gateway_tunnel_id TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_filter_rules_server_sort ON filter_rules(server_id, sort_order);
CREATE TABLE IF NOT EXISTS recursive_routes (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
dst_address TEXT NOT NULL,
gateway TEXT NOT NULL,
distance INTEGER NOT NULL DEFAULT 1,
scope INTEGER,
target_scope INTEGER,
routing_table TEXT NOT NULL DEFAULT 'main',
check_gateway TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
disabled BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_recursive_routes_server_sort ON recursive_routes(server_id, sort_order);
CREATE TABLE IF NOT EXISTS server_snapshots (
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
polled_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL CHECK (status IN ('online', 'offline')),
latency_ms DOUBLE PRECISION,
ros_version TEXT,
board_name TEXT,
uptime TEXT,
cpu_load INTEGER,
free_memory BIGINT,
total_memory BIGINT,
identity_name TEXT,
raw_interfaces JSONB,
raw_ip_addresses JSONB,
PRIMARY KEY (id, polled_at)
) PARTITION BY RANGE (polled_at);
CREATE INDEX IF NOT EXISTS idx_server_snapshots_server_time ON server_snapshots(server_id, polled_at);
CREATE INDEX IF NOT EXISTS idx_server_snapshots_polled_brin ON server_snapshots USING BRIN (polled_at);
CREATE TABLE IF NOT EXISTS traffic_samples (
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
interface_name TEXT NOT NULL,
peer_public_key TEXT NOT NULL DEFAULT '',
sampled_at TIMESTAMPTZ NOT NULL,
rx_bytes BIGINT NOT NULL DEFAULT 0,
tx_bytes BIGINT NOT NULL DEFAULT 0,
rx_bps BIGINT NOT NULL DEFAULT 0,
tx_bps BIGINT NOT NULL DEFAULT 0,
running BOOLEAN NOT NULL DEFAULT FALSE,
disabled BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (id, sampled_at)
) PARTITION BY RANGE (sampled_at);
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_time ON traffic_samples(server_id, sampled_at);
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time ON traffic_samples(server_id, interface_name, sampled_at);
CREATE INDEX IF NOT EXISTS idx_traffic_samples_sampled_brin ON traffic_samples USING BRIN (sampled_at);
CREATE TABLE IF NOT EXISTS servers_rest_ping_samples (
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
sampled_at TIMESTAMPTZ NOT NULL,
ok BOOLEAN NOT NULL,
latency_ms INTEGER,
error TEXT,
PRIMARY KEY (id, sampled_at)
) PARTITION BY RANGE (sampled_at);
CREATE INDEX IF NOT EXISTS idx_servers_rest_ping_samples_server_id ON servers_rest_ping_samples(server_id, sampled_at);
CREATE INDEX IF NOT EXISTS idx_servers_rest_ping_samples_brin ON servers_rest_ping_samples USING BRIN (sampled_at);
CREATE TABLE IF NOT EXISTS flow_buckets (
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
bucket_at TIMESTAMPTZ NOT NULL,
src TEXT NOT NULL,
dst TEXT NOT NULL,
proto INTEGER NOT NULL DEFAULT 0,
src_port INTEGER NOT NULL DEFAULT 0,
dst_port INTEGER NOT NULL DEFAULT 0,
bytes BIGINT NOT NULL DEFAULT 0,
packets BIGINT NOT NULL DEFAULT 0,
in_iface TEXT NOT NULL DEFAULT '',
out_iface TEXT NOT NULL DEFAULT '',
next_hop TEXT NOT NULL DEFAULT '',
flow_start_ms BIGINT NOT NULL DEFAULT 0,
flow_end_ms BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
) PARTITION BY RANGE (bucket_at);
CREATE INDEX IF NOT EXISTS idx_flow_buckets_server_time ON flow_buckets(server_id, bucket_at);
CREATE INDEX IF NOT EXISTS idx_flow_buckets_bucket_brin ON flow_buckets USING BRIN (bucket_at);
CREATE TABLE IF NOT EXISTS flow_minute_stats (
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
bucket_at TIMESTAMPTZ NOT NULL,
bytes BIGINT NOT NULL DEFAULT 0,
packets BIGINT NOT NULL DEFAULT 0,
unique_src INTEGER NOT NULL DEFAULT 0,
unique_dst INTEGER NOT NULL DEFAULT 0,
conversations INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, bucket_at)
) PARTITION BY RANGE (bucket_at);
CREATE INDEX IF NOT EXISTS idx_flow_minute_stats_brin ON flow_minute_stats USING BRIN (bucket_at);
CREATE TABLE IF NOT EXISTS flow_minute_dims (
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
bucket_at TIMESTAMPTZ NOT NULL,
dim TEXT NOT NULL,
key TEXT NOT NULL,
bytes BIGINT NOT NULL DEFAULT 0,
packets BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, bucket_at, dim, key)
) PARTITION BY RANGE (bucket_at);
CREATE INDEX IF NOT EXISTS idx_flow_minute_dims_time ON flow_minute_dims(bucket_at, dim);
CREATE INDEX IF NOT EXISTS idx_flow_minute_dims_brin ON flow_minute_dims USING BRIN (bucket_at);
CREATE TABLE IF NOT EXISTS flow_daily_dims (
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
day DATE NOT NULL,
dim TEXT NOT NULL,
key TEXT NOT NULL,
bytes BIGINT NOT NULL DEFAULT 0,
packets BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, day, dim, key)
) PARTITION BY RANGE (day);
CREATE INDEX IF NOT EXISTS idx_flow_daily_dims_day ON flow_daily_dims(day, dim);
CREATE TABLE IF NOT EXISTS flow_ip_meta (
prefix TEXT PRIMARY KEY,
asn INTEGER NOT NULL DEFAULT 0,
country TEXT NOT NULL DEFAULT '',
lat DOUBLE PRECISION,
lng DOUBLE PRECISION,
holder TEXT NOT NULL DEFAULT '',
ok INTEGER NOT NULL DEFAULT 1,
fetched_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS flow_asn_meta (
asn INTEGER PRIMARY KEY,
holder TEXT NOT NULL DEFAULT '',
fetched_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS uptime_probes (
id TEXT PRIMARY KEY,
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
src_interface TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
target TEXT NOT NULL,
probe_filter TEXT NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
interval_sec INTEGER NOT NULL DEFAULT 0,
show_on_dashboard BOOLEAN NOT NULL DEFAULT FALSE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_uptime_probes_server_sort ON uptime_probes(src_server_id, sort_order);
CREATE TABLE IF NOT EXISTS uptime_probe_samples (
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
probe_id TEXT NOT NULL REFERENCES uptime_probes(id) ON DELETE CASCADE,
sampled_at TIMESTAMPTZ NOT NULL,
rtt_ms INTEGER,
loss_pct INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'down' CHECK (status IN ('up', 'warn', 'down')),
PRIMARY KEY (id, sampled_at)
) PARTITION BY RANGE (sampled_at);
CREATE INDEX IF NOT EXISTS idx_uptime_probe_samples_probe_time ON uptime_probe_samples(probe_id, sampled_at);
CREATE INDEX IF NOT EXISTS idx_uptime_probe_samples_brin ON uptime_probe_samples USING BRIN (sampled_at);
CREATE TABLE IF NOT EXISTS uptime_resource_samples (
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
sampled_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL DEFAULT 'offline' CHECK (status IN ('online', 'offline')),
cpu_load INTEGER NOT NULL DEFAULT 0,
free_memory BIGINT NOT NULL DEFAULT 0,
total_memory BIGINT NOT NULL DEFAULT 0,
free_hdd_space BIGINT NOT NULL DEFAULT 0,
total_hdd_space BIGINT NOT NULL DEFAULT 0,
uptime_seconds BIGINT NOT NULL DEFAULT 0,
board_name TEXT NOT NULL DEFAULT '',
ros_version TEXT NOT NULL DEFAULT '',
PRIMARY KEY (id, sampled_at)
) PARTITION BY RANGE (sampled_at);
CREATE INDEX IF NOT EXISTS idx_uptime_resource_samples_server_time ON uptime_resource_samples(server_id, sampled_at);
CREATE INDEX IF NOT EXISTS idx_uptime_resource_samples_brin ON uptime_resource_samples USING BRIN (sampled_at);
CREATE TABLE IF NOT EXISTS uptime_speed_probes (
id TEXT PRIMARY KEY,
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
src_interface TEXT NOT NULL DEFAULT '',
dst_interface TEXT NOT NULL DEFAULT '',
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
duration_sec INTEGER NOT NULL DEFAULT 10,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_run_at TIMESTAMPTZ,
last_tx_avg_mbps DOUBLE PRECISION,
last_rx_avg_mbps DOUBLE PRECISION,
last_status TEXT CHECK (last_status IN ('done', 'error')),
last_error TEXT,
last_ping_rtt_ms INTEGER,
last_ping_loss_pct INTEGER,
last_ping_at TIMESTAMPTZ,
last_ping_error TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_uptime_speed_probes_src_sort ON uptime_speed_probes(src_server_id, sort_order);
CREATE TABLE IF NOT EXISTS uptime_speed_test_runs (
id TEXT PRIMARY KEY,
probe_id TEXT,
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
src_interface TEXT NOT NULL DEFAULT '',
dst_interface TEXT NOT NULL DEFAULT '',
src_address TEXT,
dst_address TEXT,
src_interface_address TEXT,
dst_interface_address TEXT,
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
duration_sec INTEGER NOT NULL DEFAULT 10,
tx_avg_mbps DOUBLE PRECISION,
rx_avg_mbps DOUBLE PRECISION,
ping_rtt_ms INTEGER,
ping_loss_pct INTEGER,
ping_error TEXT,
status TEXT NOT NULL DEFAULT 'done' CHECK (status IN ('done', 'error')),
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_uptime_speed_test_runs_created_at ON uptime_speed_test_runs(created_at);
CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
id TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'done', 'failed')),
step TEXT NOT NULL DEFAULT 'queued',
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'scheduler')),
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
cert_name TEXT NOT NULL,
domain_names JSONB NOT NULL,
key_type TEXT NOT NULL DEFAULT 'rsa2048',
trust_store TEXT NOT NULL DEFAULT 'www,api',
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
error TEXT
);
CREATE TABLE IF NOT EXISTS backup_entries (
id TEXT PRIMARY KEY,
server_id BIGINT REFERENCES servers(id) ON DELETE SET NULL,
server_name TEXT NOT NULL,
filename TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
kind TEXT NOT NULL DEFAULT 'manual' CHECK (kind IN ('manual', 'auto')),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_backup_entries_filename ON backup_entries(filename);
CREATE INDEX IF NOT EXISTS idx_backup_entries_server_created ON backup_entries(server_id, created_at);
CREATE TABLE IF NOT EXISTS alert_groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
combine_mode TEXT NOT NULL DEFAULT 'any' CHECK (combine_mode IN ('any', 'all')),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
cooldown_override TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS alert_rules (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
target TEXT NOT NULL,
condition TEXT NOT NULL,
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
cooldown TEXT NOT NULL DEFAULT '',
rule_chat_id TEXT NOT NULL DEFAULT '',
confirm_stability_sec INTEGER,
recovery_mode TEXT NOT NULL DEFAULT 'always' CHECK (recovery_mode IN ('always', 'never', 'conditional')),
recovery_stability_sec INTEGER,
group_id TEXT REFERENCES alert_groups(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS alert_rule_targets (
id TEXT PRIMARY KEY,
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
target TEXT NOT NULL,
sort_index INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_alert_rule_targets_rule ON alert_rule_targets(rule_id);
CREATE TABLE IF NOT EXISTS alert_rule_conditions (
id TEXT PRIMARY KEY,
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
condition_line TEXT NOT NULL,
sort_index INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_alert_rule_conditions_rule ON alert_rule_conditions(rule_id);
CREATE TABLE IF NOT EXISTS alert_engine_state (
scope_key TEXT PRIMARY KEY,
last_fired_at TEXT NOT NULL DEFAULT '',
last_payload_hash TEXT
);
CREATE TABLE IF NOT EXISTS alert_engine_prev_live (
kind TEXT PRIMARY KEY CHECK (kind IN ('gre', 'bgp')),
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS alert_engine_confirm_pending (
rule_id TEXT PRIMARY KEY,
payload_hash TEXT NOT NULL,
since_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS alert_history (
id TEXT PRIMARY KEY,
rule_id TEXT,
group_id TEXT,
rule_name TEXT NOT NULL,
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
message TEXT NOT NULL,
sent_ok BOOLEAN NOT NULL DEFAULT TRUE,
fired_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_alert_history_fired_at ON alert_history(fired_at);
CREATE INDEX IF NOT EXISTS idx_alert_history_rule_id ON alert_history(rule_id);
CREATE TABLE IF NOT EXISTS alert_outbox (
id TEXT PRIMARY KEY,
dedupe_key TEXT NOT NULL,
channel TEXT NOT NULL DEFAULT 'telegram' CHECK (channel IN ('telegram')),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
retry_count INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3,
next_attempt_at TIMESTAMPTZ NOT NULL,
payload_json JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
sent_at TIMESTAMPTZ,
last_error TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_outbox_dedupe ON alert_outbox(dedupe_key);
CREATE INDEX IF NOT EXISTS idx_alert_outbox_status_next_attempt ON alert_outbox(status, next_attempt_at);
CREATE INDEX IF NOT EXISTS idx_alert_outbox_pending_next ON alert_outbox(next_attempt_at) WHERE status = 'pending';
CREATE TABLE IF NOT EXISTS scheduler_runs (
id TEXT PRIMARY KEY,
job_key TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL,
finished_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL CHECK (status IN ('ok', 'error')),
error TEXT,
duration_ms INTEGER NOT NULL DEFAULT 0,
result_json JSONB
);
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_job_time ON scheduler_runs(job_key, started_at);
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL,
level TEXT NOT NULL CHECK (level IN ('critical', 'warning', 'info')),
event_type TEXT NOT NULL,
source_module TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
entity_type TEXT,
entity_id TEXT,
payload_json JSONB
);
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_level_created_at ON events(level, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_source_created_at ON events(source_module, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_event_type_created_at ON events(event_type, created_at DESC);
CREATE TABLE IF NOT EXISTS app_users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
login TEXT NOT NULL UNIQUE,
email TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'operator', 'viewer')),
active BOOLEAN NOT NULL DEFAULT TRUE,
avatar TEXT NOT NULL DEFAULT '',
last_seen TIMESTAMPTZ,
sections_json JSONB NOT NULL DEFAULT '[]'::jsonb,
servers_json JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS user_interface_bindings (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
interface_name TEXT NOT NULL,
interface_type TEXT NOT NULL DEFAULT 'other' CHECK (interface_type IN ('ether', 'gre', 'wg', 'other')),
peer_public_key TEXT NOT NULL DEFAULT '',
peer_name TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (server_id, interface_name, peer_public_key)
);
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user ON user_interface_bindings(user_id);
CREATE TABLE IF NOT EXISTS internet_path_snapshots (
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
sampled_at TIMESTAMPTZ NOT NULL,
payload_json JSONB NOT NULL,
PRIMARY KEY (id, sampled_at)
) PARTITION BY RANGE (sampled_at);
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_sampled ON internet_path_snapshots(sampled_at);
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_brin ON internet_path_snapshots USING BRIN (sampled_at);
INSERT INTO traffic_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO traffic_flow_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO uptime_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO evobgp_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO servers_api_ping_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO internet_path_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO alert_telegram_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO acme_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO certificate_renew_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO backup_schedule_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO alert_engine_cursor (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO data_migration (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
+620
View File
@@ -0,0 +1,620 @@
-- Compact PostgreSQL 18 schema: wipe all app tables (not schema_migrations),
-- recreate with smaller time-series rows. SQLite is re-imported after this.
DO $$
DECLARE
r record;
BEGIN
FOR r IN
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public'
AND tablename <> 'schema_migrations'
LOOP
EXECUTE format('DROP TABLE IF EXISTS public.%I CASCADE', r.tablename);
END LOOP;
END $$;
CREATE TABLE IF NOT EXISTS schema_migrations (
id TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS servers (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
host TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 443,
username TEXT NOT NULL DEFAULT 'admin',
password TEXT NOT NULL DEFAULT '',
use_ssl BOOLEAN NOT NULL DEFAULT TRUE,
verify_ssl BOOLEAN NOT NULL DEFAULT FALSE,
type TEXT NOT NULL DEFAULT 'home-router'
CHECK (type IN ('jump-host', 'exit-node', 'home-router')),
site TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
asn TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
lan_subnet TEXT NOT NULL DEFAULT '',
wan_uplinks JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
mgmt_tunnel_ip TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS traffic_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
interval_sec INTEGER NOT NULL DEFAULT 30,
retention_days INTEGER NOT NULL DEFAULT 14,
last_collected_at TIMESTAMPTZ,
last_duration_ms INTEGER,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS servers_api_ping_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT FALSE,
interval_sec INTEGER NOT NULL DEFAULT 120,
last_collected_at TIMESTAMPTZ,
last_duration_ms INTEGER,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS traffic_flow_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT FALSE,
collector_ip TEXT NOT NULL DEFAULT '10.255.254.1',
flow_listen_port INTEGER NOT NULL DEFAULT 4739,
wg_listen_port INTEGER NOT NULL DEFAULT 51821,
prefix TEXT NOT NULL DEFAULT '10.255.254.0/24',
public_endpoint TEXT NOT NULL DEFAULT '',
host_public_key TEXT NOT NULL DEFAULT '',
host_private_key TEXT NOT NULL DEFAULT '',
hub_server_id BIGINT,
retention_hours INTEGER NOT NULL DEFAULT 24,
top_n INTEGER NOT NULL DEFAULT 200,
map_service_min_share_pct DOUBLE PRECISION NOT NULL DEFAULT 5,
last_datagram_at TIMESTAMPTZ,
last_exporter_ip TEXT,
last_error TEXT,
packets_received BIGINT NOT NULL DEFAULT 0,
peers_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS uptime_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
resources_enabled BOOLEAN NOT NULL DEFAULT TRUE,
ping_enabled BOOLEAN NOT NULL DEFAULT TRUE,
speed_enabled BOOLEAN NOT NULL DEFAULT TRUE,
interval_sec INTEGER NOT NULL DEFAULT 15,
probe_interval_sec INTEGER NOT NULL DEFAULT 15,
speed_interval_sec INTEGER NOT NULL DEFAULT 60,
retention_days INTEGER NOT NULL DEFAULT 14,
last_collected_at TIMESTAMPTZ,
last_duration_ms INTEGER,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS evobgp_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
base_url TEXT NOT NULL DEFAULT '',
api_key TEXT NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT FALSE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS alert_telegram_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
bot_token TEXT NOT NULL DEFAULT '',
chat_id TEXT NOT NULL DEFAULT '',
message_thread_id INTEGER,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS acme_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
directory_url TEXT NOT NULL DEFAULT 'https://acme-v02.api.letsencrypt.org/directory',
cloudflare_api_token TEXT NOT NULL DEFAULT '',
default_zone_id TEXT NOT NULL DEFAULT '',
account_private_key TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS certificate_renew_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
interval_sec INTEGER NOT NULL DEFAULT 21600,
renew_before_days INTEGER NOT NULL DEFAULT 30,
last_collected_at TIMESTAMPTZ,
last_duration_ms INTEGER,
last_error TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS backup_schedule_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
frequency TEXT NOT NULL DEFAULT 'daily' CHECK (frequency IN ('daily', 'weekly', 'monthly')),
hour INTEGER NOT NULL DEFAULT 3,
minute INTEGER NOT NULL DEFAULT 0,
week_day INTEGER NOT NULL DEFAULT 0,
month_day INTEGER NOT NULL DEFAULT 1,
keep_count INTEGER NOT NULL DEFAULT 7,
format TEXT NOT NULL DEFAULT 'rsc' CHECK (format IN ('rsc', 'backup')),
server_ids_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
last_run_at TIMESTAMPTZ,
last_duration_ms INTEGER,
last_error TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS internet_path_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
interval_sec INTEGER NOT NULL DEFAULT 300,
retention_days INTEGER NOT NULL DEFAULT 14,
last_collected_at TIMESTAMPTZ,
last_duration_ms INTEGER,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS alert_engine_cursor (
id BIGINT PRIMARY KEY CHECK (id = 1),
last_source_finished_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS data_migration (
id INTEGER PRIMARY KEY CHECK (id = 1),
sqlite_imported_at TIMESTAMPTZ,
sqlite_path TEXT,
sqlite_sha256 TEXT,
report_json JSONB COMPRESSION lz4
);
CREATE TABLE IF NOT EXISTS filter_rules (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
community TEXT NOT NULL,
community_name TEXT,
action TEXT NOT NULL DEFAULT 'route' CHECK (action IN ('route', 'blackhole')),
gateway TEXT NOT NULL DEFAULT '',
gateway_tunnel_id TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_filter_rules_server_sort ON filter_rules(server_id, sort_order);
CREATE TABLE IF NOT EXISTS recursive_routes (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
dst_address TEXT NOT NULL,
gateway TEXT NOT NULL,
distance INTEGER NOT NULL DEFAULT 1,
scope INTEGER,
target_scope INTEGER,
routing_table TEXT NOT NULL DEFAULT 'main',
check_gateway TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
disabled BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_recursive_routes_server_sort ON recursive_routes(server_id, sort_order);
CREATE TABLE IF NOT EXISTS server_snapshots (
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
polled_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL CHECK (status IN ('online', 'offline')),
latency_ms DOUBLE PRECISION,
ros_version TEXT,
board_name TEXT,
uptime TEXT,
cpu_load INTEGER,
free_memory BIGINT,
total_memory BIGINT,
identity_name TEXT,
raw_interfaces JSONB COMPRESSION lz4,
raw_ip_addresses JSONB COMPRESSION lz4,
PRIMARY KEY (id, polled_at)
) PARTITION BY RANGE (polled_at);
CREATE INDEX IF NOT EXISTS idx_server_snapshots_server_time ON server_snapshots(server_id, polled_at);
CREATE TABLE IF NOT EXISTS traffic_samples (
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
interface_name TEXT NOT NULL,
peer_public_key TEXT NOT NULL DEFAULT '',
sampled_at TIMESTAMPTZ NOT NULL,
rx_bytes BIGINT NOT NULL DEFAULT 0,
tx_bytes BIGINT NOT NULL DEFAULT 0,
rx_bps BIGINT NOT NULL DEFAULT 0,
tx_bps BIGINT NOT NULL DEFAULT 0,
flags SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, sampled_at, interface_name, peer_public_key)
) PARTITION BY RANGE (sampled_at);
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time ON traffic_samples(server_id, interface_name, sampled_at);
CREATE TABLE IF NOT EXISTS servers_rest_ping_samples (
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
sampled_at TIMESTAMPTZ NOT NULL,
ok BOOLEAN NOT NULL,
latency_ms INTEGER,
error TEXT,
PRIMARY KEY (server_id, sampled_at)
) PARTITION BY RANGE (sampled_at);
CREATE TABLE IF NOT EXISTS flow_buckets (
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
bucket_at TIMESTAMPTZ NOT NULL,
src INET NOT NULL,
dst INET NOT NULL,
proto SMALLINT NOT NULL DEFAULT 0,
src_port INTEGER NOT NULL DEFAULT 0,
dst_port INTEGER NOT NULL DEFAULT 0,
bytes BIGINT NOT NULL DEFAULT 0,
packets BIGINT NOT NULL DEFAULT 0,
in_iface TEXT NOT NULL DEFAULT '',
out_iface TEXT NOT NULL DEFAULT '',
next_hop INET,
flow_start_ms BIGINT NOT NULL DEFAULT 0,
flow_end_ms BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
) PARTITION BY RANGE (bucket_at);
CREATE INDEX IF NOT EXISTS idx_flow_buckets_server_time ON flow_buckets(server_id, bucket_at);
CREATE TABLE IF NOT EXISTS flow_minute_stats (
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
bucket_at TIMESTAMPTZ NOT NULL,
bytes BIGINT NOT NULL DEFAULT 0,
packets BIGINT NOT NULL DEFAULT 0,
unique_src INTEGER NOT NULL DEFAULT 0,
unique_dst INTEGER NOT NULL DEFAULT 0,
conversations INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, bucket_at)
) PARTITION BY RANGE (bucket_at);
CREATE TABLE IF NOT EXISTS flow_minute_dims (
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
bucket_at TIMESTAMPTZ NOT NULL,
dim TEXT NOT NULL,
key TEXT NOT NULL,
bytes BIGINT NOT NULL DEFAULT 0,
packets BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, bucket_at, dim, key)
) PARTITION BY RANGE (bucket_at);
CREATE INDEX IF NOT EXISTS idx_flow_minute_dims_time ON flow_minute_dims(bucket_at, dim);
CREATE TABLE IF NOT EXISTS flow_daily_dims (
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
day DATE NOT NULL,
dim TEXT NOT NULL,
key TEXT NOT NULL,
bytes BIGINT NOT NULL DEFAULT 0,
packets BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, day, dim, key)
) PARTITION BY RANGE (day);
CREATE INDEX IF NOT EXISTS idx_flow_daily_dims_day ON flow_daily_dims(day, dim);
CREATE TABLE IF NOT EXISTS flow_ip_meta (
prefix TEXT PRIMARY KEY,
asn INTEGER NOT NULL DEFAULT 0,
country TEXT NOT NULL DEFAULT '',
lat DOUBLE PRECISION,
lng DOUBLE PRECISION,
holder TEXT NOT NULL DEFAULT '',
ok INTEGER NOT NULL DEFAULT 1,
fetched_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS flow_asn_meta (
asn INTEGER PRIMARY KEY,
holder TEXT NOT NULL DEFAULT '',
fetched_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS uptime_probes (
id TEXT PRIMARY KEY,
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
src_interface TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
target TEXT NOT NULL,
probe_filter TEXT NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
interval_sec INTEGER NOT NULL DEFAULT 0,
show_on_dashboard BOOLEAN NOT NULL DEFAULT FALSE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_uptime_probes_server_sort ON uptime_probes(src_server_id, sort_order);
CREATE TABLE IF NOT EXISTS uptime_probe_samples (
probe_id TEXT NOT NULL REFERENCES uptime_probes(id) ON DELETE CASCADE,
sampled_at TIMESTAMPTZ NOT NULL,
rtt_ms INTEGER,
loss_pct INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'down' CHECK (status IN ('up', 'warn', 'down')),
PRIMARY KEY (probe_id, sampled_at)
) PARTITION BY RANGE (sampled_at);
CREATE TABLE IF NOT EXISTS uptime_resource_samples (
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
sampled_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL DEFAULT 'offline' CHECK (status IN ('online', 'offline')),
cpu_load INTEGER NOT NULL DEFAULT 0,
free_memory BIGINT NOT NULL DEFAULT 0,
total_memory BIGINT NOT NULL DEFAULT 0,
free_hdd_space BIGINT NOT NULL DEFAULT 0,
total_hdd_space BIGINT NOT NULL DEFAULT 0,
uptime_seconds BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, sampled_at)
) PARTITION BY RANGE (sampled_at);
CREATE TABLE IF NOT EXISTS uptime_speed_probes (
id TEXT PRIMARY KEY,
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
src_interface TEXT NOT NULL DEFAULT '',
dst_interface TEXT NOT NULL DEFAULT '',
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
duration_sec INTEGER NOT NULL DEFAULT 10,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_run_at TIMESTAMPTZ,
last_tx_avg_mbps DOUBLE PRECISION,
last_rx_avg_mbps DOUBLE PRECISION,
last_status TEXT CHECK (last_status IN ('done', 'error')),
last_error TEXT,
last_ping_rtt_ms INTEGER,
last_ping_loss_pct INTEGER,
last_ping_at TIMESTAMPTZ,
last_ping_error TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_uptime_speed_probes_src_sort ON uptime_speed_probes(src_server_id, sort_order);
CREATE TABLE IF NOT EXISTS uptime_speed_test_runs (
id TEXT PRIMARY KEY,
probe_id TEXT,
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
src_interface TEXT NOT NULL DEFAULT '',
dst_interface TEXT NOT NULL DEFAULT '',
src_address TEXT,
dst_address TEXT,
src_interface_address TEXT,
dst_interface_address TEXT,
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
duration_sec INTEGER NOT NULL DEFAULT 10,
tx_avg_mbps DOUBLE PRECISION,
rx_avg_mbps DOUBLE PRECISION,
ping_rtt_ms INTEGER,
ping_loss_pct INTEGER,
ping_error TEXT,
status TEXT NOT NULL DEFAULT 'done' CHECK (status IN ('done', 'error')),
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_uptime_speed_test_runs_created_at ON uptime_speed_test_runs(created_at);
CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
id TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'done', 'failed')),
step TEXT NOT NULL DEFAULT 'queued',
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'scheduler')),
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
cert_name TEXT NOT NULL,
domain_names JSONB COMPRESSION lz4 NOT NULL,
key_type TEXT NOT NULL DEFAULT 'rsa2048',
trust_store TEXT NOT NULL DEFAULT 'www,api',
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
error TEXT
);
CREATE TABLE IF NOT EXISTS backup_entries (
id TEXT PRIMARY KEY,
server_id BIGINT REFERENCES servers(id) ON DELETE SET NULL,
server_name TEXT NOT NULL,
filename TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
kind TEXT NOT NULL DEFAULT 'manual' CHECK (kind IN ('manual', 'auto')),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_backup_entries_filename ON backup_entries(filename);
CREATE INDEX IF NOT EXISTS idx_backup_entries_server_created ON backup_entries(server_id, created_at);
CREATE TABLE IF NOT EXISTS alert_groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
combine_mode TEXT NOT NULL DEFAULT 'any' CHECK (combine_mode IN ('any', 'all')),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
cooldown_override TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS alert_rules (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
target TEXT NOT NULL,
condition TEXT NOT NULL,
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
cooldown TEXT NOT NULL DEFAULT '',
rule_chat_id TEXT NOT NULL DEFAULT '',
confirm_stability_sec INTEGER,
recovery_mode TEXT NOT NULL DEFAULT 'always' CHECK (recovery_mode IN ('always', 'never', 'conditional')),
recovery_stability_sec INTEGER,
group_id TEXT REFERENCES alert_groups(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS alert_rule_targets (
id TEXT PRIMARY KEY,
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
target TEXT NOT NULL,
sort_index INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_alert_rule_targets_rule ON alert_rule_targets(rule_id);
CREATE TABLE IF NOT EXISTS alert_rule_conditions (
id TEXT PRIMARY KEY,
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
condition_line TEXT NOT NULL,
sort_index INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_alert_rule_conditions_rule ON alert_rule_conditions(rule_id);
CREATE TABLE IF NOT EXISTS alert_engine_state (
scope_key TEXT PRIMARY KEY,
last_fired_at TEXT NOT NULL DEFAULT '',
last_payload_hash TEXT
);
CREATE TABLE IF NOT EXISTS alert_engine_prev_live (
kind TEXT PRIMARY KEY CHECK (kind IN ('gre', 'bgp')),
payload_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS alert_engine_confirm_pending (
rule_id TEXT PRIMARY KEY,
payload_hash TEXT NOT NULL,
since_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS alert_history (
id TEXT PRIMARY KEY,
rule_id TEXT,
group_id TEXT,
rule_name TEXT NOT NULL,
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
message TEXT NOT NULL,
sent_ok BOOLEAN NOT NULL DEFAULT TRUE,
fired_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_alert_history_fired_at ON alert_history(fired_at);
CREATE INDEX IF NOT EXISTS idx_alert_history_rule_id ON alert_history(rule_id);
CREATE TABLE IF NOT EXISTS alert_outbox (
id TEXT PRIMARY KEY,
dedupe_key TEXT NOT NULL,
channel TEXT NOT NULL DEFAULT 'telegram' CHECK (channel IN ('telegram')),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
retry_count INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3,
next_attempt_at TIMESTAMPTZ NOT NULL,
payload_json JSONB COMPRESSION lz4 NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
sent_at TIMESTAMPTZ,
last_error TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_outbox_dedupe ON alert_outbox(dedupe_key);
CREATE INDEX IF NOT EXISTS idx_alert_outbox_status_next_attempt ON alert_outbox(status, next_attempt_at);
CREATE INDEX IF NOT EXISTS idx_alert_outbox_pending_next ON alert_outbox(next_attempt_at) WHERE status = 'pending';
CREATE TABLE IF NOT EXISTS scheduler_runs (
id TEXT PRIMARY KEY,
job_key TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL,
finished_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL CHECK (status IN ('ok', 'error')),
error TEXT,
duration_ms INTEGER NOT NULL DEFAULT 0,
result_json JSONB COMPRESSION lz4
);
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_job_time ON scheduler_runs(job_key, started_at);
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL,
level TEXT NOT NULL CHECK (level IN ('critical', 'warning', 'info')),
event_type TEXT NOT NULL,
source_module TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
entity_type TEXT,
entity_id TEXT,
payload_json JSONB COMPRESSION lz4
);
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_level_created_at ON events(level, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_source_created_at ON events(source_module, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_event_type_created_at ON events(event_type, created_at DESC);
CREATE TABLE IF NOT EXISTS app_users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
login TEXT NOT NULL UNIQUE,
email TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'operator', 'viewer')),
active BOOLEAN NOT NULL DEFAULT TRUE,
avatar TEXT NOT NULL DEFAULT '',
last_seen TIMESTAMPTZ,
sections_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
servers_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS user_interface_bindings (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
interface_name TEXT NOT NULL,
interface_type TEXT NOT NULL DEFAULT 'other' CHECK (interface_type IN ('ether', 'gre', 'wg', 'other')),
peer_public_key TEXT NOT NULL DEFAULT '',
peer_name TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (server_id, interface_name, peer_public_key)
);
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user ON user_interface_bindings(user_id);
CREATE TABLE IF NOT EXISTS internet_path_snapshots (
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
sampled_at TIMESTAMPTZ NOT NULL,
payload_json JSONB COMPRESSION lz4 NOT NULL,
PRIMARY KEY (id, sampled_at)
) PARTITION BY RANGE (sampled_at);
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_sampled ON internet_path_snapshots(sampled_at);
INSERT INTO traffic_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO traffic_flow_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO uptime_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO evobgp_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO servers_api_ping_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO internet_path_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO alert_telegram_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO acme_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO certificate_renew_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO backup_schedule_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO alert_engine_cursor (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO data_migration (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
+44
View File
@@ -0,0 +1,44 @@
-- S3-compatible storage for RouterOS backups
CREATE TABLE IF NOT EXISTS backup_storage_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
provider TEXT NOT NULL DEFAULT 'local' CHECK (provider IN ('local', 's3')),
s3_endpoint TEXT NOT NULL DEFAULT '',
s3_region TEXT NOT NULL DEFAULT 'us-east-1',
s3_bucket TEXT NOT NULL DEFAULT '',
s3_prefix TEXT NOT NULL DEFAULT 'mikrotik',
s3_access_key_id TEXT NOT NULL DEFAULT '',
s3_secret_access_key TEXT NOT NULL DEFAULT '',
s3_force_path_style BOOLEAN NOT NULL DEFAULT TRUE,
keep_local_copy BOOLEAN NOT NULL DEFAULT TRUE,
last_test_at TIMESTAMPTZ,
last_test_error TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO backup_storage_settings (id)
VALUES (1)
ON CONFLICT (id) DO NOTHING;
ALTER TABLE backup_entries
ADD COLUMN IF NOT EXISTS storage TEXT NOT NULL DEFAULT 'local';
ALTER TABLE backup_entries
ADD COLUMN IF NOT EXISTS s3_key TEXT;
ALTER TABLE backup_entries
ADD COLUMN IF NOT EXISTS s3_etag TEXT;
ALTER TABLE backup_entries
ADD COLUMN IF NOT EXISTS upload_error TEXT;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'backup_entries_storage_check'
) THEN
ALTER TABLE backup_entries
ADD CONSTRAINT backup_entries_storage_check
CHECK (storage IN ('local', 's3', 'both'));
END IF;
END $$;
+19
View File
@@ -0,0 +1,19 @@
-- GeoLite2 mmdb (страна/ASN для netflow): настройки автообновления зеркала P3TERX
CREATE TABLE IF NOT EXISTS geoip_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
update_interval_sec INTEGER NOT NULL DEFAULT 604800,
last_check_at TIMESTAMPTZ,
last_success_at TIMESTAMPTZ,
last_error TEXT,
country_build_at TIMESTAMPTZ,
asn_build_at TIMESTAMPTZ,
etags_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO geoip_settings (id)
VALUES (1)
ON CONFLICT (id) DO NOTHING;
+28
View File
@@ -0,0 +1,28 @@
-- Statistics cube: hour + daily facts (server × iface × country × service × ASN).
-- Compact types. FILLFACTOR/autovacuum нельзя на partitioned parent (PG 42809) —
-- задаются на листовых партициях в ensurePartitionFor.
-- Retention: DROP partitions only (see PARTITION_SPECS).
CREATE TABLE IF NOT EXISTS flow_hour_facts (
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
bucket_at TIMESTAMPTZ NOT NULL,
iface TEXT NOT NULL,
country CHAR(2) NOT NULL,
service TEXT NOT NULL,
asn INTEGER NOT NULL,
bytes BIGINT NOT NULL DEFAULT 0,
packets BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, bucket_at, iface, country, service, asn)
) PARTITION BY RANGE (bucket_at);
CREATE TABLE IF NOT EXISTS flow_daily_facts (
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
day DATE NOT NULL,
iface TEXT NOT NULL,
country CHAR(2) NOT NULL,
service TEXT NOT NULL,
asn INTEGER NOT NULL,
bytes BIGINT NOT NULL DEFAULT 0,
packets BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, day, iface, country, service, asn)
) PARTITION BY RANGE (day);
+16
View File
@@ -0,0 +1,16 @@
-- История desired/actual снапшотов managed-секций (фильтры, рекурсивные маршруты).
-- Retention — prune в сервисе (последние 50 на пару server+section).
CREATE TABLE IF NOT EXISTS config_revisions (
id TEXT PRIMARY KEY,
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
section TEXT NOT NULL,
source TEXT NOT NULL,
fingerprint TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '[]'::jsonb,
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_config_revisions_server_section_created
ON config_revisions (server_id, section, created_at DESC);
@@ -0,0 +1,7 @@
-- IPFIX postNAT (IANA 225/226) + postNAPT ports (IANA 227/228) from MikroTik Traffic Flow.
-- Needed to rebuild facts with the same internet dest as the network map.
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_src INET;
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_dst INET;
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_src_port INTEGER NOT NULL DEFAULT 0;
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_dst_port INTEGER NOT NULL DEFAULT 0;
+3 -3
View File
@@ -1,12 +1,12 @@
{ {
"version": "7", "version": "7",
"dialect": "sqlite", "dialect": "postgresql",
"entries": [ "entries": [
{ {
"idx": 0, "idx": 0,
"version": "6", "version": "7",
"when": 1777572014210, "when": 1777572014210,
"tag": "0000_living_xorn", "tag": "0000_postgresql",
"breakpoints": true "breakpoints": true
} }
] ]
+16 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "mikrotik-manager-backend", "name": "mikrotik-manager-backend",
"version": "1.0.0", "version": "1.0.0",
"description": "MikroTik Manager backend — Fastify + Drizzle + SQLite", "description": "MikroTik Manager backend — Fastify + Drizzle + PostgreSQL",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
@@ -11,11 +11,22 @@
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:migrate-from-sqlite": "tsx src/scripts/migrate-sqlite-to-pg.ts",
"facts:rebuild": "tsx src/scripts/rebuild-flow-facts.ts",
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts", "test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts", "test:wireguard": "npx tsx src/services/wireguard-config.test.ts",
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts" "test:traffic-rate": "tsx src/services/traffic-rate.test.ts",
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts",
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts",
"test:pg": "tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/services/config-revisions.test.ts",
"test:config-sync": "tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts",
"test:backups": "tsx src/services/s3-backup-client.test.ts",
"test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts",
"test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:config-sync",
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.888.0",
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.2.2", "@fastify/jwt": "^10.2.2",
"@fastify/type-provider-zod": "^1.0.0", "@fastify/type-provider-zod": "^1.0.0",
@@ -26,12 +37,15 @@
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"fastify": "^5.8.5", "fastify": "^5.8.5",
"fastify-plugin": "^5.1.0", "fastify-plugin": "^5.1.0",
"maxmind": "^5.0.7",
"pg": "^8.23.0",
"undici": "^8.1.0", "undici": "^8.1.0",
"zod": "^4.4.1" "zod": "^4.4.1"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.15.3", "@types/node": "^22.15.3",
"@types/pg": "^8.23.1",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"jose": "^6.2.11", "jose": "^6.2.11",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
+39
View File
@@ -0,0 +1,39 @@
import Database from "better-sqlite3"
import fs from "node:fs"
const path = process.argv[2] ?? "./mikrotik.db"
if (!fs.existsSync(path)) {
console.error("NO_SQLITE", path)
process.exit(2)
}
const st = fs.statSync(path)
const db = new Database(path, { readonly: true, fileMustExist: true })
const tables = db
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY 1`)
.all()
console.log("FILE_BYTES", st.size)
console.log("TABLES", tables.length)
for (const t of tables) {
const c = db.prepare(`SELECT COUNT(*) AS c FROM "${t.name}"`).get()
console.log(`COUNT\t${t.name}\t${c.c}`)
}
try {
const d = db.prepare(`SELECT COUNT(*) AS c FROM (
SELECT dedupe_key FROM alert_outbox GROUP BY dedupe_key HAVING COUNT(*) > 1
)`).get()
console.log("DUP_DEDUPE_KEYS", d.c)
} catch (e) {
console.log("DUP_DEDUPE_ERR", e instanceof Error ? e.message : e)
}
for (const [label, sql] of [
["ORPHAN_FLOW_MINUTE", `SELECT COUNT(*) c FROM flow_minute_stats s WHERE s.server_id NOT IN (SELECT id FROM servers)`],
["ORPHAN_FLOW_BUCKETS", `SELECT COUNT(*) c FROM flow_buckets s WHERE s.server_id NOT IN (SELECT id FROM servers)`],
["ORPHAN_BINDINGS", `SELECT COUNT(*) c FROM user_interface_bindings s WHERE s.server_id NOT IN (SELECT id FROM servers)`],
]) {
try {
console.log(label, db.prepare(sql).get().c)
} catch (e) {
console.log(label + "_ERR", e instanceof Error ? e.message : e)
}
}
db.close()
+6
View File
@@ -11,7 +11,9 @@ function boolEnv(v: string | undefined, fallback: boolean): boolean {
const isProd = process.env.NODE_ENV === "production" const isProd = process.env.NODE_ENV === "production"
const envSchema = z.object({ const envSchema = z.object({
DATABASE_URL: z.string().default("postgres://mmapp:[email protected]:5432/mmapp"),
DATABASE_PATH: z.string().default("./mikrotik.db"), DATABASE_PATH: z.string().default("./mikrotik.db"),
SQLITE_IMPORT_FULL_HISTORY: z.boolean().default(false),
PORT: z.coerce.number().int().positive().default(8000), PORT: z.coerce.number().int().positive().default(8000),
CORS_ORIGIN: z.string().default("http://localhost:3000"), CORS_ORIGIN: z.string().default("http://localhost:3000"),
AUTH_REQUIRED: z.boolean().default(false), AUTH_REQUIRED: z.boolean().default(false),
@@ -21,7 +23,9 @@ const envSchema = z.object({
}) })
const raw = { const raw = {
DATABASE_URL: process.env.DATABASE_URL,
DATABASE_PATH: process.env.DATABASE_PATH, DATABASE_PATH: process.env.DATABASE_PATH,
SQLITE_IMPORT_FULL_HISTORY: boolEnv(process.env.SQLITE_IMPORT_FULL_HISTORY, false),
PORT: process.env.PORT, PORT: process.env.PORT,
CORS_ORIGIN: process.env.CORS_ORIGIN, CORS_ORIGIN: process.env.CORS_ORIGIN,
AUTH_REQUIRED: boolEnv(process.env.AUTH_REQUIRED, false), AUTH_REQUIRED: boolEnv(process.env.AUTH_REQUIRED, false),
@@ -53,7 +57,9 @@ if (parsed.data.AUTH_REQUIRED && parsed.data.AUTH_JWT_SECRET.length < 8) {
} }
export const env = { export const env = {
DATABASE_URL: parsed.data.DATABASE_URL,
DATABASE_PATH: parsed.data.DATABASE_PATH, DATABASE_PATH: parsed.data.DATABASE_PATH,
sqliteImportFullHistory: parsed.data.SQLITE_IMPORT_FULL_HISTORY,
PORT: parsed.data.PORT, PORT: parsed.data.PORT,
CORS_ORIGIN: parsed.data.CORS_ORIGIN, CORS_ORIGIN: parsed.data.CORS_ORIGIN,
authRequired: parsed.data.AUTH_REQUIRED, authRequired: parsed.data.AUTH_REQUIRED,
+42
View File
@@ -0,0 +1,42 @@
import { pool } from "./index.js"
import { applySqlMigrations } from "./migrate.js"
import { dropExpiredPartitions, ensurePartitionsAround } from "./partitions.js"
import { importSqliteToPostgres, shouldImportSqlite, sqliteFileLooksPresent } from "./sqlite-import.js"
import { env } from "../config.js"
const ETL_LOCK = 8723101
export async function initDatabase(): Promise<void> {
await pool.query("SELECT 1")
await applySqlMigrations(pool)
await ensurePartitionsAround(pool)
const client = await pool.connect()
try {
await client.query("SELECT pg_advisory_lock($1)", [ETL_LOCK])
if (await shouldImportSqlite(pool, env.DATABASE_PATH)) {
console.log(`SQLite → PostgreSQL: импорт ${env.DATABASE_PATH}`)
const report = await importSqliteToPostgres(pool, env.DATABASE_PATH, { strict: true })
console.log(
`SQLite → PostgreSQL: готово за ${report.durationMs}ms, таблиц ${Object.keys(report.tables).length}`,
)
} else {
const marker = await pool.query<{ sqlite_imported_at: string | null }>(
`SELECT sqlite_imported_at FROM data_migration WHERE id = 1`,
)
if (!marker.rows[0]?.sqlite_imported_at && !sqliteFileLooksPresent(env.DATABASE_PATH)) {
console.warn(
`SQLite → PostgreSQL: файл ${env.DATABASE_PATH} не найден, база после wipe остаётся пустой (defaults settings)`,
)
}
}
} finally {
try {
await client.query("SELECT pg_advisory_unlock($1)", [ETL_LOCK])
} catch {
/* ignore */
}
client.release()
}
await dropExpiredPartitions(pool)
await ensurePartitionsAround(pool)
}
+52 -706
View File
@@ -1,725 +1,71 @@
import Database from "better-sqlite3" import { isMainThread } from "node:worker_threads"
import { existsSync, readFileSync } from "node:fs" import pg from "pg"
import path from "node:path" import { drizzle } from "drizzle-orm/node-postgres"
type SqliteHandle = InstanceType<typeof Database>
import { drizzle } from "drizzle-orm/better-sqlite3"
import { env } from "../config.js" import { env } from "../config.js"
import * as schema from "./schema.js" import * as schema from "./schema.js"
import { bindSql } from "./sql-bind.js"
const sqlite = new Database(env.DATABASE_PATH) const INT8_OID = 20
const DATE_OID = 1082
// WAL mode for better concurrent read performance pg.types.setTypeParser(INT8_OID, (val) => {
sqlite.pragma("journal_mode = WAL") const n = Number(val)
sqlite.pragma("foreign_keys = ON") return Number.isSafeInteger(n) ? n : val
sqlite.exec(` })
CREATE TABLE IF NOT EXISTS servers ( pg.types.setTypeParser(DATE_OID, (val) => val)
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT '',
host TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 443,
username TEXT NOT NULL DEFAULT 'admin',
password TEXT NOT NULL DEFAULT '',
use_ssl INTEGER NOT NULL DEFAULT 1,
verify_ssl INTEGER NOT NULL DEFAULT 0,
type TEXT NOT NULL DEFAULT 'home-router',
site TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
asn TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
lan_subnet TEXT NOT NULL DEFAULT '',
wan_uplinks TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS server_snapshots ( export function createPool(max = 16): pg.Pool {
id INTEGER PRIMARY KEY AUTOINCREMENT, return new pg.Pool({
server_id INTEGER NOT NULL, connectionString: env.DATABASE_URL,
polled_at TEXT NOT NULL, max,
status TEXT NOT NULL, idleTimeoutMillis: 30_000,
latency_ms REAL, connectionTimeoutMillis: 8_000,
ros_version TEXT, statement_timeout: 120_000,
board_name TEXT, })
uptime TEXT,
cpu_load INTEGER,
free_memory INTEGER,
total_memory INTEGER,
identity_name TEXT,
raw_interfaces TEXT,
raw_ip_addresses TEXT,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS filter_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
community TEXT NOT NULL,
community_name TEXT,
action TEXT NOT NULL DEFAULT 'route',
gateway TEXT NOT NULL DEFAULT '',
gateway_tunnel_id TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_filter_rules_server_sort
ON filter_rules(server_id, sort_order);
CREATE TABLE IF NOT EXISTS recursive_routes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
dst_address TEXT NOT NULL,
gateway TEXT NOT NULL,
distance INTEGER NOT NULL DEFAULT 1,
scope INTEGER,
target_scope INTEGER,
routing_table TEXT NOT NULL DEFAULT 'main',
check_gateway TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_recursive_routes_server_sort
ON recursive_routes(server_id, sort_order);
CREATE TABLE IF NOT EXISTS traffic_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
interval_sec INTEGER NOT NULL DEFAULT 30,
retention_days INTEGER NOT NULL DEFAULT 14,
last_collected_at TEXT,
last_duration_ms INTEGER,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS traffic_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
interface_name TEXT NOT NULL,
sampled_at TEXT NOT NULL,
rx_bytes INTEGER NOT NULL DEFAULT 0,
tx_bytes INTEGER NOT NULL DEFAULT 0,
rx_bps INTEGER NOT NULL DEFAULT 0,
tx_bps INTEGER NOT NULL DEFAULT 0,
running INTEGER NOT NULL DEFAULT 0,
disabled INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_time
ON traffic_samples(server_id, sampled_at);
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time
ON traffic_samples(server_id, interface_name, sampled_at);
CREATE TABLE IF NOT EXISTS uptime_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
interval_sec INTEGER NOT NULL DEFAULT 15,
probe_interval_sec INTEGER NOT NULL DEFAULT 15,
speed_interval_sec INTEGER NOT NULL DEFAULT 60,
retention_days INTEGER NOT NULL DEFAULT 14,
last_collected_at TEXT,
last_duration_ms INTEGER,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS uptime_probes (
id TEXT PRIMARY KEY,
src_server_id INTEGER NOT NULL,
src_interface TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
target TEXT NOT NULL,
probe_filter TEXT NOT NULL DEFAULT '—',
enabled INTEGER NOT NULL DEFAULT 1,
show_on_dashboard INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (src_server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_uptime_probes_server_sort
ON uptime_probes(src_server_id, sort_order);
CREATE TABLE IF NOT EXISTS uptime_probe_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
probe_id TEXT NOT NULL,
sampled_at TEXT NOT NULL,
rtt_ms INTEGER,
loss_pct INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'down',
FOREIGN KEY (probe_id) REFERENCES uptime_probes(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_uptime_probe_samples_probe_time
ON uptime_probe_samples(probe_id, sampled_at);
CREATE TABLE IF NOT EXISTS uptime_resource_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
sampled_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'offline',
cpu_load INTEGER NOT NULL DEFAULT 0,
free_memory INTEGER NOT NULL DEFAULT 0,
total_memory INTEGER NOT NULL DEFAULT 0,
free_hdd_space INTEGER NOT NULL DEFAULT 0,
total_hdd_space INTEGER NOT NULL DEFAULT 0,
uptime_seconds INTEGER NOT NULL DEFAULT 0,
board_name TEXT NOT NULL DEFAULT '',
ros_version TEXT NOT NULL DEFAULT '',
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_uptime_resource_samples_server_time
ON uptime_resource_samples(server_id, sampled_at);
CREATE TABLE IF NOT EXISTS uptime_speed_probes (
id TEXT PRIMARY KEY,
src_server_id INTEGER NOT NULL,
dst_server_id INTEGER NOT NULL,
src_interface TEXT NOT NULL DEFAULT '',
dst_interface TEXT NOT NULL DEFAULT '',
protocol TEXT NOT NULL DEFAULT 'tcp',
direction TEXT NOT NULL DEFAULT 'both',
duration_sec INTEGER NOT NULL DEFAULT 10,
enabled INTEGER NOT NULL DEFAULT 1,
last_run_at TEXT,
last_tx_avg_mbps REAL,
last_rx_avg_mbps REAL,
last_status TEXT,
last_error TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (src_server_id) REFERENCES servers(id) ON DELETE CASCADE,
FOREIGN KEY (dst_server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_uptime_speed_probes_src_sort
ON uptime_speed_probes(src_server_id, sort_order);
CREATE TABLE IF NOT EXISTS uptime_speed_test_runs (
id TEXT PRIMARY KEY,
probe_id TEXT,
src_server_id INTEGER NOT NULL,
dst_server_id INTEGER NOT NULL,
src_interface TEXT NOT NULL DEFAULT '',
dst_interface TEXT NOT NULL DEFAULT '',
src_address TEXT,
dst_address TEXT,
src_interface_address TEXT,
dst_interface_address TEXT,
protocol TEXT NOT NULL DEFAULT 'tcp',
direction TEXT NOT NULL DEFAULT 'both',
duration_sec INTEGER NOT NULL DEFAULT 10,
tx_avg_mbps REAL,
rx_avg_mbps REAL,
ping_rtt_ms INTEGER,
ping_loss_pct INTEGER,
ping_error TEXT,
status TEXT NOT NULL DEFAULT 'done',
error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (src_server_id) REFERENCES servers(id) ON DELETE CASCADE,
FOREIGN KEY (dst_server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_uptime_speed_test_runs_created_at
ON uptime_speed_test_runs(created_at);
CREATE TABLE IF NOT EXISTS scheduler_runs (
id TEXT PRIMARY KEY,
job_key TEXT NOT NULL,
started_at TEXT NOT NULL,
finished_at TEXT NOT NULL,
status TEXT NOT NULL,
error TEXT,
duration_ms INTEGER NOT NULL DEFAULT 0,
result_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_job_time
ON scheduler_runs(job_key, finished_at);
CREATE TABLE IF NOT EXISTS internet_path_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
interval_sec INTEGER NOT NULL DEFAULT 300,
retention_days INTEGER NOT NULL DEFAULT 14,
last_collected_at TEXT,
last_duration_ms INTEGER,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS internet_path_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sampled_at TEXT NOT NULL,
payload_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_sampled
ON internet_path_snapshots(sampled_at DESC);
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
created_at TEXT NOT NULL,
level TEXT NOT NULL,
event_type TEXT NOT NULL,
source_module TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
entity_type TEXT,
entity_id TEXT,
payload_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_level_created_at ON events(level, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_source_created_at ON events(source_module, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_event_type_created_at ON events(event_type, created_at DESC);
CREATE TABLE IF NOT EXISTS servers_api_ping_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,
interval_sec INTEGER NOT NULL DEFAULT 120,
last_collected_at TEXT,
last_duration_ms INTEGER,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS servers_rest_ping_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
sampled_at TEXT NOT NULL,
ok INTEGER NOT NULL DEFAULT 0,
latency_ms INTEGER,
error TEXT,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_servers_rest_ping_samples_server_id
ON servers_rest_ping_samples(server_id, id DESC);
CREATE TABLE IF NOT EXISTS alert_gre_tunnel_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sampled_at TEXT NOT NULL,
target_label TEXT NOT NULL,
status TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_alert_gre_tunnel_samples_label_id
ON alert_gre_tunnel_samples(target_label, id DESC);
CREATE TABLE IF NOT EXISTS alert_bgp_peer_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sampled_at TEXT NOT NULL,
peer_key TEXT NOT NULL,
state TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_alert_bgp_peer_samples_key_id
ON alert_bgp_peer_samples(peer_key, id DESC);
CREATE TABLE IF NOT EXISTS evobgp_settings (
id INTEGER PRIMARY KEY,
base_url TEXT NOT NULL DEFAULT '',
api_key TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS alert_telegram_settings (
id INTEGER PRIMARY KEY,
bot_token TEXT NOT NULL DEFAULT '',
chat_id TEXT NOT NULL DEFAULT '',
message_thread_id INTEGER,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS acme_settings (
id INTEGER PRIMARY KEY,
directory_url TEXT NOT NULL DEFAULT 'https://acme-v02.api.letsencrypt.org/directory',
cloudflare_api_token TEXT NOT NULL DEFAULT '',
default_zone_id TEXT NOT NULL DEFAULT '',
account_private_key TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
id TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'queued',
step TEXT NOT NULL DEFAULT 'queued',
source TEXT NOT NULL DEFAULT 'manual',
server_id TEXT NOT NULL,
cert_name TEXT NOT NULL,
domain_names TEXT NOT NULL,
key_type TEXT NOT NULL DEFAULT 'rsa2048',
trust_store TEXT NOT NULL DEFAULT 'www,api',
requested_at TEXT NOT NULL DEFAULT (datetime('now')),
started_at TEXT,
finished_at TEXT,
error TEXT
);
CREATE TABLE IF NOT EXISTS certificate_renew_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
interval_sec INTEGER NOT NULL DEFAULT 21600,
renew_before_days INTEGER NOT NULL DEFAULT 30,
last_collected_at TEXT,
last_duration_ms INTEGER,
last_error TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS backup_schedule_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
frequency TEXT NOT NULL DEFAULT 'daily',
hour INTEGER NOT NULL DEFAULT 3,
minute INTEGER NOT NULL DEFAULT 0,
week_day INTEGER NOT NULL DEFAULT 0,
month_day INTEGER NOT NULL DEFAULT 1,
keep_count INTEGER NOT NULL DEFAULT 7,
format TEXT NOT NULL DEFAULT 'rsc',
server_ids_json TEXT NOT NULL DEFAULT '[]',
last_run_at TEXT,
last_duration_ms INTEGER,
last_error TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS backup_entries (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL,
server_name TEXT NOT NULL,
filename TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
kind TEXT NOT NULL DEFAULT 'manual',
notes TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_backup_entries_server_created ON backup_entries(server_id, created_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_backup_entries_filename ON backup_entries(filename);
CREATE TABLE IF NOT EXISTS alert_rules (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
target TEXT NOT NULL,
condition TEXT NOT NULL,
severity TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
cooldown TEXT NOT NULL DEFAULT '5м',
rule_chat_id TEXT NOT NULL DEFAULT '',
recovery_mode TEXT NOT NULL DEFAULT 'always',
recovery_stability_sec INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS alert_history (
id TEXT PRIMARY KEY,
rule_id TEXT,
rule_name TEXT NOT NULL,
severity TEXT NOT NULL,
message TEXT NOT NULL,
sent_ok INTEGER NOT NULL DEFAULT 1,
fired_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_alert_history_fired_at ON alert_history(fired_at);
CREATE INDEX IF NOT EXISTS idx_alert_history_rule_id ON alert_history(rule_id);
CREATE TABLE IF NOT EXISTS alert_groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
combine_mode TEXT NOT NULL DEFAULT 'any',
enabled INTEGER NOT NULL DEFAULT 1,
cooldown_override TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS alert_rule_targets (
id TEXT PRIMARY KEY,
rule_id TEXT NOT NULL,
target TEXT NOT NULL,
sort_index INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_alert_rule_targets_rule ON alert_rule_targets(rule_id);
CREATE TABLE IF NOT EXISTS alert_rule_conditions (
id TEXT PRIMARY KEY,
rule_id TEXT NOT NULL,
condition_line TEXT NOT NULL,
sort_index INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_alert_rule_conditions_rule ON alert_rule_conditions(rule_id);
CREATE TABLE IF NOT EXISTS alert_engine_state (
scope_key TEXT PRIMARY KEY,
last_fired_at TEXT NOT NULL DEFAULT '',
last_payload_hash TEXT
);
CREATE TABLE IF NOT EXISTS alert_engine_prev_live (
kind TEXT PRIMARY KEY,
payload_json TEXT NOT NULL DEFAULT '{}',
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS alert_engine_confirm_pending (
rule_id TEXT PRIMARY KEY,
payload_hash TEXT NOT NULL,
since_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS alert_destinations (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL DEFAULT 'telegram',
label TEXT NOT NULL DEFAULT '',
telegram_chat_id TEXT NOT NULL DEFAULT '',
message_thread_id INTEGER,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS alert_outbox (
id TEXT PRIMARY KEY,
dedupe_key TEXT NOT NULL,
channel TEXT NOT NULL DEFAULT 'telegram',
status TEXT NOT NULL DEFAULT 'pending',
retry_count INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3,
next_attempt_at TEXT NOT NULL,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
sent_at TEXT,
last_error TEXT
);
CREATE INDEX IF NOT EXISTS idx_alert_outbox_status_next_attempt
ON alert_outbox(status, next_attempt_at);
CREATE INDEX IF NOT EXISTS idx_alert_outbox_dedupe
ON alert_outbox(dedupe_key);
CREATE TABLE IF NOT EXISTS alert_engine_cursor (
id INTEGER PRIMARY KEY,
last_source_finished_at TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`)
// Lightweight schema evolution for existing databases without migrations
const recursiveCols = sqlite.prepare(`PRAGMA table_info('recursive_routes')`).all() as Array<{ name?: string }>
const hasCountryColumn = recursiveCols.some((c) => c.name === "country")
if (!hasCountryColumn) {
sqlite.exec(`ALTER TABLE recursive_routes ADD COLUMN country TEXT NOT NULL DEFAULT ''`)
} }
const uptimeProbeCols = sqlite.prepare(`PRAGMA table_info('uptime_probes')`).all() as Array<{ name?: string }> export const pool = createPool(isMainThread ? 16 : 4)
const hasSrcInterfaceColumn = uptimeProbeCols.some((c) => c.name === "src_interface") export const db = drizzle(pool, { schema })
if (!hasSrcInterfaceColumn) {
sqlite.exec(`ALTER TABLE uptime_probes ADD COLUMN src_interface TEXT NOT NULL DEFAULT ''`) export async function dbQuery<T extends pg.QueryResultRow = pg.QueryResultRow>(
} text: string,
const hasShowOnDashboardColumn = uptimeProbeCols.some((c) => c.name === "show_on_dashboard") params?: unknown[] | Record<string, unknown>,
if (!hasShowOnDashboardColumn) { ): Promise<pg.QueryResult<T>> {
sqlite.exec(`ALTER TABLE uptime_probes ADD COLUMN show_on_dashboard INTEGER NOT NULL DEFAULT 0`) const q = bindSql(text, params)
} return pool.query<T>(q)
const hasProbeIntervalSecColumn = uptimeProbeCols.some((c) => c.name === "interval_sec")
if (!hasProbeIntervalSecColumn) {
sqlite.exec(`ALTER TABLE uptime_probes ADD COLUMN interval_sec INTEGER NOT NULL DEFAULT 0`)
} }
const uptimeSettingsCols = sqlite.prepare(`PRAGMA table_info('uptime_settings')`).all() as Array<{ name?: string }> export async function dbAll<T extends pg.QueryResultRow = pg.QueryResultRow>(
const hasProbeIntervalColumn = uptimeSettingsCols.some((c) => c.name === "probe_interval_sec") text: string,
if (!hasProbeIntervalColumn) { params?: unknown[] | Record<string, unknown>,
sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN probe_interval_sec INTEGER NOT NULL DEFAULT 15`) ): Promise<T[]> {
} const res = await dbQuery<T>(text, params)
const hasSpeedIntervalColumn = uptimeSettingsCols.some((c) => c.name === "speed_interval_sec") return res.rows
if (!hasSpeedIntervalColumn) {
sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN speed_interval_sec INTEGER NOT NULL DEFAULT 60`)
} }
const hasUptimeJobFlags = uptimeSettingsCols.some((c) => c.name === "resources_enabled") export async function dbGet<T extends pg.QueryResultRow = pg.QueryResultRow>(
if (!hasUptimeJobFlags) { text: string,
sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN resources_enabled INTEGER NOT NULL DEFAULT 1`) params?: unknown[] | Record<string, unknown>,
sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN ping_enabled INTEGER NOT NULL DEFAULT 1`) ): Promise<T | undefined> {
sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN speed_enabled INTEGER NOT NULL DEFAULT 1`) const rows = await dbAll<T>(text, params)
sqlite.exec( return rows[0]
`UPDATE uptime_settings SET resources_enabled = enabled, ping_enabled = enabled, speed_enabled = enabled WHERE id = 1`,
)
} }
const uptimeSpeedProbeCols = sqlite.prepare(`PRAGMA table_info('uptime_speed_probes')`).all() as Array<{ name?: string }> export async function withAdvisoryLock<T>(key: number, fn: () => Promise<T>): Promise<T> {
const ensureSpeedProbeCol = (name: string, ddl: string) => { const client = await pool.connect()
if (!uptimeSpeedProbeCols.some((c) => c.name === name)) sqlite.exec(ddl) try {
} await client.query("SELECT pg_advisory_lock($1)", [key])
ensureSpeedProbeCol("last_run_at", `ALTER TABLE uptime_speed_probes ADD COLUMN last_run_at TEXT`) return await fn()
ensureSpeedProbeCol("last_tx_avg_mbps", `ALTER TABLE uptime_speed_probes ADD COLUMN last_tx_avg_mbps REAL`) } finally {
ensureSpeedProbeCol("last_rx_avg_mbps", `ALTER TABLE uptime_speed_probes ADD COLUMN last_rx_avg_mbps REAL`)
ensureSpeedProbeCol("last_status", `ALTER TABLE uptime_speed_probes ADD COLUMN last_status TEXT`)
ensureSpeedProbeCol("last_error", `ALTER TABLE uptime_speed_probes ADD COLUMN last_error TEXT`)
ensureSpeedProbeCol("last_ping_rtt_ms", `ALTER TABLE uptime_speed_probes ADD COLUMN last_ping_rtt_ms INTEGER`)
ensureSpeedProbeCol("last_ping_loss_pct", `ALTER TABLE uptime_speed_probes ADD COLUMN last_ping_loss_pct INTEGER`)
ensureSpeedProbeCol("last_ping_at", `ALTER TABLE uptime_speed_probes ADD COLUMN last_ping_at TEXT`)
ensureSpeedProbeCol("last_ping_error", `ALTER TABLE uptime_speed_probes ADD COLUMN last_ping_error TEXT`)
const schedulerRunCols = sqlite.prepare(`PRAGMA table_info('scheduler_runs')`).all() as Array<{ name?: string }>
if (!schedulerRunCols.some((c) => c.name === "result_json")) {
sqlite.exec(`ALTER TABLE scheduler_runs ADD COLUMN result_json TEXT`)
}
const serverCols = sqlite.prepare(`PRAGMA table_info('servers')`).all() as Array<{ name?: string }>
if (!serverCols.some((c) => c.name === "lan_subnet")) {
sqlite.exec(`ALTER TABLE servers ADD COLUMN lan_subnet TEXT NOT NULL DEFAULT ''`)
}
if (!serverCols.some((c) => c.name === "wan_uplinks")) {
sqlite.exec(`ALTER TABLE servers ADD COLUMN wan_uplinks TEXT NOT NULL DEFAULT '[]'`)
}
const alertTgCols = sqlite.prepare(`PRAGMA table_info('alert_telegram_settings')`).all() as Array<{ name?: string }>
if (!alertTgCols.some((c) => c.name === "message_thread_id")) {
sqlite.exec(`ALTER TABLE alert_telegram_settings ADD COLUMN message_thread_id INTEGER`)
}
const alertRulesCols = sqlite.prepare(`PRAGMA table_info('alert_rules')`).all() as Array<{ name?: string }>
if (!alertRulesCols.some((c) => c.name === "group_id")) {
sqlite.exec(`ALTER TABLE alert_rules ADD COLUMN group_id TEXT`)
}
if (!alertRulesCols.some((c) => c.name === "confirm_stability_sec")) {
sqlite.exec(`ALTER TABLE alert_rules ADD COLUMN confirm_stability_sec INTEGER`)
}
if (!alertRulesCols.some((c) => c.name === "recovery_mode")) {
sqlite.exec(`ALTER TABLE alert_rules ADD COLUMN recovery_mode TEXT NOT NULL DEFAULT 'always'`)
}
if (!alertRulesCols.some((c) => c.name === "recovery_stability_sec")) {
sqlite.exec(`ALTER TABLE alert_rules ADD COLUMN recovery_stability_sec INTEGER`)
}
const alertHistoryCols = sqlite.prepare(`PRAGMA table_info('alert_history')`).all() as Array<{ name?: string }>
if (!alertHistoryCols.some((c) => c.name === "group_id")) {
sqlite.exec(`ALTER TABLE alert_history ADD COLUMN group_id TEXT`)
}
/** Одна строка target на правило из legacy-колонки `alert_rules.target` */
sqlite.exec(`
INSERT OR IGNORE INTO alert_rule_targets (id, rule_id, target, sort_index)
SELECT 'rt-' || id || '-0', id, target, 0 FROM alert_rules
WHERE id NOT IN (SELECT rule_id FROM alert_rule_targets)
`)
/** Одна строка условия из legacy `alert_rules.condition` */
sqlite.exec(`
INSERT OR IGNORE INTO alert_rule_conditions (id, rule_id, condition_line, sort_index)
SELECT 'rc-' || id || '-0', id, condition, 0 FROM alert_rules
WHERE id NOT IN (SELECT rule_id FROM alert_rule_conditions)
`)
sqlite.exec(`
INSERT INTO traffic_settings (id, enabled, interval_sec, retention_days)
SELECT 1, 1, 30, 14
WHERE NOT EXISTS (SELECT 1 FROM traffic_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO uptime_settings (id, enabled, interval_sec, retention_days)
SELECT 1, 1, 15, 14
WHERE NOT EXISTS (SELECT 1 FROM uptime_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO evobgp_settings (id, base_url, api_key, enabled)
SELECT 1, '', '', 0
WHERE NOT EXISTS (SELECT 1 FROM evobgp_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO servers_api_ping_settings (id, enabled, interval_sec)
SELECT 1, 0, 120
WHERE NOT EXISTS (SELECT 1 FROM servers_api_ping_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO internet_path_settings (id, enabled, interval_sec, retention_days)
SELECT 1, 1, 300, 14
WHERE NOT EXISTS (SELECT 1 FROM internet_path_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO alert_telegram_settings (id, bot_token, chat_id)
SELECT 1, '', ''
WHERE NOT EXISTS (SELECT 1 FROM alert_telegram_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO acme_settings (id, directory_url, cloudflare_api_token, default_zone_id, account_private_key)
SELECT 1, 'https://acme-v02.api.letsencrypt.org/directory', '', '', ''
WHERE NOT EXISTS (SELECT 1 FROM acme_settings WHERE id = 1);
`)
const certIssueJobCols = sqlite.prepare(`PRAGMA table_info('certificate_issue_jobs')`).all() as Array<{ name?: string }>
if (!certIssueJobCols.some((c) => c.name === "source")) {
sqlite.exec(`ALTER TABLE certificate_issue_jobs ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'`)
}
sqlite.exec(`
INSERT INTO certificate_renew_settings (id, enabled, interval_sec, renew_before_days)
SELECT 1, 1, 21600, 30
WHERE NOT EXISTS (SELECT 1 FROM certificate_renew_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO backup_schedule_settings (id, enabled, frequency, hour, minute, week_day, month_day, keep_count, format, server_ids_json)
SELECT 1, 1, 'daily', 3, 0, 0, 1, 7, 'rsc', '[]'
WHERE NOT EXISTS (SELECT 1 FROM backup_schedule_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO alert_engine_cursor (id, last_source_finished_at)
SELECT 1, NULL
WHERE NOT EXISTS (SELECT 1 FROM alert_engine_cursor WHERE id = 1);
`)
const backupEntryCount = sqlite.prepare(`SELECT COUNT(*) AS c FROM backup_entries`).get() as { c: number }
if (backupEntryCount.c === 0) {
const legacyIndexPath = path.resolve(process.cwd(), "storage", "backups", "index.json")
if (existsSync(legacyIndexPath)) {
try { try {
const parsed = JSON.parse(readFileSync(legacyIndexPath, "utf8")) as unknown await client.query("SELECT pg_advisory_unlock($1)", [key])
if (Array.isArray(parsed)) {
const insert = sqlite.prepare(`
INSERT OR IGNORE INTO backup_entries (id, server_id, server_name, filename, size_bytes, kind, notes, created_at)
VALUES (@id, @serverId, @serverName, @filename, @sizeBytes, @kind, @notes, @createdAt)
`)
for (const row of parsed) {
if (!row || typeof row !== "object") continue
const item = row as Record<string, unknown>
const id = String(item.id ?? "").trim()
const filename = String(item.filename ?? "").trim()
if (!id || !filename) continue
insert.run({
id,
serverId: String(item.serverId ?? ""),
serverName: String(item.serverName ?? ""),
filename,
sizeBytes: Number(item.sizeBytes ?? 0) || 0,
kind: item.kind === "auto" ? "auto" : "manual",
notes: item.notes == null ? null : String(item.notes),
createdAt: String(item.createdAt ?? new Date().toISOString()),
})
}
}
} catch { } catch {
/* legacy index.json не читается — пропускаем */ /* ignore */
} }
client.release()
} }
} }
export const db = drizzle(sqlite, { schema }) export async function closePool(): Promise<void> {
await pool.end()
/** Прямой доступ к better-sqlite3 для сложных read-only запросов (напр. /api/alerts). */ }
export const sqliteDatabase: SqliteHandle = sqlite
+43
View File
@@ -0,0 +1,43 @@
/** JSONB from node-pg may already be an object; SQLite leftovers may be strings. */
export function parseJsonObject(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>
}
if (typeof value === "string" && value.trim()) {
try {
const parsed = JSON.parse(value) as unknown
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>
}
} catch {
/* ignore */
}
}
return {}
}
export function parseJsonArray(value: unknown): unknown[] {
if (Array.isArray(value)) return value
if (typeof value === "string" && value.trim()) {
try {
const parsed = JSON.parse(value) as unknown
if (Array.isArray(parsed)) return parsed
} catch {
/* ignore */
}
}
return []
}
export function toJsonb(value: unknown): unknown {
if (value == null) return null
if (typeof value === "string") {
try {
return JSON.parse(value) as unknown
} catch {
return value
}
}
return value
}
+54
View File
@@ -0,0 +1,54 @@
import { readdirSync, readFileSync } from "node:fs"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import type { Pool } from "pg"
const FIRST_MIGRATION = "0000_postgresql.sql"
function migrationsDir(): string {
const here = dirname(fileURLToPath(import.meta.url))
const candidates = [
join(here, "..", "..", "drizzle"),
join(process.cwd(), "drizzle"),
join(process.cwd(), "backend", "drizzle"),
]
for (const dir of candidates) {
try {
readFileSync(join(dir, FIRST_MIGRATION), "utf8")
return dir
} catch {
/* try next */
}
}
throw new Error("Не найден backend/drizzle/0000_postgresql.sql")
}
function migrationId(file: string): string {
return file.replace(/\.sql$/i, "")
}
export async function applySqlMigrations(pool: Pool): Promise<void> {
await pool.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
id TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`)
const dir = migrationsDir()
const files = readdirSync(dir)
.filter((f) => /^\d{4}_.+\.sql$/i.test(f))
.sort((a, b) => a.localeCompare(b))
const applied = new Set(
(await pool.query<{ id: string }>(`SELECT id FROM schema_migrations`)).rows.map((r) => r.id),
)
for (const file of files) {
const id = migrationId(file)
if (applied.has(id)) continue
const sql = readFileSync(join(dir, file), "utf8")
await pool.query(sql)
await pool.query(
`INSERT INTO schema_migrations (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`,
[id],
)
}
}
+192
View File
@@ -0,0 +1,192 @@
import type { Pool } from "pg"
export type PartitionKind = "day" | "week" | "month"
export interface PartitionSpec {
parent: string
kind: PartitionKind
keepDays: number
}
/** Leaf-only: PG forbids storage params on partitioned parents (SQLSTATE 42809). */
const FACT_LEAF_STORAGE =
"fillfactor = 70, autovacuum_vacuum_scale_factor = 0.05, autovacuum_vacuum_cost_limit = 2000"
const FACT_PARENTS = new Set(["flow_hour_facts", "flow_daily_facts"])
export const PARTITION_SPECS: PartitionSpec[] = [
{ parent: "flow_buckets", kind: "day", keepDays: 4 },
{ parent: "flow_minute_stats", kind: "day", keepDays: 4 },
{ parent: "flow_minute_dims", kind: "day", keepDays: 4 },
{ parent: "flow_daily_dims", kind: "month", keepDays: 420 },
{ parent: "flow_hour_facts", kind: "day", keepDays: 3 },
{ parent: "flow_daily_facts", kind: "month", keepDays: 420 },
{ parent: "traffic_samples", kind: "week", keepDays: 21 },
{ parent: "servers_rest_ping_samples", kind: "week", keepDays: 35 },
{ parent: "uptime_probe_samples", kind: "week", keepDays: 21 },
{ parent: "uptime_resource_samples", kind: "week", keepDays: 21 },
{ parent: "server_snapshots", kind: "week", keepDays: 21 },
{ parent: "internet_path_snapshots", kind: "week", keepDays: 21 },
]
const WEEK_SLACK_DAYS = 7
async function resolveKeepDays(pool: Pool): Promise<Map<string, number>> {
const map = new Map(PARTITION_SPECS.map((s) => [s.parent, s.keepDays]))
try {
const traffic = await pool.query<{ retention_days: number }>(
`SELECT retention_days FROM traffic_settings WHERE id = 1`,
)
const td = Number(traffic.rows[0]?.retention_days)
if (Number.isFinite(td) && td > 0) map.set("traffic_samples", td + WEEK_SLACK_DAYS)
const uptime = await pool.query<{ retention_days: number }>(
`SELECT retention_days FROM uptime_settings WHERE id = 1`,
)
const ud = Number(uptime.rows[0]?.retention_days)
if (Number.isFinite(ud) && ud > 0) {
map.set("uptime_probe_samples", ud + WEEK_SLACK_DAYS)
map.set("uptime_resource_samples", ud + WEEK_SLACK_DAYS)
}
const path = await pool.query<{ retention_days: number }>(
`SELECT retention_days FROM internet_path_settings WHERE id = 1`,
)
const pd = Number(path.rows[0]?.retention_days)
if (Number.isFinite(pd) && pd > 0) map.set("internet_path_snapshots", pd + WEEK_SLACK_DAYS)
} catch {
/* settings may be absent mid-migration */
}
return map
}
function utcDate(d: Date): Date {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))
}
function addUtcDays(d: Date, n: number): Date {
const x = utcDate(d)
x.setUTCDate(x.getUTCDate() + n)
return x
}
function startOfWeekUtc(d: Date): Date {
const x = utcDate(d)
const day = x.getUTCDay() || 7
x.setUTCDate(x.getUTCDate() - (day - 1))
return x
}
function startOfMonthUtc(d: Date): Date {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1))
}
function fmtDay(d: Date): string {
return d.toISOString().slice(0, 10)
}
function partName(parent: string, kind: PartitionKind, start: Date): string {
if (kind === "day") return `${parent}_d_${fmtDay(start).replaceAll("-", "")}`
if (kind === "week") return `${parent}_w_${fmtDay(start).replaceAll("-", "")}`
return `${parent}_m_${start.getUTCFullYear()}${String(start.getUTCMonth() + 1).padStart(2, "0")}`
}
function rangeFor(kind: PartitionKind, ts: Date): { start: Date; end: Date } {
if (kind === "day") {
const start = utcDate(ts)
return { start, end: addUtcDays(start, 1) }
}
if (kind === "week") {
const start = startOfWeekUtc(ts)
return { start, end: addUtcDays(start, 7) }
}
const start = startOfMonthUtc(ts)
const end = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, 1))
return { start, end }
}
export async function ensurePartitionFor(
pool: Pool,
parent: string,
kind: PartitionKind,
ts: Date,
): Promise<string> {
const { start, end } = rangeFor(kind, ts)
const name = partName(parent, kind, start)
const from = fmtDay(start)
const to = fmtDay(end)
await pool.query(
`CREATE TABLE IF NOT EXISTS ${name} PARTITION OF ${parent} FOR VALUES FROM ('${from}') TO ('${to}')`,
)
if (FACT_PARENTS.has(parent)) {
await pool.query(`ALTER TABLE ${name} SET (${FACT_LEAF_STORAGE})`)
}
return name
}
export async function ensurePartitionsBetween(
pool: Pool,
parent: string,
kind: PartitionKind,
from: Date,
to: Date,
): Promise<void> {
const start = from.getTime() <= to.getTime() ? from : to
const end = from.getTime() <= to.getTime() ? to : from
for (let t = new Date(start.getTime()); t <= end; ) {
await ensurePartitionFor(pool, parent, kind, t)
if (kind === "day") t = addUtcDays(t, 1)
else if (kind === "week") t = addUtcDays(t, 7)
else t = new Date(Date.UTC(t.getUTCFullYear(), t.getUTCMonth() + 1, 1))
}
}
export async function ensurePartitionsAround(pool: Pool, around = new Date()): Promise<void> {
const keepDays = await resolveKeepDays(pool)
for (const spec of PARTITION_SPECS) {
const keep = keepDays.get(spec.parent) ?? spec.keepDays
const daysAhead = spec.kind === "month" ? 40 : spec.kind === "week" ? 21 : 8
const start = addUtcDays(around, -keep)
const end = addUtcDays(around, daysAhead)
for (let t = new Date(start.getTime()); t < end; ) {
await ensurePartitionFor(pool, spec.parent, spec.kind, t)
if (spec.kind === "day") t = addUtcDays(t, 1)
else if (spec.kind === "week") t = addUtcDays(t, 7)
else t = new Date(Date.UTC(t.getUTCFullYear(), t.getUTCMonth() + 1, 1))
}
}
}
export async function dropExpiredPartitions(pool: Pool, around = new Date()): Promise<void> {
const keepDays = await resolveKeepDays(pool)
for (const spec of PARTITION_SPECS) {
const keep = keepDays.get(spec.parent) ?? spec.keepDays
const cutoff = addUtcDays(around, -keep)
const { rows } = await pool.query<{ relname: string }>(
`SELECT c.relname
FROM pg_inherits i
JOIN pg_class c ON c.oid = i.inhrelid
JOIN pg_class p ON p.oid = i.inhparent
WHERE p.relname = $1`,
[spec.parent],
)
for (const row of rows) {
const m = row.relname.match(/_([dwm])_(\d{8}|\d{6})$/)
if (!m) continue
const stamp = m[2]
let start: Date
if (stamp.length === 6) {
start = new Date(Date.UTC(Number(stamp.slice(0, 4)), Number(stamp.slice(4, 6)) - 1, 1))
} else {
start = new Date(`${stamp.slice(0, 4)}-${stamp.slice(4, 6)}-${stamp.slice(6, 8)}T00:00:00Z`)
}
if (start < cutoff) {
await pool.query(`DROP TABLE IF EXISTS ${row.relname}`)
}
}
}
}
export function specForParent(parent: string): PartitionSpec | undefined {
return PARTITION_SPECS.find((s) => s.parent === parent)
}
+202
View File
@@ -0,0 +1,202 @@
import assert from "node:assert/strict"
import { withPgOrSkip } from "../test/pg.js"
import { dbQuery, pool } from "./index.js"
import { applySqlMigrations } from "./migrate.js"
import { ensurePartitionFor } from "./partitions.js"
if (!(await withPgOrSkip())) {
console.log("pg-schema.test.ts: skip")
process.exit(0)
}
await applySqlMigrations(pool)
{
const { rows } = await dbQuery<{ n: string }>(`SELECT COUNT(*)::text AS n FROM servers`)
assert.ok(rows[0])
}
{
await dbQuery(`
INSERT INTO servers (name, host) VALUES ('pg-schema-test', '127.0.0.1')
`)
const { rows } = await dbQuery<{ id: number }>(`SELECT id FROM servers WHERE name = 'pg-schema-test' LIMIT 1`)
const id = rows[0]?.id
assert.ok(id)
await ensurePartitionFor(pool, "flow_buckets", "day", new Date())
const bucketAt = new Date().toISOString()
await dbQuery(`
INSERT INTO flow_buckets (
server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface
) VALUES ($1, $2, '10.0.0.1', '8.8.8.8', 6, 1, 443, 10, 1, 'wg-flow')
ON CONFLICT DO NOTHING
`, [id, bucketAt])
await dbQuery(`DELETE FROM flow_buckets WHERE server_id = $1`, [id])
await dbQuery(`DELETE FROM servers WHERE id = $1`, [id])
}
{
await dbQuery(`
INSERT INTO alert_outbox (id, dedupe_key, payload_json, next_attempt_at)
VALUES ('pg-test-1', 'pg-dedupe-key', '{}'::jsonb, now())
ON CONFLICT (dedupe_key) DO NOTHING
`)
let threw = false
try {
await dbQuery(`
INSERT INTO alert_outbox (id, dedupe_key, payload_json, next_attempt_at)
VALUES ('pg-test-2', 'pg-dedupe-key', '{}'::jsonb, now())
`)
} catch {
threw = true
}
assert.equal(threw, true, "alert_outbox.dedupe_key UNIQUE")
await dbQuery(`DELETE FROM alert_outbox WHERE dedupe_key = 'pg-dedupe-key'`)
}
{
const peers = [{ endpoint: "msk-gw02.rtnt.top:13232", publicKey: "x" }]
await dbQuery(
`INSERT INTO alert_outbox (id, dedupe_key, payload_json, next_attempt_at)
VALUES ('pg-json-arr', 'pg-json-arr', $1::jsonb, now())`,
[JSON.stringify(peers)],
)
const { rows } = await dbQuery<{ payload_json: unknown }>(
`SELECT payload_json FROM alert_outbox WHERE id = 'pg-json-arr'`,
)
assert.equal(Array.isArray(rows[0]?.payload_json), true)
await dbQuery(`DELETE FROM alert_outbox WHERE id = 'pg-json-arr'`)
let arrayAsPgArrayFailed = false
try {
await dbQuery(
`INSERT INTO alert_outbox (id, dedupe_key, payload_json, next_attempt_at)
VALUES ('pg-json-bad', 'pg-json-bad', $1, now())`,
[peers],
)
} catch (err) {
arrayAsPgArrayFailed = err instanceof Error && /json|22P02/i.test(err.message)
}
await dbQuery(`DELETE FROM alert_outbox WHERE id = 'pg-json-bad'`).catch(() => undefined)
assert.equal(arrayAsPgArrayFailed, true, "JS array must not be bound as jsonb without stringify")
}
{
const { rows } = await dbQuery<{ attname: string }>(`
SELECT a.attname
FROM pg_index i
JOIN unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord) ON true
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum
WHERE i.indrelid = 'traffic_samples'::regclass AND i.indisprimary
ORDER BY k.ord
`)
assert.deepEqual(
rows.map((r) => r.attname),
["server_id", "sampled_at", "interface_name", "peer_public_key"],
"traffic_samples PK без id",
)
}
{
const { rows } = await dbQuery<{ column_name: string }>(`
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'traffic_samples'
`)
const cols = new Set(rows.map((r) => r.column_name))
assert.equal(cols.has("id"), false)
assert.equal(cols.has("running"), false)
assert.equal(cols.has("disabled"), false)
assert.equal(cols.has("flags"), true)
}
{
const { rows } = await dbQuery<{ column_name: string; udt_name: string }>(`
SELECT column_name, udt_name
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'flow_buckets'
AND column_name IN ('src', 'dst', 'next_hop', 'proto', 'nat_src', 'nat_dst', 'nat_src_port', 'nat_dst_port')
`)
const by = Object.fromEntries(rows.map((r) => [r.column_name, r.udt_name]))
assert.equal(by.src, "inet")
assert.equal(by.dst, "inet")
assert.equal(by.next_hop, "inet")
assert.equal(by.proto, "int2")
assert.equal(by.nat_src, "inet")
assert.equal(by.nat_dst, "inet")
assert.equal(by.nat_src_port, "int4")
assert.equal(by.nat_dst_port, "int4")
}
{
const { rows } = await dbQuery<{ indexdef: string }>(`
SELECT indexdef FROM pg_indexes
WHERE schemaname = 'public' AND tablename = 'traffic_samples'
`)
const defs = rows.map((r) => r.indexdef.toLowerCase())
assert.equal(defs.some((d) => d.includes("using brin")), false, "нет BRIN на traffic_samples")
assert.equal(
defs.filter((d) => d.includes("idx_traffic_samples_server_iface_time")).length,
1,
"один btree (server_id, interface_name, sampled_at)",
)
assert.equal(defs.some((d) => d.includes("idx_traffic_samples_server_time")), false)
}
{
const { rows } = await dbQuery<{ column_name: string }>(`
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'uptime_resource_samples'
`)
const cols = new Set(rows.map((r) => r.column_name))
assert.equal(cols.has("board_name"), false)
assert.equal(cols.has("ros_version"), false)
}
{
const mig = await dbQuery<{ id: string }>(
`SELECT id FROM schema_migrations WHERE id = '0002_compact_schema'`,
)
assert.equal(mig.rows.length, 1, "0002 применена")
await dbQuery(`INSERT INTO servers (name, host) VALUES ('pg-wipe-idempotent', '127.0.0.1')`)
await applySqlMigrations(pool)
const still = await dbQuery<{ n: string }>(
`SELECT COUNT(*)::text AS n FROM servers WHERE name = 'pg-wipe-idempotent'`,
)
assert.equal(still.rows[0]?.n, "1", "повторный applySqlMigrations не wipe")
await dbQuery(`DELETE FROM servers WHERE name = 'pg-wipe-idempotent'`)
}
{
const mig = await dbQuery<{ id: string }>(
`SELECT id FROM schema_migrations WHERE id = '0006_config_revisions'`,
)
assert.equal(mig.rows.length, 1, "0006 применена")
const { rows } = await dbQuery<{ column_name: string; udt_name: string }>(`
SELECT column_name, udt_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'config_revisions'
`)
const by = Object.fromEntries(rows.map((r) => [r.column_name, r.udt_name]))
assert.equal(by.payload, "jsonb")
assert.equal(by.fingerprint, "text")
assert.equal(by.section, "text")
}
{
const mig = await dbQuery<{ id: string }>(
`SELECT id FROM schema_migrations WHERE id = '0007_flow_buckets_nat'`,
)
assert.equal(mig.rows.length, 1, "0007 применена")
}
{
const marker = await dbQuery<{ sqlite_imported_at: string | null }>(
`SELECT sqlite_imported_at FROM data_migration WHERE id = 1`,
)
assert.ok(marker.rows[0], "data_migration singleton после wipe")
}
console.log("pg-schema.test.ts: ok")
+493 -290
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
import assert from "node:assert/strict"
import { bindSql } from "./sql-bind.js"
{
const q = bindSql("SELECT 1")
assert.deepEqual(q, { text: "SELECT 1", values: [] })
}
{
const q = bindSql("SELECT * FROM t WHERE a = ? AND b = ?", [1, "x"])
assert.equal(q.text, "SELECT * FROM t WHERE a = $1 AND b = $2")
assert.deepEqual(q.values, [1, "x"])
}
{
const q = bindSql("INSERT INTO t (a, b) VALUES (@a, @b)", { a: 3, b: "y" })
assert.equal(q.text, "INSERT INTO t (a, b) VALUES ($1, $2)")
assert.deepEqual(q.values, [3, "y"])
}
console.log("sql-bind.test.ts: ok")
+22
View File
@@ -0,0 +1,22 @@
/** Convert SQLite-style @name / ? placeholders to node-pg $n. */
export function bindSql(
sql: string,
params?: unknown[] | Record<string, unknown>,
): { text: string; values: unknown[] } {
if (params == null) return { text: sql, values: [] }
if (Array.isArray(params)) {
let i = 0
const text = sql.replace(/\?/g, () => {
i += 1
return `$${i}`
})
return { text, values: params }
}
const values: unknown[] = []
const text = sql.replace(/@([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name: string) => {
values.push(params[name])
return `$${values.length}`
})
return { text, values }
}
+537
View File
@@ -0,0 +1,537 @@
import { createHash } from "node:crypto"
import { existsSync, readFileSync } from "node:fs"
import Database from "better-sqlite3"
import type { Pool } from "pg"
import { env } from "../config.js"
import { ensurePartitionsBetween, specForParent } from "./partitions.js"
import { encodeTrafficFlags } from "./traffic-flags.js"
export interface ImportReport {
sqlitePath: string
sqliteSha256: string
tables: Record<string, { sqlite: number; copied: number; skipped: number }>
rejects: string[]
durationMs: number
}
const SNAPSHOT_RETENTION_DAYS = 14
type ColKind = "ts" | "date" | "bool" | "json" | "json-null" | "bigint-id" | "int" | "text" | "num" | "flags" | "inet"
const INSERT_CHUNK = 1000
interface TableCopy {
table: string
columns: Array<[string, ColKind]>
timeCol?: string
retentionDays?: number
identity?: boolean
upsert?: boolean
}
const TABLES: TableCopy[] = [
{ table: "servers", identity: true, columns: [
["id", "int"], ["name", "text"], ["host", "text"], ["port", "int"], ["username", "text"],
["password", "text"], ["use_ssl", "bool"], ["verify_ssl", "bool"], ["type", "text"],
["site", "text"], ["country", "text"], ["asn", "text"], ["comment", "text"], ["enabled", "bool"],
["lan_subnet", "text"], ["wan_uplinks", "json"], ["mgmt_tunnel_ip", "text"],
["created_at", "ts"], ["updated_at", "ts"],
]},
{ table: "traffic_settings", upsert: true, columns: [
["id", "int"], ["enabled", "bool"], ["interval_sec", "int"], ["retention_days", "int"],
["last_collected_at", "ts"], ["last_duration_ms", "int"], ["last_error", "text"],
["created_at", "ts"], ["updated_at", "ts"],
]},
{ table: "servers_api_ping_settings", upsert: true, columns: [
["id", "int"], ["enabled", "bool"], ["interval_sec", "int"],
["last_collected_at", "ts"], ["last_duration_ms", "int"], ["last_error", "text"],
["created_at", "ts"], ["updated_at", "ts"],
]},
{ table: "traffic_flow_settings", upsert: true, columns: [
["id", "int"], ["enabled", "bool"], ["collector_ip", "text"], ["flow_listen_port", "int"],
["wg_listen_port", "int"], ["prefix", "text"], ["public_endpoint", "text"],
["host_public_key", "text"], ["host_private_key", "text"], ["hub_server_id", "int"],
["retention_hours", "int"], ["top_n", "int"], ["map_service_min_share_pct", "num"],
["last_datagram_at", "ts"], ["last_exporter_ip", "text"], ["last_error", "text"],
["packets_received", "int"], ["peers_json", "json"], ["created_at", "ts"], ["updated_at", "ts"],
]},
{ table: "uptime_settings", upsert: true, columns: [
["id", "int"], ["enabled", "bool"], ["resources_enabled", "bool"], ["ping_enabled", "bool"],
["speed_enabled", "bool"], ["interval_sec", "int"], ["probe_interval_sec", "int"],
["speed_interval_sec", "int"], ["retention_days", "int"], ["last_collected_at", "ts"],
["last_duration_ms", "int"], ["last_error", "text"], ["created_at", "ts"], ["updated_at", "ts"],
]},
{ table: "evobgp_settings", upsert: true, columns: [
["id", "int"], ["base_url", "text"], ["api_key", "text"], ["enabled", "bool"], ["updated_at", "ts"],
]},
{ table: "alert_telegram_settings", upsert: true, columns: [
["id", "int"], ["bot_token", "text"], ["chat_id", "text"], ["message_thread_id", "int"], ["updated_at", "ts"],
]},
{ table: "acme_settings", upsert: true, columns: [
["id", "int"], ["directory_url", "text"], ["cloudflare_api_token", "text"],
["default_zone_id", "text"], ["account_private_key", "text"], ["updated_at", "ts"],
]},
{ table: "certificate_renew_settings", upsert: true, columns: [
["id", "int"], ["enabled", "bool"], ["interval_sec", "int"], ["renew_before_days", "int"],
["last_collected_at", "ts"], ["last_duration_ms", "int"], ["last_error", "text"], ["updated_at", "ts"],
]},
{ table: "backup_schedule_settings", upsert: true, columns: [
["id", "int"], ["enabled", "bool"], ["frequency", "text"], ["hour", "int"], ["minute", "int"],
["week_day", "int"], ["month_day", "int"], ["keep_count", "int"], ["format", "text"],
["server_ids_json", "json"], ["last_run_at", "ts"], ["last_duration_ms", "int"],
["last_error", "text"], ["updated_at", "ts"],
]},
{ table: "backup_storage_settings", upsert: true, columns: [
["id", "int"], ["provider", "text"], ["s3_endpoint", "text"], ["s3_region", "text"],
["s3_bucket", "text"], ["s3_prefix", "text"], ["s3_access_key_id", "text"],
["s3_secret_access_key", "text"], ["s3_force_path_style", "bool"], ["keep_local_copy", "bool"],
["last_test_at", "ts"], ["last_test_error", "text"], ["updated_at", "ts"],
]},
{ table: "internet_path_settings", upsert: true, columns: [
["id", "int"], ["enabled", "bool"], ["interval_sec", "int"], ["retention_days", "int"],
["last_collected_at", "ts"], ["last_duration_ms", "int"], ["last_error", "text"],
["created_at", "ts"], ["updated_at", "ts"],
]},
{ table: "alert_engine_cursor", upsert: true, columns: [
["id", "int"], ["last_source_finished_at", "ts"], ["updated_at", "ts"],
]},
{ table: "filter_rules", identity: true, columns: [
["id", "int"], ["server_id", "int"], ["sort_order", "int"], ["community", "text"],
["community_name", "text"], ["action", "text"], ["gateway", "text"],
["gateway_tunnel_id", "text"], ["description", "text"], ["created_at", "ts"], ["updated_at", "ts"],
]},
{ table: "recursive_routes", identity: true, columns: [
["id", "int"], ["server_id", "int"], ["sort_order", "int"], ["dst_address", "text"],
["gateway", "text"], ["distance", "int"], ["scope", "int"], ["target_scope", "int"],
["routing_table", "text"], ["check_gateway", "text"], ["country", "text"], ["comment", "text"],
["disabled", "bool"], ["created_at", "ts"], ["updated_at", "ts"],
]},
{ table: "server_snapshots", identity: true, timeCol: "polled_at", retentionDays: SNAPSHOT_RETENTION_DAYS, columns: [
["id", "int"], ["server_id", "int"], ["polled_at", "ts"], ["status", "text"], ["latency_ms", "num"],
["ros_version", "text"], ["board_name", "text"], ["uptime", "text"], ["cpu_load", "int"],
["free_memory", "int"], ["total_memory", "int"], ["identity_name", "text"],
["raw_interfaces", "json-null"], ["raw_ip_addresses", "json-null"],
]},
{ table: "traffic_samples", timeCol: "sampled_at", retentionDays: 14, columns: [
["server_id", "int"], ["interface_name", "text"], ["peer_public_key", "text"],
["sampled_at", "ts"], ["rx_bytes", "int"], ["tx_bytes", "int"], ["rx_bps", "int"], ["tx_bps", "int"],
["flags", "flags"],
]},
{ table: "servers_rest_ping_samples", timeCol: "sampled_at", retentionDays: 30, columns: [
["server_id", "int"], ["sampled_at", "ts"], ["ok", "bool"], ["latency_ms", "int"], ["error", "text"],
]},
{ table: "flow_buckets", timeCol: "bucket_at", retentionDays: 2, columns: [
["server_id", "int"], ["bucket_at", "ts"], ["src", "inet"], ["dst", "inet"], ["proto", "int"],
["src_port", "int"], ["dst_port", "int"], ["bytes", "int"], ["packets", "int"],
["in_iface", "text"], ["out_iface", "text"], ["next_hop", "inet"],
["flow_start_ms", "int"], ["flow_end_ms", "int"],
["nat_src", "inet"], ["nat_dst", "inet"], ["nat_src_port", "int"], ["nat_dst_port", "int"],
]},
{ table: "flow_minute_stats", timeCol: "bucket_at", retentionDays: 3, columns: [
["server_id", "int"], ["bucket_at", "ts"], ["bytes", "int"], ["packets", "int"],
["unique_src", "int"], ["unique_dst", "int"], ["conversations", "int"],
]},
{ table: "flow_minute_dims", timeCol: "bucket_at", retentionDays: 3, columns: [
["server_id", "int"], ["bucket_at", "ts"], ["dim", "text"], ["key", "text"], ["bytes", "int"], ["packets", "int"],
]},
{ table: "flow_daily_dims", timeCol: "day", retentionDays: 396, columns: [
["server_id", "int"], ["day", "date"], ["dim", "text"], ["key", "text"], ["bytes", "int"], ["packets", "int"],
]},
{ table: "flow_ip_meta", columns: [
["prefix", "text"], ["asn", "int"], ["country", "text"], ["lat", "num"], ["lng", "num"],
["holder", "text"], ["ok", "int"], ["fetched_at", "ts"],
]},
{ table: "flow_asn_meta", columns: [
["asn", "int"], ["holder", "text"], ["fetched_at", "ts"],
]},
{ table: "uptime_probes", columns: [
["id", "text"], ["src_server_id", "int"], ["src_interface", "text"], ["name", "text"],
["target", "text"], ["probe_filter", "text"], ["enabled", "bool"], ["interval_sec", "int"],
["show_on_dashboard", "bool"], ["sort_order", "int"], ["created_at", "ts"], ["updated_at", "ts"],
]},
{ table: "uptime_probe_samples", timeCol: "sampled_at", retentionDays: 14, columns: [
["probe_id", "text"], ["sampled_at", "ts"], ["rtt_ms", "int"], ["loss_pct", "int"], ["status", "text"],
]},
{ table: "uptime_resource_samples", timeCol: "sampled_at", retentionDays: 14, columns: [
["server_id", "int"], ["sampled_at", "ts"], ["status", "text"], ["cpu_load", "int"],
["free_memory", "int"], ["total_memory", "int"], ["free_hdd_space", "int"], ["total_hdd_space", "int"],
["uptime_seconds", "int"],
]},
{ table: "uptime_speed_probes", columns: [
["id", "text"], ["src_server_id", "int"], ["dst_server_id", "int"], ["src_interface", "text"],
["dst_interface", "text"], ["protocol", "text"], ["direction", "text"], ["duration_sec", "int"],
["enabled", "bool"], ["last_run_at", "ts"], ["last_tx_avg_mbps", "num"], ["last_rx_avg_mbps", "num"],
["last_status", "text"], ["last_error", "text"], ["last_ping_rtt_ms", "int"],
["last_ping_loss_pct", "int"], ["last_ping_at", "ts"], ["last_ping_error", "text"],
["sort_order", "int"], ["created_at", "ts"], ["updated_at", "ts"],
]},
{ table: "uptime_speed_test_runs", columns: [
["id", "text"], ["probe_id", "text"], ["src_server_id", "int"], ["dst_server_id", "int"],
["src_interface", "text"], ["dst_interface", "text"], ["src_address", "text"], ["dst_address", "text"],
["src_interface_address", "text"], ["dst_interface_address", "text"], ["protocol", "text"],
["direction", "text"], ["duration_sec", "int"], ["tx_avg_mbps", "num"], ["rx_avg_mbps", "num"],
["ping_rtt_ms", "int"], ["ping_loss_pct", "int"], ["ping_error", "text"], ["status", "text"],
["error", "text"], ["created_at", "ts"],
]},
{ table: "app_users", columns: [
["id", "text"], ["name", "text"], ["login", "text"], ["email", "text"], ["role", "text"],
["active", "bool"], ["avatar", "text"], ["last_seen", "ts"], ["sections_json", "json"],
["servers_json", "json"], ["created_at", "ts"], ["updated_at", "ts"],
]},
{ table: "user_interface_bindings", columns: [
["id", "text"], ["user_id", "text"], ["server_id", "int"], ["interface_name", "text"],
["interface_type", "text"], ["peer_public_key", "text"], ["peer_name", "text"],
["comment", "text"], ["created_at", "ts"], ["updated_at", "ts"],
]},
{ table: "alert_groups", columns: [
["id", "text"], ["name", "text"], ["combine_mode", "text"], ["enabled", "bool"],
["cooldown_override", "text"], ["created_at", "ts"], ["updated_at", "ts"],
]},
{ table: "alert_rules", columns: [
["id", "text"], ["name", "text"], ["type", "text"], ["target", "text"], ["condition", "text"],
["severity", "text"], ["enabled", "bool"], ["cooldown", "text"], ["rule_chat_id", "text"],
["confirm_stability_sec", "int"], ["recovery_mode", "text"], ["recovery_stability_sec", "int"],
["group_id", "text"], ["created_at", "ts"], ["updated_at", "ts"],
]},
{ table: "alert_rule_targets", columns: [
["id", "text"], ["rule_id", "text"], ["target", "text"], ["sort_index", "int"],
]},
{ table: "alert_rule_conditions", columns: [
["id", "text"], ["rule_id", "text"], ["condition_line", "text"], ["sort_index", "int"],
]},
{ table: "alert_engine_state", columns: [
["scope_key", "text"], ["last_fired_at", "text"], ["last_payload_hash", "text"],
]},
{ table: "alert_engine_prev_live", columns: [
["kind", "text"], ["payload_json", "json"], ["updated_at", "ts"],
]},
{ table: "alert_engine_confirm_pending", columns: [
["rule_id", "text"], ["payload_hash", "text"], ["since_at", "ts"],
]},
{ table: "alert_history", columns: [
["id", "text"], ["rule_id", "text"], ["group_id", "text"], ["rule_name", "text"],
["severity", "text"], ["message", "text"], ["sent_ok", "bool"], ["fired_at", "ts"],
]},
{ table: "alert_outbox", columns: [
["id", "text"], ["dedupe_key", "text"], ["channel", "text"], ["status", "text"],
["retry_count", "int"], ["max_retries", "int"], ["next_attempt_at", "ts"],
["payload_json", "json"], ["created_at", "ts"], ["sent_at", "ts"], ["last_error", "text"],
]},
{ table: "certificate_issue_jobs", columns: [
["id", "text"], ["status", "text"], ["step", "text"], ["source", "text"],
["server_id", "bigint-id"], ["cert_name", "text"], ["domain_names", "json"],
["key_type", "text"], ["trust_store", "text"], ["requested_at", "ts"],
["started_at", "ts"], ["finished_at", "ts"], ["error", "text"],
]},
{ table: "backup_entries", columns: [
["id", "text"], ["server_id", "bigint-id"], ["server_name", "text"], ["filename", "text"],
["size_bytes", "int"], ["kind", "text"], ["notes", "text"], ["created_at", "ts"],
]},
{ table: "scheduler_runs", timeCol: "started_at", retentionDays: 30, columns: [
["id", "text"], ["job_key", "text"], ["started_at", "ts"], ["finished_at", "ts"],
["status", "text"], ["error", "text"], ["duration_ms", "int"], ["result_json", "json-null"],
]},
{ table: "events", timeCol: "created_at", retentionDays: 30, columns: [
["id", "text"], ["created_at", "ts"], ["level", "text"], ["event_type", "text"],
["source_module", "text"], ["title", "text"], ["message", "text"],
["entity_type", "text"], ["entity_id", "text"], ["payload_json", "json-null"],
]},
{ table: "internet_path_snapshots", identity: true, timeCol: "sampled_at", retentionDays: 14, columns: [
["id", "int"], ["sampled_at", "ts"], ["payload_json", "json"],
]},
]
function parseTs(value: unknown, strict: boolean, rejects: string[], ctx: string): string | null {
if (value == null || value === "") return null
const s = String(value)
try {
if (/^\d{4}-\d{2}-\d{2}T/.test(s)) return new Date(s).toISOString()
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(s)) {
return new Date(`${s.replace(" ", "T")}Z`).toISOString()
}
const d = new Date(s)
if (!Number.isNaN(d.getTime())) return d.toISOString()
} catch {
/* fallthrough */
}
const msg = `${ctx}: неразобранный timestamp ${s}`
if (strict) throw new Error(msg)
rejects.push(msg)
return null
}
function parseDate(value: unknown): string | null {
if (value == null || value === "") return null
return String(value).slice(0, 10)
}
function parseBool(value: unknown): boolean {
return value === true || value === 1 || value === "1" || value === "true"
}
function parseJson(value: unknown, fallback: unknown): unknown {
if (value == null || value === "") return fallback
if (typeof value !== "string") return value
try {
return JSON.parse(value) as unknown
} catch {
return fallback
}
}
function parseInet(value: unknown): string | null {
const s = String(value ?? "").trim()
if (!s) return null
return s
}
function coerce(
kind: ColKind,
value: unknown,
strict: boolean,
rejects: string[],
ctx: string,
row?: Record<string, unknown>,
): unknown {
switch (kind) {
case "ts":
return parseTs(value, strict, rejects, ctx)
case "date":
return parseDate(value)
case "bool":
return parseBool(value)
case "json":
// Always JSON text. JS arrays must not go to node-pg as values — it encodes
// them as PG arrays (`{...}`), which jsonb rejects (22P02).
return JSON.stringify(parseJson(value, []))
case "json-null":
return value == null || value === "" ? null : JSON.stringify(parseJson(value, null))
case "bigint-id": {
const t = String(value ?? "").trim()
if (!t) return null
const n = Number(t)
if (!Number.isFinite(n)) {
const msg = `${ctx}: server_id ${t}`
if (strict) throw new Error(msg)
rejects.push(msg)
return null
}
return n
}
case "int":
if (value == null || value === "") return null
return Number(value)
case "num":
if (value == null || value === "") return null
return Number(value)
case "text":
return value == null ? "" : String(value)
case "flags":
return encodeTrafficFlags(parseBool(row?.running), parseBool(row?.disabled))
case "inet":
return parseInet(value)
default:
return value == null ? null : String(value)
}
}
function sqliteTableExists(sqlite: Database.Database, name: string): boolean {
const row = sqlite.prepare(
`SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?`,
).get(name) as { ok?: number } | undefined
return Boolean(row)
}
async function setval(pool: Pool, table: string): Promise<void> {
await pool.query(
`SELECT setval(
pg_get_serial_sequence($1, 'id'),
GREATEST(COALESCE((SELECT MAX(id) FROM ${table}), 1), 1),
true
)`,
[table],
)
}
function placeholderFor(kind: ColKind, index: number): string {
if (kind === "json" || kind === "json-null") return `$${index}::jsonb`
if (kind === "inet") return `$${index}::inet`
return `$${index}`
}
async function precreatePartitions(
sqlite: Database.Database,
pool: Pool,
spec: TableCopy,
where: string,
): Promise<void> {
const part = specForParent(spec.table)
if (!part || !spec.timeCol) return
const bounds = sqlite.prepare(
`SELECT MIN(${spec.timeCol}) AS a, MAX(${spec.timeCol}) AS b FROM ${spec.table}${where}`,
).get() as { a?: unknown; b?: unknown }
if (bounds?.a == null || bounds?.b == null) return
const isDate = spec.columns.find((c) => c[0] === spec.timeCol)?.[1] === "date"
const fromIso = isDate
? `${String(bounds.a).slice(0, 10)}T00:00:00Z`
: parseTs(bounds.a, false, [], spec.table)
const toIso = isDate
? `${String(bounds.b).slice(0, 10)}T00:00:00Z`
: parseTs(bounds.b, false, [], spec.table)
if (!fromIso || !toIso) return
await ensurePartitionsBetween(pool, spec.table, part.kind, new Date(fromIso), new Date(toIso))
}
async function copyTable(
sqlite: Database.Database,
pool: Pool,
spec: TableCopy,
opts: { strict: boolean; fullHistory: boolean; rejects: string[] },
): Promise<{ sqlite: number; copied: number; skipped: number }> {
if (!sqliteTableExists(sqlite, spec.table)) {
return { sqlite: 0, copied: 0, skipped: 0 }
}
let where = ""
if (spec.timeCol && spec.retentionDays && !opts.fullHistory) {
const cutoff = new Date(Date.now() - spec.retentionDays * 86400_000).toISOString()
where = ` WHERE ${spec.timeCol} >= '${cutoff.replace("T", " ").slice(0, 19)}' OR ${spec.timeCol} >= '${cutoff}'`
}
const total = (sqlite.prepare(`SELECT COUNT(*) AS c FROM ${spec.table}${where}`).get() as { c: number }).c
await precreatePartitions(sqlite, pool, spec, where)
const cols = spec.columns.map(([c]) => c)
const conflictSql = spec.upsert
? `ON CONFLICT (id) DO UPDATE SET ${cols.filter((c) => c !== "id").map((c) => `${c} = EXCLUDED.${c}`).join(", ")}`
: `ON CONFLICT DO NOTHING`
let copied = 0
let skipped = 0
const stmt = sqlite.prepare(`SELECT * FROM ${spec.table}${where}`)
const batch: unknown[][] = []
const flush = async () => {
if (batch.length === 0) return
const n = spec.columns.length
const valuesSql = batch.map((_, i) =>
`(${spec.columns.map(([, kind], j) => placeholderFor(kind, i * n + j + 1)).join(", ")})`,
).join(", ")
const insertSql = `INSERT INTO ${spec.table} (${cols.join(", ")}) VALUES ${valuesSql} ${conflictSql}`
const client = await pool.connect()
try {
await client.query("BEGIN")
await client.query(insertSql, batch.flat())
copied += batch.length
await client.query("COMMIT")
} catch (err) {
await client.query("ROLLBACK")
const detail = err instanceof Error ? err.message : String(err)
throw new Error(`${spec.table}: ${detail}`)
} finally {
client.release()
batch.length = 0
}
}
for (const row of stmt.iterate() as Iterable<Record<string, unknown>>) {
try {
const values = spec.columns.map(([col, kind]) =>
coerce(kind, row[col], opts.strict, opts.rejects, `${spec.table}.${col}`, row),
)
if (spec.table === "certificate_issue_jobs" && values[4] == null) {
skipped += 1
continue
}
batch.push(values)
if (batch.length >= INSERT_CHUNK) await flush()
} catch (err) {
skipped += 1
const msg = `${spec.table}: ${err instanceof Error ? err.message : String(err)}`
opts.rejects.push(msg)
if (opts.strict) throw err
}
}
await flush()
if (spec.identity) {
try {
await setval(pool, spec.table)
} catch {
/* partitioned identity sequence name may differ */
}
}
return { sqlite: total, copied, skipped }
}
export function sqliteFileLooksPresent(path: string): boolean {
if (!existsSync(path) || path === ":memory:") return false
try {
const buf = readFileSync(path)
return buf.subarray(0, 16).toString("utf8").startsWith("SQLite format 3")
} catch {
return false
}
}
export async function shouldImportSqlite(pool: Pool, sqlitePath: string): Promise<boolean> {
if (!sqliteFileLooksPresent(sqlitePath)) return false
const marker = await pool.query<{ sqlite_imported_at: string | null }>(
`SELECT sqlite_imported_at FROM data_migration WHERE id = 1`,
)
if (marker.rows[0]?.sqlite_imported_at) return false
const servers = await pool.query<{ c: string }>(`SELECT COUNT(*)::text AS c FROM servers`)
if (Number(servers.rows[0]?.c ?? 0) > 0) {
console.warn(
"SQLite → PostgreSQL: повтор недописанного импорта (маркера нет, servers уже не пустые)",
)
}
return true
}
export async function importSqliteToPostgres(
pool: Pool,
sqlitePath: string,
opts?: { dryRun?: boolean; strict?: boolean; fullHistory?: boolean },
): Promise<ImportReport> {
const started = Date.now()
const rejects: string[] = []
const sqlite = new Database(sqlitePath, { readonly: true, fileMustExist: true })
try {
sqlite.pragma("journal_mode = WAL")
} catch {
/* readonly */
}
const sha = createHash("sha256").update(readFileSync(sqlitePath)).digest("hex")
const report: ImportReport = {
sqlitePath,
sqliteSha256: sha,
tables: {},
rejects,
durationMs: 0,
}
const fullHistory = opts?.fullHistory ?? env.sqliteImportFullHistory
const strict = opts?.strict ?? true
if (opts?.dryRun) {
for (const spec of TABLES) {
if (!sqliteTableExists(sqlite, spec.table)) {
report.tables[spec.table] = { sqlite: 0, copied: 0, skipped: 0 }
continue
}
const c = (sqlite.prepare(`SELECT COUNT(*) AS c FROM ${spec.table}`).get() as { c: number }).c
report.tables[spec.table] = { sqlite: c, copied: 0, skipped: 0 }
}
sqlite.close()
report.durationMs = Date.now() - started
return report
}
for (const spec of TABLES) {
console.log(`SQLite → PostgreSQL: таблица ${spec.table}`)
report.tables[spec.table] = await copyTable(sqlite, pool, spec, { strict, fullHistory, rejects })
}
sqlite.close()
await pool.query(
`UPDATE data_migration
SET sqlite_imported_at = now(), sqlite_path = $1, sqlite_sha256 = $2, report_json = $3::jsonb
WHERE id = 1`,
[sqlitePath, sha, JSON.stringify(report.tables)],
)
report.durationMs = Date.now() - started
if (strict && rejects.length > 0) {
throw new Error(`SQLite import strict: ${rejects.length} rejects\n${rejects.slice(0, 20).join("\n")}`)
}
return report
}
+11
View File
@@ -0,0 +1,11 @@
import assert from "node:assert/strict"
/** node-pg encodes a JS array as a PostgreSQL array (`{...}`), not JSON (`[...]`). */
{
const peers = [{ endpoint: "msk-gw02.rtnt.top:13232" }]
const asJson = JSON.stringify(peers)
assert.equal(asJson.startsWith("["), true)
assert.equal(asJson.includes("msk-gw02.rtnt.top:13232"), true)
}
console.log("sqlite-json.test.ts: ok")
+19
View File
@@ -0,0 +1,19 @@
import assert from "node:assert/strict"
import {
decodeTrafficSampleFlags,
encodeTrafficFlags,
trafficFlagDisabled,
trafficFlagRunning,
} from "./traffic-flags.js"
assert.equal(encodeTrafficFlags(true, false), 1)
assert.equal(encodeTrafficFlags(false, true), 2)
assert.equal(encodeTrafficFlags(true, true), 3)
assert.equal(encodeTrafficFlags(false, false), 0)
assert.equal(trafficFlagRunning(1), true)
assert.equal(trafficFlagDisabled(1), false)
assert.deepEqual(decodeTrafficSampleFlags(1), { running: true, disabled: false })
assert.deepEqual(decodeTrafficSampleFlags(0), { running: false, disabled: false })
assert.deepEqual(decodeTrafficSampleFlags(undefined), { running: false, disabled: false })
console.log("traffic-flags.test.ts: ok")
+24
View File
@@ -0,0 +1,24 @@
export const TRAFFIC_FLAG_RUNNING = 1
export const TRAFFIC_FLAG_DISABLED = 2
export function encodeTrafficFlags(running: boolean, disabled: boolean): number {
return (running ? TRAFFIC_FLAG_RUNNING : 0) | (disabled ? TRAFFIC_FLAG_DISABLED : 0)
}
export function trafficFlagRunning(flags: number | null | undefined): boolean {
return ((Number(flags) || 0) & TRAFFIC_FLAG_RUNNING) !== 0
}
export function trafficFlagDisabled(flags: number | null | undefined): boolean {
return ((Number(flags) || 0) & TRAFFIC_FLAG_DISABLED) !== 0
}
export function decodeTrafficSampleFlags(flags: number | null | undefined): {
running: boolean
disabled: boolean
} {
return {
running: trafficFlagRunning(flags),
disabled: trafficFlagDisabled(flags),
}
}
+67 -3
View File
@@ -1,7 +1,10 @@
import Fastify, { type FastifyInstance } from "fastify" import Fastify, { type FastifyError, type FastifyInstance } from "fastify"
import cors from "@fastify/cors" import cors from "@fastify/cors"
import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod" import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod"
import { monitorEventLoopDelay } from "node:perf_hooks"
import { env } from "./config.js" import { env } from "./config.js"
import { initDatabase } from "./db/bootstrap.js"
import { closePool } from "./db/index.js"
import authPlugin, { requireAuth } from "./plugins/auth.js" import authPlugin, { requireAuth } from "./plugins/auth.js"
import serversRoutes from "./routes/servers.js" import serversRoutes from "./routes/servers.js"
import bgpRoutes from "./routes/bgp.js" import bgpRoutes from "./routes/bgp.js"
@@ -10,6 +13,8 @@ import execRoutes from "./routes/exec.js"
import filtersRoutes from "./routes/filters.js" import filtersRoutes from "./routes/filters.js"
import recursiveRoutes from "./routes/recursive-routes.js" import recursiveRoutes from "./routes/recursive-routes.js"
import trafficRoutes from "./routes/traffic.js" import trafficRoutes from "./routes/traffic.js"
import trafficFlowRoutes from "./routes/traffic-flow.js"
import geoipRoutes from "./routes/geoip.js"
import serversApiPingRoutes from "./routes/servers-api-ping.js" import serversApiPingRoutes from "./routes/servers-api-ping.js"
import uptimeRoutes from "./routes/uptime.js" import uptimeRoutes from "./routes/uptime.js"
import networkRoutes from "./routes/network.js" import networkRoutes from "./routes/network.js"
@@ -24,8 +29,18 @@ import certificatesRoutes from "./routes/certificates.js"
import systemDatabaseRoutes from "./routes/system-database.js" import systemDatabaseRoutes from "./routes/system-database.js"
import eventsRoutes from "./routes/events.js" import eventsRoutes from "./routes/events.js"
import wireguardRoutes from "./routes/wireguard.js" import wireguardRoutes from "./routes/wireguard.js"
import vxlanRoutes from "./routes/vxlan.js"
import containersRoutes from "./routes/containers.js"
import firewallRoutes from "./routes/firewall.js" import firewallRoutes from "./routes/firewall.js"
import greRoutes from "./routes/gre.js"
import usersRoutes from "./routes/users.js"
import statisticsRoutes from "./routes/statistics.js"
import { refreshScheduler, stopScheduler } from "./services/scheduler.js" import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
import { getFlowWorkerHealth, startTrafficFlowListener, stopTrafficFlowListener } from "./services/traffic-flow-ingest.js"
import { initGeoip } from "./services/traffic-flow-geoip.js"
const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 })
eventLoopDelay.enable()
export async function buildApp(opts?: { export async function buildApp(opts?: {
logger?: boolean logger?: boolean
@@ -34,7 +49,7 @@ export async function buildApp(opts?: {
const usePrettyLogger = const usePrettyLogger =
opts?.logger !== false && process.env.NODE_ENV !== "production" opts?.logger !== false && process.env.NODE_ENV !== "production"
const app = Fastify({ const app = Fastify({
bodyLimit: 512 * 1024 * 1024, bodyLimit: 2 * 1024 * 1024,
requestTimeout: 10 * 60 * 1000, requestTimeout: 10 * 60 * 1000,
logger: logger:
opts?.logger === false opts?.logger === false
@@ -56,6 +71,18 @@ export async function buildApp(opts?: {
app.setValidatorCompiler(validatorCompiler) app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler) app.setSerializerCompiler(serializerCompiler)
app.setErrorHandler((error: FastifyError, request, reply) => {
const status = typeof error.statusCode === "number" && error.statusCode >= 400
? error.statusCode
: 500
if (status >= 500) {
request.log.error(error)
return reply.status(status).send({ error: "Внутренняя ошибка сервера" })
}
const message = error instanceof Error ? error.message : "Ошибка запроса"
return reply.status(status).send({ error: message })
})
await app.register(cors, { await app.register(cors, {
origin: env.CORS_ORIGIN, origin: env.CORS_ORIGIN,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
@@ -67,6 +94,8 @@ export async function buildApp(opts?: {
status: "ok", status: "ok",
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
version: process.env.APP_VERSION ?? "dev", version: process.env.APP_VERSION ?? "dev",
eventLoopDelayMs: Math.round(eventLoopDelay.mean / 1e6),
flowWorker: getFlowWorkerHealth(),
})) }))
app.get("/api/auth/config", async () => ({ app.get("/api/auth/config", async () => ({
@@ -92,6 +121,8 @@ export async function buildApp(opts?: {
await app.register(filtersRoutes, { prefix: "/api" }) await app.register(filtersRoutes, { prefix: "/api" })
await app.register(recursiveRoutes, { prefix: "/api" }) await app.register(recursiveRoutes, { prefix: "/api" })
await app.register(trafficRoutes, { prefix: "/api" }) await app.register(trafficRoutes, { prefix: "/api" })
await app.register(trafficFlowRoutes, { prefix: "/api" })
await app.register(geoipRoutes, { prefix: "/api" })
await app.register(serversApiPingRoutes, { prefix: "/api" }) await app.register(serversApiPingRoutes, { prefix: "/api" })
await app.register(uptimeRoutes, { prefix: "/api" }) await app.register(uptimeRoutes, { prefix: "/api" })
await app.register(networkRoutes, { prefix: "/api" }) await app.register(networkRoutes, { prefix: "/api" })
@@ -106,12 +137,20 @@ export async function buildApp(opts?: {
await app.register(systemDatabaseRoutes, { prefix: "/api" }) await app.register(systemDatabaseRoutes, { prefix: "/api" })
await app.register(eventsRoutes, { prefix: "/api" }) await app.register(eventsRoutes, { prefix: "/api" })
await app.register(wireguardRoutes, { prefix: "/api" }) await app.register(wireguardRoutes, { prefix: "/api" })
await app.register(vxlanRoutes, { prefix: "/api" })
await app.register(containersRoutes, { prefix: "/api" })
await app.register(firewallRoutes, { prefix: "/api" }) await app.register(firewallRoutes, { prefix: "/api" })
await app.register(greRoutes, { prefix: "/api" })
await app.register(usersRoutes, { prefix: "/api" })
await app.register(statisticsRoutes, { prefix: "/api" })
if (opts?.startScheduler !== false) { if (opts?.startScheduler !== false) {
refreshScheduler() await refreshScheduler()
await initGeoip()
await startTrafficFlowListener()
app.addHook("onClose", async () => { app.addHook("onClose", async () => {
stopScheduler() stopScheduler()
stopTrafficFlowListener()
}) })
} }
@@ -124,7 +163,32 @@ const isMain =
if (isMain) { if (isMain) {
try { try {
await initDatabase()
const app = await buildApp() const app = await buildApp()
let shuttingDown = false
const shutdown = async (code: number) => {
if (shuttingDown) return
shuttingDown = true
try {
stopTrafficFlowListener()
await app.close()
await closePool()
} catch (err) {
console.error(err)
} finally {
process.exit(code)
}
}
process.on("SIGTERM", () => { void shutdown(0) })
process.on("SIGINT", () => { void shutdown(0) })
process.on("uncaughtException", (err) => {
console.error(err)
void shutdown(1)
})
process.on("unhandledRejection", (reason) => {
console.error(reason)
void shutdown(1)
})
await app.listen({ port: env.PORT, host: "0.0.0.0" }) await app.listen({ port: env.PORT, host: "0.0.0.0" })
console.log( console.log(
`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`, `\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`,
+21 -2
View File
@@ -18,9 +18,13 @@ assert.equal(
"mm:settings:admin", "mm:settings:admin",
) )
assert.equal( assert.equal(
permissionForRequest("GET", "/api/traffic/servers/1/live"), permissionForRequest("GET", "/api/statistics"),
"mm:traffic:read", "mm:traffic:read",
) )
assert.equal(
permissionForRequest("POST", "/api/traffic/flow/purge"),
"mm:traffic:write",
)
assert.equal( assert.equal(
permissionForRequest("GET", "/api/unknown-thing"), permissionForRequest("GET", "/api/unknown-thing"),
"mm:dashboard:read", "mm:dashboard:read",
@@ -38,8 +42,23 @@ assert.equal(
"mm:network:read", "mm:network:read",
) )
assert.equal( assert.equal(
permissionForRequest("POST", "/api/firewall/rules"), permissionForRequest("GET", "/api/gre/tunnels"),
"mm:network:read",
)
assert.equal(
permissionForRequest("POST", "/api/gre/tunnels"),
"mm:network:write", "mm:network:write",
) )
assert.equal(
permissionForRequest("GET", "/api/users"),
"mm:users:read",
)
assert.equal(
permissionForRequest("POST", "/api/users"),
"mm:users:write",
)
assert.equal(hasPermission(["mm:settings:admin"], "mm:users:read"), true)
assert.equal(hasPermission(["mm:settings:admin"], "mm:users:write"), true)
assert.equal(hasPermission(["mm:dashboard:read"], "mm:users:write"), false)
console.log("permissions.test.ts: ok") console.log("permissions.test.ts: ok")
+19 -5
View File
@@ -17,6 +17,9 @@ export function hasPermission(
required: string, required: string,
): boolean { ): boolean {
if (granted.includes(required)) return true if (granted.includes(required)) return true
if (required.startsWith("mm:users:") && granted.includes("mm:settings:admin")) {
return true
}
const parts = required.split(":") const parts = required.split(":")
if (parts.length !== 3) return false if (parts.length !== 3) return false
const [app, section, action] = parts const [app, section, action] = parts
@@ -49,8 +52,17 @@ const RULES: Rule[] = [
}, },
{ {
methods: ["GET"], methods: ["GET"],
match: (p) => match: (p) => p.startsWith("/api/users"),
p.startsWith("/api/sidebar-counts") || p.startsWith("/api/events"), permission: "mm:users:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/users"),
permission: "mm:users:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/sidebar-counts") || p.startsWith("/api/events"),
permission: "mm:dashboard:read", permission: "mm:dashboard:read",
}, },
{ {
@@ -95,7 +107,7 @@ const RULES: Rule[] = [
}, },
{ {
methods: ["GET"], methods: ["GET"],
match: (p) => p.startsWith("/api/traffic"), match: (p) => p.startsWith("/api/traffic") || p.startsWith("/api/statistics"),
permission: "mm:traffic:read", permission: "mm:traffic:read",
}, },
{ {
@@ -143,7 +155,8 @@ const RULES: Rule[] = [
p.startsWith("/api/internet-path") || p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec") || p.startsWith("/api/exec") ||
p.startsWith("/api/wireguard") || p.startsWith("/api/wireguard") ||
p.startsWith("/api/firewall"), p.startsWith("/api/firewall") ||
p.startsWith("/api/gre"),
permission: "mm:network:read", permission: "mm:network:read",
}, },
{ {
@@ -156,7 +169,8 @@ const RULES: Rule[] = [
p.startsWith("/api/internet-path") || p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec") || p.startsWith("/api/exec") ||
p.startsWith("/api/wireguard") || p.startsWith("/api/wireguard") ||
p.startsWith("/api/firewall"), p.startsWith("/api/firewall") ||
p.startsWith("/api/gre"),
permission: "mm:network:write", permission: "mm:network:write",
}, },
] ]
+27 -1
View File
@@ -19,5 +19,31 @@ export function managedComment(label: string): string {
} }
export function managedRecursiveComment(comment?: string | null): string { export function managedRecursiveComment(comment?: string | null): string {
return comment ? `${PRODUCT_NAME}:recursive ${comment}` : `${PRODUCT_NAME}:recursive` const stripped = stripManagedRecursiveComment(comment ?? "")
return stripped ? `${PRODUCT_NAME}:recursive ${stripped}` : `${PRODUCT_NAME}:recursive`
}
const LEGACY_RECURSIVE_PREFIX = /^recursive:\s*/i
export function stripManagedRecursiveComment(comment: string): string {
const value = comment.trim()
if (!value) return ""
const managedPrefixes = [
`${PRODUCT_NAME}:recursive`,
`${LEGACY_PRODUCT_NAME}:recursive`,
]
for (const prefix of managedPrefixes) {
if (value.startsWith(prefix)) return value.slice(prefix.length).trim()
}
if (LEGACY_RECURSIVE_PREFIX.test(value)) {
return value.replace(LEGACY_RECURSIVE_PREFIX, "").trim()
}
return value
}
/** Owned recursive route: MM/legacy prefix or old `recursive:` mask. */
export function isOwnedRecursiveComment(comment: string | undefined): boolean {
if (!comment) return false
const value = comment.trim()
return hasManagedRecursiveComment(value) || LEGACY_RECURSIVE_PREFIX.test(value)
} }
@@ -24,18 +24,7 @@ export type ListEventsParams = {
to?: string to?: string
} }
function parsePayload(payloadJson: string | null): Record<string, unknown> { import { parseJsonObject } from "../../../db/json.js"
if (!payloadJson) return {}
try {
const parsed = JSON.parse(payloadJson) as unknown
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>
}
} catch {
// keep backwards compatibility with malformed legacy payloads
}
return {}
}
function mapEventRow(row: typeof events.$inferSelect): EventItem { function mapEventRow(row: typeof events.$inferSelect): EventItem {
return { return {
@@ -48,13 +37,13 @@ function mapEventRow(row: typeof events.$inferSelect): EventItem {
message: row.message, message: row.message,
entityType: row.entityType ?? null, entityType: row.entityType ?? null,
entityId: row.entityId ?? null, entityId: row.entityId ?? null,
payload: parsePayload(row.payloadJson ?? null), payload: parseJsonObject(row.payloadJson),
} }
} }
export function insertEventsBatch(items: EventInsertInput[]) { export async function insertEventsBatch(items: EventInsertInput[]) {
if (items.length === 0) return if (items.length === 0) return
db.insert(events) await db.insert(events)
.values( .values(
items.map((item) => ({ items.map((item) => ({
id: item.id, id: item.id,
@@ -66,25 +55,23 @@ export function insertEventsBatch(items: EventInsertInput[]) {
message: item.message, message: item.message,
entityType: item.entityType ?? null, entityType: item.entityType ?? null,
entityId: item.entityId ?? null, entityId: item.entityId ?? null,
payloadJson: item.payload ? JSON.stringify(item.payload) : null, payloadJson: item.payload ?? null,
})), })),
) )
.run()
} }
export function listEvents(params: ListEventsParams): EventItem[] { export async function listEvents(params: ListEventsParams): Promise<EventItem[]> {
const where = and( const where = and(
params.level ? eq(events.level, params.level) : undefined, params.level ? eq(events.level, params.level) : undefined,
params.sourceModule ? eq(events.sourceModule, params.sourceModule) : undefined, params.sourceModule ? eq(events.sourceModule, params.sourceModule) : undefined,
params.from ? gte(events.createdAt, params.from) : undefined, params.from ? gte(events.createdAt, params.from) : undefined,
params.to ? lte(events.createdAt, params.to) : undefined, params.to ? lte(events.createdAt, params.to) : undefined,
) )
const rows = db const rows = await db
.select() .select()
.from(events) .from(events)
.where(where) .where(where)
.orderBy(desc(events.createdAt)) .orderBy(desc(events.createdAt))
.limit(params.limit) .limit(params.limit)
.all()
return rows.map(mapEventRow) return rows.map(mapEventRow)
} }
@@ -23,17 +23,17 @@ function normalizeEventInput(input: AppendEventBody): EventInsertInput {
} }
} }
export function appendEvent(input: AppendEventBody) { export async function appendEvent(input: AppendEventBody) {
const parsed = appendEventSchema.parse(input) const parsed = appendEventSchema.parse(input)
insertEventsBatch([normalizeEventInput(parsed)]) await insertEventsBatch([normalizeEventInput(parsed)])
} }
export function appendEvents(inputs: AppendEventBody[]) { export async function appendEvents(inputs: AppendEventBody[]) {
const rows = inputs.map((entry) => normalizeEventInput(appendEventSchema.parse(entry))) const rows = inputs.map((entry) => normalizeEventInput(appendEventSchema.parse(entry)))
insertEventsBatch(rows) await insertEventsBatch(rows)
} }
export function readEvents(query: Partial<ListEventsQuery>): EventItem[] { export async function readEvents(query: Partial<ListEventsQuery>): Promise<EventItem[]> {
const parsed = listEventsQuerySchema.parse(query) const parsed = listEventsQuerySchema.parse(query)
return listEvents(parsed) return await listEvents(parsed)
} }
@@ -1,29 +1,24 @@
import { parseJsonArray } from "../../../db/json.js"
import type { ServerRead, WanUplinkRead } from "../../../types/server.js" import type { ServerRead, WanUplinkRead } from "../../../types/server.js"
import type { ServerRow, SnapshotRow } from "../repository/servers-repository.js" import type { ServerRow, SnapshotRow } from "../repository/servers-repository.js"
function parseWanUplinksJson(raw: string | null | undefined): WanUplinkRead[] { function parseWanUplinksJson(raw: unknown): WanUplinkRead[] {
if (raw == null || raw === "") return [] const data = parseJsonArray(raw)
try { const out: WanUplinkRead[] = []
const data = JSON.parse(raw) as unknown for (const row of data) {
if (!Array.isArray(data)) return [] if (typeof row !== "object" || row === null) continue
const out: WanUplinkRead[] = [] const r = row as Record<string, unknown>
for (const row of data) { out.push({
if (typeof row !== "object" || row === null) continue id: typeof r.id === "string" ? r.id : "",
const r = row as Record<string, unknown> name: typeof r.name === "string" ? r.name : "",
out.push({ isp: typeof r.isp === "string" ? r.isp : "",
id: typeof r.id === "string" ? r.id : "", iface: typeof r.iface === "string" ? r.iface : "",
name: typeof r.name === "string" ? r.name : "", ip: typeof r.ip === "string" ? r.ip : "",
isp: typeof r.isp === "string" ? r.isp : "", maxDl: typeof r.maxDl === "number" && Number.isFinite(r.maxDl) ? r.maxDl : 0,
iface: typeof r.iface === "string" ? r.iface : "", maxUl: typeof r.maxUl === "number" && Number.isFinite(r.maxUl) ? r.maxUl : 0,
ip: typeof r.ip === "string" ? r.ip : "", })
maxDl: typeof r.maxDl === "number" && Number.isFinite(r.maxDl) ? r.maxDl : 0,
maxUl: typeof r.maxUl === "number" && Number.isFinite(r.maxUl) ? r.maxUl : 0,
})
}
return out
} catch {
return []
} }
return out
} }
export function toServerRead(server: ServerRow, snap: SnapshotRow | undefined): ServerRead { export function toServerRead(server: ServerRow, snap: SnapshotRow | undefined): ServerRead {
@@ -1,53 +1,57 @@
import { desc, eq } from "drizzle-orm" import { desc, eq } from "drizzle-orm"
import { db } from "../../../db/index.js" import { db } from "../../../db/index.js"
import { serverSnapshots, servers } from "../../../db/schema.js" import { serverSnapshots, servers } from "../../../db/schema.js"
import { invalidateFlowCatalogCache } from "../../../services/traffic-flow-topology.js"
export type ServerRow = typeof servers.$inferSelect export type ServerRow = typeof servers.$inferSelect
export type SnapshotRow = typeof serverSnapshots.$inferSelect export type SnapshotRow = typeof serverSnapshots.$inferSelect
export function listServerRows(): ServerRow[] { export async function listServerRows(): Promise<ServerRow[]> {
return db.select().from(servers).all() return await db.select().from(servers)
} }
export function getServerRowById(id: number): ServerRow | undefined { export async function getServerRowById(id: number): Promise<ServerRow | undefined> {
return db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0] const rows = await db.select().from(servers).where(eq(servers.id, id)).limit(1)
return rows[0]
} }
export function createServerRow( export async function createServerRow(
values: Omit<typeof servers.$inferInsert, "id">, values: Omit<typeof servers.$inferInsert, "id">,
): ServerRow { ): Promise<ServerRow> {
const [inserted] = db.insert(servers).values(values).returning().all() const [inserted] = await db.insert(servers).values(values).returning()
invalidateFlowCatalogCache()
return inserted return inserted
} }
export function updateServerRowById( export async function updateServerRowById(
id: number, id: number,
values: Partial<ServerRow>, values: Partial<ServerRow>,
): ServerRow { ): Promise<ServerRow> {
const [updated] = db.update(servers).set(values).where(eq(servers.id, id)).returning().all() const [updated] = await db.update(servers).set(values).where(eq(servers.id, id)).returning()
invalidateFlowCatalogCache()
return updated return updated
} }
export function deleteServerRowById(id: number): void { export async function deleteServerRowById(id: number): Promise<void> {
db.delete(servers).where(eq(servers.id, id)).run() await db.delete(servers).where(eq(servers.id, id))
invalidateFlowCatalogCache()
} }
export function listSnapshotsByServerId(serverId: number, limit: number): SnapshotRow[] { export async function listSnapshotsByServerId(serverId: number, limit: number): Promise<SnapshotRow[]> {
return db return db
.select() .select()
.from(serverSnapshots) .from(serverSnapshots)
.where(eq(serverSnapshots.serverId, serverId)) .where(eq(serverSnapshots.serverId, serverId))
.orderBy(desc(serverSnapshots.polledAt)) .orderBy(desc(serverSnapshots.polledAt))
.limit(limit) .limit(limit)
.all()
} }
export function getLatestSnapshot(serverId: number): SnapshotRow | undefined { export async function getLatestSnapshot(serverId: number): Promise<SnapshotRow | undefined> {
return db const rows = await db
.select() .select()
.from(serverSnapshots) .from(serverSnapshots)
.where(eq(serverSnapshots.serverId, serverId)) .where(eq(serverSnapshots.serverId, serverId))
.orderBy(desc(serverSnapshots.polledAt)) .orderBy(desc(serverSnapshots.polledAt))
.limit(1) .limit(1)
.all()[0] return rows[0]
} }
@@ -11,45 +11,46 @@ import {
import { toServerRead } from "../mapper/servers-mapper.js" import { toServerRead } from "../mapper/servers-mapper.js"
import type { ServerCreate, ServerRead, ServerUpdate, SnapshotRead } from "../../../types/server.js" import type { ServerCreate, ServerRead, ServerUpdate, SnapshotRead } from "../../../types/server.js"
export function listServersRead(): ServerRead[] { export async function listServersRead(): Promise<ServerRead[]> {
return listServerRows().map((server) => toServerRead(server, getLatestSnapshot(server.id))) const rows = await listServerRows()
return Promise.all(rows.map(async (server) => toServerRead(server, await getLatestSnapshot(server.id))))
} }
export function getServerReadById(id: number): ServerRead | undefined { export async function getServerReadById(id: number): Promise<ServerRead | undefined> {
const row = getServerRowById(id) const row = await getServerRowById(id)
if (!row) return undefined if (!row) return undefined
return toServerRead(row, getLatestSnapshot(row.id)) return toServerRead(row, await getLatestSnapshot(row.id))
} }
export function createServer(input: ServerCreate): ServerRead { export async function createServer(input: ServerCreate): Promise<ServerRead> {
const now = new Date().toISOString() const now = new Date().toISOString()
const { wanUplinks, ...rest } = input const { wanUplinks, ...rest } = input
const inserted = createServerRow({ const inserted = await createServerRow({
...rest, ...rest,
wanUplinks: JSON.stringify(wanUplinks ?? []), wanUplinks: wanUplinks ?? [],
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}) })
return toServerRead(inserted, undefined) return toServerRead(inserted, undefined)
} }
export function updateServer(id: number, input: ServerUpdate): ServerRead { export async function updateServer(id: number, input: ServerUpdate): Promise<ServerRead> {
const { wanUplinks, ...rest } = input const { wanUplinks, ...rest } = input
const setPayload: Record<string, unknown> = { updatedAt: new Date().toISOString() } const setPayload: Record<string, unknown> = { updatedAt: new Date().toISOString() }
for (const [k, v] of Object.entries(rest)) { for (const [k, v] of Object.entries(rest)) {
if (v !== undefined) setPayload[k] = v if (v !== undefined) setPayload[k] = v
} }
if (wanUplinks !== undefined) { if (wanUplinks !== undefined) {
setPayload.wanUplinks = JSON.stringify(wanUplinks) setPayload.wanUplinks = wanUplinks
} }
const updated = updateServerRowById(id, setPayload as never) const updated = await updateServerRowById(id, setPayload as never)
return toServerRead(updated, getLatestSnapshot(updated.id)) return toServerRead(updated, await getLatestSnapshot(updated.id))
} }
export function deleteServer(id: number): void { export async function deleteServer(id: number): Promise<void> {
deleteServerRowById(id) await deleteServerRowById(id)
} }
export function listServerSnapshots(id: number, limit: number): SnapshotRead[] { export async function listServerSnapshots(id: number, limit: number): Promise<SnapshotRead[]> {
return listSnapshotsByServerId(id, limit).map(toSnapshotRead) return (await listSnapshotsByServerId(id, limit)).map(toSnapshotRead)
} }
@@ -0,0 +1,74 @@
import assert from "node:assert/strict"
import { normalizeBindingPeer, PeerBindError } from "./peer-bind.js"
import { withPgOrSkip } from "../../test/pg.js"
import { dbQuery } from "../../db/index.js"
assert.throws(
() => normalizeBindingPeer("wg", ""),
(err: unknown) => err instanceof PeerBindError && err.status === 400,
"WG без ключа — 400",
)
assert.equal(normalizeBindingPeer("ether", "ignored"), "")
assert.equal(normalizeBindingPeer("wg", " abc "), "abc")
if (!(await withPgOrSkip())) {
console.log("users bindings unique+cascade tests skip")
process.exit(0)
}
await dbQuery(`
INSERT INTO servers (id, name, host) VALUES (91001, 'jh-bind-test', '10.0.0.1')
ON CONFLICT (id) DO NOTHING
`)
await dbQuery(`
INSERT INTO app_users (id, name, login)
VALUES ('u-bind-1', 'A', 'a.bind.test'), ('u-bind-2', 'B', 'b.bind.test')
ON CONFLICT (id) DO NOTHING
`)
await dbQuery(`DELETE FROM user_interface_bindings WHERE server_id = 91001`)
await dbQuery(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type)
VALUES ('b-bind-1', 'u-bind-1', 91001, 'gre-office', 'gre')
`)
let uniqueIface = false
try {
await dbQuery(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type)
VALUES ('b-bind-2', 'u-bind-2', 91001, 'gre-office', 'gre')
`)
} catch {
uniqueIface = true
}
assert.equal(uniqueIface, true, "один интерфейс на сервере — один пользователь")
await dbQuery(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name)
VALUES ('wg-bind-1', 'u-bind-1', 91001, 'wg-server', 'wg', 'peer-key-aaa', 'phone')
`)
await dbQuery(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name)
VALUES ('wg-bind-2', 'u-bind-2', 91001, 'wg-server', 'wg', 'peer-key-bbb', 'laptop')
`)
let uniquePeer = false
try {
await dbQuery(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key)
VALUES ('wg-bind-3', 'u-bind-2', 91001, 'wg-server', 'wg', 'peer-key-aaa')
`)
} catch {
uniquePeer = true
}
assert.equal(uniquePeer, true, "один пир — один пользователь")
await dbQuery(`DELETE FROM app_users WHERE id = 'u-bind-1'`)
const leftover = await dbQuery<{ n: number }>(
`SELECT COUNT(*)::int AS n FROM user_interface_bindings WHERE server_id = 91001`,
)
assert.equal(leftover.rows[0]?.n, 1, "каскад: привязки u1 удаляются, пир u2 остаётся")
await dbQuery(`DELETE FROM app_users WHERE id IN ('u-bind-1', 'u-bind-2')`)
await dbQuery(`DELETE FROM servers WHERE id = 91001`)
console.log("users bindings unique+cascade tests ok")
@@ -0,0 +1,41 @@
import assert from "node:assert/strict"
import { mapRosInterfaceType, parseRawInterfaces, isUniqueConstraintError } from "./iface-type.js"
assert.equal(mapRosInterfaceType("ether"), "ether")
assert.equal(mapRosInterfaceType("ethernet"), "ether")
assert.equal(mapRosInterfaceType("GRE"), "gre")
assert.equal(mapRosInterfaceType("gre-tunnel"), "gre")
assert.equal(mapRosInterfaceType("gre6-tunnel"), "gre")
assert.equal(mapRosInterfaceType("wg"), "wg")
assert.equal(mapRosInterfaceType("wireguard"), "wg")
assert.equal(mapRosInterfaceType("vlan"), "other")
assert.equal(mapRosInterfaceType(""), "other")
assert.equal(mapRosInterfaceType("", "gre-tunnel1"), "gre")
assert.equal(mapRosInterfaceType("", "MSK-DC"), "other")
assert.equal(mapRosInterfaceType("gre-tunnel", "MSK-DC"), "gre")
assert.equal(mapRosInterfaceType("", "wg-msk-spb"), "wg")
assert.equal(mapRosInterfaceType("", "ether1"), "ether")
const parsed = parseRawInterfaces(JSON.stringify([
{ name: "ether1", type: "ether", running: "true", disabled: "false" },
{ name: "gre-office", type: "gre-tunnel", running: "false", disabled: "false" },
{ name: "wg-msk", type: "wg", running: true, disabled: false },
{ name: "MSK-DC", type: "gre-tunnel", running: true, disabled: false },
{ name: "", type: "ether" },
]))
assert.equal(parsed.length, 4)
assert.equal(parsed[0]?.type, "ether")
assert.equal(parsed[0]?.running, true)
assert.equal(parsed[1]?.type, "gre")
assert.equal(parsed[1]?.running, false)
assert.equal(parsed[2]?.type, "wg")
assert.equal(parsed[3]?.type, "gre")
assert.equal(parseRawInterfaces("not-json").length, 0)
assert.equal(parseRawInterfaces(null).length, 0)
assert.equal(isUniqueConstraintError({ code: "SQLITE_CONSTRAINT_UNIQUE", message: "UNIQUE" }), true)
assert.equal(isUniqueConstraintError({ message: "UNIQUE constraint failed: t.c" }), true)
assert.equal(isUniqueConstraintError({ message: "other" }), false)
console.log("users iface-type tests ok")
+60
View File
@@ -0,0 +1,60 @@
export type InterfaceType = "ether" | "gre" | "wg" | "other"
export function mapRosInterfaceType(raw: string | undefined | null, name?: string): InterfaceType {
const t = String(raw ?? "").trim().toLowerCase()
if (t === "ether" || t === "ethernet" || t.startsWith("ether")) return "ether"
// RouterOS /interface type for GRE is "gre-tunnel" (also gre, gre6, gre6-tunnel)
if (t === "gre" || t.startsWith("gre-") || t.startsWith("gre6")) return "gre"
if (t === "wg" || t === "wireguard") return "wg"
const n = String(name ?? "").trim().toLowerCase()
if (n.startsWith("gre") || n.includes("gre-tunnel")) return "gre"
if (n.startsWith("wg-") || n.startsWith("wireguard")) return "wg"
if (n.startsWith("ether") || n.startsWith("sfp")) return "ether"
return "other"
}
export interface ParsedRosIface {
name: string
type: InterfaceType
running: boolean
disabled: boolean
}
function asBool(raw: unknown): boolean {
if (typeof raw === "boolean") return raw
const s = String(raw ?? "").trim().toLowerCase()
return s === "true" || s === "yes" || s === "1"
}
export function parseRawInterfaces(json: unknown): ParsedRosIface[] {
if (json == null || json === "") return []
try {
const parsed = typeof json === "string" ? JSON.parse(json) as unknown : json
const arr = Array.isArray(parsed) ? parsed : []
const out: ParsedRosIface[] = []
for (const item of arr) {
if (!item || typeof item !== "object") continue
const rec = item as Record<string, unknown>
const name = String(rec.name ?? "").trim()
if (!name) continue
out.push({
name,
type: mapRosInterfaceType(String(rec.type ?? ""), name),
running: asBool(rec.running),
disabled: asBool(rec.disabled),
})
}
return out
} catch {
return []
}
}
export function isUniqueConstraintError(err: unknown): boolean {
if (!err || typeof err !== "object") return false
const rec = err as { code?: unknown; message?: unknown }
const code = String(rec.code ?? "")
const msg = String(rec.message ?? "")
return code === "23505" || code.includes("SQLITE_CONSTRAINT") || /unique constraint/i.test(msg)
}
+44
View File
@@ -0,0 +1,44 @@
import type { InterfaceType } from "./iface-type.js"
export class PeerBindError extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message)
this.name = "PeerBindError"
}
}
export function truncPeerKey(key: string): string {
const k = key.trim()
if (k.length <= 20) return k
return `${k.slice(0, 8)}${k.slice(-8)}`
}
export function peerDisplayName(opts: {
publicKey: string
name?: string | null
comment?: string | null
}): string {
const name = (opts.name ?? "").trim()
if (name) return name
const comment = (opts.comment ?? "").trim()
if (comment) return comment
return truncPeerKey(opts.publicKey)
}
/** Ether/GRE — пустой ключ. WG — обязательный public-key. */
export function normalizeBindingPeer(
type: InterfaceType,
peerPublicKey: string | undefined,
): string {
const key = (peerPublicKey ?? "").trim()
if (type === "wg") {
if (!key) {
throw new PeerBindError("Для WireGuard укажите пир (public-key)", 400)
}
return key
}
return ""
}
@@ -0,0 +1,87 @@
import { and, count, eq } from "drizzle-orm"
import { db } from "../../../db/index.js"
import { appUsers, userInterfaceBindings } from "../../../db/schema.js"
import { invalidateFlowCatalogCache } from "../../../services/traffic-flow-topology.js"
export type AppUserRow = typeof appUsers.$inferSelect
export type BindingRow = typeof userInterfaceBindings.$inferSelect
export async function listUserRows(): Promise<AppUserRow[]> {
return await db.select().from(appUsers)
}
export async function getUserRowById(id: string): Promise<AppUserRow | undefined> {
const rows = await db.select().from(appUsers).where(eq(appUsers.id, id)).limit(1)
return rows[0]
}
export async function getUserRowByLogin(login: string): Promise<AppUserRow | undefined> {
const rows = await db.select().from(appUsers).where(eq(appUsers.login, login)).limit(1)
return rows[0]
}
export async function createUserRow(values: typeof appUsers.$inferInsert): Promise<AppUserRow> {
const [inserted] = await db.insert(appUsers).values(values).returning()
invalidateFlowCatalogCache()
return inserted
}
export async function updateUserRowById(
id: string,
values: Partial<AppUserRow>,
): Promise<AppUserRow> {
const [updated] = await db.update(appUsers).set(values).where(eq(appUsers.id, id)).returning()
invalidateFlowCatalogCache()
return updated
}
export async function deleteUserRowById(id: string): Promise<void> {
await db.delete(appUsers).where(eq(appUsers.id, id))
invalidateFlowCatalogCache()
}
export async function listBindingRows(): Promise<BindingRow[]> {
return await db.select().from(userInterfaceBindings)
}
export async function listBindingRowsByUser(userId: string): Promise<BindingRow[]> {
return await db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId))
}
export async function getBindingRowById(id: string): Promise<BindingRow | undefined> {
const rows = await db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.id, id)).limit(1)
return rows[0]
}
export async function getBindingByServerIfacePeer(
serverId: number,
interfaceName: string,
peerPublicKey = "",
): Promise<BindingRow | undefined> {
const rows = await db
.select()
.from(userInterfaceBindings)
.where(and(
eq(userInterfaceBindings.serverId, serverId),
eq(userInterfaceBindings.interfaceName, interfaceName),
eq(userInterfaceBindings.peerPublicKey, peerPublicKey),
))
.limit(1)
return rows[0]
}
export async function createBindingRow(values: typeof userInterfaceBindings.$inferInsert): Promise<BindingRow> {
const [inserted] = await db.insert(userInterfaceBindings).values(values).returning()
invalidateFlowCatalogCache()
return inserted
}
export async function deleteBindingRowById(id: string): Promise<void> {
await db.delete(userInterfaceBindings).where(eq(userInterfaceBindings.id, id))
invalidateFlowCatalogCache()
}
export async function countUserRows(): Promise<number> {
const rows = await db.select({ n: count() }).from(appUsers)
return rows[0]?.n ?? 0
}
@@ -0,0 +1,341 @@
import { randomUUID } from "node:crypto"
import { desc, eq } from "drizzle-orm"
import type {
AppUserCreate,
AppUserRead,
AppUserUpdate,
CatalogInterface,
InterfaceType,
SectionPerm,
ServerPerm,
UserBinding,
UserBindingCreate,
} from "@mmapp/contracts/users"
import { db } from "../../../db/index.js"
import { parseJsonArray } from "../../../db/json.js"
import { decodeTrafficSampleFlags } from "../../../db/traffic-flags.js"
import { servers, trafficSamples } from "../../../db/schema.js"
import {
createBindingRow,
createUserRow,
deleteBindingRowById,
deleteUserRowById,
getBindingByServerIfacePeer,
getBindingRowById,
getUserRowById,
getUserRowByLogin,
listBindingRows,
listBindingRowsByUser,
listUserRows,
updateUserRowById,
type AppUserRow,
type BindingRow,
} from "../repository/users-repository.js"
import { getLatestSnapshot } from "../../servers/repository/servers-repository.js"
import {
isUniqueConstraintError,
mapRosInterfaceType,
parseRawInterfaces,
} from "../iface-type.js"
import {
normalizeBindingPeer,
PeerBindError,
peerDisplayName,
} from "../peer-bind.js"
import { listWireGuardPeersForCatalog } from "../../../services/wireguard-live.js"
export class UsersServiceError extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message)
this.name = "UsersServiceError"
}
}
function asPermArray<T>(raw: unknown, fallback: T[]): T[] {
const arr = parseJsonArray(raw)
return arr.length ? arr as T[] : fallback
}
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
return parts.map((p) => p[0] ?? "").slice(0, 2).join("").toUpperCase() || "??"
}
async function serverMeta(serverId: number): Promise<{ name: string; site: string; country: string }> {
const rows = await db.select().from(servers).where(eq(servers.id, serverId)).limit(1)
const row = rows[0]
return {
name: row?.name || row?.host || String(serverId),
site: row?.site || "—",
country: row?.country || "UN",
}
}
async function toBindingDto(row: BindingRow): Promise<UserBinding> {
const meta = await serverMeta(row.serverId)
return {
id: row.id,
userId: row.userId,
serverId: row.serverId,
serverName: meta.name,
serverSite: meta.site,
serverCountry: meta.country,
interfaceName: row.interfaceName,
interfaceType: row.interfaceType,
peerPublicKey: row.peerPublicKey ?? "",
peerName: row.peerName ?? "",
comment: row.comment,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}
}
async function toUserDto(row: AppUserRow, bindings: BindingRow[]): Promise<AppUserRead> {
return {
id: row.id,
name: row.name,
login: row.login,
email: row.email,
role: row.role,
active: Boolean(row.active),
avatar: row.avatar,
lastSeen: row.lastSeen ?? null,
sections: asPermArray<SectionPerm>(row.sectionsJson, []),
servers: asPermArray<ServerPerm>(row.serversJson, []),
bindings: await Promise.all(bindings.map((b) => toBindingDto(b))),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}
}
export async function listUsers(): Promise<AppUserRead[]> {
const users = await listUserRows()
const allBindings = await listBindingRows()
const byUser = new Map<string, BindingRow[]>()
for (const b of allBindings) {
const arr = byUser.get(b.userId) ?? []
arr.push(b)
byUser.set(b.userId, arr)
}
return Promise.all(users.map((u) => toUserDto(u, byUser.get(u.id) ?? [])))
}
export async function getUserById(id: string): Promise<AppUserRead | undefined> {
const row = await getUserRowById(id)
if (!row) return undefined
return await toUserDto(row, await listBindingRowsByUser(id))
}
export async function createUser(input: AppUserCreate): Promise<AppUserRead> {
const login = input.login.trim()
if (await getUserRowByLogin(login)) {
throw new UsersServiceError("Логин уже занят", 409)
}
const now = new Date().toISOString()
const row = await createUserRow({
id: randomUUID(),
name: input.name.trim(),
login,
email: input.email.trim(),
role: input.role ?? "viewer",
active: input.active ?? true,
avatar: (input.avatar ?? "").trim() || initials(input.name),
lastSeen: input.lastSeen ?? null,
sectionsJson: input.sections ?? [],
serversJson: input.servers ?? [],
createdAt: now,
updatedAt: now,
})
return await toUserDto(row, [])
}
export async function updateUser(id: string, input: AppUserUpdate): Promise<AppUserRead> {
const existing = await getUserRowById(id)
if (!existing) throw new UsersServiceError("Пользователь не найден", 404)
if (input.login != null) {
const other = await getUserRowByLogin(input.login.trim())
if (other && other.id !== id) throw new UsersServiceError("Логин уже занят", 409)
}
const patch: Partial<AppUserRow> = { updatedAt: new Date().toISOString() }
if (input.name != null) patch.name = input.name.trim()
if (input.login != null) patch.login = input.login.trim()
if (input.email != null) patch.email = input.email.trim()
if (input.role != null) patch.role = input.role
if (input.active != null) patch.active = input.active
if (input.avatar != null) patch.avatar = input.avatar.trim() || existing.avatar
if (input.lastSeen !== undefined) patch.lastSeen = input.lastSeen
if (input.sections != null) patch.sectionsJson = input.sections
if (input.servers != null) patch.serversJson = input.servers
const updated = await updateUserRowById(id, patch)
return await toUserDto(updated, await listBindingRowsByUser(id))
}
export async function deleteUser(id: string): Promise<void> {
const existing = await getUserRowById(id)
if (!existing) throw new UsersServiceError("Пользователь не найден", 404)
await deleteUserRowById(id)
}
export async function addBinding(userId: string, input: UserBindingCreate): Promise<UserBinding> {
const user = await getUserRowById(userId)
if (!user) throw new UsersServiceError("Пользователь не найден", 404)
const serverRows = await db.select().from(servers).where(eq(servers.id, input.serverId)).limit(1)
if (!serverRows[0]) throw new UsersServiceError("Сервер не найден", 404)
const ifaceName = input.interfaceName.trim()
if (!ifaceName) throw new UsersServiceError("Имя интерфейса обязательно", 400)
const type: InterfaceType = input.interfaceType ?? await inferIfaceType(input.serverId, ifaceName)
let peerPublicKey = ""
try {
peerPublicKey = normalizeBindingPeer(type, input.peerPublicKey)
} catch (err) {
if (err instanceof PeerBindError) throw new UsersServiceError(err.message, err.status)
throw err
}
const peerName = type === "wg"
? peerDisplayName({
publicKey: peerPublicKey,
name: input.peerName,
})
: ""
const taken = await getBindingByServerIfacePeer(input.serverId, ifaceName, peerPublicKey)
if (taken) {
throw new UsersServiceError(
type === "wg"
? "Этот пир уже привязан к другому пользователю"
: "Интерфейс уже привязан к другому пользователю",
409,
)
}
const now = new Date().toISOString()
try {
const row = await createBindingRow({
id: randomUUID(),
userId,
serverId: input.serverId,
interfaceName: ifaceName,
interfaceType: type,
peerPublicKey,
peerName,
comment: (input.comment ?? "").trim(),
createdAt: now,
updatedAt: now,
})
return await toBindingDto(row)
} catch (err) {
if (isUniqueConstraintError(err)) {
throw new UsersServiceError(
type === "wg"
? "Этот пир уже привязан к другому пользователю"
: "Интерфейс уже привязан к другому пользователю",
409,
)
}
throw err
}
}
export async function removeBinding(userId: string, bindingId: string): Promise<void> {
const row = await getBindingRowById(bindingId)
if (!row || row.userId !== userId) {
throw new UsersServiceError("Привязка не найдена", 404)
}
await deleteBindingRowById(bindingId)
}
async function inferIfaceType(serverId: number, ifaceName: string): Promise<InterfaceType> {
const snap = await getLatestSnapshot(serverId)
const parsed = parseRawInterfaces(snap?.rawInterfaces)
const found = parsed.find((i) => i.name === ifaceName)
return found?.type ?? "other"
}
export async function listInterfaceCatalog(serverId: number): Promise<CatalogInterface[]> {
const serverRows = await db.select().from(servers).where(eq(servers.id, serverId)).limit(1)
if (!serverRows[0]) throw new UsersServiceError("Сервер не найден", 404)
const snap = await getLatestSnapshot(serverId)
let ifaces = parseRawInterfaces(snap?.rawInterfaces)
if (ifaces.length === 0) {
const lastRows = await db
.select({ sampledAt: trafficSamples.sampledAt })
.from(trafficSamples)
.where(eq(trafficSamples.serverId, serverId))
.orderBy(desc(trafficSamples.sampledAt))
.limit(1)
const last = lastRows[0]
if (last) {
const rows = (await db
.select({
interfaceName: trafficSamples.interfaceName,
peerPublicKey: trafficSamples.peerPublicKey,
flags: trafficSamples.flags,
})
.from(trafficSamples)
.where(eq(trafficSamples.serverId, serverId)))
.filter((r) => r.interfaceName && !/^(lo|loopback)/i.test(r.interfaceName) && !(r.peerPublicKey ?? ""))
const seen = new Set<string>()
ifaces = []
for (const r of rows) {
if (seen.has(r.interfaceName)) continue
seen.add(r.interfaceName)
const decoded = decodeTrafficSampleFlags(r.flags)
ifaces.push({
name: r.interfaceName,
type: mapRosInterfaceType("", r.interfaceName),
running: decoded.running,
disabled: decoded.disabled,
})
}
}
}
const bindings = (await listBindingRows()).filter((b) => b.serverId === serverId)
const usersById = new Map((await listUserRows()).map((u) => [u.id, u]))
const hasWg = ifaces.some((i) => i.type === "wg")
const wgLive = hasWg
? await listWireGuardPeersForCatalog(serverId)
: { peers: [] as Awaited<ReturnType<typeof listWireGuardPeersForCatalog>>["peers"] }
const peersByIface = new Map<string, typeof wgLive.peers>()
for (const peer of wgLive.peers) {
const list = peersByIface.get(peer.interfaceName) ?? []
list.push(peer)
peersByIface.set(peer.interfaceName, list)
}
return ifaces.map((iface) => {
const ifaceBind = bindings.find((b) => b.interfaceName === iface.name && !(b.peerPublicKey ?? ""))
const owner = ifaceBind ? usersById.get(ifaceBind.userId) : undefined
const base: CatalogInterface = {
name: iface.name,
type: iface.type,
running: iface.running,
disabled: iface.disabled,
boundUserId: ifaceBind?.userId ?? null,
boundUserLogin: owner?.login ?? null,
}
if (iface.type !== "wg") return base
const livePeers = peersByIface.get(iface.name) ?? []
return {
...base,
peersError: wgLive.error,
peers: livePeers.map((p) => {
const bind = bindings.find((b) => b.interfaceName === iface.name && b.peerPublicKey === p.publicKey)
const peerOwner = bind ? usersById.get(bind.userId) : undefined
return {
publicKey: p.publicKey,
name: peerDisplayName({ publicKey: p.publicKey, name: p.name, comment: p.comment }),
comment: p.comment,
allowedIps: p.allowedIps,
latestHandshake: p.latestHandshake,
boundUserId: bind?.userId ?? null,
boundUserLogin: peerOwner?.login ?? null,
}
}),
}
}).sort((a, b) => a.name.localeCompare(b.name))
}
export { parseRawInterfaces, mapRosInterfaceType }
-1
View File
@@ -10,7 +10,6 @@ process.env.AUTH_JWT_SECRET = "test-secret-at-least-8"
process.env.AUTH_ISSUER = "https://auth.test.local" process.env.AUTH_ISSUER = "https://auth.test.local"
process.env.AUTH_PORTAL_URL = "http://localhost:5175" process.env.AUTH_PORTAL_URL = "http://localhost:5175"
process.env.CORS_ORIGIN = "http://localhost:3000" process.env.CORS_ORIGIN = "http://localhost:3000"
process.env.DATABASE_PATH = ":memory:"
process.env.NODE_ENV = "test" process.env.NODE_ENV = "test"
// Dynamic import after env is set // Dynamic import after env is set
+12 -12
View File
@@ -20,14 +20,14 @@ import {
const alertsRoutes: FastifyPluginAsyncZod = async (app) => { const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/alerts", async (_req, reply) => { app.get("/alerts", async (_req, reply) => {
const tg = getTelegramPublic() const tg = await getTelegramPublic()
const connected = tg.tokenConfigured && Boolean(tg.chatId?.trim()) const connected = tg.tokenConfigured && Boolean(tg.chatId?.trim())
return reply.send({ return reply.send({
telegram: { ...tg, connected }, telegram: { ...tg, connected },
groups: listAlertGroups(), groups: await listAlertGroups(),
rules: listAlertRules(), rules: await listAlertRules(),
history: listMergedHistory(), history: await listMergedHistory(),
meta: getAlertsMeta(), meta: await getAlertsMeta(),
}) })
}) })
@@ -38,7 +38,7 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
} }
const groups = const groups =
parsed.data.groups ?? parsed.data.groups ??
listAlertGroups().map((g) => ({ (await listAlertGroups()).map((g) => ({
id: g.id, id: g.id,
name: g.name, name: g.name,
combineMode: g.combineMode, combineMode: g.combineMode,
@@ -67,8 +67,8 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
recoveryStabilitySec: r.recoveryStabilitySec ?? null, recoveryStabilitySec: r.recoveryStabilitySec ?? null,
chatId: r.chatId, chatId: r.chatId,
})) }))
replaceAlertsConfig({ groups, rules }) await replaceAlertsConfig({ groups, rules })
return reply.send({ ok: true, groups: listAlertGroups(), rules: listAlertRules() }) return reply.send({ ok: true, groups: await listAlertGroups(), rules: await listAlertRules() })
}) })
app.put("/alerts/telegram", async (req, reply) => { app.put("/alerts/telegram", async (req, reply) => {
@@ -76,7 +76,7 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
} }
const next = updateTelegramSettings({ const next = await updateTelegramSettings({
token: parsed.data.token, token: parsed.data.token,
chatId: parsed.data.chatId, chatId: parsed.data.chatId,
messageThreadId: parsed.data.messageThreadId, messageThreadId: parsed.data.messageThreadId,
@@ -90,9 +90,9 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
} }
const fromDb = getTelegramBotToken() const fromDb = await getTelegramBotToken()
const token = (parsed.data.token?.trim() || fromDb).trim() const token = (parsed.data.token?.trim() || fromDb).trim()
const pub = getTelegramPublic() const pub = await getTelegramPublic()
const chatId = (parsed.data.chatId?.trim() || pub.chatId || "").trim() const chatId = (parsed.data.chatId?.trim() || pub.chatId || "").trim()
if (!token) { if (!token) {
return reply.status(400).send({ error: "Не задан Bot Token (сохраните в БД или передайте в запросе)" }) return reply.status(400).send({ error: "Не задан Bot Token (сохраните в БД или передайте в запросе)" })
@@ -103,7 +103,7 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
const threadId = const threadId =
parsed.data.messageThreadId != null && parsed.data.messageThreadId >= 1 parsed.data.messageThreadId != null && parsed.data.messageThreadId >= 1
? parsed.data.messageThreadId ? parsed.data.messageThreadId
: getTelegramMessageThreadIdForApi() : await getTelegramMessageThreadIdForApi()
const text = parsed.data.rulePreview const text = parsed.data.rulePreview
? formatAlertRuleTestTelegramText(parsed.data.rulePreview) ? formatAlertRuleTestTelegramText(parsed.data.rulePreview)
: "MikroTik Manager — тест оповещений (Telegram)." : "MikroTik Manager — тест оповещений (Telegram)."
+82 -21
View File
@@ -1,20 +1,26 @@
import { randomUUID } from "node:crypto" import { randomUUID } from "node:crypto"
import { readFile } from "node:fs/promises"
import path from "node:path"
import { z } from "zod" import { z } from "zod"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { putBackupScheduleSettingsSchema } from "@mmapp/contracts/backups" import {
putBackupScheduleSettingsSchema,
putBackupStorageSettingsSchema,
} from "@mmapp/contracts/backups"
import { listServersRead } from "../modules/servers/service/servers-service.js" import { listServersRead } from "../modules/servers/service/servers-service.js"
import { appendEvent } from "../modules/events/service/events-service.js" import { appendEvent } from "../modules/events/service/events-service.js"
import { refreshScheduler } from "../services/scheduler.js" import { refreshScheduler } from "../services/scheduler.js"
import { import {
deleteBackupRecord, deleteBackupRecord,
getBackupById, getBackupById,
getBackupsDir,
getBackupScheduleSettings, getBackupScheduleSettings,
getBackupStorageSettings,
listBackups, listBackups,
readBackupContent,
restoreBackupToDevice,
runBackupForServer, runBackupForServer,
syncBackupsFromS3,
testBackupStorageConnection,
updateBackupScheduleSettings, updateBackupScheduleSettings,
updateBackupStorageSettings,
type BackupMeta, type BackupMeta,
} from "../services/backup-service.js" } from "../services/backup-service.js"
@@ -61,7 +67,7 @@ async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
} }
job.status = "done" job.status = "done"
job.finishedAt = new Date().toISOString() job.finishedAt = new Date().toISOString()
appendEvent({ await appendEvent({
level: job.failures.length > 0 ? "warning" : "info", level: job.failures.length > 0 ? "warning" : "info",
eventType: "backups.job.done", eventType: "backups.job.done",
sourceModule: "backups", sourceModule: "backups",
@@ -80,11 +86,11 @@ async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
const backupsRoutes: FastifyPluginAsyncZod = async (app) => { const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/backups", async (_req, reply) => { app.get("/backups", async (_req, reply) => {
return reply.send(listBackups()) return reply.send(await listBackups())
}) })
app.get("/backups/schedule", async (_req, reply) => { app.get("/backups/schedule", async (_req, reply) => {
return reply.send(getBackupScheduleSettings()) return reply.send(await getBackupScheduleSettings())
}) })
app.put("/backups/schedule", async (req, reply) => { app.put("/backups/schedule", async (req, reply) => {
@@ -92,15 +98,48 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
} }
const result = updateBackupScheduleSettings(parsed.data) const result = await updateBackupScheduleSettings(parsed.data)
refreshScheduler() await refreshScheduler()
return reply.send(result) return reply.send(result)
}) })
app.get("/backups/storage", async (_req, reply) => {
return reply.send(await getBackupStorageSettings())
})
app.put("/backups/storage", async (req, reply) => {
const parsed = putBackupStorageSettingsSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
return reply.send(await updateBackupStorageSettings(parsed.data))
})
app.post("/backups/storage/test", async (_req, reply) => {
try {
return reply.send(await testBackupStorageConnection())
} catch (err) {
return reply.status(400).send({
error: err instanceof Error ? err.message : "Ошибка проверки S3",
settings: await getBackupStorageSettings(),
})
}
})
app.post("/backups/storage/sync", async (_req, reply) => {
try {
return reply.send(await syncBackupsFromS3())
} catch (err) {
return reply.status(400).send({
error: err instanceof Error ? err.message : "Ошибка синхронизации S3",
})
}
})
app.post("/backups/create", { schema: { body: CreateBackupBodySchema } }, async (req, reply) => { app.post("/backups/create", { schema: { body: CreateBackupBodySchema } }, async (req, reply) => {
const inputIds = req.body.serverIds.map((x) => String(x)) const inputIds = req.body.serverIds.map((x) => String(x))
const notes = req.body.notes?.trim() || undefined const notes = req.body.notes?.trim() || undefined
const existingServers = new Set(listServersRead().map((s) => String(s.id))) const existingServers = new Set((await listServersRead()).map((s) => String(s.id)))
const ids = [...new Set(inputIds)].filter((id) => existingServers.has(id)) const ids = [...new Set(inputIds)].filter((id) => existingServers.has(id))
if (ids.length === 0) return reply.status(400).send({ error: "Не выбраны валидные серверы" }) if (ids.length === 0) return reply.status(400).send({ error: "Не выбраны валидные серверы" })
@@ -115,7 +154,7 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
failures: [], failures: [],
} }
backupJobs.set(jobId, job) backupJobs.set(jobId, job)
appendEvent({ await appendEvent({
level: "info", level: "info",
eventType: "backups.job.started", eventType: "backups.job.started",
sourceModule: "backups", sourceModule: "backups",
@@ -128,8 +167,8 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
notes: notes ?? null, notes: notes ?? null,
}, },
}) })
queueMicrotask(() => { queueMicrotask(async () => {
void processBackupJob(job, ids, notes).catch((err) => { void processBackupJob(job, ids, notes).catch(async (err) => {
job.status = "failed" job.status = "failed"
job.finishedAt = new Date().toISOString() job.finishedAt = new Date().toISOString()
const failure = { const failure = {
@@ -137,7 +176,7 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
} }
job.failures.push(failure) job.failures.push(failure)
appendEvent({ await appendEvent({
level: "critical", level: "critical",
eventType: "backups.job.failed", eventType: "backups.job.failed",
sourceModule: "backups", sourceModule: "backups",
@@ -167,14 +206,36 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
}) })
app.get("/backups/:id/download", { schema: { params: BackupIdParamSchema } }, async (req, reply) => { app.get("/backups/:id/download", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
const hit = getBackupById(req.params.id) const hit = await getBackupById(req.params.id)
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" }) if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
const filePath = path.join(getBackupsDir(), hit.filename) try {
const content = await readFile(filePath, "utf8").catch(() => null) const content = await readBackupContent(hit)
if (content == null) return reply.status(404).send({ error: "Файл бэкапа не найден" }) reply.header("Content-Type", "text/plain; charset=utf-8")
reply.header("Content-Type", "text/plain; charset=utf-8") reply.header("Content-Disposition", `attachment; filename="${hit.filename}"`)
reply.header("Content-Disposition", `attachment; filename="${hit.filename}"`) return reply.send(content)
return reply.send(content) } catch {
return reply.status(404).send({ error: "Файл бэкапа не найден" })
}
})
app.post("/backups/:id/restore", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
try {
const result = await restoreBackupToDevice(req.params.id)
await appendEvent({
level: "warning",
eventType: "backups.restore",
sourceModule: "backups",
title: "Восстановление бэкапа",
message: `${result.filename}${result.serverName}`,
entityType: "backup",
entityId: req.params.id,
})
return reply.send(result)
} catch (err) {
return reply.status(400).send({
error: err instanceof Error ? err.message : "Не удалось восстановить бэкап",
})
}
}) })
app.delete("/backups/:id", { schema: { params: BackupIdParamSchema } }, async (req, reply) => { app.delete("/backups/:id", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
+4 -4
View File
@@ -12,7 +12,7 @@ const bgpRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/bgp/sessions/raw/:id — raw RouterOS response for a single server (debug) // GET /api/bgp/sessions/raw/:id — raw RouterOS response for a single server (debug)
app.get("/bgp/sessions/raw/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { app.get("/bgp/sessions/raw/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams const params = req.params as ServerIdParams
const server = db.select().from(servers).where(eq(servers.id, params.id)).limit(1).all()[0] const server = (await db.select().from(servers).where(eq(servers.id, params.id)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server) return reply.status(404).send({ error: "Server not found" })
try { try {
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
@@ -26,7 +26,7 @@ const bgpRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/bgp/sessions — aggregate from ALL enabled servers // GET /api/bgp/sessions — aggregate from ALL enabled servers
app.get("/bgp/sessions", async (_req, reply) => { app.get("/bgp/sessions", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all() const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const results: BgpSessionRead[][] = await Promise.all( const results: BgpSessionRead[][] = await Promise.all(
allServers.map(async (server) => { allServers.map(async (server) => {
@@ -46,10 +46,10 @@ const bgpRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/servers/:id/bgp/sessions — single server // GET /api/servers/:id/bgp/sessions — single server
app.get("/servers/:id/bgp/sessions", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { app.get("/servers/:id/bgp/sessions", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams const params = req.params as ServerIdParams
const server = db const server = (await db
.select().from(servers) .select().from(servers)
.where(eq(servers.id, params.id)) .where(eq(servers.id, params.id))
.limit(1).all()[0] .limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server) return reply.status(404).send({ error: "Server not found" })
+10 -10
View File
@@ -32,7 +32,7 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
}) })
app.get("/certificates/acme-settings", async (_req, reply) => { app.get("/certificates/acme-settings", async (_req, reply) => {
return reply.send(getAcmeSettingsPublic()) return reply.send(await getAcmeSettingsPublic())
}) })
app.put("/certificates/acme-settings", async (req, reply) => { app.put("/certificates/acme-settings", async (req, reply) => {
@@ -40,11 +40,11 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
} }
return reply.send(updateAcmeSettings(parsed.data)) return reply.send(await updateAcmeSettings(parsed.data))
}) })
app.get("/certificates/renew-settings", async (_req, reply) => { app.get("/certificates/renew-settings", async (_req, reply) => {
return reply.send(getCertificateRenewSettings()) return reply.send(await getCertificateRenewSettings())
}) })
app.put("/certificates/renew-settings", async (req, reply) => { app.put("/certificates/renew-settings", async (req, reply) => {
@@ -52,8 +52,8 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
} }
const result = updateCertificateRenewSettings(parsed.data) const result = await updateCertificateRenewSettings(parsed.data)
refreshScheduler() await refreshScheduler()
return reply.send(result) return reply.send(result)
}) })
@@ -62,7 +62,7 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
} }
const effective = parsed.data.cloudflareApiToken?.trim() || getAcmeCloudflareToken() const effective = parsed.data.cloudflareApiToken?.trim() || await getAcmeCloudflareToken()
if (!effective) { if (!effective) {
return reply.status(400).send({ error: "Не задан Cloudflare API token" }) return reply.status(400).send({ error: "Не задан Cloudflare API token" })
} }
@@ -83,12 +83,12 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
} }
const serverId = String(parsed.data.serverId) const serverId = String(parsed.data.serverId)
const server = getServerRowByIdString(serverId) const server = await getServerRowByIdString(serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" }) if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const jobId = randomUUID() const jobId = randomUUID()
const trustStore = (parsed.data.trustStore ?? ["www", "api"]).join(",") const trustStore = (parsed.data.trustStore ?? ["www", "api"]).join(",")
createIssueJobRecord({ await createIssueJobRecord({
id: jobId, id: jobId,
serverId, serverId,
certName: parsed.data.certName.trim(), certName: parsed.data.certName.trim(),
@@ -103,9 +103,9 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/certificates/issue/:jobId", async (req, reply) => { app.get("/certificates/issue/:jobId", async (req, reply) => {
const jobId = String((req.params as { jobId: string }).jobId) const jobId = String((req.params as { jobId: string }).jobId)
const row = getIssueJobRecord(jobId) const row = await getIssueJobRecord(jobId)
if (!row) return reply.status(404).send({ error: "Задача не найдена" }) if (!row) return reply.status(404).send({ error: "Задача не найдена" })
return reply.send(toIssueJobDto(row)) return reply.send(await toIssueJobDto(row))
}) })
} }
+58
View File
@@ -0,0 +1,58 @@
import { z } from "zod"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import {
getEnabledServerById,
listContainers,
listContainersForServer,
removeContainer,
restartContainer,
startContainer,
stopContainer,
} from "../services/containers-live.js"
import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js"
const RosIdBodySchema = z.object({
rosId: z.string().min(1),
})
type RosIdBody = z.infer<typeof RosIdBodySchema>
type MutateFn = typeof startContainer
const containersRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/containers", async (_req, reply) => {
const containers = await listContainers()
return reply.send({ containers })
})
app.get("/servers/:id/containers", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams
const server = await getEnabledServerById(params.id)
if (!server) return reply.status(404).send({ error: "Server not found" })
const containers = await listContainersForServer(server)
return reply.send({ containers })
})
function registerMutate(path: string, fn: MutateFn) {
app.post(path, { schema: { params: ServerIdParamSchema, body: RosIdBodySchema } }, async (req, reply) => {
const params = req.params as ServerIdParams
const body = req.body as RosIdBody
const server = await getEnabledServerById(params.id)
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
await fn(server, body.rosId)
return reply.send({ ok: true })
} catch (err) {
return reply.status(502).send({
error: err instanceof Error ? err.message : "Ошибка RouterOS",
})
}
})
}
registerMutate("/servers/:id/containers/start", startContainer)
registerMutate("/servers/:id/containers/stop", stopContainer)
registerMutate("/servers/:id/containers/restart", restartContainer)
registerMutate("/servers/:id/containers/remove", removeContainer)
}
export default containersRoutes
+2 -2
View File
@@ -8,7 +8,7 @@ const eventsRoutes: FastifyPluginAsyncZod = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: "Некорректные параметры запроса", details: parsed.error.flatten() }) return reply.status(400).send({ error: "Некорректные параметры запроса", details: parsed.error.flatten() })
} }
return reply.send({ events: readEvents(parsed.data) }) return reply.send({ events: await readEvents(parsed.data) })
}) })
app.post("/events/batch", async (req, reply) => { app.post("/events/batch", async (req, reply) => {
@@ -16,7 +16,7 @@ const eventsRoutes: FastifyPluginAsyncZod = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
} }
appendEvents(parsed.data.events) await appendEvents(parsed.data.events)
return reply.status(201).send({ ok: true, inserted: parsed.data.events.length }) return reply.status(201).send({ ok: true, inserted: parsed.data.events.length })
}) })
} }
+17 -19
View File
@@ -17,11 +17,11 @@ const TestEvobgpSchema = z.object({
apiKey: z.string().optional(), apiKey: z.string().optional(),
}) })
function ensureEvobgpRow() { async function ensureEvobgpRow() {
let row = db.select().from(evobgpSettings).where(eq(evobgpSettings.id, 1)).limit(1).all()[0] let row = (await db.select().from(evobgpSettings).where(eq(evobgpSettings.id, 1)).limit(1))[0]
if (!row) { if (!row) {
db.insert(evobgpSettings).values({ id: 1 }).run() await db.insert(evobgpSettings).values({ id: 1 })
row = db.select().from(evobgpSettings).where(eq(evobgpSettings.id, 1)).limit(1).all()[0] row = (await db.select().from(evobgpSettings).where(eq(evobgpSettings.id, 1)).limit(1))[0]
} }
return row! return row!
} }
@@ -108,11 +108,10 @@ function aggregateCommunityRouteCounts(catalog: EvoCatalogRaw): Map<string, numb
* Уникальные серверы из локальной БД, у которых в BGP-фильтре указан community (строка AS:NNN). * Уникальные серверы из локальной БД, у которых в BGP-фильтре указан community (строка AS:NNN).
* Ключ значение `filter_rules.community` после trim (как в EvoBGP `community`). * Ключ значение `filter_rules.community` после trim (как в EvoBGP `community`).
*/ */
function distinctServersByFilterCommunityValue(): Map<string, number> { async function distinctServersByFilterCommunityValue(): Promise<Map<string, number>> {
const rows = db const rows = await db
.select({ serverId: filterRules.serverId, community: filterRules.community }) .select({ serverId: filterRules.serverId, community: filterRules.community })
.from(filterRules) .from(filterRules)
.all()
const byVal = new Map<string, Set<number>>() const byVal = new Map<string, Set<number>>()
for (const r of rows) { for (const r of rows) {
const v = (r.community ?? "").trim() const v = (r.community ?? "").trim()
@@ -161,8 +160,8 @@ async function fetchEvoJson<T>(root: string, path: string, token: string): Promi
} }
} }
function credentialsFromDb(): { root: string; apiKey: string } | null { async function credentialsFromDb(): Promise<{ root: string; apiKey: string } | null> {
const row = ensureEvobgpRow() const row = await ensureEvobgpRow()
const root = normalizeBaseUrl(row.baseUrl) const root = normalizeBaseUrl(row.baseUrl)
const apiKey = normalizeApiKey(row.apiKey) const apiKey = normalizeApiKey(row.apiKey)
if (!root || !apiKey) return null if (!root || !apiKey) return null
@@ -171,7 +170,7 @@ function credentialsFromDb(): { root: string; apiKey: string } | null {
const evobgpRoutes: FastifyPluginAsyncZod = async (app) => { const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/evobgp/settings", async (_req, reply) => { app.get("/evobgp/settings", async (_req, reply) => {
const row = ensureEvobgpRow() const row = await ensureEvobgpRow()
return reply.send({ return reply.send({
baseUrl: row.baseUrl ?? "", baseUrl: row.baseUrl ?? "",
enabled: Boolean(row.enabled), enabled: Boolean(row.enabled),
@@ -184,7 +183,7 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
} }
const cur = ensureEvobgpRow() const cur = await ensureEvobgpRow()
let nextBase = cur.baseUrl let nextBase = cur.baseUrl
let nextEnabled = cur.enabled let nextEnabled = cur.enabled
let nextKey = cur.apiKey let nextKey = cur.apiKey
@@ -200,17 +199,16 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
: normalizeApiKey(parsed.data.apiKey) : normalizeApiKey(parsed.data.apiKey)
} }
db.update(evobgpSettings) await db.update(evobgpSettings)
.set({ .set({
baseUrl: nextBase, baseUrl: nextBase,
enabled: nextEnabled, enabled: nextEnabled,
apiKey: nextKey, apiKey: nextKey,
updatedAt: sql`(datetime('now'))`, updatedAt: sql`now()`,
}) })
.where(eq(evobgpSettings.id, 1)) .where(eq(evobgpSettings.id, 1))
.run()
const row = ensureEvobgpRow() const row = await ensureEvobgpRow()
return reply.send({ return reply.send({
baseUrl: row.baseUrl ?? "", baseUrl: row.baseUrl ?? "",
enabled: Boolean(row.enabled), enabled: Boolean(row.enabled),
@@ -228,7 +226,7 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
} }
const row = ensureEvobgpRow() const row = await ensureEvobgpRow()
const d = parsed.data const d = parsed.data
const urlRaw = d.baseUrl !== undefined ? d.baseUrl : row.baseUrl const urlRaw = d.baseUrl !== undefined ? d.baseUrl : row.baseUrl
const keyRaw = const keyRaw =
@@ -253,11 +251,11 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
* Прокси к GET /v1/router-lists/catalog учётные данные только из БД. * Прокси к GET /v1/router-lists/catalog учётные данные только из БД.
*/ */
app.post("/evobgp/catalog", async (_req, reply) => { app.post("/evobgp/catalog", async (_req, reply) => {
const row = ensureEvobgpRow() const row = await ensureEvobgpRow()
if (!row.enabled) { if (!row.enabled) {
return reply.status(400).send({ error: "Интеграция EvoBGP выключена в настройках" }) return reply.status(400).send({ error: "Интеграция EvoBGP выключена в настройках" })
} }
const cred = credentialsFromDb() const cred = await credentialsFromDb()
if (!cred) { if (!cred) {
return reply.status(400).send({ error: "Не заданы базовый URL или API-ключ в БД" }) return reply.status(400).send({ error: "Не заданы базовый URL или API-ключ в БД" })
} }
@@ -333,7 +331,7 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
}) })
const routeCountsByCommUuid = aggregateCommunityRouteCounts(catalog) const routeCountsByCommUuid = aggregateCommunityRouteCounts(catalog)
const serversByCommunityStr = distinctServersByFilterCommunityValue() const serversByCommunityStr = await distinctServersByFilterCommunityValue()
const communities = commItems.map((c) => { const communities = commItems.map((c) => {
const valueStr = (c.community ?? "").trim() const valueStr = (c.community ?? "").trim()
+2 -2
View File
@@ -314,10 +314,10 @@ const execRoutes: FastifyPluginAsyncZod = async (app) => {
{ schema: { params: ServerIdParamSchema, body: ExecBodySchema } }, { schema: { params: ServerIdParamSchema, body: ExecBodySchema } },
async (req, reply) => { async (req, reply) => {
const params = req.params as ServerIdParams const params = req.params as ServerIdParams
const server = db const server = (await db
.select().from(servers) .select().from(servers)
.where(eq(servers.id, params.id)) .where(eq(servers.id, params.id))
.limit(1).all()[0] .limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server) return reply.status(404).send({ error: "Server not found" })
+223 -282
View File
@@ -1,14 +1,20 @@
import { and, asc, eq, inArray } from "drizzle-orm" import { and, asc, eq } from "drizzle-orm"
import { z } from "zod"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js" import { db } from "../db/index.js"
import { filterRules, recursiveRoutes, servers } from "../db/schema.js" import { filterRules, recursiveRoutes, servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js" import { MikrotikClient } from "../services/mikrotik.js"
import { parseDbServerId } from "../utils/server-id.js" import { parseDbServerId } from "../utils/server-id.js"
import { appendEvent } from "../modules/events/service/events-service.js" import { appendEvent } from "../modules/events/service/events-service.js"
import { managedComment } from "../managed-markers.js"
import { planBgpInApply } from "../services/config-apply-plan.js"
import { import {
hasManagedCommentPrefix, appendRevisionIfChanged,
managedComment, canonicalFilterRules,
} from "../managed-markers.js" getRevisionById,
listRevisions,
type ConfigRevisionSource,
} from "../services/config-revisions.js"
type ServerRow = typeof servers.$inferSelect type ServerRow = typeof servers.$inferSelect
@@ -186,12 +192,11 @@ function gatewayFromRecursiveDst(dstAddress: string): string {
* Импорт с роутера даёт только `set gateway` без `set out-interface` в таком виде не отличить от «поломанного» GRE. * Импорт с роутера даёт только `set gateway` без `set out-interface` в таком виде не отличить от «поломанного» GRE.
* Сопоставляем hop с локальной таблицей recursive_routes и восстанавливаем gatewayTunnelId = rec:<id>. * Сопоставляем hop с локальной таблицей recursive_routes и восстанавливаем gatewayTunnelId = rec:<id>.
*/ */
function enrichRulesWithRecursiveGateway(serverId: number, rules: ApiFilterRule[]): ApiFilterRule[] { async function enrichRulesWithRecursiveGateway(serverId: number, rules: ApiFilterRule[]): Promise<ApiFilterRule[]> {
const rows = db const rows = await db
.select() .select()
.from(recursiveRoutes) .from(recursiveRoutes)
.where(and(eq(recursiveRoutes.serverId, serverId), eq(recursiveRoutes.disabled, false))) .where(and(eq(recursiveRoutes.serverId, serverId), eq(recursiveRoutes.disabled, false)))
.all()
return rules.map(rule => { return rules.map(rule => {
if (rule.action === "blackhole") return rule if (rule.action === "blackhole") return rule
@@ -249,7 +254,7 @@ async function fetchServerFilters(server: ServerRow) {
.filter(r => (r.chain ?? "").trim().toLowerCase() === "bgp-in") .filter(r => (r.chain ?? "").trim().toLowerCase() === "bgp-in")
.flatMap(parseFilterRule) .flatMap(parseFilterRule)
const rules = enrichRulesWithRecursiveGateway(server.id, rulesRaw) const rules = await enrichRulesWithRecursiveGateway(server.id, rulesRaw)
return { return {
serverId: String(server.id), serverId: String(server.id),
@@ -258,8 +263,8 @@ async function fetchServerFilters(server: ServerRow) {
} }
} }
function toApiRulesets(serverRows: ServerRow[]) { async function toApiRulesets(serverRows: ServerRow[]) {
const dbRules = db.select().from(filterRules).orderBy(asc(filterRules.serverId), asc(filterRules.sortOrder)).all() const dbRules = await db.select().from(filterRules).orderBy(asc(filterRules.serverId), asc(filterRules.sortOrder))
return serverRows.map(s => ({ return serverRows.map(s => ({
serverId: String(s.id), serverId: String(s.id),
rules: dbRules rules: dbRules
@@ -277,7 +282,7 @@ function toApiRulesets(serverRows: ServerRow[]) {
} }
/** GRE: gatewayTunnelId = имя интерфейса; рекурсивный: rec:<id строки recursive_routes */ /** GRE: gatewayTunnelId = имя интерфейса; рекурсивный: rec:<id строки recursive_routes */
function resolveRouteTargets(serverId: number, rule: ApiFilterRule): { gateway: string; outIface: string } { async function resolveRouteTargets(serverId: number, rule: ApiFilterRule): Promise<{ gateway: string; outIface: string }> {
if (rule.action === "blackhole") return { gateway: "", outIface: "" } if (rule.action === "blackhole") return { gateway: "", outIface: "" }
const tid = (rule.gatewayTunnelId ?? "").trim() const tid = (rule.gatewayTunnelId ?? "").trim()
if (tid.startsWith("rec:")) { if (tid.startsWith("rec:")) {
@@ -285,12 +290,11 @@ function resolveRouteTargets(serverId: number, rule: ApiFilterRule): { gateway:
if (!Number.isFinite(rid)) { if (!Number.isFinite(rid)) {
return { gateway: rule.gateway, outIface: "" } return { gateway: rule.gateway, outIface: "" }
} }
const row = db const row = (await db
.select() .select()
.from(recursiveRoutes) .from(recursiveRoutes)
.where(and(eq(recursiveRoutes.serverId, serverId), eq(recursiveRoutes.id, rid))) .where(and(eq(recursiveRoutes.serverId, serverId), eq(recursiveRoutes.id, rid)))
.limit(1) .limit(1))[0]
.all()[0]
if (!row) return { gateway: rule.gateway, outIface: "" } if (!row) return { gateway: rule.gateway, outIface: "" }
const gw = gatewayFromRecursiveDst(row.dstAddress) const gw = gatewayFromRecursiveDst(row.dstAddress)
return { gateway: gw || rule.gateway, outIface: "" } return { gateway: gw || rule.gateway, outIface: "" }
@@ -298,48 +302,7 @@ function resolveRouteTargets(serverId: number, rule: ApiFilterRule): { gateway:
return { gateway: rule.gateway, outIface: tid } return { gateway: rule.gateway, outIface: tid }
} }
function normalizeCommunity(c: string): string { async function toRouterRuleBody(serverId: number, rules: ApiFilterRule[]): Promise<string> {
return (c ?? "").trim()
}
/** Одинаковый эффект на роутере при одинаковой community (blackhole vs gateway + out-interface) */
function ruleEffectSignature(serverId: number, r: ApiFilterRule): string {
if (r.action === "blackhole") return `bh:${normalizeCommunity(r.community)}`
const { gateway, outIface } = resolveRouteTargets(serverId, r)
return `rt:${normalizeCommunity(r.community)}:${gateway}:${outIface}`
}
export type FilterRouterCompareStatus = "synced" | "drift" | "missing"
function compareDbRulesWithRouter(
serverId: number,
dbRules: ApiFilterRule[],
remoteRules: ApiFilterRule[],
): Record<string, FilterRouterCompareStatus> {
const remoteSigByComm = new Map<string, string>()
for (const rr of remoteRules) {
const c = normalizeCommunity(rr.community)
if (!remoteSigByComm.has(c)) {
remoteSigByComm.set(c, ruleEffectSignature(serverId, rr))
}
}
const out: Record<string, FilterRouterCompareStatus> = {}
for (const dr of dbRules) {
const c = normalizeCommunity(dr.community)
const sigD = ruleEffectSignature(serverId, dr)
const sigR = remoteSigByComm.get(c)
if (sigR === undefined) {
out[c] = "missing"
} else if (sigR !== sigD) {
out[c] = "drift"
} else {
out[c] = "synced"
}
}
return out
}
function toRouterRuleBody(serverId: number, rules: ApiFilterRule[]): string {
if (rules.length === 0) return "" if (rules.length === 0) return ""
// Группируем по эффекту (action + gateway + out-interface). Communities с одним и тем же // Группируем по эффекту (action + gateway + out-interface). Communities с одним и тем же
// `set gw` объединяются через `||` в один if-блок — компактнее и ближе к привычному // `set gw` объединяются через `||` в один if-блок — компактнее и ближе к привычному
@@ -361,7 +324,7 @@ function toRouterRuleBody(serverId: number, rules: ApiFilterRule[]): string {
const isBlackhole = rule.action === "blackhole" const isBlackhole = rule.action === "blackhole"
const { gateway, outIface } = isBlackhole const { gateway, outIface } = isBlackhole
? { gateway: "", outIface: "" } ? { gateway: "", outIface: "" }
: resolveRouteTargets(serverId, rule) : await resolveRouteTargets(serverId, rule)
const key = isBlackhole ? "bh" : `rt:${gateway}:${outIface}` const key = isBlackhole ? "bh" : `rt:${gateway}:${outIface}`
let idx = indexByKey.get(key) let idx = indexByKey.get(key)
if (idx === undefined) { if (idx === undefined) {
@@ -404,10 +367,10 @@ function toRouterRuleBody(serverId: number, rules: ApiFilterRule[]): string {
} }
async function replaceDbRules(serverId: number, rules: ApiFilterRule[]) { async function replaceDbRules(serverId: number, rules: ApiFilterRule[]) {
db.delete(filterRules).where(eq(filterRules.serverId, serverId)).run() await db.delete(filterRules).where(eq(filterRules.serverId, serverId))
if (rules.length === 0) return if (rules.length === 0) return
const now = new Date().toISOString() const now = new Date().toISOString()
db.insert(filterRules).values( await db.insert(filterRules).values(
rules.map((r, i) => ({ rules.map((r, i) => ({
serverId, serverId,
sortOrder: i, sortOrder: i,
@@ -420,53 +383,88 @@ async function replaceDbRules(serverId: number, rules: ApiFilterRule[]) {
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
})), })),
).run() )
} }
const filtersRoutes: FastifyPluginAsyncZod = async (app) => { async function cacheRulesetsForServer(serverId: number): Promise<ApiFilterRule[]> {
/** Сравнение правил в БД с живым bgp-in на MikroTik (один запрос API к роутеру) */ const rows = await db
app.get("/filters/router-compare", async (req, reply) => { .select()
const q = req.query as { serverId?: string | number } .from(filterRules)
const serverId = parseDbServerId(q.serverId) .where(eq(filterRules.serverId, serverId))
if (serverId === null) { .orderBy(asc(filterRules.sortOrder))
return reply.status(400).send({ error: "serverId is required" }) return rows.map((r) => ({
} id: String(r.id),
community: r.community,
communityName: r.communityName ?? undefined,
action: r.action,
gateway: r.gateway,
gatewayTunnelId: r.gatewayTunnelId,
description: r.description,
}))
}
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0] async function applyFiltersToServer(
if (!server) return reply.status(404).send({ error: "Server not found" }) server: ServerRow,
rules: ApiFilterRule[],
source: ConfigRevisionSource,
): Promise<{ pushed: number; action: string; conflictsRemoved: number }> {
const client = MikrotikClient.fromServer(server)
const existing = await client.get<RosFilterRule[]>("/routing/filter/rule")
const plan = planBgpInApply(existing, rules.length)
const managedCommentValue = managedComment(server.name || server.host)
try { if (plan.action === "patch" && plan.managedId) {
const remote = await fetchServerFilters(server) const ruleBody = await toRouterRuleBody(server.id, rules)
const rows = db await client.patch(
.select() `/routing/filter/rule/${encodeURIComponent(plan.managedId)}`,
.from(filterRules) {
.where(eq(filterRules.serverId, serverId)) chain: "bgp-in",
.orderBy(asc(filterRules.sortOrder)) comment: managedCommentValue,
.all() rule: ruleBody,
disabled: "no",
},
)
} else if (plan.action === "create") {
const ruleBody = await toRouterRuleBody(server.id, rules)
await client.post("/routing/filter/rule/add", {
chain: "bgp-in",
comment: managedCommentValue,
rule: ruleBody,
})
} else if (plan.action === "delete" && plan.managedId) {
await client.delete(
`/routing/filter/rule/${encodeURIComponent(plan.managedId)}`,
)
}
const dbRules: ApiFilterRule[] = rows.map(r => ({ for (const id of plan.conflictIds) {
id: String(r.id), await client.delete(`/routing/filter/rule/${encodeURIComponent(id)}`)
community: r.community, }
communityName: r.communityName ?? undefined,
action: r.action,
gateway: r.gateway,
gatewayTunnelId: r.gatewayTunnelId,
description: r.description,
}))
const byCommunity = compareDbRulesWithRouter(serverId, dbRules, remote.rules) await replaceDbRules(server.id, rules)
return reply.send({ byCommunity }) await appendRevisionIfChanged({
} catch (err) { serverId: server.id,
app.log.error({ serverId, err: String(err) }, "filters router-compare failed") section: "filters",
return reply.status(500).send({ error: String(err) }) source,
} payload: canonicalFilterRules(rules),
}) })
return {
pushed: rules.length,
action: plan.action,
conflictsRemoved: plan.conflictIds.length,
}
}
const RevisionIdParamSchema = z.object({ id: z.string().min(1) })
const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
/** GRE с роутеров: один сервер (?serverId) или все включённые (без query) — для /gre, карты сети */ /** GRE с роутеров: один сервер (?serverId) или все включённые (без query) — для /gre, карты сети */
app.get("/filters/gre-tunnels", async (req, reply) => { app.get("/filters/gre-tunnels", async (req, reply) => {
const q = req.query as { serverId?: string | number } const q = req.query as { serverId?: string | number }
const sid = parseDbServerId(q.serverId) const sid = parseDbServerId(q.serverId)
const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all() const enabledServers = await db.select().from(servers).where(eq(servers.enabled, true))
if (sid !== null) { if (sid !== null) {
const server = enabledServers.find(s => s.id === sid) const server = enabledServers.find(s => s.id === sid)
@@ -490,74 +488,103 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.send({ tunnels: results.flat() }) return reply.send({ tunnels: results.flat() })
}) })
/** Только правила фильтров из БД (без опроса MikroTik за GRE) */ /** Без serverId — cache для дашборда. С serverId — live с CHR, cache fallback. */
app.get("/filters/rules", async (_req, reply) => { app.get("/filters/rules", async (req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all() const q = req.query as { serverId?: string | number }
const dbRulesets = toApiRulesets(allServers) const serverId = parseDbServerId(q.serverId)
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
return reply.send({
rulesets: dbRulesets,
greTunnels: [] as LiveGreTunnel[],
})
})
app.put("/filters/rules", async (req, reply) => {
const body = req.body as { rulesets?: Array<{ serverId: string; rules: ApiFilterRule[] }> }
const payload = body.rulesets ?? []
const serverIds = payload.map(r => Number.parseInt(r.serverId, 10)).filter(Number.isFinite)
if (serverIds.length > 0) {
db.delete(filterRules).where(inArray(filterRules.serverId, serverIds)).run()
}
for (const rs of payload) {
const sid = Number.parseInt(rs.serverId, 10)
if (!Number.isFinite(sid)) continue
await replaceDbRules(sid, rs.rules ?? [])
}
return reply.send({ ok: true })
})
app.post("/filters/sync/from-router", async (_req, reply) => {
const body = _req.body as { serverId?: string | number } | undefined
const rawServerId = body?.serverId
const serverId = parseDbServerId(rawServerId)
if (serverId === null) { if (serverId === null) {
return reply.status(400).send({ error: "serverId is required" }) const dbRulesets = await toApiRulesets(allServers)
return reply.send({
rulesets: dbRulesets,
greTunnels: [] as LiveGreTunnel[],
live: false,
stale: false,
})
} }
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0] const server = allServers.find((s) => s.id === serverId)
?? (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server) return reply.status(404).send({ error: "Server not found" })
try { try {
app.log.info({ serverId, host: server.host }, "Filters sync from router started")
appendEvent({
level: "info",
eventType: "filters.sync.from_router.started",
sourceModule: "filters",
title: "Синхронизация фильтров запущена",
message: `${server.name || server.host} → БД`,
entityType: "server",
entityId: String(serverId),
})
const remote = await fetchServerFilters(server) const remote = await fetchServerFilters(server)
await replaceDbRules(server.id, remote.rules) await replaceDbRules(server.id, remote.rules)
app.log.info({ serverId, totalRules: remote.rules.length }, "Filters sync from router completed") await appendRevisionIfChanged({
appendEvent({ serverId: server.id,
section: "filters",
source: "observed",
payload: canonicalFilterRules(remote.rules),
})
const cached = await cacheRulesetsForServer(server.id)
return reply.send({
rulesets: [{ serverId: String(server.id), rules: cached }],
greTunnels: remote.tunnels,
live: true,
stale: false,
})
} catch (err) {
app.log.warn({ serverId, err: String(err) }, "filters live GET failed, serving cache")
const cached = await cacheRulesetsForServer(server.id)
return reply.send({
rulesets: [{ serverId: String(server.id), rules: cached }],
greTunnels: [] as LiveGreTunnel[],
live: false,
stale: true,
error: String(err),
})
}
})
app.put("/filters/rules", async (req, reply) => {
const body = req.body as {
serverId?: string | number
rules?: ApiFilterRule[]
source?: ConfigRevisionSource
}
const serverId = parseDbServerId(body.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
const rules = body.rules ?? []
const source: ConfigRevisionSource = body.source === "copy" ? "copy" : "apply"
try {
await appendEvent({
level: "info", level: "info",
eventType: "filters.sync.from_router.done", eventType: "filters.apply.started",
sourceModule: "filters", sourceModule: "filters",
title: "Синхронизация фильтров завершена", title: "Применение фильтров на роутер",
message: `${server.name || server.host}: ${remote.rules.length} правил`, message: `${server.name || server.host}: ${rules.length} правил`,
entityType: "server", entityType: "server",
entityId: String(serverId), entityId: String(serverId),
}) })
return reply.send({ ok: true, updatedServers: 1, totalRules: remote.rules.length, serverId }) const result = await applyFiltersToServer(server, rules, source)
} catch (err) { const cached = await cacheRulesetsForServer(server.id)
app.log.error({ serverId, err: String(err) }, "Filters sync from router failed") await appendEvent({
appendEvent({ level: "info",
level: "critical", eventType: "filters.apply.done",
eventType: "filters.sync.from_router.failed",
sourceModule: "filters", sourceModule: "filters",
title: "Ошибка синхронизации фильтров", title: "Фильтры применены",
message: `${server.name || server.host}: ${result.pushed} правил (${result.action})`,
entityType: "server",
entityId: String(serverId),
})
return reply.send({
ok: true,
serverId,
pushedRules: result.pushed,
action: result.action,
rules: cached,
})
} catch (err) {
app.log.error({ serverId, err: String(err) }, "filters apply failed")
await appendEvent({
level: "critical",
eventType: "filters.apply.failed",
sourceModule: "filters",
title: "Ошибка применения фильтров",
message: `${server.name || server.host}: ${String(err)}`, message: `${server.name || server.host}: ${String(err)}`,
entityType: "server", entityType: "server",
entityId: String(serverId), entityId: String(serverId),
@@ -566,146 +593,60 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
} }
}) })
app.post("/filters/sync/to-router", async (req, reply) => { app.get("/filters/revisions", async (req, reply) => {
const q = req.query as { serverId?: string | number }
const serverId = parseDbServerId(q.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const revisions = await listRevisions(serverId, "filters")
return reply.send({ revisions })
})
app.post("/filters/revisions/:id/restore", {
schema: { params: RevisionIdParamSchema },
}, async (req, reply) => {
const { id } = req.params
const body = req.body as { serverId?: string | number } | undefined const body = req.body as { serverId?: string | number } | undefined
const requestedServerId = parseDbServerId(body?.serverId) const rev = await getRevisionById(id)
if (!rev) return reply.status(404).send({ error: "Revision not found" })
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all() if (rev.section !== "filters") return reply.status(400).send({ error: "Revision section mismatch" })
const targetServers = requestedServerId !== null const requested = parseDbServerId(body?.serverId)
? allServers.filter(s => s.id === requestedServerId) if (requested !== null && requested !== rev.serverId) {
: allServers return reply.status(400).send({ error: "Revision belongs to another server" })
if (requestedServerId !== null && targetServers.length === 0) {
return reply.status(404).send({ error: "Server not found" })
} }
const server = (await db.select().from(servers).where(eq(servers.id, rev.serverId)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
let updatedServers = 0 const raw = Array.isArray(rev.payload) ? rev.payload : []
let pushedRules = 0 const rules: ApiFilterRule[] = raw.map((item, idx) => {
const errors: Array<{ serverId: number; error: string }> = [] const r = item as Partial<ApiFilterRule>
appendEvent({ return {
level: "info", id: `rev-${idx}`,
eventType: "filters.sync.to_router.started", community: r.community ?? "",
sourceModule: "filters", communityName: r.communityName,
title: "Отправка фильтров на роутеры запущена", action: r.action === "blackhole" ? "blackhole" : "route",
message: `Целевых серверов: ${targetServers.length}`, gateway: r.gateway ?? "",
payload: { requestedServerId }, gatewayTunnelId: r.gatewayTunnelId ?? "",
}) description: r.description ?? "",
for (const server of targetServers) {
try {
app.log.info({ serverId: server.id, host: server.host }, "Filters sync to router started")
const client = MikrotikClient.fromServer(server)
const existing = await client.get<RosFilterRule[]>("/routing/filter/rule")
const isInBgpIn = (r: RosFilterRule) =>
(r.chain ?? "").trim().toLowerCase() === "bgp-in"
const managedCommentValue = managedComment(server.name || server.host)
// Уже созданное нами правило — будем PATCH'ить, чтобы сохранить ID/позицию в цепочке.
const managedRule = existing.find(
r => isInBgpIn(r) && hasManagedCommentPrefix(r.comment ?? ""),
)
// Конфликтующие легаси-правила в bgp-in (без нашего comment, но с bgp-communities) —
// удаляем после успешного upsert: иначе старое правило с `else { reject; }`
// отрабатывает первым и перебивает наш upsert.
const conflictIds = existing
.filter(r =>
isInBgpIn(r) &&
!hasManagedCommentPrefix(r.comment ?? "") &&
/bgp-communities/i.test(r.rule ?? ""),
)
.map(r => r[".id"])
.filter((id): id is string => Boolean(id))
const rows = db.select().from(filterRules)
.where(and(eq(filterRules.serverId, server.id)))
.orderBy(asc(filterRules.sortOrder))
.all()
const rules: ApiFilterRule[] = rows.map(r => ({
id: String(r.id),
community: r.community,
communityName: r.communityName ?? undefined,
action: r.action,
gateway: r.gateway,
gatewayTunnelId: r.gatewayTunnelId,
description: r.description,
}))
// Upsert: PATCH существующего managed-правила или POST /add нового.
// Если ошибка — конфликтные правила НЕ удаляем (роутер не остаётся с пустым bgp-in).
// Путь `/routing/filter/rule/add` обязателен: голый POST на коллекцию RouterOS REST
// трактует как «вызов команды» и отдаёт 400 «no such command».
// См. https://help.mikrotik.com/docs/spaces/ROS/pages/47579162/REST+API
if (rules.length > 0) {
const ruleBody = toRouterRuleBody(server.id, rules)
if (managedRule && managedRule[".id"]) {
await client.patch(
`/routing/filter/rule/${encodeURIComponent(managedRule[".id"])}`,
{
chain: "bgp-in",
comment: managedCommentValue,
rule: ruleBody,
disabled: "no",
},
)
app.log.info({ serverId: server.id, id: managedRule[".id"] }, "bgp-in rule updated")
} else {
await client.post("/routing/filter/rule/add", {
chain: "bgp-in",
comment: managedCommentValue,
rule: ruleBody,
})
app.log.info({ serverId: server.id }, "bgp-in rule created")
}
pushedRules += rules.length
} else if (managedRule && managedRule[".id"]) {
// В БД нет правил → удаляем наш managed-rule на роутере.
await client.delete(
`/routing/filter/rule/${encodeURIComponent(managedRule[".id"])}`,
)
app.log.info({ serverId: server.id }, "bgp-in rule removed (no rules in DB)")
}
for (const id of conflictIds) {
await client.delete(`/routing/filter/rule/${encodeURIComponent(id)}`)
}
updatedServers += 1
app.log.info(
{
serverId: server.id,
mode: managedRule ? "patch" : "create",
conflictsRemoved: conflictIds.length,
pushed: rules.length,
},
"Filters sync to router completed",
)
} catch (err) {
app.log.error({ serverId: server.id, err: String(err) }, "filters sync to-router failed")
errors.push({ serverId: server.id, error: String(err) })
} }
} })
appendEvent({ try {
level: errors.length === 0 ? "info" : "warning", const result = await applyFiltersToServer(server, rules, "rollback")
eventType: errors.length === 0 ? "filters.sync.to_router.done" : "filters.sync.to_router.partial", const cached = await cacheRulesetsForServer(server.id)
sourceModule: "filters", await appendEvent({
title: errors.length === 0 ? "Отправка фильтров завершена" : "Отправка фильтров завершена с ошибками", level: "info",
message: `Успешно: ${updatedServers}, ошибок: ${errors.length}, правил: ${pushedRules}`, eventType: "filters.rollback.done",
payload: { sourceModule: "filters",
updatedServers, title: "Откат фильтров",
pushedRules, message: `${server.name || server.host}: ${result.pushed} правил`,
errors, entityType: "server",
}, entityId: String(server.id),
}) })
return reply.send({ return reply.send({ ok: true, rules: cached, pushedRules: result.pushed })
ok: errors.length === 0, } catch (err) {
updatedServers, app.log.error({ serverId: server.id, err: String(err) }, "filters restore failed")
pushedRules, return reply.status(500).send({ error: String(err) })
errors, }
})
}) })
} }
+84 -12
View File
@@ -2,7 +2,20 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { z } from "zod" import { z } from "zod"
import { MikrotikClient, MikrotikError, encodeRosId, firewallRestPath } from "../services/mikrotik.js" import { MikrotikClient, MikrotikError, encodeRosId, firewallRestPath } from "../services/mikrotik.js"
import { getEnabledServerById } from "../services/wireguard-live.js" import { getEnabledServerById } from "../services/wireguard-live.js"
import { listFirewallAll } from "../services/firewall-live.js" import {
captureFirewallSnapshot,
fetchFirewallState,
listFirewallAll,
} from "../services/firewall-live.js"
import {
captureAndAppendRevision,
listRevisions,
loadRevisionForRestore,
type ConfigRevisionSource,
} from "../services/config-revisions.js"
import { parseFirewallSnapshot, planFirewallRestore } from "../services/entity-snapshots.js"
import { executeRosOps } from "../services/ros-ops.js"
import { parseDbServerId } from "../utils/server-id.js"
import type { FirewallFamily, FirewallTable } from "../types/server.js" import type { FirewallFamily, FirewallTable } from "../types/server.js"
const FamilySchema = z.enum(["ip", "ip6"]) const FamilySchema = z.enum(["ip", "ip6"])
@@ -116,10 +129,24 @@ function rosErr(e: unknown): string {
return String(e) return String(e)
} }
function requireServer(serverId: string) { async function requireServer(serverId: string) {
return getEnabledServerById(serverId) return await getEnabledServerById(serverId)
} }
async function recordFirewall(
server: NonNullable<Awaited<ReturnType<typeof requireServer>>>,
source: ConfigRevisionSource,
) {
await captureAndAppendRevision({
serverId: server.id,
section: "firewall",
source,
capture: () => captureFirewallSnapshot(server),
})
}
const RevisionIdParamSchema = z.object({ id: z.string().min(1) })
const firewallRoutes: FastifyPluginAsyncZod = async (app) => { const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/firewall/all", async (_req, reply) => { app.get("/firewall/all", async (_req, reply) => {
const data = await listFirewallAll() const data = await listFirewallAll()
@@ -132,12 +159,13 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
} }
const body = parsed.data const body = parsed.data
const server = requireServer(body.serverId) const server = await requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" }) if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
const path = firewallRestPath(body.family as FirewallFamily, body.table as FirewallTable) const path = firewallRestPath(body.family as FirewallFamily, body.table as FirewallTable)
try { try {
await client.put(path, ruleToRos(body)) await client.put(path, ruleToRos(body))
await recordFirewall(server, "apply")
return reply.status(201).send({ ok: true }) return reply.status(201).send({ ok: true })
} catch (e) { } catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` }) return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
@@ -150,12 +178,13 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
} }
const body = parsed.data const body = parsed.data
const server = requireServer(body.serverId) const server = await requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" }) if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
const path = `${firewallRestPath(body.family as FirewallFamily, body.table as FirewallTable)}/${encodeRosId(body.rosId)}` const path = `${firewallRestPath(body.family as FirewallFamily, body.table as FirewallTable)}/${encodeRosId(body.rosId)}`
try { try {
await client.patch(path, ruleToRos(body)) await client.patch(path, ruleToRos(body))
await recordFirewall(server, "apply")
return reply.send({ ok: true }) return reply.send({ ok: true })
} catch (e) { } catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` }) return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
@@ -168,12 +197,13 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
} }
const body = parsed.data const body = parsed.data
const server = requireServer(body.serverId) const server = await requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" }) if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
const path = `${firewallRestPath(body.family, body.table)}/${encodeRosId(body.rosId)}` const path = `${firewallRestPath(body.family, body.table)}/${encodeRosId(body.rosId)}`
try { try {
await client.patch(path, { disabled: body.disabled ? "yes" : "no" }) await client.patch(path, { disabled: body.disabled ? "yes" : "no" })
await recordFirewall(server, "apply")
return reply.send({ ok: true }) return reply.send({ ok: true })
} catch (e) { } catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` }) return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
@@ -186,12 +216,13 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
} }
const body = parsed.data const body = parsed.data
const server = requireServer(body.serverId) const server = await requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" }) if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
const path = `${firewallRestPath(body.family, body.table)}/${encodeRosId(body.rosId)}` const path = `${firewallRestPath(body.family, body.table)}/${encodeRosId(body.rosId)}`
try { try {
await client.delete(path) await client.delete(path)
await recordFirewall(server, "apply")
return reply.send({ ok: true }) return reply.send({ ok: true })
} catch (e) { } catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` }) return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
@@ -204,7 +235,7 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
} }
const body = parsed.data const body = parsed.data
const server = requireServer(body.serverId) const server = await requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" }) if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
const path = `${firewallRestPath(body.family, body.table)}/move` const path = `${firewallRestPath(body.family, body.table)}/move`
@@ -213,6 +244,7 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
numbers: body.rosId, numbers: body.rosId,
...(body.destinationRosId ? { destination: body.destinationRosId } : {}), ...(body.destinationRosId ? { destination: body.destinationRosId } : {}),
}) })
await recordFirewall(server, "apply")
return reply.send({ ok: true }) return reply.send({ ok: true })
} catch (e) { } catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` }) return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
@@ -225,11 +257,12 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
} }
const body = parsed.data const body = parsed.data
const server = requireServer(body.serverId) const server = await requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" }) if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
try { try {
await client.put(firewallRestPath(body.family, "address-list"), addressToRos(body)) await client.put(firewallRestPath(body.family, "address-list"), addressToRos(body))
await recordFirewall(server, "apply")
return reply.status(201).send({ ok: true }) return reply.status(201).send({ ok: true })
} catch (e) { } catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` }) return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
@@ -242,12 +275,13 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
} }
const body = parsed.data const body = parsed.data
const server = requireServer(body.serverId) const server = await requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" }) if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}` const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
try { try {
await client.patch(path, addressToRos(body)) await client.patch(path, addressToRos(body))
await recordFirewall(server, "apply")
return reply.send({ ok: true }) return reply.send({ ok: true })
} catch (e) { } catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` }) return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
@@ -260,12 +294,13 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
} }
const body = parsed.data const body = parsed.data
const server = requireServer(body.serverId) const server = await requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" }) if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}` const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
try { try {
await client.patch(path, { disabled: body.disabled ? "yes" : "no" }) await client.patch(path, { disabled: body.disabled ? "yes" : "no" })
await recordFirewall(server, "apply")
return reply.send({ ok: true }) return reply.send({ ok: true })
} catch (e) { } catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` }) return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
@@ -278,17 +313,54 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
} }
const body = parsed.data const body = parsed.data
const server = requireServer(body.serverId) const server = await requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" }) if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}` const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
try { try {
await client.delete(path) await client.delete(path)
await recordFirewall(server, "apply")
return reply.send({ ok: true }) return reply.send({ ok: true })
} catch (e) { } catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` }) return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
} }
}) })
app.get("/firewall/revisions", async (req, reply) => {
const q = req.query as { serverId?: string | number }
const serverId = parseDbServerId(q.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const revisions = await listRevisions(serverId, "firewall")
return reply.send({ revisions })
})
app.post("/firewall/revisions/:id/restore", {
schema: { params: RevisionIdParamSchema },
}, async (req, reply) => {
const { id } = req.params
const body = req.body as { serverId?: string | number } | undefined
const loaded = await loadRevisionForRestore({
id,
section: "firewall",
requestedServerId: parseDbServerId(body?.serverId),
})
if (!loaded.ok) return reply.status(loaded.status).send({ error: loaded.error })
const client = MikrotikClient.fromServer(loaded.server)
try {
const desired = parseFirewallSnapshot(loaded.row.payload)
const state = await fetchFirewallState(loaded.server)
const ops = planFirewallRestore(desired, {
rules: state.liveRules,
addressLists: state.liveLists,
})
await executeRosOps(client, ops)
await recordFirewall(loaded.server, "rollback")
const next = await fetchFirewallState(loaded.server)
return reply.send({ ok: true, rules: next.rules, addressLists: next.addressLists })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
} }
export default firewallRoutes export default firewallRoutes
+57
View File
@@ -0,0 +1,57 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { geoipSettingsPatchSchema } from "@mmapp/contracts/geoip"
import { refreshScheduler } from "../services/scheduler.js"
import { getGeoipSettings, updateGeoipSettings } from "../services/geoip-settings.js"
import {
GEOIP_ASN_FILE,
GEOIP_COUNTRY_FILE,
geoipReadersStatus,
initGeoip,
} from "../services/traffic-flow-geoip.js"
import { collectGeoipUpdateOnce, getGeoipUpdateState } from "../services/geoip-update-collector.js"
async function buildGeoipStatus() {
await initGeoip()
const readers = geoipReadersStatus()
return {
ready: readers.countryLoaded && readers.asnLoaded,
countryLoaded: readers.countryLoaded,
asnLoaded: readers.asnLoaded,
countryFile: GEOIP_COUNTRY_FILE,
asnFile: GEOIP_ASN_FILE,
dir: readers.dir,
running: getGeoipUpdateState().running,
settings: await getGeoipSettings(),
}
}
const geoipRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/geoip", async (_req, reply) => {
return reply.send(await buildGeoipStatus())
})
app.put("/geoip", async (req, reply) => {
const parsed = geoipSettingsPatchSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply
.status(400)
.send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
await updateGeoipSettings(parsed.data)
await refreshScheduler()
return reply.send({ ok: true, status: await buildGeoipStatus() })
})
app.post("/geoip/update", async (_req, reply) => {
try {
const snapshot = await collectGeoipUpdateOnce({ force: true })
return reply.send({ ok: !snapshot.fatalError && snapshot.errors.length === 0, snapshot })
} catch (e) {
const status = (e as { statusCode?: number }).statusCode ?? 502
const msg = e instanceof Error ? e.message : String(e)
return reply.status(status).send({ error: msg })
}
})
}
export default geoipRoutes
+229
View File
@@ -0,0 +1,229 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { z } from "zod"
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
import { getEnabledServerById } from "../services/wireguard-live.js"
import {
captureGreSnapshot,
fetchGreState,
formatKeepalive,
listGreTunnels,
parseKeepalive,
} from "../services/gre-live.js"
import {
canonicalGreSnapshot,
parseGreSnapshot,
planGreCreate,
planGreDelete,
planGreRestore,
} from "../services/entity-snapshots.js"
import { executeRosOps } from "../services/ros-ops.js"
import {
captureAndAppendRevision,
listRevisions,
loadRevisionForRestore,
type ConfigRevisionSource,
} from "../services/config-revisions.js"
import { parseDbServerId } from "../utils/server-id.js"
const TunnelWriteSchema = z.object({
serverId: z.string().min(1),
name: z.string().min(1),
rosId: z.string().optional(),
localAddress: z.string().optional(),
remoteAddress: z.string().min(1),
localInnerIp: z.string().optional(),
remoteInnerIp: z.string().optional(),
comment: z.string().optional(),
enabled: z.boolean().optional(),
mtu: z.number().optional(),
keepaliveInterval: z.number().optional(),
keepaliveRetries: z.number().optional(),
dscp: z.union([z.literal("inherit"), z.number(), z.string()]).optional(),
clampTcpMss: z.boolean().optional(),
allowFastPath: z.boolean().optional(),
ipsecSecret: z.string().optional(),
})
const TunnelKeySchema = z.object({
serverId: z.string().min(1),
rosId: z.string().optional(),
name: z.string().optional(),
disabled: z.boolean().optional(),
})
const RevisionIdParamSchema = z.object({ id: z.string().min(1) })
function rosErr(e: unknown): string {
if (e instanceof MikrotikError) return e.message
if (e instanceof Error) return e.message
return String(e)
}
async function recordGre(
server: NonNullable<Awaited<ReturnType<typeof getEnabledServerById>>>,
source: ConfigRevisionSource,
) {
await captureAndAppendRevision({
serverId: server.id,
section: "gre",
source,
capture: () => captureGreSnapshot(server),
})
}
function tunnelFromBody(body: z.infer<typeof TunnelWriteSchema> & { keepaliveInterval?: number; keepaliveRetries?: number }) {
const dscp = body.dscp == null
? "inherit"
: typeof body.dscp === "number"
? String(body.dscp)
: body.dscp
const keepalive = body.keepaliveInterval === undefined && body.keepaliveRetries === undefined
? undefined
: formatKeepalive(body.keepaliveInterval ?? 0, body.keepaliveRetries ?? 10)
return canonicalGreSnapshot({
tunnels: [{
name: body.name,
localAddress: body.localAddress ?? "",
remoteAddress: body.remoteAddress,
localInnerIp: body.localInnerIp ?? "",
remoteInnerIp: body.remoteInnerIp ?? "",
comment: body.comment ?? "",
disabled: body.enabled === false,
mtu: body.mtu ?? 1476,
keepalive: keepalive ?? "0",
dscp,
clampTcpMss: body.clampTcpMss,
allowFastPath: body.allowFastPath,
ipsecSecret: body.ipsecSecret ?? "",
}],
}).tunnels[0]!
}
const greRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/gre/tunnels", async (req, reply) => {
const q = req.query as { serverId?: string | number }
const sid = parseDbServerId(q.serverId)
const result = await listGreTunnels({ serverId: sid !== null ? String(sid) : undefined })
return reply.send(result)
})
app.post("/gre/tunnels", async (req, reply) => {
const parsed = TunnelWriteSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = await getEnabledServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
const tunnel = tunnelFromBody(body)
await executeRosOps(client, planGreCreate(tunnel))
await recordGre(server, "apply")
const state = await fetchGreState(server)
const created = state.tunnels.find((t) => t.name === tunnel.name)
return reply.status(201).send(created ?? { ok: true, name: tunnel.name })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
app.patch("/gre/tunnels", async (req, reply) => {
const parsed = TunnelWriteSchema.partial().required({ serverId: true }).safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = await getEnabledServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
try {
const state = await fetchGreState(server)
const live = state.gre.find((g) =>
(body.rosId && g.rosId === body.rosId) || (body.name && g.name === body.name),
)
if (!live) return reply.status(404).send({ error: "Туннель не найден" })
const merged = tunnelFromBody({
serverId: body.serverId,
name: body.name || live.name,
localAddress: body.localAddress ?? live.localAddress,
remoteAddress: body.remoteAddress || live.remoteAddress,
localInnerIp: body.localInnerIp ?? state.addrs.find((a) => a.interfaceName === live.name)?.address ?? "",
remoteInnerIp: body.remoteInnerIp,
comment: body.comment ?? live.comment,
enabled: body.enabled ?? !live.disabled,
mtu: body.mtu ?? live.mtu,
keepaliveInterval: body.keepaliveInterval ?? parseKeepalive(live.keepalive).interval,
keepaliveRetries: body.keepaliveRetries ?? parseKeepalive(live.keepalive).retries,
dscp: body.dscp ?? live.dscp,
clampTcpMss: body.clampTcpMss ?? live.clampTcpMss,
allowFastPath: body.allowFastPath ?? live.allowFastPath,
ipsecSecret: body.ipsecSecret ?? live.ipsecSecret,
})
const ops = planGreRestore(
{ tunnels: state.snapshot.tunnels.map((t) => t.name === live.name ? merged : t) },
{ gre: state.gre, addrs: state.addrs },
)
await executeRosOps(state.client, ops)
await recordGre(server, "apply")
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
app.delete("/gre/tunnels", async (req, reply) => {
const parsed = TunnelKeySchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = await getEnabledServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
try {
const state = await fetchGreState(server)
const live = state.gre.find((g) =>
(body.rosId && g.rosId === body.rosId) || (body.name && g.name === body.name),
)
if (!live) return reply.status(404).send({ error: "Туннель не найден" })
await executeRosOps(state.client, planGreDelete(live.name, { gre: state.gre, addrs: state.addrs }))
await recordGre(server, "apply")
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
app.get("/gre/revisions", async (req, reply) => {
const q = req.query as { serverId?: string | number }
const serverId = parseDbServerId(q.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const revisions = await listRevisions(serverId, "gre")
return reply.send({ revisions })
})
app.post("/gre/revisions/:id/restore", {
schema: { params: RevisionIdParamSchema },
}, async (req, reply) => {
const { id } = req.params
const body = req.body as { serverId?: string | number } | undefined
const loaded = await loadRevisionForRestore({
id,
section: "gre",
requestedServerId: parseDbServerId(body?.serverId),
})
if (!loaded.ok) return reply.status(loaded.status).send({ error: loaded.error })
try {
const desired = parseGreSnapshot(loaded.row.payload)
const state = await fetchGreState(loaded.server)
const ops = planGreRestore(desired, { gre: state.gre, addrs: state.addrs })
await executeRosOps(state.client, ops)
await recordGre(loaded.server, "rollback")
const next = await fetchGreState(loaded.server)
return reply.send({ ok: true, tunnels: next.tunnels })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
}
export default greRoutes
+11 -9
View File
@@ -8,7 +8,7 @@ import {
const internetPathRoutes: FastifyPluginAsyncZod = async (app) => { const internetPathRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/internet-path/settings", async (_req, reply) => { app.get("/internet-path/settings", async (_req, reply) => {
const s = getInternetPathSettings() const s = await getInternetPathSettings()
return reply.send({ return reply.send({
enabled: s.enabled, enabled: s.enabled,
intervalSec: s.intervalSec, intervalSec: s.intervalSec,
@@ -25,12 +25,12 @@ const internetPathRoutes: FastifyPluginAsyncZod = async (app) => {
intervalSec?: number | string intervalSec?: number | string
retentionDays?: number | string retentionDays?: number | string
} }
const updated = updateInternetPathSettings({ const updated = await updateInternetPathSettings({
enabled: body.enabled, enabled: body.enabled,
intervalSec: body.intervalSec == null ? undefined : Math.max(30, Number.parseInt(String(body.intervalSec), 10) || 300), intervalSec: body.intervalSec == null ? undefined : Math.max(30, Number.parseInt(String(body.intervalSec), 10) || 300),
retentionDays: body.retentionDays == null ? undefined : Math.max(1, Number.parseInt(String(body.retentionDays), 10) || 14), retentionDays: body.retentionDays == null ? undefined : Math.max(1, Number.parseInt(String(body.retentionDays), 10) || 14),
}) })
refreshScheduler() await refreshScheduler()
return reply.send({ return reply.send({
ok: true, ok: true,
settings: { settings: {
@@ -45,13 +45,15 @@ const internetPathRoutes: FastifyPluginAsyncZod = async (app) => {
}) })
app.get("/internet-path/latest", async (_req, reply) => { app.get("/internet-path/latest", async (_req, reply) => {
const row = getLatestInternetPathSnapshot() const row = await getLatestInternetPathSnapshot()
if (!row) return reply.send({ snapshot: null }) if (!row) return reply.send({ snapshot: null })
let payload: unknown = null let payload: unknown = row.payloadJson
try { if (typeof row.payloadJson === "string") {
payload = JSON.parse(row.payloadJson) try {
} catch { payload = JSON.parse(row.payloadJson)
payload = null } catch {
payload = null
}
} }
return reply.send({ return reply.send({
snapshot: payload, snapshot: payload,
+58 -35
View File
@@ -6,9 +6,10 @@ import { MikrotikClient } from "../services/mikrotik.js"
import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js" import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js"
import type { import type {
RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance, RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance,
RosBfdSession, RosBfdSession, RosIpRoute,
OspfNeighborRead, OspfInterfaceRead, OspfInstanceRead, BfdSessionRead, OspfNeighborRead, OspfInterfaceRead, OspfInstanceRead, OspfRouteRead, BfdSessionRead,
} from "../types/server.js" } from "../types/server.js"
import { parseOspfGateway, parseOspfRouteType } from "../services/ospf-route-parse.js"
import { z } from "zod" import { z } from "zod"
type ServerRow = typeof servers.$inferSelect type ServerRow = typeof servers.$inferSelect
@@ -74,14 +75,15 @@ function parseAddrIface(addr: string): { ip: string; iface: string } {
/** Fetch all OSPF + BFD data for one server */ /** Fetch all OSPF + BFD data for one server */
async function fetchServerOspf(server: ServerRow) { async function fetchServerOspf(server: ServerRow) {
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
const [neighbors, areas, ifaceTemplates, instances, bfdSessions] = await Promise.all([ const [neighbors, areas, ifaceTemplates, instances, bfdSessions, ipRoutes] = await Promise.all([
client.getOspfNeighbors(), client.getOspfNeighbors(),
client.getOspfAreas(), client.getOspfAreas(),
client.getOspfInterfaceTemplates(), client.getOspfInterfaceTemplates(),
client.getOspfInstances(), client.getOspfInstances(),
client.getBfdSessions().catch(() => [] as RosBfdSession[]), // BFD is optional client.getBfdSessions().catch(() => [] as RosBfdSession[]), // BFD is optional
client.getIpRoutes().catch(() => [] as RosIpRoute[]),
]) ])
return { neighbors, areas, ifaceTemplates, instances, bfdSessions } return { neighbors, areas, ifaceTemplates, instances, bfdSessions, ipRoutes }
} }
// ── BFD parser ──────────────────────────────────────────────────────────────── // ── BFD parser ────────────────────────────────────────────────────────────────
@@ -200,6 +202,29 @@ function parseInstances(
})) }))
} }
function parseOspfRoutes(server: ServerRow, routes: RosIpRoute[]): OspfRouteRead[] {
const out: OspfRouteRead[] = []
for (const [idx, r] of routes.entries()) {
const type = parseOspfRouteType(r)
if (!type) continue
const { nextHop, via } = parseOspfGateway(r)
const metric = parseInt(r["ospf-metric"] ?? r.distance ?? "0") || 0
out.push({
id: r[".id"] ?? String(idx),
serverId: server.id,
serverName: server.name || server.host,
serverSite: server.site,
destination: r["dst-address"] ?? "",
type,
cost: metric,
nextHop,
via,
area: r["ospf-area"] ?? "",
})
}
return out
}
function calcRouteScore(pingMs: number, dlMbps: number, ulMbps: number, pingWeight: number) { function calcRouteScore(pingMs: number, dlMbps: number, ulMbps: number, pingWeight: number) {
const pingScore = Math.max(0, 100 - pingMs * 0.6) const pingScore = Math.max(0, 100 - pingMs * 0.6)
const speedScore = Math.min(100, (dlMbps + ulMbps) / 18) const speedScore = Math.min(100, (dlMbps + ulMbps) / 18)
@@ -207,23 +232,21 @@ function calcRouteScore(pingMs: number, dlMbps: number, ulMbps: number, pingWeig
return Math.round(w * pingScore + (1 - w) * speedScore) return Math.round(w * pingScore + (1 - w) * speedScore)
} }
function getLatestSnapshotLatencyMs(serverId: number): number { async function getLatestSnapshotLatencyMs(serverId: number): Promise<number> {
const latest = db const latest = await db
.select() .select()
.from(serverSnapshots) .from(serverSnapshots)
.where(eq(serverSnapshots.serverId, serverId)) .where(eq(serverSnapshots.serverId, serverId))
.orderBy(desc(serverSnapshots.polledAt)) .orderBy(desc(serverSnapshots.polledAt))
.limit(1) .limit(1)
.all()
return latest.length > 0 ? Math.max(1, Math.round(latest[0].latencyMs ?? 100)) : 100 return latest.length > 0 ? Math.max(1, Math.round(latest[0].latencyMs ?? 100)) : 100
} }
function latestTrafficByInterface(serverId: number): Map<string, TrafficSampleRow> { async function latestTrafficByInterface(serverId: number): Promise<Map<string, TrafficSampleRow>> {
const rows = db const rows = await db
.select() .select()
.from(trafficSamples) .from(trafficSamples)
.where(eq(trafficSamples.serverId, serverId)) .where(eq(trafficSamples.serverId, serverId))
.all()
const map = new Map<string, TrafficSampleRow>() const map = new Map<string, TrafficSampleRow>()
for (const row of rows) { for (const row of rows) {
const prev = map.get(row.interfaceName) const prev = map.get(row.interfaceName)
@@ -302,14 +325,14 @@ async function buildOspfOptimizationPlan(server: ServerRow, pingWeight: number):
const areaMap = buildAreaMap(areas) const areaMap = buildAreaMap(areas)
const parsed = parseInterfaces(server, ifaceTemplates, areas, instances, areaMap) const parsed = parseInterfaces(server, ifaceTemplates, areas, instances, areaMap)
const editable = parsed.filter((i) => !i.disabled && !isRefInterfaceName(i.interface)) const editable = parsed.filter((i) => !i.disabled && !isRefInterfaceName(i.interface))
const localLatencyMs = getLatestSnapshotLatencyMs(server.id) const localLatencyMs = await getLatestSnapshotLatencyMs(server.id)
const latestTraffic = latestTrafficByInterface(server.id) const latestTraffic = await latestTrafficByInterface(server.id)
// 1) Сопоставляем remote OSPF router-id -> сервер из каталога (чтобы взять унифицированный ping как в карте /route-optimizer). // 1) Сопоставляем remote OSPF router-id -> сервер из каталога (чтобы взять унифицированный ping как в карте /route-optimizer).
const neededRouterIds = new Set(neighbors.map((n) => String(n["router-id"] ?? "")).filter((x) => x.length > 0)) const neededRouterIds = new Set(neighbors.map((n) => String(n["router-id"] ?? "")).filter((x) => x.length > 0))
async function buildRouterIdToServerMap(needed: Set<string>): Promise<Map<string, ServerRow["id"]>> { async function buildRouterIdToServerMap(needed: Set<string>): Promise<Map<string, ServerRow["id"]>> {
const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all() const enabledServers = await db.select().from(servers).where(eq(servers.enabled, true))
const out = new Map<string, ServerRow["id"]>() const out = new Map<string, ServerRow["id"]>()
const neededLeft = new Set(needed) const neededLeft = new Set(needed)
@@ -356,11 +379,10 @@ async function buildOspfOptimizationPlan(server: ServerRow, pingWeight: number):
} }
// 3) Предгружаем uptimeSpeedProbes: оттуда берём и ping, и скорость (единый источник как у карты). // 3) Предгружаем uptimeSpeedProbes: оттуда берём и ping, и скорость (единый источник как у карты).
const allSourceProbes = db const allSourceProbes = (await db
.select() .select()
.from(uptimeSpeedProbes) .from(uptimeSpeedProbes)
.where(eq(uptimeSpeedProbes.srcServerId, server.id)) .where(eq(uptimeSpeedProbes.srcServerId, server.id)))
.all()
.filter((r: SpeedProbeRow) => r.enabled !== false) .filter((r: SpeedProbeRow) => r.enabled !== false)
const speedProbesByDest = new Map<number, SpeedProbeRow[]>() const speedProbesByDest = new Map<number, SpeedProbeRow[]>()
for (const p of allSourceProbes) { for (const p of allSourceProbes) {
@@ -482,8 +504,7 @@ async function buildOspfOptimizationPlan(server: ServerRow, pingWeight: number):
for (const [iface, probe] of assigned.entries()) assignedProbeByIface.set(iface, probe) for (const [iface, probe] of assigned.entries()) assignedProbeByIface.set(iface, probe)
} }
return editable return (await Promise.all(editable.map(async (iface) => {
.map((iface) => {
const t = latestTraffic.get(iface.interface) const t = latestTraffic.get(iface.interface)
const speed = normalizeSpeedMbps(t) const speed = normalizeSpeedMbps(t)
@@ -494,7 +515,7 @@ async function buildOspfOptimizationPlan(server: ServerRow, pingWeight: number):
bestProbe?.lastPingRttMs != null bestProbe?.lastPingRttMs != null
? Math.max(1, Math.round(bestProbe.lastPingRttMs)) ? Math.max(1, Math.round(bestProbe.lastPingRttMs))
: dstServerId != null : dstServerId != null
? Math.min(995, Math.round(localLatencyMs + getLatestSnapshotLatencyMs(dstServerId))) ? Math.min(995, Math.round(localLatencyMs + await getLatestSnapshotLatencyMs(dstServerId)))
: localLatencyMs : localLatencyMs
const dlMbps = const dlMbps =
@@ -517,8 +538,7 @@ async function buildOspfOptimizationPlan(server: ServerRow, pingWeight: number):
score: calcRouteScore(pingMs, dlMbps, ulMbps, pingWeight), score: calcRouteScore(pingMs, dlMbps, ulMbps, pingWeight),
optimalCost: 0, optimalCost: 0,
} }
}) }))).sort((a, b) => b.score - a.score || a.interface.localeCompare(b.interface))
.sort((a, b) => b.score - a.score || a.interface.localeCompare(b.interface))
.map((row, idx) => ({ ...row, optimalCost: (idx + 1) * 10 })) .map((row, idx) => ({ ...row, optimalCost: (idx + 1) * 10 }))
} }
@@ -528,7 +548,7 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/ospf/neighbors — aggregate OSPF neighbors from ALL enabled servers // GET /api/ospf/neighbors — aggregate OSPF neighbors from ALL enabled servers
app.get("/ospf/neighbors", async (_req, reply) => { app.get("/ospf/neighbors", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all() const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const results = await Promise.all( const results = await Promise.all(
allServers.map(async (server) => { allServers.map(async (server) => {
@@ -547,7 +567,7 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/ospf/interfaces — aggregate OSPF interface templates from ALL enabled servers // GET /api/ospf/interfaces — aggregate OSPF interface templates from ALL enabled servers
app.get("/ospf/interfaces", async (_req, reply) => { app.get("/ospf/interfaces", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all() const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const results = await Promise.all( const results = await Promise.all(
allServers.map(async (server) => { allServers.map(async (server) => {
@@ -566,7 +586,7 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/ospf/instances — aggregate OSPF instances from ALL enabled servers // GET /api/ospf/instances — aggregate OSPF instances from ALL enabled servers
app.get("/ospf/instances", async (_req, reply) => { app.get("/ospf/instances", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all() const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const results = await Promise.all( const results = await Promise.all(
allServers.map(async (server) => { allServers.map(async (server) => {
@@ -584,21 +604,22 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/ospf/all — single round-trip: neighbors + interfaces + instances + BFD // GET /api/ospf/all — single round-trip: neighbors + interfaces + instances + BFD
app.get("/ospf/all", async (_req, reply) => { app.get("/ospf/all", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all() const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const perServer = await Promise.all( const perServer = await Promise.all(
allServers.map(async (server) => { allServers.map(async (server) => {
try { try {
const { neighbors, areas, ifaceTemplates, instances, bfdSessions } = await fetchServerOspf(server) const { neighbors, areas, ifaceTemplates, instances, bfdSessions, ipRoutes } = await fetchServerOspf(server)
const areaMap = buildAreaMap(areas) const areaMap = buildAreaMap(areas)
return { return {
neighbors: parseNeighbors(server, neighbors, areaMap), neighbors: parseNeighbors(server, neighbors, areaMap),
interfaces: parseInterfaces(server, ifaceTemplates, areas, instances, areaMap), interfaces: parseInterfaces(server, ifaceTemplates, areas, instances, areaMap),
instances: parseInstances(server, instances), instances: parseInstances(server, instances),
bfdSessions: parseBfdSessions(server, bfdSessions), bfdSessions: parseBfdSessions(server, bfdSessions),
routes: parseOspfRoutes(server, ipRoutes),
} }
} catch { } catch {
return { neighbors: [], interfaces: [], instances: [], bfdSessions: [] } return { neighbors: [], interfaces: [], instances: [], bfdSessions: [], routes: [] }
} }
}), }),
) )
@@ -608,12 +629,13 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
interfaces: perServer.flatMap(r => r.interfaces), interfaces: perServer.flatMap(r => r.interfaces),
instances: perServer.flatMap(r => r.instances), instances: perServer.flatMap(r => r.instances),
bfdSessions: perServer.flatMap(r => r.bfdSessions), bfdSessions: perServer.flatMap(r => r.bfdSessions),
routes: perServer.flatMap(r => r.routes),
}) })
}) })
// GET /api/bfd/sessions — BFD sessions only (for direct access) // GET /api/bfd/sessions — BFD sessions only (for direct access)
app.get("/bfd/sessions", async (_req, reply) => { app.get("/bfd/sessions", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all() const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const results = await Promise.all( const results = await Promise.all(
allServers.map(async (server) => { allServers.map(async (server) => {
try { try {
@@ -631,21 +653,22 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/servers/:id/ospf — single server OSPF + BFD data // GET /api/servers/:id/ospf — single server OSPF + BFD data
app.get("/servers/:id/ospf", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { app.get("/servers/:id/ospf", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams const params = req.params as ServerIdParams
const server = db const server = (await db
.select().from(servers) .select().from(servers)
.where(eq(servers.id, params.id)) .where(eq(servers.id, params.id))
.limit(1).all()[0] .limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server) return reply.status(404).send({ error: "Server not found" })
try { try {
const { neighbors, areas, ifaceTemplates, instances, bfdSessions } = await fetchServerOspf(server) const { neighbors, areas, ifaceTemplates, instances, bfdSessions, ipRoutes } = await fetchServerOspf(server)
const areaMap = buildAreaMap(areas) const areaMap = buildAreaMap(areas)
return reply.send({ return reply.send({
neighbors: parseNeighbors(server, neighbors, areaMap), neighbors: parseNeighbors(server, neighbors, areaMap),
interfaces: parseInterfaces(server, ifaceTemplates, areas, instances, areaMap), interfaces: parseInterfaces(server, ifaceTemplates, areas, instances, areaMap),
instances: parseInstances(server, instances), instances: parseInstances(server, instances),
bfdSessions: parseBfdSessions(server, bfdSessions), bfdSessions: parseBfdSessions(server, bfdSessions),
routes: parseOspfRoutes(server, ipRoutes),
areas: areas.map(a => ({ name: a.name, areaId: a["area-id"] ?? "0.0.0.0", type: a.type, disabled: a.disabled === "true", inactive: a.inactive === "true", instance: a.instance })), areas: areas.map(a => ({ name: a.name, areaId: a["area-id"] ?? "0.0.0.0", type: a.type, disabled: a.disabled === "true", inactive: a.inactive === "true", instance: a.instance })),
}) })
} catch (err) { } catch (err) {
@@ -659,10 +682,10 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
{ schema: { params: ServerIdParamSchema, body: OspfOptimizeBodySchema } }, { schema: { params: ServerIdParamSchema, body: OspfOptimizeBodySchema } },
async (req, reply) => { async (req, reply) => {
const params = req.params as ServerIdParams const params = req.params as ServerIdParams
const server = db const server = (await db
.select().from(servers) .select().from(servers)
.where(eq(servers.id, params.id)) .where(eq(servers.id, params.id))
.limit(1).all()[0] .limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server) return reply.status(404).send({ error: "Server not found" })
@@ -691,10 +714,10 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
{ schema: { params: ServerIdParamSchema, body: OspfOptimizeBodySchema } }, { schema: { params: ServerIdParamSchema, body: OspfOptimizeBodySchema } },
async (req, reply) => { async (req, reply) => {
const params = req.params as ServerIdParams const params = req.params as ServerIdParams
const server = db const server = (await db
.select().from(servers) .select().from(servers)
.where(eq(servers.id, params.id)) .where(eq(servers.id, params.id))
.limit(1).all()[0] .limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server) return reply.status(404).send({ error: "Server not found" })
+6 -6
View File
@@ -256,14 +256,14 @@ async function mtuDiscover(client: MikrotikClient, address: string, srcIpv4: str
return lines.join("\n") return lines.join("\n")
} }
function resolveBtestPeer(remoteHost: string, explicitDstId?: number) { async function resolveBtestPeer(remoteHost: string, explicitDstId?: number) {
if (explicitDstId != null && Number.isFinite(explicitDstId)) { if (explicitDstId != null && Number.isFinite(explicitDstId)) {
const s = db.select().from(servers).where(eq(servers.id, explicitDstId)).limit(1).all()[0] const s = (await db.select().from(servers).where(eq(servers.id, explicitDstId)).limit(1))[0]
if (s) return s if (s) return s
} }
const norm = remoteHost.trim().toLowerCase() const norm = remoteHost.trim().toLowerCase()
return db.select().from(servers).where(eq(servers.enabled, true)).all() const enabled = await db.select().from(servers).where(eq(servers.enabled, true))
.find((x) => x.host.trim().toLowerCase() === norm) return enabled.find((x) => x.host.trim().toLowerCase() === norm)
} }
const probesRoutes: FastifyPluginAsyncZod = async (app) => { const probesRoutes: FastifyPluginAsyncZod = async (app) => {
@@ -271,7 +271,7 @@ const probesRoutes: FastifyPluginAsyncZod = async (app) => {
const sid = parseServerId(String((req.params as { id?: string }).id ?? "")) const sid = parseServerId(String((req.params as { id?: string }).id ?? ""))
if (sid === null) return reply.status(400).send({ error: "Invalid server id" }) if (sid === null) return reply.status(400).send({ error: "Invalid server id" })
const server = db.select().from(servers).where(eq(servers.id, sid)).limit(1).all()[0] const server = (await db.select().from(servers).where(eq(servers.id, sid)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server) return reply.status(404).send({ error: "Server not found" })
const body = req.body as { const body = req.body as {
@@ -369,7 +369,7 @@ const probesRoutes: FastifyPluginAsyncZod = async (app) => {
case "bandwidth": { case "bandwidth": {
const remote = String(body.bwRemoteAddress ?? "").trim() const remote = String(body.bwRemoteAddress ?? "").trim()
if (!remote) return reply.status(400).send({ error: "bwRemoteAddress required" }) if (!remote) return reply.status(400).send({ error: "bwRemoteAddress required" })
const dst = resolveBtestPeer(remote, body.dstServerId) const dst = await resolveBtestPeer(remote, body.dstServerId)
if (!dst) { if (!dst) {
return reply.status(400).send({ return reply.status(400).send({
error: "Не найден сервер назначения для bandwidth-test: добавьте узел с host = GRE remote или укажите dstServerId", error: "Не найден сервер назначения для bandwidth-test: добавьте узел с host = GRE remote или укажите dstServerId",
+149 -89
View File
@@ -1,13 +1,23 @@
import { asc, eq } from "drizzle-orm" import { asc, eq } from "drizzle-orm"
import { z } from "zod"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js" import { db } from "../db/index.js"
import { recursiveRoutes, servers } from "../db/schema.js" import { recursiveRoutes, servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js" import { MikrotikClient } from "../services/mikrotik.js"
import { parseDbServerId } from "../utils/server-id.js" import { parseDbServerId } from "../utils/server-id.js"
import { managedRecursiveComment } from "../managed-markers.js"
import { import {
hasManagedRecursiveComment, mapRosManagedRoutes,
managedRecursiveComment, planRecursiveApply,
} from "../managed-markers.js" userRecursiveComment,
} from "../services/config-apply-plan.js"
import {
appendRevisionIfChanged,
canonicalRecursiveRoutes,
getRevisionById,
listRevisions,
type ConfigRevisionSource,
} from "../services/config-revisions.js"
type ServerRow = typeof servers.$inferSelect type ServerRow = typeof servers.$inferSelect
@@ -51,27 +61,6 @@ interface RecursiveRouteDto {
disabled: boolean disabled: boolean
} }
function isIpGateway(gw: string): boolean {
return /^\d{1,3}(\.\d{1,3}){3}(?:%\S+)?$/.test(gw.trim())
}
function isRecursiveRoute(r: RosRoute): boolean {
if ((r.static ?? "false") !== "true") return false
if ((r.dynamic ?? "false") === "true") return false
if ((r.blackhole ?? "false") === "true") return false
if ((r.unreachable ?? "false") === "true") return false
if ((r.prohibit ?? "false") === "true") return false
const dst = r["dst-address"] ?? ""
const gw = r.gateway ?? ""
if (!dst || !gw) return false
return isIpGateway(gw)
}
function hasRecursiveCommentMask(comment: string | undefined): boolean {
if (!comment) return false
return /^recursive:\s*/i.test(comment.trim())
}
function splitGateway(raw: string): { ip: string; name: string } | null { function splitGateway(raw: string): { ip: string; name: string } | null {
const v = raw.trim() const v = raw.trim()
if (!v) return null if (!v) return null
@@ -80,13 +69,12 @@ function splitGateway(raw: string): { ip: string; name: string } | null {
return { ip, name: name || ip } return { ip, name: name || ip }
} }
function mapDbRoutes(serverId: number): RecursiveRouteDto[] { async function mapDbRoutes(serverId: number): Promise<RecursiveRouteDto[]> {
const rows = db const rows = await db
.select() .select()
.from(recursiveRoutes) .from(recursiveRoutes)
.where(eq(recursiveRoutes.serverId, serverId)) .where(eq(recursiveRoutes.serverId, serverId))
.orderBy(asc(recursiveRoutes.sortOrder)) .orderBy(asc(recursiveRoutes.sortOrder))
.all()
return rows.map((r) => ({ return rows.map((r) => ({
id: String(r.id), id: String(r.id),
@@ -103,6 +91,21 @@ function mapDbRoutes(serverId: number): RecursiveRouteDto[] {
})) }))
} }
function mergeCachedCountry(
live: RecursiveRouteDto[],
cached: RecursiveRouteDto[],
): RecursiveRouteDto[] {
return live.map((row) => {
if (row.country) return row
const match = cached.find((c) =>
c.dstAddress === row.dstAddress &&
c.gateway === row.gateway &&
c.distance === row.distance,
)
return match?.country ? { ...row, country: match.country } : row
})
}
function toRouterPayload(route: RecursiveRouteDto): Record<string, string> { function toRouterPayload(route: RecursiveRouteDto): Record<string, string> {
return { return {
"dst-address": route.dstAddress, "dst-address": route.dstAddress,
@@ -118,10 +121,10 @@ function toRouterPayload(route: RecursiveRouteDto): Record<string, string> {
} }
async function replaceDbRoutes(serverId: number, routes: RecursiveRouteDto[]) { async function replaceDbRoutes(serverId: number, routes: RecursiveRouteDto[]) {
db.delete(recursiveRoutes).where(eq(recursiveRoutes.serverId, serverId)).run() await db.delete(recursiveRoutes).where(eq(recursiveRoutes.serverId, serverId))
if (routes.length === 0) return if (routes.length === 0) return
const now = new Date().toISOString() const now = new Date().toISOString()
db.insert(recursiveRoutes).values( await db.insert(recursiveRoutes).values(
routes.map((r, i) => ({ routes.map((r, i) => ({
serverId, serverId,
sortOrder: i, sortOrder: i,
@@ -133,20 +136,48 @@ async function replaceDbRoutes(serverId: number, routes: RecursiveRouteDto[]) {
routingTable: r.routingTable || "main", routingTable: r.routingTable || "main",
checkGateway: r.checkGateway ?? "", checkGateway: r.checkGateway ?? "",
country: r.country ?? "", country: r.country ?? "",
comment: r.comment ?? "", comment: userRecursiveComment(r.comment),
disabled: r.disabled, disabled: r.disabled,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
})), })),
).run() )
} }
async function applyRecursiveToServer(
server: ServerRow,
routes: RecursiveRouteDto[],
source: ConfigRevisionSource,
): Promise<{ pushed: number; deleted: number }> {
const client = MikrotikClient.fromServer(server)
const existing = await client.get<RosRoute[]>("/ip/route")
const { deleteIds } = planRecursiveApply(existing)
for (const route of routes) {
await client.post("/ip/route", toRouterPayload(route))
}
for (const id of deleteIds) {
await client.delete(`/ip/route/${encodeURIComponent(id)}`)
}
await replaceDbRoutes(server.id, routes)
await appendRevisionIfChanged({
serverId: server.id,
section: "recursive-routes",
source,
payload: canonicalRecursiveRoutes(routes),
})
return { pushed: routes.length, deleted: deleteIds.length }
}
const RevisionIdParamSchema = z.object({ id: z.string().min(1) })
const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => { const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
app.get("/recursive-routes/gateways", async (req, reply) => { app.get("/recursive-routes/gateways", async (req, reply) => {
const q = req.query as { serverId?: string | number } const q = req.query as { serverId?: string | number }
const serverId = parseDbServerId(q.serverId) const serverId = parseDbServerId(q.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" }) if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0] const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server) return reply.status(404).send({ error: "Server not found" })
try { try {
@@ -176,79 +207,108 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
const q = req.query as { serverId?: string | number } const q = req.query as { serverId?: string | number }
const serverId = parseDbServerId(q.serverId) const serverId = parseDbServerId(q.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" }) if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0] const server: ServerRow | undefined = (await db
if (!server) return reply.status(404).send({ error: "Server not found" })
return reply.send({ routes: mapDbRoutes(serverId) })
})
app.put("/recursive-routes", async (req, reply) => {
const body = req.body as { serverId?: string | number; routes?: RecursiveRouteDto[] }
const serverId = parseDbServerId(body.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
await replaceDbRoutes(serverId, body.routes ?? [])
return reply.send({ ok: true })
})
app.post("/recursive-routes/sync/from-router", async (req, reply) => {
const body = req.body as { serverId?: string | number } | undefined
const serverId = parseDbServerId(body?.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server: ServerRow | undefined = db
.select().from(servers) .select().from(servers)
.where(eq(servers.id, serverId)) .where(eq(servers.id, serverId))
.limit(1).all()[0] .limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server) return reply.status(404).send({ error: "Server not found" })
const cached = await mapDbRoutes(serverId)
try { try {
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
const rosRoutes = await client.get<RosRoute[]>("/ip/route") const rosRoutes = await client.get<RosRoute[]>("/ip/route")
const rec = rosRoutes.filter(r => const live = mergeCachedCountry(mapRosManagedRoutes(rosRoutes), cached)
isRecursiveRoute(r) && hasRecursiveCommentMask(r.comment), await replaceDbRoutes(serverId, live)
) await appendRevisionIfChanged({
const mapped: RecursiveRouteDto[] = rec.map((r, i) => ({ serverId,
id: r[".id"] ?? `ros-${i}`, section: "recursive-routes",
dstAddress: r["dst-address"] ?? "", source: "observed",
gateway: r.gateway ?? "", payload: canonicalRecursiveRoutes(live),
distance: Number.parseInt(r.distance ?? "1", 10) || 1, })
scope: r.scope ? (Number.parseInt(r.scope, 10) || null) : null, const stored = await mapDbRoutes(serverId)
targetScope: r["target-scope"] ? (Number.parseInt(r["target-scope"], 10) || null) : null, return reply.send({ routes: stored, live: true, stale: false })
routingTable: r["routing-table"] ?? "main", } catch (err) {
checkGateway: r["check-gateway"] ?? "", app.log.warn({ serverId, err: String(err) }, "recursive live GET failed, serving cache")
country: "", return reply.send({
comment: r.comment ?? "", routes: cached,
disabled: r.disabled === "true", live: false,
})) stale: true,
await replaceDbRoutes(serverId, mapped) error: String(err),
return reply.send({ ok: true, serverId, totalRoutes: mapped.length }) })
}
})
app.put("/recursive-routes", async (req, reply) => {
const body = req.body as {
serverId?: string | number
routes?: RecursiveRouteDto[]
source?: ConfigRevisionSource
}
const serverId = parseDbServerId(body.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
const routes = (body.routes ?? []).map((r) => ({
...r,
comment: userRecursiveComment(r.comment),
}))
const source: ConfigRevisionSource = body.source === "copy" ? "copy" : "apply"
try {
const result = await applyRecursiveToServer(server, routes, source)
const stored = await mapDbRoutes(serverId)
return reply.send({ ok: true, routes: stored, pushedRoutes: result.pushed })
} catch (err) { } catch (err) {
return reply.status(500).send({ error: String(err) }) return reply.status(500).send({ error: String(err) })
} }
}) })
app.post("/recursive-routes/sync/to-router", async (req, reply) => { app.get("/recursive-routes/revisions", async (req, reply) => {
const body = req.body as { serverId?: string | number } | undefined const q = req.query as { serverId?: string | number }
const serverId = parseDbServerId(body?.serverId) const serverId = parseDbServerId(q.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" }) if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0] const revisions = await listRevisions(serverId, "recursive-routes")
return reply.send({ revisions })
})
app.post("/recursive-routes/revisions/:id/restore", {
schema: { params: RevisionIdParamSchema },
}, async (req, reply) => {
const { id } = req.params
const body = req.body as { serverId?: string | number } | undefined
const rev = await getRevisionById(id)
if (!rev) return reply.status(404).send({ error: "Revision not found" })
if (rev.section !== "recursive-routes") {
return reply.status(400).send({ error: "Revision section mismatch" })
}
const requested = parseDbServerId(body?.serverId)
if (requested !== null && requested !== rev.serverId) {
return reply.status(400).send({ error: "Revision belongs to another server" })
}
const server = (await db.select().from(servers).where(eq(servers.id, rev.serverId)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server) return reply.status(404).send({ error: "Server not found" })
const raw = Array.isArray(rev.payload) ? rev.payload : []
const routes: RecursiveRouteDto[] = raw.map((item, idx) => {
const r = item as Partial<RecursiveRouteDto>
return {
id: `rev-${idx}`,
dstAddress: r.dstAddress ?? "",
gateway: r.gateway ?? "",
distance: r.distance ?? 1,
scope: r.scope ?? null,
targetScope: r.targetScope ?? null,
routingTable: r.routingTable || "main",
checkGateway: r.checkGateway ?? "",
country: r.country ?? "",
comment: userRecursiveComment(r.comment),
disabled: Boolean(r.disabled),
}
})
try { try {
const client = MikrotikClient.fromServer(server) const result = await applyRecursiveToServer(server, routes, "rollback")
const existing = await client.get<RosRoute[]>("/ip/route") const stored = await mapDbRoutes(server.id)
const managed = existing.filter(r => hasManagedRecursiveComment(r.comment ?? "")) return reply.send({ ok: true, routes: stored, pushedRoutes: result.pushed })
for (const r of managed) {
if (!r[".id"]) continue
await client.delete(`/ip/route/${encodeURIComponent(r[".id"])}`)
}
const dbRows = mapDbRoutes(serverId)
for (const route of dbRows) {
await client.post("/ip/route", toRouterPayload(route))
}
return reply.send({ ok: true, serverId, pushedRoutes: dbRows.length })
} catch (err) { } catch (err) {
return reply.status(500).send({ error: String(err) }) return reply.status(500).send({ error: String(err) })
} }
+5 -5
View File
@@ -14,7 +14,7 @@ function parseJobKey(raw: string | undefined): typeof JOB_KEYS[number] | null {
const schedulerRoutes: FastifyPluginAsyncZod = async (app) => { const schedulerRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/scheduler/status", async (_req, reply) => { app.get("/scheduler/status", async (_req, reply) => {
return reply.send(getSchedulerStatus()) return reply.send(await getSchedulerStatus())
}) })
app.get("/scheduler/runs", async (req, reply) => { app.get("/scheduler/runs", async (req, reply) => {
@@ -25,12 +25,12 @@ const schedulerRoutes: FastifyPluginAsyncZod = async (app) => {
} }
const limit = Math.min(200, Math.max(1, Number.parseInt(String(q.limit ?? "50"), 10) || 50)) const limit = Math.min(200, Math.max(1, Number.parseInt(String(q.limit ?? "50"), 10) || 50))
const offset = Math.max(0, Number.parseInt(String(q.offset ?? "0"), 10) || 0) const offset = Math.max(0, Number.parseInt(String(q.offset ?? "0"), 10) || 0)
return reply.send(listSchedulerRuns({ jobKey: jobKey ?? undefined, limit, offset })) return reply.send(await listSchedulerRuns({ jobKey: jobKey ?? undefined, limit, offset }))
}) })
app.post("/scheduler/refresh", async (_req, reply) => { app.post("/scheduler/refresh", async (_req, reply) => {
refreshScheduler() await refreshScheduler()
return reply.send({ ok: true, status: getSchedulerStatus() }) return reply.send({ ok: true, status: await getSchedulerStatus() })
}) })
app.post("/scheduler/jobs/:jobKey/run-now", async (req, reply) => { app.post("/scheduler/jobs/:jobKey/run-now", async (req, reply) => {
@@ -38,7 +38,7 @@ const schedulerRoutes: FastifyPluginAsyncZod = async (app) => {
if (!jobKey) return reply.status(400).send({ error: "Некорректный jobKey" }) if (!jobKey) return reply.status(400).send({ error: "Некорректный jobKey" })
try { try {
await runSchedulerJobNow(jobKey) await runSchedulerJobNow(jobKey)
return reply.send({ ok: true, status: getSchedulerStatus() }) return reply.send({ ok: true, status: await getSchedulerStatus() })
} catch (e) { } catch (e) {
const statusCode = (e as Error & { statusCode?: number }).statusCode const statusCode = (e as Error & { statusCode?: number }).statusCode
if (statusCode === 409) { if (statusCode === 409) {
+4 -4
View File
@@ -10,7 +10,7 @@ import {
const serversApiPingRoutes: FastifyPluginAsyncZod = async (app) => { const serversApiPingRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/servers-api-ping/settings", async (_req, reply) => { app.get("/servers-api-ping/settings", async (_req, reply) => {
const settings = getServersApiPingSettings() const settings = await getServersApiPingSettings()
const state = getServersRestPingCollectorState() const state = getServersRestPingCollectorState()
return reply.send({ return reply.send({
enabled: settings.enabled, enabled: settings.enabled,
@@ -29,11 +29,11 @@ const serversApiPingRoutes: FastifyPluginAsyncZod = async (app) => {
} }
const intervalSec = const intervalSec =
body.intervalSec == null ? undefined : Math.max(10, Number.parseInt(String(body.intervalSec), 10) || 120) body.intervalSec == null ? undefined : Math.max(10, Number.parseInt(String(body.intervalSec), 10) || 120)
const updated = updateServersApiPingSettings({ const updated = await updateServersApiPingSettings({
enabled: body.enabled, enabled: body.enabled,
intervalSec, intervalSec,
}) })
refreshScheduler() await refreshScheduler()
return reply.send({ return reply.send({
ok: true, ok: true,
settings: { settings: {
@@ -49,7 +49,7 @@ const serversApiPingRoutes: FastifyPluginAsyncZod = async (app) => {
app.post("/servers-api-ping/collect-now", async (_req, reply) => { app.post("/servers-api-ping/collect-now", async (_req, reply) => {
await collectServersRestPingOnce() await collectServersRestPingOnce()
scheduleAlertEngineAfterDataCollectors() scheduleAlertEngineAfterDataCollectors()
const row = getServersApiPingSettings() const row = await getServersApiPingSettings()
return reply.send({ return reply.send({
ok: true, ok: true,
lastCollectedAt: row.lastCollectedAt ?? null, lastCollectedAt: row.lastCollectedAt ?? null,
+15 -13
View File
@@ -77,13 +77,13 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/servers // GET /api/servers
app.get("/", async (_req, reply) => { app.get("/", async (_req, reply) => {
return reply.send(listServersRead()) return reply.send(await listServersRead())
}) })
// GET /api/servers/:id/ros-src-address — IPv4 для src-address в RouterOS (не FQDN) // GET /api/servers/:id/ros-src-address — IPv4 для src-address в RouterOS (не FQDN)
app.get("/:id/ros-src-address", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { app.get("/:id/ros-src-address", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams const params = req.params as ServerIdParams
const server = getServerRowById(params.id) const server = await getServerRowById(params.id)
if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server) return reply.status(404).send({ error: "Server not found" })
const ipv4 = await resolveRosSrcIpv4(server.host) const ipv4 = await resolveRosSrcIpv4(server.host)
@@ -93,7 +93,7 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/servers/:id/wan-runtime — DHCP lease + active default route for HomeRouter WAN uplinks // GET /api/servers/:id/wan-runtime — DHCP lease + active default route for HomeRouter WAN uplinks
app.get("/:id/wan-runtime", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { app.get("/:id/wan-runtime", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams const params = req.params as ServerIdParams
const server = getServerReadById(params.id) const server = await getServerReadById(params.id)
if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server) return reply.status(404).send({ error: "Server not found" })
const toIp = (raw: string | null | undefined): string | null => { const toIp = (raw: string | null | undefined): string | null => {
@@ -141,7 +141,9 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
} }
try { try {
const client = MikrotikClient.fromServer(getServerRowById(params.id)!) const row = await getServerRowById(params.id)
if (!row) return reply.status(404).send({ error: "Server not found" })
const client = MikrotikClient.fromServer(row)
const isTrue = (v: unknown) => { const isTrue = (v: unknown) => {
const s = String(v ?? "").trim().toLowerCase() const s = String(v ?? "").trim().toLowerCase()
return s === "true" || s === "yes" return s === "true" || s === "yes"
@@ -228,13 +230,13 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
// POST /api/servers // POST /api/servers
app.post("/", { schema: { body: ServerCreateSchema } }, async (req, reply) => { app.post("/", { schema: { body: ServerCreateSchema } }, async (req, reply) => {
return reply.status(201).send(createServer(req.body as ServerCreateRequest)) return reply.status(201).send(await createServer(req.body as ServerCreateRequest))
}) })
// GET /api/servers/:id // GET /api/servers/:id
app.get("/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { app.get("/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams const params = req.params as ServerIdParams
const server = getServerReadById(params.id) const server = await getServerReadById(params.id)
if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server) return reply.status(404).send({ error: "Server not found" })
return reply.send(server) return reply.send(server)
}) })
@@ -245,25 +247,25 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
{ schema: { params: ServerIdParamSchema, body: ServerUpdateSchema } }, { schema: { params: ServerIdParamSchema, body: ServerUpdateSchema } },
async (req, reply) => { async (req, reply) => {
const params = req.params as ServerIdParams const params = req.params as ServerIdParams
const existing = getServerReadById(params.id) const existing = await getServerReadById(params.id)
if (!existing) return reply.status(404).send({ error: "Server not found" }) if (!existing) return reply.status(404).send({ error: "Server not found" })
return reply.send(updateServer(params.id, req.body as ServerUpdateRequest)) return reply.send(await updateServer(params.id, req.body as ServerUpdateRequest))
}, },
) )
// DELETE /api/servers/:id // DELETE /api/servers/:id
app.delete("/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { app.delete("/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams const params = req.params as ServerIdParams
const existing = getServerReadById(params.id) const existing = await getServerReadById(params.id)
if (!existing) return reply.status(404).send({ error: "Server not found" }) if (!existing) return reply.status(404).send({ error: "Server not found" })
deleteServer(params.id) await deleteServer(params.id)
return reply.status(204).send() return reply.status(204).send()
}) })
// POST /api/servers/:id/poll // POST /api/servers/:id/poll
app.post("/:id/poll", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { app.post("/:id/poll", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams const params = req.params as ServerIdParams
const existing = getServerReadById(params.id) const existing = await getServerReadById(params.id)
if (!existing) return reply.status(404).send({ error: "Server not found" }) if (!existing) return reply.status(404).send({ error: "Server not found" })
try { try {
@@ -281,9 +283,9 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
async (req, reply) => { async (req, reply) => {
const params = req.params as ServerIdParams const params = req.params as ServerIdParams
const query = req.query as SnapshotsQuery const query = req.query as SnapshotsQuery
const existing = getServerReadById(params.id) const existing = await getServerReadById(params.id)
if (!existing) return reply.status(404).send({ error: "Server not found" }) if (!existing) return reply.status(404).send({ error: "Server not found" })
return reply.send(listServerSnapshots(params.id, query.limit)) return reply.send(await listServerSnapshots(params.id, query.limit))
}, },
) )
} }
+26 -15
View File
@@ -1,6 +1,10 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { count } from "drizzle-orm"
import { listCertificatesFromServers } from "../services/certificates-service.js" import { listCertificatesFromServers } from "../services/certificates-service.js"
import { countWireGuardInterfaces } from "../services/wireguard-live.js" import { countWireGuardInterfaces } from "../services/wireguard-live.js"
import { countVxlanTunnels } from "../services/vxlan-live.js"
import { countContainers } from "../services/containers-live.js"
import { countBgpSessions } from "../services/bgp-peers-live.js"
import { db } from "../db/index.js" import { db } from "../db/index.js"
import { import {
filterRules, filterRules,
@@ -9,26 +13,29 @@ import {
uptimeProbes, uptimeProbes,
uptimeSpeedProbes, uptimeSpeedProbes,
} from "../db/schema.js" } from "../db/schema.js"
import { listUsers } from "../modules/users/service/users-service.js"
async function tableCount(table: typeof servers | typeof filterRules | typeof uptimeProbes | typeof uptimeSpeedProbes | typeof recursiveRoutes): Promise<number> {
const rows = await db.select({ n: count() }).from(table)
return rows[0]?.n ?? 0
}
const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => { const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/sidebar-counts", async (_req, reply) => { app.get("/sidebar-counts", async (_req, reply) => {
const [ const serversTotal = await tableCount(servers)
serversTotal, const filterRulesTotal = await tableCount(filterRules)
filterRulesTotal, const uptimeProbesTotal = await tableCount(uptimeProbes)
uptimeProbesTotal, const uptimeSpeedProbesTotal = await tableCount(uptimeSpeedProbes)
uptimeSpeedProbesTotal, const recursiveRoutesTotal = await tableCount(recursiveRoutes)
recursiveRoutesTotal, const [certRes, wireguardTotal, bgpTotal, vxlanTotal, containersTotal] = await Promise.all([
certificatesTotal, listCertificatesFromServers(),
wireguardTotal,
] = await Promise.all([
Promise.resolve(db.select().from(servers).all().length),
Promise.resolve(db.select().from(filterRules).all().length),
Promise.resolve(db.select().from(uptimeProbes).all().length),
Promise.resolve(db.select().from(uptimeSpeedProbes).all().length),
Promise.resolve(db.select().from(recursiveRoutes).all().length),
listCertificatesFromServers().then((res) => res.certificates.length),
countWireGuardInterfaces().catch(() => 0), countWireGuardInterfaces().catch(() => 0),
countBgpSessions().catch(() => 0),
countVxlanTunnels().catch(() => 0),
countContainers().catch(() => 0),
]) ])
const certificatesTotal = certRes.certificates.length
const usersTotal = (await listUsers()).length
return reply.send({ return reply.send({
servers: serversTotal, servers: serversTotal,
@@ -39,6 +46,10 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
recursiveRoutes: recursiveRoutesTotal, recursiveRoutes: recursiveRoutesTotal,
certificates: certificatesTotal, certificates: certificatesTotal,
wireguard: wireguardTotal, wireguard: wireguardTotal,
users: usersTotal,
bgpSessions: bgpTotal,
vxlan: vxlanTotal,
containers: containersTotal,
}) })
}) })
} }
+26
View File
@@ -0,0 +1,26 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { statisticsPivotQuerySchema, statisticsQuerySchema } from "@mmapp/contracts/statistics"
import { getStatistics, getStatisticsPivot, pivotDimsConflict } from "../services/statistics-aggregate.js"
const statisticsRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/statistics", async (req, reply) => {
const parsed = statisticsQuerySchema.safeParse(req.query ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректный период или фильтры", details: parsed.error.flatten() })
}
return reply.send(await getStatistics(parsed.data))
})
app.get("/statistics/pivot", async (req, reply) => {
const parsed = statisticsPivotQuerySchema.safeParse(req.query ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректный период или измерения", details: parsed.error.flatten() })
}
if (pivotDimsConflict(parsed.data.row, parsed.data.col)) {
return reply.status(400).send({ error: "Строки и колонки должны отличаться" })
}
return reply.send(await getStatisticsPivot(parsed.data))
})
}
export default statisticsRoutes
+4 -4
View File
@@ -18,7 +18,7 @@ const systemDatabaseRoutes: FastifyPluginAsync = async (app) => {
app.get("/system/database/backup", async (_req, reply) => { app.get("/system/database/backup", async (_req, reply) => {
try { try {
const { filename, buffer } = await exportSystemDatabaseBackup() const { filename, buffer } = await exportSystemDatabaseBackup()
appendEvent({ await appendEvent({
level: "info", level: "info",
eventType: "system.database.backup", eventType: "system.database.backup",
sourceModule: "system", sourceModule: "system",
@@ -44,16 +44,16 @@ const systemDatabaseRoutes: FastifyPluginAsync = async (app) => {
app.post("/system/database/restore", async (req, reply) => { app.post("/system/database/restore", async (req, reply) => {
const body = req.body const body = req.body
if (!Buffer.isBuffer(body) || body.length === 0) { if (!Buffer.isBuffer(body) || body.length === 0) {
return reply.status(400).send({ error: "Ожидается тело запроса с файлом SQLite" }) return reply.status(400).send({ error: "Ожидается тело запроса с файлом pg_dump (custom, PGDMP)" })
} }
try { try {
await restoreSystemDatabaseBackup(body) await restoreSystemDatabaseBackup(body)
appendEvent({ await appendEvent({
level: "warning", level: "warning",
eventType: "system.database.restore", eventType: "system.database.restore",
sourceModule: "system", sourceModule: "system",
title: "Восстановлена база приложения", title: "Восстановлена база приложения",
message: "Данные SQLite заменены из загруженного файла", message: "Данные PostgreSQL заменены из загруженного дампа",
entityType: "system_database", entityType: "system_database",
entityId: "restore", entityId: "restore",
payload: { payload: {
+319
View File
@@ -0,0 +1,319 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import type { FastifyReply, FastifyRequest } from "fastify"
import { env } from "../config.js"
import {
trafficFlowOverlayRequestSchema,
trafficFlowSettingsPatchSchema,
} from "@mmapp/contracts/traffic-flow"
import {
ensureHostKeys,
getTrafficFlowSettingsRow,
toTrafficFlowSettingsDto,
updateTrafficFlowSettings,
} from "../services/traffic-flow-settings.js"
import {
getFlowListenerState,
purgeTrafficFlowStore,
startTrafficFlowListener,
listFlowTalkers,
} from "../services/traffic-flow-ingest.js"
import {
buildFlowAnalytics,
getFlowMonthly,
listFlowClients,
listFlowExporters,
safeBuildLiveFlowSample,
} from "../services/traffic-flow-analytics.js"
import { buildFlowMapHops } from "../services/traffic-flow-map-hops.js"
import { applyFlowOverlay } from "../services/traffic-flow-overlay.js"
import { listTrafficFlowHostFiles } from "../services/traffic-flow-host-files.js"
import { rebuildFlowFactsFromBuckets } from "../services/traffic-flow-facts-rebuild.js"
import { appendEvent } from "../modules/events/service/events-service.js"
const LIVE_TICK_MS = 2000
export const MAX_FLOW_LIVE_SUBSCRIBERS = 4
let liveSubscribers = 0
export function tryAcquireFlowLiveSlot(): boolean {
if (liveSubscribers >= MAX_FLOW_LIVE_SUBSCRIBERS) return false
liveSubscribers += 1
return true
}
export function releaseFlowLiveSlot(): void {
liveSubscribers = Math.max(0, liveSubscribers - 1)
}
export function resetFlowLiveSlotsForTests(): void {
liveSubscribers = 0
}
function rangeToMinutes(range: string | undefined): number {
switch ((range ?? "5m").toLowerCase()) {
case "5m": return 5
case "15m": return 15
case "1h": return 60
case "4h": return 240
case "24h": return 1440
case "30d": return 1440
default: return 5
}
}
function parseId(raw: unknown): number | undefined {
if (raw == null || raw === "") return undefined
const n = Number.parseInt(String(raw), 10)
return Number.isFinite(n) ? n : undefined
}
function parseDedup(raw: unknown): boolean {
if (raw == null || raw === "") return true
const s = String(raw).toLowerCase()
return s !== "0" && s !== "false" && s !== "off"
}
function analyticsQuery(req: FastifyRequest) {
const q = req.query as {
range?: string
serverId?: string
userId?: string
iface?: string
dedup?: string
excludeMesh?: string
excludeOverlay?: string
}
return {
minutes: rangeToMinutes(q.range),
serverId: parseId(q.serverId),
userId: q.userId?.trim() || undefined,
iface: q.iface?.trim() || undefined,
dedup: parseDedup(q.dedup),
excludeMesh: parseDedup(q.excludeMesh),
excludeOverlay: parseDedup(q.excludeOverlay),
}
}
async function sendFlowTalkers(req: FastifyRequest, reply: FastifyReply) {
const q = req.query as { range?: string }
return reply.send(await listFlowTalkers(rangeToMinutes(q.range)))
}
function requestPublicHost(req: FastifyRequest): string {
const forwarded = req.headers["x-forwarded-host"]
const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded
return raw || req.hostname || ""
}
async function applyOverlayHandler(req: FastifyRequest, reply: FastifyReply) {
const parsed = trafficFlowOverlayRequestSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
try {
const result = await applyFlowOverlay(parsed.data.serverId, {
publicEndpoint: parsed.data.publicEndpoint,
requestHost: requestPublicHost(req),
disableGreFastPath: parsed.data.disableGreFastPath,
})
return reply.send(result)
} catch (e) {
const status = (e as { statusCode?: number }).statusCode ?? 502
const msg = e instanceof Error ? e.message : String(e)
return reply.status(status).send({ error: msg })
}
}
function writeSse(raw: NodeJS.WritableStream, event: string, data: unknown) {
raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
}
function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(new Error("aborted"))
return
}
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort)
resolve()
}, ms)
const onAbort = () => {
clearTimeout(timer)
reject(new Error("aborted"))
}
signal.addEventListener("abort", onAbort, { once: true })
})
}
const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/traffic/flow/settings", async (_req, reply) => {
return reply.send(await toTrafficFlowSettingsDto(getFlowListenerState()))
})
app.put("/traffic/flow/settings", async (req, reply) => {
const parsed = trafficFlowSettingsPatchSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
await updateTrafficFlowSettings(parsed.data)
await startTrafficFlowListener()
return reply.send({ ok: true, settings: await toTrafficFlowSettingsDto(getFlowListenerState()) })
})
app.post("/traffic/flow/settings/generate-keys", async (_req, reply) => {
const result = await ensureHostKeys()
return reply.send({
ok: true,
created: result.created,
publicKey: result.publicKey,
settings: await toTrafficFlowSettingsDto(getFlowListenerState()),
})
})
app.get("/traffic/flow/host-files", async (_req, reply) => {
const row = await getTrafficFlowSettingsRow()
if (!row.hostPrivateKey) await ensureHostKeys()
return reply.send({ files: await listTrafficFlowHostFiles() })
})
app.post("/traffic/flow/purge", async (_req, reply) => {
try {
const result = await purgeTrafficFlowStore()
await appendEvent({
level: "warning",
eventType: "traffic.flow.purge",
sourceModule: "traffic",
title: "Сброшены данные NetFlow",
message: `Удалены сессии ${result.deleted.buckets}, minute ${result.deleted.minuteStats}, daily ${result.deleted.dailyDims}`,
entityType: "traffic_flow",
entityId: "purge",
payload: {
buckets: result.deleted.buckets,
minuteStats: result.deleted.minuteStats,
minuteDims: result.deleted.minuteDims,
dailyDims: result.deleted.dailyDims,
fileBytesBefore: result.fileBytesBefore,
fileBytesAfter: result.fileBytesAfter,
vacuumed: result.vacuumed,
},
})
return reply.send(result)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const status = message.includes("уже выполняется") ? 409 : 500
return reply.status(status).send({ error: message })
}
})
app.post("/traffic/flow/rebuild-facts", async (_req, reply) => {
try {
const result = await rebuildFlowFactsFromBuckets()
await appendEvent({
level: "info",
eventType: "traffic.flow.rebuild_facts",
sourceModule: "traffic",
title: "Пересчитан куб NetFlow",
message: `Факты ${result.facts} из ${result.buckets} сессий, дней ${result.days.length}`,
entityType: "traffic_flow",
entityId: "rebuild-facts",
payload: { buckets: result.buckets, facts: result.facts, days: result.days },
})
return reply.send(result)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const status = message.includes("уже выполняется") ? 409 : 500
return reply.status(status).send({ error: message })
}
})
app.post("/traffic/flow/overlay", applyOverlayHandler)
app.post("/traffic/flow-overlay", applyOverlayHandler)
app.get("/traffic/flow", sendFlowTalkers)
app.get("/traffic/flows", sendFlowTalkers)
app.get("/traffic/flow/exporters", async (req, reply) => {
const q = req.query as { range?: string }
return reply.send(await listFlowExporters(rangeToMinutes(q.range)))
})
app.get("/traffic/flow/clients", async (req, reply) => {
const q = req.query as { range?: string }
return reply.send(await listFlowClients(rangeToMinutes(q.range)))
})
app.get("/traffic/flow/analytics", async (req, reply) => {
return reply.send(await buildFlowAnalytics(analyticsQuery(req)))
})
app.get("/traffic/flow/map-hops", async (req, reply) => {
return reply.send(await buildFlowMapHops(analyticsQuery(req)))
})
app.get("/traffic/flow/monthly", async (req, reply) => {
const q = req.query as { month?: string; serverId?: string }
const now = new Date()
const month = /^\d{4}-\d{2}$/.test(q.month ?? "")
? (q.month as string)
: `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`
return reply.send(await getFlowMonthly(month, parseId(q.serverId)))
})
app.get("/traffic/flow/live", async (req, reply) => {
if (!tryAcquireFlowLiveSlot()) {
return reply.status(429).send({ error: "Слишком много live-подписок" })
}
const query = analyticsQuery(req)
const liveQuery = {
serverId: query.serverId,
userId: query.userId,
iface: query.iface,
dedup: query.dedup,
excludeMesh: query.excludeMesh,
excludeOverlay: query.excludeOverlay,
}
const abort = new AbortController()
const onClose = () => abort.abort()
req.raw.on("close", onClose)
reply.hijack()
req.raw.setTimeout(0)
reply.raw.setTimeout(0)
const origin = typeof req.headers.origin === "string" ? req.headers.origin : ""
const allowed = env.CORS_ORIGIN
const sseHeaders: Record<string, string> = {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
}
if (origin && (allowed === "*" || allowed === origin)) {
sseHeaders["Access-Control-Allow-Origin"] = origin
sseHeaders["Access-Control-Allow-Credentials"] = "true"
sseHeaders["Access-Control-Allow-Headers"] = "Authorization, Accept"
sseHeaders.Vary = "Origin"
}
reply.raw.writeHead(200, sseHeaders)
reply.raw.write(":\n\n")
try {
while (!abort.signal.aborted) {
const payload = await safeBuildLiveFlowSample(liveQuery)
writeSse(reply.raw, payload.event, payload.data)
await sleep(LIVE_TICK_MS, abort.signal)
}
} catch {
/* abort / disconnect */
} finally {
releaseFlowLiveSlot()
req.raw.off("close", onClose)
try {
reply.raw.end()
} catch {
/* already closed */
}
}
})
}
export default trafficFlowRoutes
+57 -24
View File
@@ -22,6 +22,10 @@ import {
parseMonitorTraffic, parseMonitorTraffic,
rateBpsFromDelta, rateBpsFromDelta,
} from "../services/traffic-rate.js" } from "../services/traffic-rate.js"
import {
buildBoundInterfaceTraffic,
buildUserTrafficList,
} from "../services/traffic-users.js"
type SnapshotRow = typeof serverSnapshots.$inferSelect type SnapshotRow = typeof serverSnapshots.$inferSelect
@@ -53,14 +57,13 @@ interface TrafficInterfaceDto {
const LIVE_TICK_MS = 1500 const LIVE_TICK_MS = 1500
const LIVE_ROS_TIMEOUT_MS = 4000 const LIVE_ROS_TIMEOUT_MS = 4000
function latestSnapshot(serverId: number): SnapshotRow | undefined { async function latestSnapshot(serverId: number) {
return db return (await db
.select() .select()
.from(serverSnapshots) .from(serverSnapshots)
.where(eq(serverSnapshots.serverId, serverId)) .where(eq(serverSnapshots.serverId, serverId))
.orderBy(desc(serverSnapshots.polledAt)) .orderBy(desc(serverSnapshots.polledAt))
.limit(1) .limit(1))[0]
.all()[0]
} }
function rangeToMinutes(range: string | undefined): number { function rangeToMinutes(range: string | undefined): number {
@@ -110,8 +113,8 @@ function buildServerTraffic(
} }
} }
function snapshotStatus(serverId: number): TrafficServerDto["status"] { async function snapshotStatus(serverId: number): Promise<TrafficServerDto["status"]> {
const snap = latestSnapshot(serverId) const snap = await latestSnapshot(serverId)
return snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded") return snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded")
} }
@@ -218,8 +221,8 @@ async function monitorTrafficOnce(
const trafficRoutes: FastifyPluginAsyncZod = async (app) => { const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/traffic/settings", async (_req, reply) => { app.get("/traffic/settings", async (_req, reply) => {
const settings = getTrafficSettings() const settings = await getTrafficSettings()
const state = getTrafficCollectorState() const state = await getTrafficCollectorState()
return reply.send({ return reply.send({
enabled: settings.enabled, enabled: settings.enabled,
intervalSec: settings.intervalSec, intervalSec: settings.intervalSec,
@@ -239,12 +242,12 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
} }
const intervalSec = body.intervalSec == null ? undefined : Math.max(5, Number.parseInt(String(body.intervalSec), 10) || 30) const intervalSec = body.intervalSec == null ? undefined : Math.max(5, Number.parseInt(String(body.intervalSec), 10) || 30)
const retentionDays = body.retentionDays == null ? undefined : Math.max(1, Number.parseInt(String(body.retentionDays), 10) || 14) const retentionDays = body.retentionDays == null ? undefined : Math.max(1, Number.parseInt(String(body.retentionDays), 10) || 14)
const updated = updateTrafficSettings({ const updated = await updateTrafficSettings({
enabled: body.enabled, enabled: body.enabled,
intervalSec, intervalSec,
retentionDays, retentionDays,
}) })
refreshScheduler() await refreshScheduler()
return reply.send({ return reply.send({
ok: true, ok: true,
settings: { settings: {
@@ -262,8 +265,8 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
try { try {
await collectTrafficOnce() await collectTrafficOnce()
scheduleAlertEngineAfterDataCollectors() scheduleAlertEngineAfterDataCollectors()
const updated = getTrafficSettings() const updated = await getTrafficSettings()
appendEvent({ await appendEvent({
level: "info", level: "info",
eventType: "traffic.collect.manual.ok", eventType: "traffic.collect.manual.ok",
sourceModule: "traffic", sourceModule: "traffic",
@@ -281,7 +284,7 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
}) })
} catch (error) { } catch (error) {
const msg = error instanceof Error ? error.message : String(error) const msg = error instanceof Error ? error.message : String(error)
appendEvent({ await appendEvent({
level: "critical", level: "critical",
eventType: "traffic.collect.manual.failed", eventType: "traffic.collect.manual.failed",
sourceModule: "traffic", sourceModule: "traffic",
@@ -298,11 +301,11 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
const rangeEndMs = Date.now() const rangeEndMs = Date.now()
const rangeStartMs = rangeEndMs - minutes * 60_000 const rangeStartMs = rangeEndMs - minutes * 60_000
const sinceIso = new Date(rangeStartMs).toISOString() const sinceIso = new Date(rangeStartMs).toISOString()
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all() const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const data = allServers.map((s): TrafficServerDto => { const data = await Promise.all(allServers.map(async (s): Promise<TrafficServerDto> => {
const rows = readServerSamplesInRange(s.id, sinceIso) const rows = await readServerSamplesInRange(s.id, sinceIso)
return buildServerTraffic(s, snapshotStatus(s.id), rows, rangeStartMs, rangeEndMs) return buildServerTraffic(s, await snapshotStatus(s.id), rows, rangeStartMs, rangeEndMs)
}) }))
return reply.send({ servers: data }) return reply.send({ servers: data })
}) })
@@ -310,7 +313,7 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/traffic/servers/:id/live", async (req, reply) => { app.get("/traffic/servers/:id/live", async (req, reply) => {
const p = req.params as { id?: string | number } const p = req.params as { id?: string | number }
const q = req.query as { iface?: string } const q = req.query as { iface?: string }
const server = getEnabledServerById(p.id ?? "") const server = await getEnabledServerById(p.id ?? "")
if (!server || !server.enabled) return reply.status(404).send({ error: "Server not found" }) if (!server || !server.enabled) return reply.status(404).send({ error: "Server not found" })
const onlyInterface = q.iface && q.iface !== "__all__" ? q.iface : undefined const onlyInterface = q.iface && q.iface !== "__all__" ? q.iface : undefined
@@ -371,12 +374,12 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
const serverId = Number.parseInt(String(p.id ?? ""), 10) const serverId = Number.parseInt(String(p.id ?? ""), 10)
if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "id is required" }) if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "id is required" })
const allRows = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all() const allRows = await db.select().from(servers).where(eq(servers.id, serverId)).limit(1)
const server = allRows[0] const server = allRows[0]
if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server) return reply.status(404).send({ error: "Server not found" })
const sinceIso = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString() const sinceIso = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
const rows = readServerSamplesInRange(serverId, sinceIso) const rows = await readServerSamplesInRange(serverId, sinceIso)
const byIface = new Map<string, typeof rows>() const byIface = new Map<string, typeof rows>()
for (const r of rows) { for (const r of rows) {
if (isLoopbackName(r.interfaceName)) continue if (isLoopbackName(r.interfaceName)) continue
@@ -409,17 +412,17 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
const q = req.query as { range?: string; iface?: string } const q = req.query as { range?: string; iface?: string }
const serverId = Number.parseInt(String(p.id ?? ""), 10) const serverId = Number.parseInt(String(p.id ?? ""), 10)
if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "id is required" }) if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "id is required" })
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0] const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server) return reply.status(404).send({ error: "Server not found" })
const minutes = rangeToMinutes(q.range) const minutes = rangeToMinutes(q.range)
const rangeEndMs = Date.now() const rangeEndMs = Date.now()
const rangeStartMs = rangeEndMs - minutes * 60_000 const rangeStartMs = rangeEndMs - minutes * 60_000
const sinceIso = new Date(rangeStartMs).toISOString() const sinceIso = new Date(rangeStartMs).toISOString()
const rows = readServerSamplesInRange(server.id, sinceIso) const rows = await readServerSamplesInRange(server.id, sinceIso)
const data = buildServerTraffic( const data = buildServerTraffic(
server, server,
snapshotStatus(server.id), await snapshotStatus(server.id),
rows, rows,
rangeStartMs, rangeStartMs,
rangeEndMs, rangeEndMs,
@@ -427,6 +430,36 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
) )
return reply.send({ server: data }) return reply.send({ server: data })
}) })
app.get("/traffic/users", async (req, reply) => {
const q = req.query as { range?: string }
const minutes = rangeToMinutes(q.range)
const rangeEndMs = Date.now()
const rangeStartMs = rangeEndMs - minutes * 60_000
return reply.send({ users: await buildUserTrafficList(rangeStartMs, rangeEndMs) })
})
app.get("/traffic/users/:id", async (req, reply) => {
const p = req.params as { id?: string }
const q = req.query as { range?: string }
const id = String(p.id ?? "")
if (!id) return reply.status(400).send({ error: "id is required" })
const minutes = rangeToMinutes(q.range)
const rangeEndMs = Date.now()
const rangeStartMs = rangeEndMs - minutes * 60_000
const users = await buildUserTrafficList(rangeStartMs, rangeEndMs)
const user = users.find((u) => u.id === id)
if (!user) return reply.status(404).send({ error: "Пользователь не найден" })
return reply.send({ user })
})
app.get("/traffic/bound-interfaces", async (req, reply) => {
const q = req.query as { range?: string }
const minutes = rangeToMinutes(q.range)
const rangeEndMs = Date.now()
const rangeStartMs = rangeEndMs - minutes * 60_000
return reply.send({ interfaces: await buildBoundInterfaceTraffic(rangeStartMs, rangeEndMs) })
})
} }
export default trafficRoutes export default trafficRoutes
+42 -35
View File
@@ -1,7 +1,7 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { asc, desc, eq } from "drizzle-orm" import { asc, desc, eq } from "drizzle-orm"
import { db } from "../db/index.js" import { db } from "../db/index.js"
import { servers, uptimeSpeedProbes, uptimeSpeedTestRuns } from "../db/schema.js" import { servers, serverSnapshots, uptimeSpeedProbes, uptimeSpeedTestRuns } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js" import { MikrotikClient } from "../services/mikrotik.js"
import { scheduleAlertEngineAfterDataCollectors } from "../services/alert-collector-hooks.js" import { scheduleAlertEngineAfterDataCollectors } from "../services/alert-collector-hooks.js"
import { refreshScheduler, getSchedulerStatus } from "../services/scheduler.js" import { refreshScheduler, getSchedulerStatus } from "../services/scheduler.js"
@@ -36,8 +36,8 @@ function toSeries(values: number[], target = 40): number[] {
return [...Array(target - values.length).fill(values[0] ?? 0), ...values] return [...Array(target - values.length).fill(values[0] ?? 0), ...values]
} }
/** Сэмпл ресурсов из SQLite (readResourceSamplesSince). */ /** Сэмпл ресурсов из PostgreSQL (readResourceSamplesSince). */
type ResourceSampleRow = ReturnType<typeof readResourceSamplesSince>[number] type ResourceSampleRow = Awaited<ReturnType<typeof readResourceSamplesSince>>[number]
/** /**
* Последний сэмпл по времени может быть offline после сбоя опроса тогда UI «пустой». * Последний сэмпл по времени может быть offline после сбоя опроса тогда UI «пустой».
@@ -67,7 +67,7 @@ function pickResourceDisplayRow(rows: ResourceSampleRow[], maxGapMs: number): {
const uptimeRoutes: FastifyPluginAsyncZod = async (app) => { const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/uptime/settings", async (_req, reply) => { app.get("/uptime/settings", async (_req, reply) => {
const s = readUptimeSettings() const s = await readUptimeSettings()
return reply.send({ return reply.send({
enabled: s.enabled, enabled: s.enabled,
resourcesEnabled: s.resourcesEnabled ?? s.enabled, resourcesEnabled: s.resourcesEnabled ?? s.enabled,
@@ -82,7 +82,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
lastCollectedAt: s.lastCollectedAt ?? null, lastCollectedAt: s.lastCollectedAt ?? null,
lastDurationMs: s.lastDurationMs ?? null, lastDurationMs: s.lastDurationMs ?? null,
lastError: s.lastError || null, lastError: s.lastError || null,
scheduler: getSchedulerStatus(), scheduler: await getSchedulerStatus(),
}) })
}) })
@@ -97,7 +97,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
speedIntervalSec?: number | string speedIntervalSec?: number | string
retentionDays?: number | string retentionDays?: number | string
} }
const updated = updateUptimeSettings({ const updated = await updateUptimeSettings({
enabled: body.enabled, enabled: body.enabled,
resourcesEnabled: body.resourcesEnabled, resourcesEnabled: body.resourcesEnabled,
pingEnabled: body.pingEnabled, pingEnabled: body.pingEnabled,
@@ -107,7 +107,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
speedIntervalSec: body.speedIntervalSec == null ? undefined : Math.max(10, Number.parseInt(String(body.speedIntervalSec), 10) || 60), speedIntervalSec: body.speedIntervalSec == null ? undefined : Math.max(10, Number.parseInt(String(body.speedIntervalSec), 10) || 60),
retentionDays: body.retentionDays == null ? undefined : Math.max(1, Number.parseInt(String(body.retentionDays), 10) || 14), retentionDays: body.retentionDays == null ? undefined : Math.max(1, Number.parseInt(String(body.retentionDays), 10) || 14),
}) })
refreshScheduler() await refreshScheduler()
return reply.send({ return reply.send({
ok: true, ok: true,
settings: { settings: {
@@ -122,7 +122,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
lastCollectedAt: updated.lastCollectedAt ?? null, lastCollectedAt: updated.lastCollectedAt ?? null,
lastDurationMs: updated.lastDurationMs ?? null, lastDurationMs: updated.lastDurationMs ?? null,
lastError: updated.lastError || null, lastError: updated.lastError || null,
scheduler: getSchedulerStatus(), scheduler: await getSchedulerStatus(),
}, },
}) })
}) })
@@ -130,7 +130,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
app.post("/uptime/collect-now", async (_req, reply) => { app.post("/uptime/collect-now", async (_req, reply) => {
await collectUptimeOnce() await collectUptimeOnce()
scheduleAlertEngineAfterDataCollectors() scheduleAlertEngineAfterDataCollectors()
const s = readUptimeSettings() const s = await readUptimeSettings()
return reply.send({ return reply.send({
ok: true, ok: true,
lastCollectedAt: s.lastCollectedAt ?? null, lastCollectedAt: s.lastCollectedAt ?? null,
@@ -158,7 +158,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
}) })
app.get("/uptime/probes", async (_req, reply) => { app.get("/uptime/probes", async (_req, reply) => {
const rows = readProbeRows() const rows = await readProbeRows()
return reply.send({ return reply.send({
probes: rows.map((p) => ({ probes: rows.map((p) => ({
id: p.id, id: p.id,
@@ -178,7 +178,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
}) })
app.get("/uptime/speed-probes", async (_req, reply) => { app.get("/uptime/speed-probes", async (_req, reply) => {
const rows = db.select().from(uptimeSpeedProbes).orderBy(asc(uptimeSpeedProbes.sortOrder)).all() const rows = await db.select().from(uptimeSpeedProbes).orderBy(asc(uptimeSpeedProbes.sortOrder))
return reply.send({ return reply.send({
probes: rows.map((r) => ({ probes: rows.map((r) => ({
id: r.id, id: r.id,
@@ -204,7 +204,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
}) })
app.get("/uptime/speed-test/runs", async (_req, reply) => { app.get("/uptime/speed-test/runs", async (_req, reply) => {
const rows = db.select().from(uptimeSpeedTestRuns).orderBy(desc(uptimeSpeedTestRuns.createdAt)).limit(200).all() const rows = await db.select().from(uptimeSpeedTestRuns).orderBy(desc(uptimeSpeedTestRuns.createdAt)).limit(200)
return reply.send({ return reply.send({
runs: rows.map((r) => ({ runs: rows.map((r) => ({
id: r.id, id: r.id,
@@ -263,12 +263,12 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
.filter((p) => Number.isFinite(p.srcServerId) && Number.isFinite(p.dstServerId) && p.srcServerId !== p.dstServerId) .filter((p) => Number.isFinite(p.srcServerId) && Number.isFinite(p.dstServerId) && p.srcServerId !== p.dstServerId)
const now = new Date().toISOString() const now = new Date().toISOString()
const existing = db.select({ id: uptimeSpeedProbes.id }).from(uptimeSpeedProbes).all() const existing = await db.select({ id: uptimeSpeedProbes.id }).from(uptimeSpeedProbes)
const nextIds = new Set(normalized.map((p) => p.id)) const nextIds = new Set(normalized.map((p) => p.id))
for (const row of existing) { for (const row of existing) {
if (nextIds.has(row.id)) continue if (nextIds.has(row.id)) continue
db.delete(uptimeSpeedProbes).where(eq(uptimeSpeedProbes.id, row.id)).run() await db.delete(uptimeSpeedProbes).where(eq(uptimeSpeedProbes.id, row.id))
} }
for (let i = 0; i < normalized.length; i += 1) { for (let i = 0; i < normalized.length; i += 1) {
@@ -285,15 +285,15 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
sortOrder: i, sortOrder: i,
updatedAt: now, updatedAt: now,
} }
const updated = db.update(uptimeSpeedProbes).set(patch).where(eq(uptimeSpeedProbes.id, p.id)).run() const updated = await db.update(uptimeSpeedProbes).set(patch).where(eq(uptimeSpeedProbes.id, p.id)).returning({ id: uptimeSpeedProbes.id })
if ((updated.changes ?? 0) > 0) continue if (updated.length > 0) continue
db.insert(uptimeSpeedProbes).values({ await db.insert(uptimeSpeedProbes).values({
id: p.id, id: p.id,
...patch, ...patch,
createdAt: now, createdAt: now,
}).run() })
} }
refreshScheduler() await refreshScheduler()
return reply.send({ ok: true }) return reply.send({ ok: true })
}) })
@@ -326,8 +326,8 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
: Number.parseInt(String(p.intervalSec), 10), : Number.parseInt(String(p.intervalSec), 10),
})) }))
.filter((p) => Number.isFinite(p.srcServerId) && p.name && p.target) .filter((p) => Number.isFinite(p.srcServerId) && p.name && p.target)
replaceProbes(normalized) await replaceProbes(normalized)
refreshScheduler() await refreshScheduler()
return reply.send({ ok: true }) return reply.send({ ok: true })
}) })
@@ -339,7 +339,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
if (typeof body.showOnDashboard !== "boolean") { if (typeof body.showOnDashboard !== "boolean") {
return reply.status(400).send({ error: "showOnDashboard boolean required" }) return reply.status(400).send({ error: "showOnDashboard boolean required" })
} }
const ok = updateProbeShowOnDashboard(probeId, body.showOnDashboard) const ok = await updateProbeShowOnDashboard(probeId, body.showOnDashboard)
if (!ok) return reply.status(404).send({ error: "Probe not found" }) if (!ok) return reply.status(404).send({ error: "Probe not found" })
return reply.send({ ok: true }) return reply.send({ ok: true })
}) })
@@ -348,7 +348,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
const params = req.params as { id?: string | number } const params = req.params as { id?: string | number }
const serverId = Number.parseInt(String(params.id ?? ""), 10) const serverId = Number.parseInt(String(params.id ?? ""), 10)
if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "Invalid server id" }) if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "Invalid server id" })
const srv = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0] const srv = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!srv) return reply.status(404).send({ error: "Server not found" }) if (!srv) return reply.status(404).send({ error: "Server not found" })
try { try {
const rows = await MikrotikClient.fromServer(srv).getInterfaces() const rows = await MikrotikClient.fromServer(srv).getInterfaces()
@@ -386,8 +386,8 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.status(400).send({ error: "Invalid src/dst server id" }) return reply.status(400).send({ error: "Invalid src/dst server id" })
} }
if (srcId === dstId) return reply.status(400).send({ error: "Source and destination must be different" }) if (srcId === dstId) return reply.status(400).send({ error: "Source and destination must be different" })
const src = db.select().from(servers).where(eq(servers.id, srcId)).limit(1).all()[0] const src = (await db.select().from(servers).where(eq(servers.id, srcId)).limit(1))[0]
const dst = db.select().from(servers).where(eq(servers.id, dstId)).limit(1).all()[0] const dst = (await db.select().from(servers).where(eq(servers.id, dstId)).limit(1))[0]
if (!src || !dst) return reply.status(404).send({ error: "Server not found" }) if (!src || !dst) return reply.status(404).send({ error: "Server not found" })
try { try {
const result = await runBandwidthSpeedTest({ const result = await runBandwidthSpeedTest({
@@ -404,7 +404,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.send({ ok: true, result }) return reply.send({ ok: true, result })
} catch (e) { } catch (e) {
const raw = e instanceof Error ? e.message : "Bandwidth test failed" const raw = e instanceof Error ? e.message : "Bandwidth test failed"
recordSpeedTestFailure({ await recordSpeedTestFailure({
probeId: String(body.probeId ?? "").trim() || undefined, probeId: String(body.probeId ?? "").trim() || undefined,
runId: String(body.runId ?? "").trim() || undefined, runId: String(body.runId ?? "").trim() || undefined,
srcServerId: srcId, srcServerId: srcId,
@@ -430,7 +430,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
const params = req.params as { id?: string | number } const params = req.params as { id?: string | number }
const serverId = Number.parseInt(String(params.id ?? ""), 10) const serverId = Number.parseInt(String(params.id ?? ""), 10)
if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "Invalid server id" }) if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "Invalid server id" })
const srv = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0] const srv = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!srv) return reply.status(404).send({ error: "Server not found" }) if (!srv) return reply.status(404).send({ error: "Server not found" })
try { try {
const client = MikrotikClient.fromServer(srv) const client = MikrotikClient.fromServer(srv)
@@ -473,10 +473,10 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
const minutes = rangeToMinutes(q.range) const minutes = rangeToMinutes(q.range)
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString() const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
const allServers = db.select().from(servers).all() const allServers = await db.select().from(servers)
const probeRows = readProbeRows() const probeRows = await readProbeRows()
const probeSamples = readProbeSamplesSince(sinceIso) const probeSamples = await readProbeSamplesSince(sinceIso)
const uptimeCfg = readUptimeSettings() const uptimeCfg = await readUptimeSettings()
const intervalSec = Math.max(15, uptimeCfg.intervalSec ?? 300) const intervalSec = Math.max(15, uptimeCfg.intervalSec ?? 300)
/** Допустимый разрыв между последней попыткой и последним «хорошим» сэмплом (не показывать данные часовой давности). */ /** Допустимый разрыв между последней попыткой и последним «хорошим» сэмплом (не показывать данные часовой давности). */
const resourceFallbackMaxGapMs = Math.min(2 * 3600 * 1000, Math.max(10 * 60 * 1000, intervalSec * 6 * 1000)) const resourceFallbackMaxGapMs = Math.min(2 * 3600 * 1000, Math.max(10 * 60 * 1000, intervalSec * 6 * 1000))
@@ -501,8 +501,15 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
} }
}) })
const resources = allServers.map((s) => { const resources = await Promise.all(allServers.map(async (s) => {
const rows = readResourceSamplesSince(sinceIso, s.id) const [rows, snap] = await Promise.all([
readResourceSamplesSince(sinceIso, s.id),
db.select({ boardName: serverSnapshots.boardName })
.from(serverSnapshots)
.where(eq(serverSnapshots.serverId, s.id))
.orderBy(desc(serverSnapshots.polledAt))
.limit(1),
])
const { row: pick, hasData } = pickResourceDisplayRow(rows, resourceFallbackMaxGapMs) const { row: pick, hasData } = pickResourceDisplayRow(rows, resourceFallbackMaxGapMs)
const cpuHistory = toSeries(rows.map((r) => r.cpuLoad), 40) const cpuHistory = toSeries(rows.map((r) => r.cpuLoad), 40)
return { return {
@@ -515,10 +522,10 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
hddUsed: Math.max(0, ((pick?.totalHddSpace ?? 0) - (pick?.freeHddSpace ?? 0)) / (1024 * 1024)), hddUsed: Math.max(0, ((pick?.totalHddSpace ?? 0) - (pick?.freeHddSpace ?? 0)) / (1024 * 1024)),
hddTotal: Math.max(0, (pick?.totalHddSpace ?? 0) / (1024 * 1024)), hddTotal: Math.max(0, (pick?.totalHddSpace ?? 0) / (1024 * 1024)),
uptimeSeconds: pick?.uptimeSeconds ?? 0, uptimeSeconds: pick?.uptimeSeconds ?? 0,
boardName: pick?.boardName || "RouterBOARD", boardName: snap[0]?.boardName || "RouterBOARD",
temp: undefined as number | undefined, temp: undefined as number | undefined,
} }
}) }))
return reply.send({ probes, resources }) return reply.send({ probes, resources })
}) })
+117
View File
@@ -0,0 +1,117 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import {
appUserCreateSchema,
appUserIdParamSchema,
appUserUpdateSchema,
bindingIdParamSchema,
interfaceCatalogQuerySchema,
userBindingCreateSchema,
type AppUserCreate,
type AppUserUpdate,
type UserBindingCreate,
} from "@mmapp/contracts/users"
import {
addBinding,
createUser,
deleteUser,
getUserById,
listInterfaceCatalog,
listUsers,
removeBinding,
updateUser,
UsersServiceError,
} from "../modules/users/service/users-service.js"
function sendServiceError(reply: { status: (c: number) => { send: (b: unknown) => unknown } }, err: unknown) {
if (err instanceof UsersServiceError) {
return reply.status(err.status).send({ error: err.message })
}
throw err
}
const usersRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/users", async (_req, reply) => {
return reply.send({ users: await listUsers() })
})
app.get("/users/interface-catalog", {
schema: { querystring: interfaceCatalogQuerySchema },
}, async (req, reply) => {
const q = req.query as { serverId: number }
try {
return reply.send({ interfaces: await listInterfaceCatalog(q.serverId) })
} catch (err) {
return sendServiceError(reply, err)
}
})
app.get("/users/:id", {
schema: { params: appUserIdParamSchema },
}, async (req, reply) => {
const { id } = req.params as { id: string }
const user = await getUserById(id)
if (!user) return reply.status(404).send({ error: "Пользователь не найден" })
return reply.send({ user })
})
app.post("/users", {
schema: { body: appUserCreateSchema },
}, async (req, reply) => {
try {
const user = await createUser(req.body as AppUserCreate)
return reply.status(201).send({ user })
} catch (err) {
return sendServiceError(reply, err)
}
})
app.patch("/users/:id", {
schema: { params: appUserIdParamSchema, body: appUserUpdateSchema },
}, async (req, reply) => {
const { id } = req.params as { id: string }
try {
const user = await updateUser(id, req.body as AppUserUpdate)
return reply.send({ user })
} catch (err) {
return sendServiceError(reply, err)
}
})
app.delete("/users/:id", {
schema: { params: appUserIdParamSchema },
}, async (req, reply) => {
const { id } = req.params as { id: string }
try {
await deleteUser(id)
return reply.status(204).send()
} catch (err) {
return sendServiceError(reply, err)
}
})
app.post("/users/:id/bindings", {
schema: { params: appUserIdParamSchema, body: userBindingCreateSchema },
}, async (req, reply) => {
const { id } = req.params as { id: string }
try {
const binding = await addBinding(id, req.body as UserBindingCreate)
return reply.status(201).send({ binding })
} catch (err) {
return sendServiceError(reply, err)
}
})
app.delete("/users/:id/bindings/:bindingId", {
schema: { params: bindingIdParamSchema },
}, async (req, reply) => {
const { id, bindingId } = req.params as { id: string; bindingId: string }
try {
await removeBinding(id, bindingId)
return reply.status(204).send()
} catch (err) {
return sendServiceError(reply, err)
}
})
}
export default usersRoutes
+23
View File
@@ -0,0 +1,23 @@
import { eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { listVxlanTunnels, listVxlanTunnelsForServer } from "../services/vxlan-live.js"
import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js"
const vxlanRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/vxlan", async (_req, reply) => {
const tunnels = await listVxlanTunnels()
return reply.send({ tunnels })
})
app.get("/servers/:id/vxlan", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams
const server = (await db.select().from(servers).where(eq(servers.id, params.id)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
const tunnels = await listVxlanTunnelsForServer(server)
return reply.send({ tunnels })
})
}
export default vxlanRoutes
+108 -44
View File
@@ -18,9 +18,27 @@ import {
type WgParsedConfig, type WgParsedConfig,
} from "../services/wireguard-config.js" } from "../services/wireguard-config.js"
import { import {
captureWireguardSnapshot,
fetchWireguardRestoreState,
getEnabledServerById, getEnabledServerById,
listWireGuardInterfaces, listWireGuardInterfaces,
} from "../services/wireguard-live.js" } from "../services/wireguard-live.js"
import {
putIpAddress,
putWireguardInterface,
putWireguardPeer,
toRosBody,
} from "../services/wireguard-ros.js"
import {
captureAndAppendRevision,
listRevisions,
loadRevisionForRestore,
type ConfigRevisionSource,
} from "../services/config-revisions.js"
import { parseWireguardSnapshot, planWireguardRestore } from "../services/entity-snapshots.js"
import { executeRosOps } from "../services/ros-ops.js"
import { parseDbServerId } from "../utils/server-id.js"
import { z } from "zod"
function serverIdParam(v: string): string { function serverIdParam(v: string): string {
return decodeURIComponent(v) return decodeURIComponent(v)
@@ -30,14 +48,6 @@ function rosIdParam(v: string): string {
return decodeURIComponent(v) return decodeURIComponent(v)
} }
function toRosBody(obj: Record<string, string | undefined>): Record<string, string> {
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(obj)) {
if (v !== undefined && v !== "") out[k] = v
}
return out
}
function peerToRosBody(p: Omit<WgCreatePeerRequest, "serverId" | "interfaceName"> & { interfaceName: string }) { function peerToRosBody(p: Omit<WgCreatePeerRequest, "serverId" | "interfaceName"> & { interfaceName: string }) {
return toRosBody({ return toRosBody({
interface: p.interfaceName, interface: p.interfaceName,
@@ -99,20 +109,17 @@ async function applyParsedConfig(
comment: parsed.interface.comment, comment: parsed.interface.comment,
disabled: parsed.interface.disabled ? "yes" : undefined, disabled: parsed.interface.disabled ? "yes" : undefined,
}) })
await client.put("/interface/wireguard", ifaceBody) await putWireguardInterface(client, ifaceBody)
if (parsed.interface.address) { if (parsed.interface.address) {
await client.put("/ip/address", { await putIpAddress(client, parsed.interface.address, name)
address: parsed.interface.address,
interface: name,
})
} }
let peersCreated = 0 let peersCreated = 0
for (const p of parsed.peers) { for (const p of parsed.peers) {
if (!p.publicKey) continue if (!p.publicKey) continue
await client.put( await putWireguardPeer(
"/interface/wireguard/peers", client,
peerToRosBody({ peerToRosBody({
interfaceName: name, interfaceName: name,
publicKey: p.publicKey, publicKey: p.publicKey,
@@ -142,6 +149,20 @@ function findIface(
return list.find((i) => i.serverId === serverId && i.name === interfaceName) return list.find((i) => i.serverId === serverId && i.name === interfaceName)
} }
async function recordWireguard(
server: NonNullable<Awaited<ReturnType<typeof getEnabledServerById>>>,
source: ConfigRevisionSource,
) {
await captureAndAppendRevision({
serverId: server.id,
section: "wireguard",
source,
capture: () => captureWireguardSnapshot(server),
})
}
const RevisionIdParamSchema = z.object({ id: z.string().min(1) })
const wireguardRoutes: FastifyPluginAsyncZod = async (app) => { const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/wireguard", async (req, reply) => { app.get("/wireguard", async (req, reply) => {
const q = req.query as { serverId?: string; includePrivateKey?: string } const q = req.query as { serverId?: string; includePrivateKey?: string }
@@ -150,6 +171,11 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
serverId: q.serverId, serverId: q.serverId,
includePrivateKey, includePrivateKey,
}) })
const sid = parseDbServerId(q.serverId)
if (sid !== null) {
const server = await getEnabledServerById(sid)
if (server) await recordWireguard(server, "observed")
}
return reply.send(result) return reply.send(result)
}) })
@@ -159,35 +185,26 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
} }
const body = parsed.data const body = parsed.data
const server = getEnabledServerById(body.serverId) const server = await getEnabledServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" }) if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
try { try {
await client.put( await putWireguardInterface(client, {
"/interface/wireguard", name: body.name,
toRosBody({ "listen-port": String(body.listenPort),
name: body.name, mtu: String(body.mtu),
"listen-port": String(body.listenPort), comment: body.comment,
mtu: String(body.mtu), "private-key": body.privateKey,
comment: body.comment, disabled: body.disabled ? "yes" : undefined,
"private-key": body.privateKey, })
disabled: body.disabled ? "yes" : undefined,
}),
)
if (body.address) { if (body.address) {
await client.put("/ip/address", { await putIpAddress(client, body.address, body.name)
address: body.address,
interface: body.name,
})
} }
if (body.peer) { if (body.peer) {
await client.put( await putWireguardPeer(client, peerToRosBody({ ...body.peer, interfaceName: body.name }))
"/interface/wireguard/peers",
peerToRosBody({ ...body.peer, interfaceName: body.name }),
)
} }
const list = await listWireGuardInterfaces({ const list = await listWireGuardInterfaces({
@@ -195,6 +212,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
includePrivateKey: true, includePrivateKey: true,
}) })
const created = list.interfaces.find((i) => i.name === body.name) const created = list.interfaces.find((i) => i.name === body.name)
await recordWireguard(server, "apply")
return reply.status(201).send(created ?? { ok: true, name: body.name }) return reply.status(201).send(created ?? { ok: true, name: body.name })
} catch (e) { } catch (e) {
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e) const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
@@ -208,7 +226,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
} }
const server = getEnabledServerById(serverIdParam(serverId)) const server = await getEnabledServerById(serverIdParam(serverId))
if (!server) return reply.status(404).send({ error: "Сервер не найден" }) if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
const d = parsed.data const d = parsed.data
@@ -224,6 +242,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined, disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
}), }),
) )
await recordWireguard(server, "apply")
return reply.send({ ok: true }) return reply.send({ ok: true })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e) const msg = e instanceof Error ? e.message : String(e)
@@ -233,11 +252,12 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
app.delete("/wireguard/interfaces/:serverId/:rosId", async (req, reply) => { app.delete("/wireguard/interfaces/:serverId/:rosId", async (req, reply) => {
const { serverId, rosId } = req.params as { serverId: string; rosId: string } const { serverId, rosId } = req.params as { serverId: string; rosId: string }
const server = getEnabledServerById(serverIdParam(serverId)) const server = await getEnabledServerById(serverIdParam(serverId))
if (!server) return reply.status(404).send({ error: "Сервер не найден" }) if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
try { try {
await client.delete(`/interface/wireguard/${encodeURIComponent(rosIdParam(rosId))}`) await client.delete(`/interface/wireguard/${encodeURIComponent(rosIdParam(rosId))}`)
await recordWireguard(server, "apply")
return reply.send({ ok: true }) return reply.send({ ok: true })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e) const msg = e instanceof Error ? e.message : String(e)
@@ -251,11 +271,12 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
} }
const body = parsed.data const body = parsed.data
const server = getEnabledServerById(body.serverId) const server = await getEnabledServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" }) if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
try { try {
await client.put("/interface/wireguard/peers", peerToRosBody(body)) await putWireguardPeer(client, peerToRosBody(body))
await recordWireguard(server, "apply")
return reply.status(201).send({ ok: true }) return reply.status(201).send({ ok: true })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e) const msg = e instanceof Error ? e.message : String(e)
@@ -269,7 +290,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
} }
const server = getEnabledServerById(serverIdParam(serverId)) const server = await getEnabledServerById(serverIdParam(serverId))
if (!server) return reply.status(404).send({ error: "Сервер не найден" }) if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const d = parsed.data const d = parsed.data
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
@@ -291,6 +312,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined, disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
}), }),
) )
await recordWireguard(server, "apply")
return reply.send({ ok: true }) return reply.send({ ok: true })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e) const msg = e instanceof Error ? e.message : String(e)
@@ -300,11 +322,12 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
app.delete("/wireguard/peers/:serverId/:rosId", async (req, reply) => { app.delete("/wireguard/peers/:serverId/:rosId", async (req, reply) => {
const { serverId, rosId } = req.params as { serverId: string; rosId: string } const { serverId, rosId } = req.params as { serverId: string; rosId: string }
const server = getEnabledServerById(serverIdParam(serverId)) const server = await getEnabledServerById(serverIdParam(serverId))
if (!server) return reply.status(404).send({ error: "Сервер не найден" }) if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
try { try {
await client.delete(`/interface/wireguard/peers/${encodeURIComponent(rosIdParam(rosId))}`) await client.delete(`/interface/wireguard/peers/${encodeURIComponent(rosIdParam(rosId))}`)
await recordWireguard(server, "apply")
return reply.send({ ok: true }) return reply.send({ ok: true })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e) const msg = e instanceof Error ? e.message : String(e)
@@ -329,11 +352,12 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.send({ dryRun: true, preview }) return reply.send({ dryRun: true, preview })
} }
const server = getEnabledServerById(body.serverId) const server = await getEnabledServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" }) if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
try { try {
const applied = await applyParsedConfig(client, config) const applied = await applyParsedConfig(client, config)
await recordWireguard(server, "copy")
return reply.send({ dryRun: false, preview, applied }) return reply.send({ dryRun: false, preview, applied })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e) const msg = e instanceof Error ? e.message : String(e)
@@ -347,14 +371,14 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
} }
const body = parsed.data const body = parsed.data
const server = getEnabledServerById(body.serverId) const server = await getEnabledServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" }) if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const list = await listWireGuardInterfaces({ const list = await listWireGuardInterfaces({
serverId: String(server.id), serverId: String(server.id),
includePrivateKey: body.includePrivateKey === true, includePrivateKey: body.includePrivateKey === true,
}) })
const iface = findIface(list.interfaces, String(server.id), body.interfaceName) const iface = await findIface(list.interfaces, String(server.id), body.interfaceName)
if (!iface) return reply.status(404).send({ error: "Интерфейс не найден" }) if (!iface) return reply.status(404).send({ error: "Интерфейс не найден" })
if (body.format === "rsc") { if (body.format === "rsc") {
@@ -451,6 +475,46 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
content, content,
}) })
}) })
app.get("/wireguard/revisions", async (req, reply) => {
const q = req.query as { serverId?: string | number }
const serverId = parseDbServerId(q.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const revisions = await listRevisions(serverId, "wireguard")
return reply.send({ revisions })
})
app.post("/wireguard/revisions/:id/restore", {
schema: { params: RevisionIdParamSchema },
}, async (req, reply) => {
const { id } = req.params
const body = req.body as { serverId?: string | number } | undefined
const loaded = await loadRevisionForRestore({
id,
section: "wireguard",
requestedServerId: parseDbServerId(body?.serverId),
})
if (!loaded.ok) return reply.status(loaded.status).send({ error: loaded.error })
try {
const desired = parseWireguardSnapshot(loaded.row.payload)
const state = await fetchWireguardRestoreState(loaded.server)
const ops = planWireguardRestore(desired, {
ifaces: state.ifaces,
peers: state.peers,
addrs: state.addrs,
})
await executeRosOps(state.client, ops)
await recordWireguard(loaded.server, "rollback")
const list = await listWireGuardInterfaces({
serverId: String(loaded.server.id),
includePrivateKey: true,
})
return reply.send({ ok: true, interfaces: list.interfaces })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return reply.status(502).send({ error: `RouterOS: ${msg}` })
}
})
} }
export default wireguardRoutes export default wireguardRoutes
@@ -0,0 +1,20 @@
import { env } from "../config.js"
import { closePool, pool } from "../db/index.js"
import { applySqlMigrations } from "../db/migrate.js"
import { ensurePartitionsAround } from "../db/partitions.js"
import { importSqliteToPostgres } from "../db/sqlite-import.js"
const sqlitePath = process.argv.find((a) => a.startsWith("--sqlite="))?.slice(9) ?? env.DATABASE_PATH
const dryRun = process.argv.includes("--dry-run")
const fullHistory = process.argv.includes("--full-history")
const strict = process.argv.includes("--strict") || !dryRun
await applySqlMigrations(pool)
await ensurePartitionsAround(pool)
const report = await importSqliteToPostgres(pool, sqlitePath, { dryRun, strict, fullHistory })
console.log(JSON.stringify(report, null, 2))
if (!dryRun && report.rejects.length > 0 && strict) {
await closePool()
process.exit(1)
}
await closePool()
@@ -0,0 +1,8 @@
import { initDatabase } from "../db/bootstrap.js"
import { closePool } from "../db/index.js"
import { rebuildFlowFactsFromBuckets } from "../services/traffic-flow-facts-rebuild.js"
await initDatabase()
const result = await rebuildFlowFactsFromBuckets()
console.log(JSON.stringify(result, null, 2))
await closePool()
+9 -13
View File
@@ -85,15 +85,11 @@ async function deleteTxtRecord(token: string, zoneId: string, recordId: string):
await cloudflareRequest(token, `/zones/${zoneId}/dns_records/${recordId}`, { method: "DELETE" }) await cloudflareRequest(token, `/zones/${zoneId}/dns_records/${recordId}`, { method: "DELETE" })
} }
function parseWanUplinks(raw: string | null | undefined): WanUplinkRow[] { function parseWanUplinks(raw: unknown): WanUplinkRow[] {
if (!raw?.trim()) return [] const data = Array.isArray(raw) ? raw : typeof raw === "string" && raw.trim()
try { ? (() => { try { const p = JSON.parse(raw) as unknown; return Array.isArray(p) ? p : [] } catch { return [] } })()
const data = JSON.parse(raw) as unknown : []
if (!Array.isArray(data)) return [] return data.filter((row): row is WanUplinkRow => row != null && typeof row === "object")
return data.filter((row): row is WanUplinkRow => row != null && typeof row === "object")
} catch {
return []
}
} }
function normalizeIpv4(raw: string | undefined): string | null { function normalizeIpv4(raw: string | undefined): string | null {
@@ -217,10 +213,10 @@ async function sleep(ms: number) {
} }
async function getOrCreateAccountKey(): Promise<Buffer> { async function getOrCreateAccountKey(): Promise<Buffer> {
const existing = getAcmeAccountPrivateKey() const existing = await getAcmeAccountPrivateKey()
if (existing) return Buffer.from(existing) if (existing) return Buffer.from(existing)
const key = await acme.crypto.createPrivateKey() const key = await acme.crypto.createPrivateKey()
saveAcmeAccountPrivateKey(key.toString("utf8")) await saveAcmeAccountPrivateKey(key.toString("utf8"))
return key return key
} }
@@ -247,8 +243,8 @@ export async function issueCertificateWithCloudflareDns(params: {
trustStore: string[] trustStore: string[]
onStep?: (step: string) => void onStep?: (step: string) => void
}): Promise<void> { }): Promise<void> {
const settings = getAcmeSettingsPublic() const settings = await getAcmeSettingsPublic()
const token = getAcmeCloudflareToken() const token = await getAcmeCloudflareToken()
if (!token) throw new Error("Не настроен Cloudflare API token") if (!token) throw new Error("Не настроен Cloudflare API token")
const domains = [...new Set(params.domainNames.map((d) => d.trim().toLowerCase()).filter(Boolean))] const domains = [...new Set(params.domainNames.map((d) => d.trim().toLowerCase()).filter(Boolean))]
@@ -1,5 +1,5 @@
/** /**
* Внутренние хуки: после записи сигналов в SQLite (коллекторы) отложенный прогон `alert_engine`. * Внутренние хуки: после записи сигналов в PostgreSQL (коллекторы) отложенный прогон `alert_engine`.
* Не импортирует `scheduler.ts` (избегаем цикла); runner задаётся через `wireAlertEngineRunner`. * Не импортирует `scheduler.ts` (избегаем цикла); runner задаётся через `wireAlertEngineRunner`.
* *
* Любой код, пишущий таблицы для `buildSignalSnapshot`, после успешного коммита должен вызывать * Любой код, пишущий таблицы для `buildSignalSnapshot`, после успешного коммита должен вызывать
@@ -15,7 +15,7 @@ let alertAfterCollectChain: Promise<void> = Promise.resolve()
let alertEngineRunner: (() => Promise<void>) | null = null let alertEngineRunner: (() => Promise<void>) | null = null
/** Задать runner один раз при старте приложения (обычно `() => executeSchedulerJob("alert_engine")`). */ /** Задать runner один раз при старте приложения (обычно `() => await executeSchedulerJob("alert_engine")`). */
export function wireAlertEngineRunner(run: () => Promise<void>): void { export function wireAlertEngineRunner(run: () => Promise<void>): void {
alertEngineRunner = run alertEngineRunner = run
} }
@@ -39,7 +39,7 @@ async function runAlertEngineWhenIdle(): Promise<void> {
/** После успешной записи сигналов в БД коллекторами (не из `alert_engine`). */ /** После успешной записи сигналов в БД коллекторами (не из `alert_engine`). */
export function scheduleAlertEngineAfterDataCollectors(): void { export function scheduleAlertEngineAfterDataCollectors(): void {
if (alertAfterCollectTimer != null) clearTimeout(alertAfterCollectTimer) if (alertAfterCollectTimer != null) clearTimeout(alertAfterCollectTimer)
alertAfterCollectTimer = setTimeout(() => { alertAfterCollectTimer = setTimeout(async () => {
alertAfterCollectTimer = null alertAfterCollectTimer = null
alertAfterCollectChain = alertAfterCollectChain alertAfterCollectChain = alertAfterCollectChain
.then(() => runAlertEngineWhenIdle()) .then(() => runAlertEngineWhenIdle())
@@ -4,11 +4,10 @@ import { db } from "../../db/index.js"
import { alertEngineConfirmPending } from "../../db/schema.js" import { alertEngineConfirmPending } from "../../db/schema.js"
import type { ApiAlertRule } from "../alerts-service.js" import type { ApiAlertRule } from "../alerts-service.js"
import { computeStabilityReadyMap, deleteConfirmPending } from "./confirm-stability.js" import { computeStabilityReadyMap, deleteConfirmPending } from "./confirm-stability.js"
import { withPgOrSkip } from "../../test/pg.js"
const ruleId = "test-confirm-stability-recovery-bypass" const ruleId = "test-confirm-stability-recovery-bypass"
deleteConfirmPending(ruleId)
const baseRule: ApiAlertRule = { const baseRule: ApiAlertRule = {
id: ruleId, id: ruleId,
name: "Server transition", name: "Server transition",
@@ -46,25 +45,31 @@ const recoveryHit = {
payloadHash: "recovery-1", payloadHash: "recovery-1",
} }
if (!(await withPgOrSkip())) {
console.log("alert-engine confirm-stability tests skip")
process.exit(0)
}
await deleteConfirmPending(ruleId)
{ {
const m = computeStabilityReadyMap([baseRule], new Map([[ruleId, problemHit]])) const m = await computeStabilityReadyMap([baseRule], new Map([[ruleId, problemHit]]))
assert.equal(m.get(ruleId), false, "проблемный переход должен ждать confirmStabilitySec") assert.equal(m.get(ruleId), false, "проблемный переход должен ждать confirmStabilitySec")
} }
{ {
const m = computeStabilityReadyMap([baseRule], new Map([[ruleId, recoveryHit]])) const m = await computeStabilityReadyMap([baseRule], new Map([[ruleId, recoveryHit]]))
assert.equal(m.get(ruleId), true, "восстановление должно отправляться без задержки") assert.equal(m.get(ruleId), true, "восстановление должно отправляться без задержки")
} }
{ {
const m = computeStabilityReadyMap([baseRule], new Map([[ruleId, null]])) const m = await computeStabilityReadyMap([baseRule], new Map([[ruleId, null]]))
assert.equal(m.get(ruleId), false, "без hit отправка не готова") assert.equal(m.get(ruleId), false, "без hit отправка не готова")
const left = db const left = (await db
.select() .select()
.from(alertEngineConfirmPending) .from(alertEngineConfirmPending)
.where(eq(alertEngineConfirmPending.ruleId, ruleId)) .where(eq(alertEngineConfirmPending.ruleId, ruleId))
.limit(1) .limit(1))[0]
.all()[0]
assert.equal(left, undefined, "pending должен очищаться после отсутствия hit") assert.equal(left, undefined, "pending должен очищаться после отсутствия hit")
} }

Some files were not shown because too many files have changed in this diff Show More