550 Commits
Author SHA1 Message Date
denozord e8ba6d47e3 Refactor project structure and improve component organization
Publish Docker image / build-and-push (push) Successful in 1m0s
- Restructured project files for better maintainability and clarity.
- Enhanced component organization to streamline imports and improve readability.
- Updated relevant documentation to reflect changes in project structure.

Made-with: Cursor
2026-04-26 15:26:04 +07:00
denozord fe7acd655e Rollback frontend and config changes to commit a9a7e98 state.
Publish Docker image / build-and-push (push) Successful in 2m12s
Restore project files to match the requested baseline before subsequent updates.

Made-with: Cursor
2026-04-26 14:05:50 +07:00
denozord d86434e9c9 Remove unused dependencies from package-lock.json to streamline project and reduce bundle size.
Publish Docker image / build-and-push (push) Successful in 1m2s
2026-04-26 14:02:46 +07:00
denozord f290288f43 Enhance Tailwind CSS configuration and clean up CSS imports
Publish Docker image / build-and-push (push) Successful in 1m56s
- Extended Tailwind CSS configuration to include new color schemes, border radius settings, and font family for improved theming.
- Removed unnecessary CSS import for shadcn/tailwind.css to streamline styles.

Made-with: Cursor
2026-04-26 14:00:42 +07:00
denozord e6dc99f403 Refactor Dashboard and App components to utilize new UI elements
Publish Docker image / build-and-push (push) Failing after 1m3s
- Updated App.jsx to enhance the SidebarInset and main content layout with improved styling.
- Refactored Dashboard.jsx to replace traditional HTML elements with Card, CardContent, Button, and Badge components for a more consistent UI.
- Improved error handling display and button interactions for better user experience.

Made-with: Cursor
2026-04-26 13:56:10 +07:00
denozord 58ee09b70e Update frontend dependencies, enhance Vite configuration, and refactor components
Publish Docker image / build-and-push (push) Successful in 2m24s
- Added new dependencies including @base-ui/react, @fontsource-variable/geist, and tailwindcss for improved UI and styling.
- Updated Vite configuration to include path aliasing for easier imports.
- Refactored App.jsx to implement a theme context and improved routing structure.
- Enhanced various components (e.g., ConfirmDialog, DataTable) to utilize new UI components and improve user experience.
- Cleaned up CSS imports and ensured proper styling integration.

Made-with: Cursor
2026-04-26 13:48:59 +07:00
denozord a9a7e98be5 Enhance SQLite database initialization in sqliteDb.js
Publish Docker image / build-and-push (push) Successful in 1m27s
- Added checks to ensure the SQLite database file exists before opening, improving resilience during container startup.
- Implemented error handling for file accessibility, providing clearer error messages if the database file is not readable or writable.

Made-with: Cursor
2026-04-26 02:07:35 +07:00
denozord cf3437c70c Update Docker documentation and README for SQLite storage and environment variables
Publish Docker image / build-and-push (push) Successful in 2m13s
- Enhanced Docker.md with detailed instructions on building and running Docker images, including environment variable requirements and tag usage.
- Updated README.md to reflect the current architecture using SQLite for data storage and clarified environment variable settings for backend and frontend operations.
- Removed references to AWS S3, emphasizing local storage for MikroTik backups and SQLite database management.

Made-with: Cursor
2026-04-26 01:45:43 +07:00
denozord 50f9c91843 Docker: include backend/db in runtime image
Publish Docker image / build-and-push (push) Successful in 1m30s
Made-with: Cursor
2026-04-20 02:04:08 +07:00
denozord 0d0ac2936a CI: build v5 branch with Docker tags v5 only
Publish Docker image / build-and-push (push) Successful in 5m3s
Made-with: Cursor
2026-04-20 01:36:21 +07:00
denozord 1c6e6ab24a Backend: SQLite storage, EvoBGP integration, filters in SQL
Made-with: Cursor
2026-04-20 01:17:00 +07:00
denozord 7f3eaea40a chore(git): create v5 branch from v4
Made-with: Cursor
2026-04-19 22:27:09 +07:00
denozord 6473ff8bbd refactor(FilterManager): replace API call with listCommunities utility for community data retrieval
Publish Docker image / build-and-push (push) Successful in 2m7s
- Updated the community data fetching logic in FilterManager to utilize the listCommunities function, improving code clarity and maintainability.
- Ensured proper handling of aborted requests to prevent unnecessary state updates.

Made-with: Cursor
2026-04-08 00:24:42 +07:00
denozord e99700a916 fix(evobgp): update header handling in evobgpProxyRoutes to preserve content-encoding
Publish Docker image / build-and-push (push) Successful in 1m45s
- Modified header processing to retain content-encoding, ensuring proper handling of gzip responses from upstream servers.
- Added comments for clarity on the importance of this change to avoid issues with client response interpretation.

Made-with: Cursor
2026-04-08 00:13:10 +07:00
denozord 3e0c968e7f fix(evobgp): health GET /api/evobgp, дублирующий mount /evobgp для nginx proxy_pass
Publish Docker image / build-and-push (push) Successful in 1m19s
- диагностика: JSON на GET /api/evobgp и /evobgp
- совместимость с nginx, срезающим префикс /api
- DOCKER.md: пример location /api/ и типичная ошибка

Made-with: Cursor
2026-04-07 23:52:04 +07:00
denozord a3291d6b2b feat(evobgp): прокси /api/evobgp на бэкенде, URL и токен из env Docker
Publish Docker image / build-and-push (push) Successful in 1m39s
- Express проксирует на EVOBGP_API_URL с EVOBGP_API_TOKEN (без CORS)
- фронт по умолчанию baseURL /api/evobgp; прямой режим через VITE или localStorage
- Communities: подсказка про переменные контейнера; очистка URL включает прокси
- DOCKER.md и Dockerfile.fast: документация переменных

Made-with: Cursor
2026-04-07 23:45:57 +07:00
denozord 0746fb4f1e fix(evobgp): запросы к API на абсолютный URL EvoBGP, не на хост SPA
Publish Docker image / build-and-push (push) Successful in 1m39s
- base URL: VITE_EVOBGP_API_URL или localStorage evobgp_api_url, в dev — proxy /v1
- нормализация до …/v1; убран относительный /v1 в production (запросы больше не идут на порт UI)
- форма URL и токена в Communities для сохранения в localStorage

Made-with: Cursor
2026-04-07 23:36:36 +07:00
denozord f1fd378f83 feat(integration): use EvoBGP router-lists catalog endpoint
Publish Docker image / build-and-push (push) Successful in 1m42s
Load communities, domains, IP ranges, and ASNs from /v1/router-lists/catalog with fallback to legacy per-endpoint reads to keep compatibility during rollout.

Made-with: Cursor
2026-04-07 23:24:15 +07:00
denozord 23d7b67813 feat(integration): switch data sections to EvoBGP v1 API
Publish Docker image / build-and-push (push) Successful in 2m12s
Move Domains, IP ranges, ASNs and Communities managers from legacy /api endpoints to direct EvoBGP /v1 modules/communities endpoints with a dedicated adapter layer.

Made-with: Cursor
2026-04-07 22:50:54 +07:00
denozord 0703d2eccd feat(BillingManager): implement billing items normalization and refactor state management for improved data handling
Publish Docker image / build-and-push (push) Successful in 1m45s
2026-03-16 00:26:55 +07:00
denozord a5bada97bf chore(workflows): allow webhook trigger to continue on error in docker-publish workflow
Publish Docker image / build-and-push (push) Successful in 56s
2026-03-16 00:18:35 +07:00
denozord ccd1b15683 chore(workflows): remove frontend CI workflow to streamline CI process
Publish Docker image / build-and-push (push) Failing after 55s
2026-03-16 00:12:44 +07:00
denozord 50a7edd50f feat: enhance various components with useCallback for performance optimization and improve chunk size warning limit in Vite config
Frontend CI / frontend (push) Has been cancelled
Publish Docker image / build-and-push (push) Failing after 56s
2026-03-16 00:07:25 +07:00
denozord 972f3ab5ca chore(workflows): remove lint-and-test job from docker-publish workflow to simplify CI process
Publish Docker image / build-and-push (push) Successful in 2m28s
2026-03-15 23:57:31 +07:00
denozord 8539317f2d chore(workflows): remove deprecated docker-publish-fast workflow and enhance docker-publish with bundle size check
Publish Docker image / build-and-push (push) Has been cancelled
Publish Docker image / lint-and-test (push) Has been cancelled
2026-03-15 23:56:42 +07:00
denozord 4705e2489b refactor(workflows): streamline lint-and-test jobs by setting default working directory and updating test command to build
Publish Fast Tabler Docker image / build-and-push-fast (push) Has been cancelled
Publish Fast Tabler Docker image / lint-and-test (push) Has been cancelled
Publish Docker image / build-and-push (push) Has been cancelled
Publish Docker image / lint-and-test (push) Has been cancelled
2026-03-15 23:54:10 +07:00
denozord a71072168d chore(workflows): add support for manual triggering and include 'v4' branch in Docker publish workflow
Publish Docker image / build-and-push (push) Has been cancelled
Publish Docker image / lint-and-test (push) Has been cancelled
2026-03-15 23:47:35 +07:00
denozord e529aa23ec refactor(tests): update package.json scripts for linting and testing; enhance CI workflows for linting and testing integration
Frontend CI / frontend (push) Successful in 10m38s
2026-03-15 23:29:24 +07:00
denozord 164507513c refactor(OspfToolsPage): streamline template cost updates by consolidating state management logic
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m55s
2026-03-15 21:44:37 +07:00
denozord b9d45bb8b7 feat(OspfToolsPage): add cost optimization feature for OSPF interfaces with user notifications
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m3s
2026-03-15 21:40:33 +07:00
denozord fd9b8b3c22 refactor(OspfToolsPage): rename and restructure optimal cost hints to probability map for improved clarity and functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m59s
2026-03-05 17:05:34 +07:00
denozord 81347c6c8d refactor(OspfToolsPage): improve layout and responsiveness of router groups and item display
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m0s
2026-03-05 16:59:48 +07:00
denozord 897dd52eac feat(OspfToolsPage): implement optimal cost hints for OSPF routes and enhance loading logic
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m2s
2026-03-05 16:47:11 +07:00
denozord 99f8c5d0bf feat(app): add OSPF Tools page and integrate it into the main layout and language support
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m1s
2026-03-05 16:38:35 +07:00
denozord 817f550f4d fix(network-config-manager): correct sorting logic for cost in areas and item lists, update cost calculation during reordering
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s
2026-03-05 16:30:21 +07:00
denozord 114d7b2731 feat(network-config-manager): add function to identify unknown OSPF interface names and display active status in templates
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m54s
2026-03-05 16:26:01 +07:00
denozord 5d66f04286 feat(mikrotik-config): add OSPF area comparison and enhance template application logic
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m35s
2026-03-05 16:13:13 +07:00
denozord f77d71f22c feat(network-config-manager): enhance OSPF router identity handling and improve template management logic
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m54s
2026-03-05 15:49:35 +07:00
denozord e631ffab51 feat(mikrotik-config): add OSPF interface templates management with loading and applying functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m35s
2026-03-05 15:43:02 +07:00
denozord 113fa59a63 feat(network-config-manager): implement OSPF candidate management and normalization for interface templates
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2026-03-05 15:31:41 +07:00
denozord 1dcdc0e6c2 refactor(route-optimizer): enhance layout and structure of optimized routes table in RouteOptimizerPage component
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m53s
2026-03-05 14:26:16 +07:00
denozord 31ce1ae02a fix(route-optimizer): close div tag for improved layout in RouteOptimizerPage component
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m49s
2026-03-05 11:10:32 +07:00
denozord baa230c0d2 feat(route-optimizer): implement community full routes display with optimized scoring and detailed metrics in UI
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 57s
2026-03-05 11:08:16 +07:00
denozord aab9ed0601 feat(route-optimizer): add configurable download and upload weights for bandwidth scoring in AI settings
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m34s
2026-03-05 11:00:46 +07:00
denozord 2f4b1f6671 feat(route-optimizer): refactor bandwidth scoring and enhance speed display in UI for improved clarity
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
2026-03-05 10:55:35 +07:00
denozord e1198b0c91 feat(route-optimizer): update table headers and enhance probability display for improved clarity in route optimization UI
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m53s
2026-03-05 10:42:47 +07:00
denozord b792cede50 feat(route-optimizer): improve table layout and enhance data display with optimized column widths and text wrapping
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m51s
2026-03-05 10:35:04 +07:00
denozord 79828eace9 feat(route-optimizer): add Home to Jumphost candidate display and probability metrics in route optimization UI
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m54s
2026-03-05 10:28:16 +07:00
denozord 8012968ab5 feat(route-optimizer): implement pinned community gateways feature with UI integration for settings and recommendations
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
2026-03-04 23:39:43 +07:00
denozord 624c0d6fd3 feat(route-optimizer): add country and interface name display in route optimization UI
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m33s
2026-03-04 23:32:33 +07:00
denozord f5829fb35c feat(route-optimizer): add distance penalty configuration and enhance UI for gateway recommendations
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m31s
2026-03-04 23:28:24 +07:00
denozord 7b7ef1ab45 feat(route-optimizer): enhance AI settings management and UI integration for route optimization
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m35s
2026-03-04 23:22:09 +07:00
denozord 963828f9e8 feat(route-optimizer): implement community optimization logic and enhance UI for server filters recommendations
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
2026-03-04 23:17:59 +07:00
denozord 661b42f776 feat(route-optimizer): integrate AI route optimizer functionality into the application with routing endpoint and UI components
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s
2026-03-04 23:09:23 +07:00
denozord d31c90b60e feat(network-map): enhance node metadata handling and display for improved network issue identification
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m7s
2026-03-04 22:27:28 +07:00
denozord d68b3aa104 feat(network-map): add network speed thresholds for alerts and enhance settings UI for configuration
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m32s
2026-02-25 02:15:10 +07:00
denozord d53100f5bd feat(network-map): enhance card layout and issue display with badges for critical and warning nodes
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m28s
2026-02-25 02:04:54 +07:00
denozord e1b5bb4c97 feat(network-map): refine stale node representation with updated dimensions and positioning for improved clarity
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m15s
2026-02-25 02:01:24 +07:00
denozord d43a77e368 feat(docker): add 'latest' tag to Docker image for improved versioning
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m16s
2026-02-25 01:58:05 +07:00
denozord 945d67289a fix
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m7s
2026-02-25 01:51:12 +07:00
denozord 489cb3b312 feat(network-map): implement next run scheduling persistence and recovery logic for network map scheduler 2026-02-25 01:27:39 +07:00
denozord 6010a95208 feat(network-map): add critical and warning node displays with risk assessment for improved network monitoring
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m28s
2026-02-25 00:44:19 +07:00
denozord 4d91b4fe04 feat(network-map): introduce compact edge label display for improved readability in dense mode
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m37s
2026-02-25 00:38:06 +07:00
denozord b76cbc15cb feat(network-map): add jitter-based label placement and clamp function for improved edge label positioning
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m20s
2026-02-24 23:39:52 +07:00
denozord d657b03abc feat(network-map): implement advanced edge routing and geometry calculations for improved visual representation
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m18s
2026-02-24 23:30:44 +07:00
denozord 7029fa2897 fix
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m38s
2026-02-24 23:03:27 +07:00
denozord dcf1853efb feat(network-map): enhance node representation and edge styling with new color schemes and layout adjustments 2026-02-24 22:54:41 +07:00
denozord 80de7160b9 refactor(alerts): consolidate tunnel alert settings into a single structure with enhanced UI controls
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m47s
2026-02-24 22:23:12 +07:00
denozord 53be4cd4ad feat(alerts): add tunnel high ping and low speed alert settings with corresponding UI controls
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m55s
2026-02-24 22:07:31 +07:00
denozord 228a1b9525 feat(network-map): add force option to speed request for manual refresh and improved testing
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m13s
2026-02-24 16:22:36 +07:00
denozord b09ba5cd77 feat(network-map): enhance ping and speed request handling with options for specific connections and loading states
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m1s
2026-02-24 15:52:02 +07:00
denozord cc92502de2 feat(mikrotik): optimize address list entry addition with chunked execution and improved error handling
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m46s
2026-02-24 15:10:54 +07:00
denozord 9de7fd104c feat(mikrotik): add concurrent processing for address list entries to improve performance and efficiency
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m53s
2026-02-24 14:59:12 +07:00
denozord 1f391d9398 feat(mikrotik): implement batch removal of address lists with fallback to individual deletes for improved efficiency and error handling
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s
2026-02-24 14:39:25 +07:00
denozord b56df61c8f refactor(logging): remove agent logging from various components to streamline code and improve maintainability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m49s
2026-02-24 13:53:41 +07:00
denozord 8035b7751f feat(logging): update debug logging endpoint and enhance resource stats logging for better diagnostics
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m55s
2026-02-24 13:46:56 +07:00
denozord 657c23d237 feat(logging): enhance API request logging and implement noRetry flag for improved error handling and diagnostics
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m54s
2026-02-24 13:39:59 +07:00
denozord 6290a84cd3 feat(logging): implement debug logging for network map scheduler to enhance monitoring and diagnostics
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s
2026-02-24 13:26:28 +07:00
denozord 07bcbe70c2 feat(api): add request timeout to API calls in ResourceStatsPage and TrafficDashboard for improved reliability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m12s
2026-02-24 12:59:58 +07:00
denozord 3655428da4 feat(uptime-monitor): enhance uptime checks with request cancellation and logging on page leave for improved resource management
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m6s
2026-02-24 12:54:51 +07:00
denozord 3840a91ba5 feat(ping): implement scoped request handling and cancellation for ping requests to improve performance and resource management
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m14s
2026-02-24 12:46:08 +07:00
denozord 69e360b940 feat(api): implement logging for canceled API requests to improve error handling and debugging
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m7s
2026-02-24 12:41:44 +07:00
denozord 4370e13524 feat(logging): add agent logging for API interactions and error handling across backend and frontend components
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m48s
2026-02-24 12:33:19 +07:00
denozord f48e30ab44 refactor(communities): remove gatewayDefault from community model and UI components for streamlined data handling
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m51s
2026-02-24 12:18:44 +07:00
denozord 5e6435e2e1 feat(communities): add icon support to community model and UI components for enhanced visual representation
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s
2026-02-24 12:03:07 +07:00
denozord 02a45fce71 feat(EasySwitch): enhance Easy Switch UI with new styles and layout adjustments for improved user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m5s
2026-02-24 11:46:30 +07:00
denozord 71685ad310 refactor(App): improve dropdown menu styling and accessibility in sidebar and language selection components
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m10s
2026-02-24 01:46:57 +07:00
denozord 00d977dfb5 feat(alerts): refactor AlertsBell component to support dynamic positioning and improve alert display with a new panel layout
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m11s
2026-02-24 01:40:41 +07:00
denozord 574c0d25c2 feat(alerts): add dropup functionality to AlertsBell component and improve dropdown styling for better user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m12s
2026-02-24 01:32:22 +07:00
denozord 513a22008b refactor(App.css): update sidebar dropdown styles for improved clarity and specificity in menu item targeting
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m12s
2026-02-24 01:26:08 +07:00
denozord 2e02d550f6 feat(alerts): enhance alert settings with customizable durations for server offline and resource usage notifications
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m59s
2026-02-24 01:04:29 +07:00
denozord b35f787626 feat(alerts): implement customizable alert settings for server availability and resource usage, enhancing user control over notifications
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m54s
2026-02-24 00:48:34 +07:00
denozord e4394ef35c refactor(docs): update project references from S3 Lists Manager to RouterOS Manager across documentation and frontend files
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s
2026-02-24 00:30:50 +07:00
denozord a44b49fdeb fix(App.css): adjust z-index values for sidebar overlay and navbar to ensure proper clickability and visibility 2026-02-24 00:28:43 +07:00
denozord 3f41bd4c7c feat(api): add alerts API endpoint and refactor resource stats retrieval for improved data handling 2026-02-24 00:25:36 +07:00
denozord f0db9ed013 feat(App): enhance mobile sidebar layout with new top bar and responsive adjustments; improve navbar styling for better usability on small screens
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m53s
2026-02-24 00:09:17 +07:00
denozord d397612fa5 feat(workflows): add support for v4 branch in docker-publish-fast workflow
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m3s
2026-02-24 00:00:46 +07:00
denozord 00afe2d6b2 feat(App): implement mobile sidebar overlay with backdrop and responsive adjustments; enhance layout for better usability on small screens 2026-02-23 23:56:55 +07:00
denozord 0b5ec5bd85 refactor(ResourceStatsPage, TrafficDashboard): update button click handlers to use arrow functions for consistency and improve error handling by retaining previous data on fetch failure
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m11s
2026-02-23 01:52:48 +07:00
denozord a14f10d048 refactor(api): implement abort signal handling in API requests across multiple components to improve cancellation of ongoing requests and prevent memory leaks
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m12s
2026-02-22 23:53:15 +07:00
denozord 3fda43cf2c docs(README): update locks API documentation to clarify optional soft-locks for editing and S3 access; enhance speed test route comments to explain non-blocking behavior
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m49s
2026-02-22 23:36:31 +07:00
denozord 353d14d64e feat(Validators): add server ID validation function to prevent path traversal and unsafe characters; update routes to utilize new validation logic
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m55s
2026-02-22 23:28:19 +07:00
denozord c3fba50de8 refactor(ResourceStatsPage): update RouterResourceCard layout to include an icon and improve styling
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m25s
2026-02-22 22:17:16 +07:00
denozord 9798e7fe95 feat(ResourceStats): add resource statistics API endpoint and integrate into frontend with routing and localization updates
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m35s
2026-02-22 22:05:47 +07:00
denozord 484dd56312 feat(Dashboard): display average ping response time in PingServiceCard based on historical data
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m7s
2026-02-22 21:50:53 +07:00
denozord 586f8246fb feat(PingServices): implement history tracking for ping results and update API responses to include historical data
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m46s
2026-02-22 21:40:55 +07:00
denozord 800f94354b feat(PingServices): add cache refresh option to fetchPingServices function and update tooltip for manual refresh
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 4m3s
2026-02-22 21:31:07 +07:00
denozord fbd740aef7 feat(Dashboard): enhance PingServiceCard with expandable view and sparkline for historical ping data
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m57s
2026-02-22 21:24:48 +07:00
denozord 7d3804202f feat(UptimeMonitor): add uptime monitor scheduler settings and endpoints for improved monitoring functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m47s
2026-02-22 21:10:50 +07:00
denozord 1548de7064 feat(Settings): add ping services scheduler settings for cache refresh interval and enable/disable functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m31s
2026-02-22 20:50:46 +07:00
denozord 431314b973 feat(PingServicesScheduler): implement ping services scheduler endpoints and cache refresh logic for improved service monitoring
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m29s
2026-02-22 20:40:57 +07:00
denozord 4c7af43be7 refactor(UptimeMonitorPage): update background check logic to utilize cached data on page load
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m10s
2026-02-22 19:18:37 +07:00
denozord 1be7e3bb22 feat(UptimeMonitor): implement uptime cache retrieval and update logic for improved monitoring performance
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m36s
2026-02-22 18:36:02 +07:00
denozord 64d9178784 feat(UptimeMonitorPage): add current checking server ID state to improve UI feedback during uptime checks
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m56s
2026-02-22 18:20:21 +07:00
denozord 8c13ae9df4 feat(MikrotikConfigRoutes): enhance gateway and interface matching logic to support additional server IDs and improve route extraction
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m24s
2026-02-22 18:03:58 +07:00
denozord f80e962767 feat(UptimeMonitor): add uptime check endpoint and frontend settings for monitoring server availability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m34s
2026-02-22 12:57:53 +07:00
denozord afa093781e feat(NetworkMap): adjust network map scheduler to run initial tick after 10 seconds and enhance cache handling in dashboard for better data display
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m54s
2026-02-22 01:30:21 +07:00
denozord f0cb694fa6 feat(UptimeMonitorPage): implement history tracking and visualization for server uptime checks
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m19s
2026-02-21 22:46:36 +07:00
denozord 28a6978243 refactor(UptimeMonitorPage): optimize uptime checks with memoization and update check interval to 2 minutes
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m9s
2026-02-21 22:31:20 +07:00
denozord 236f9f9d2e feat(App, CommandPalette): add Uptime Monitor page and integrate into navigation and command palette
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m19s
2026-02-21 22:08:46 +07:00
denozord e72b3f539d fix(api): extend request timeout to 3 minutes for long-running operations
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m13s
2026-02-21 21:18:38 +07:00
denozord 56f1baa7ba feat(FirewallPage): enhance apply summary functionality with confirmation for individual and bulk actions
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m8s
2026-02-21 19:32:17 +07:00
denozord ef7a4817c9 feat(FirewallPage): implement IP info loading from ipinfo.io for subnet analysis suggestions
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m6s
2026-02-21 19:09:11 +07:00
denozord 8e8ce7cec0 feat(MikrotikConfigRoutes, FirewallPage): add apply-summary endpoint and frontend analysis feature for address lists
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m47s
2026-02-21 19:01:28 +07:00
denozord 4ad5821b5e fix(MikrotikConfigRoutes): update response format for address lists to use res.json for consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m47s
2026-02-21 18:52:30 +07:00
denozord dfbd38d9a3 feat(MikrotikConfigRoutes): add logging for address list requests and responses to improve debugging
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s
2026-02-21 18:47:21 +07:00
denozord c3c30b1c75 refactor(MikrotikConfigRoutes): improve address list retrieval by filtering after fetching all entries
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m54s
2026-02-21 18:40:42 +07:00
denozord f9ac5ac3a3 feat(MikrotikConfigRoutes, App): add endpoint for retrieving address lists and integrate firewall page in frontend
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m58s
2026-02-21 18:29:38 +07:00
denozord 357503ee2f fix(NetworkMapScheduler): increase speed test request timeout to 5 minutes and improve error logging for aborted requests
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m44s
2026-02-18 16:01:21 +07:00
denozord e269b14f5e refactor(NetworkMapScheduler, NetworkMapDashboard): update speed test logic to ensure single test per server to avoid bandwidth contention
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s
2026-02-18 15:30:47 +07:00
denozord 9a8b72de82 feat(NetworkMapDashboard, NetworkMapUnifi): add stale data handling for ping and speed metrics, enhancing UI feedback with visual indicators
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m59s
2026-02-18 15:13:23 +07:00
denozord 8cc3575902 feat(NetworkMapScheduler): add next scheduled run time to settings response and update frontend to display next run time
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m36s
2026-02-18 14:11:27 +07:00
denozord 1fda3a5f4e feat(NetworkMapUnifi): enhance label placement logic and update country flag rendering with CDN support
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m57s
2026-02-18 14:05:20 +07:00
denozord fdc39060a1 feat(NetworkMapUnifi): add short speed formatting and country flag display to network edges
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2026-02-18 13:36:26 +07:00
denozord 283ab53b80 feat(NetworkMapScheduler): improve logging for speed test results by including server labels
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m39s
2026-02-18 13:29:26 +07:00
denozord 89d71ff060 feat(NetworkMapScheduler): enhance job execution logging and add force refresh option for ping and speed tests
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m44s
2026-02-18 13:23:03 +07:00
denozord b1834e988b feat(NetworkMapDashboard): implement cache handling for ping and speed requests to optimize data fetching
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m0s
2026-02-18 13:06:00 +07:00
denozord 6f13f39685 fix(schedulerRoutes): standardize comment formatting for API route documentation
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m35s
2026-02-18 12:45:52 +07:00
denozord dbca38d31a feat(NetworkMapScheduler): integrate network map scheduler functionality with API routes and frontend components for enhanced network monitoring
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m53s
2026-02-18 12:41:20 +07:00
denozord fbe9a07727 feat(InterfaceSpeedTest, MikrotikConfigRoutes): enhance speed test functionality with cache handling and force refresh options
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m53s
2026-02-18 12:24:51 +07:00
denozord 8b362f0f55 feat(MikrotikConfigRoutes): add function to retrieve remote MikroTik credentials for speed tests, enhancing server configuration flexibility
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m55s
2026-02-18 12:16:58 +07:00
denozord 9f85351b6b refactor(NetworkMap): unify ping handling by implementing edge-based keying for ping timestamps in NetworkMapDashboard and NetworkMapUnifi components
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m59s
2026-02-18 12:02:29 +07:00
denozord b5eb47e690 feat(NetworkMapDashboard): implement runWithOnePerKey function to ensure single task execution per node during speed tests
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m0s
2026-02-18 11:55:36 +07:00
denozord 034e0d1eee feat(NetworkMap): add speed test functionality to Network Map Dashboard and Unifi components, including UI updates for speed display and handling
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m9s
2026-02-18 11:43:33 +07:00
denozord 29161ae0cb refactor(InterfaceSpeedTest): simplify TCP speed display by separating download and upload metrics
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m23s
2026-02-18 00:43:14 +07:00
denozord 89c75f8e2e fix(InterfaceSpeedTest): increase timeout for speed test API request to accommodate longer test durations
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2026-02-17 23:35:28 +07:00
denozord 270cee077f refactor(MikrotikConfigRoutes): update speed test configuration to include 'proplist' for TCP metrics and remove deprecated 'protocol' parameter
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m44s
2026-02-17 23:29:36 +07:00
denozord 1a283f64f6 refactor(TrafficRoutes): remove deprecated interface speed test endpoint to streamline API
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m33s
2026-02-17 23:25:48 +07:00
denozord 99b346fd94 refactor(TrafficRoutes): remove deprecated interface statistics and speed test functions; streamline MikroTik credentials retrieval
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m27s
2026-02-17 23:22:38 +07:00
denozord 5c03ec7bd0 feat(TrafficRoutes, InterfaceSpeedTest, Settings): implement MikroTik interface statistics route; update speed test duration settings to default to 5 seconds across UI components
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
2026-02-17 23:18:21 +07:00
denozord 31ed777334 fix(MikrotikSpeedTest): remove 'direction' parameter from speed test configuration to streamline request handling
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
2026-02-17 23:06:05 +07:00
denozord 9dc931a10b feat(MikrotikSpeedTest): add speed test API route and frontend integration; implement caching and error handling for speed test functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s
2026-02-17 23:00:49 +07:00
denozord d29f1aef51 feat(TrafficRoutes, App): implement interface speed test route and UI integration; update error handling for interface measurement
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m37s
2026-02-17 22:41:02 +07:00
denozord ab4ba37d53 refactor(MikrotikTools): streamline interface filtering logic by moving it to a new useMemo hook for improved performance and readability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m3s
2026-02-17 22:32:28 +07:00
denozord d9667a06e6 feat(TrafficStats, Settings): add interface speed test functionality with configurable settings for protocol, duration, and caching; enhance UI for speed test integration
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m39s
2026-02-17 22:25:01 +07:00
denozord baa2fc8044 feat(NetworkMapUnifi): implement edge hover and selection features with detailed connection modal for enhanced user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s
2026-02-17 22:02:42 +07:00
denozord 98b39c5703 feat(NetworkMapUnifi): add fullscreen toggle functionality and improve button layout for better user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s
2026-02-17 21:52:32 +07:00
denozord c9babc1634 refactor(NetworkMapUnifi): simplify curve point calculations and improve label positioning to enhance edge rendering and avoid collisions
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s
2026-02-17 17:35:03 +07:00
denozord fe67f1c10b refactor(NetworkMapUnifi): adjust stub length and base offset calculations for smoother edge rendering
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2026-02-17 17:27:15 +07:00
denozord 08693affc3 refactor(NetworkMapUnifi): enhance curve point calculations for improved edge rendering and layout aesthetics
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m59s
2026-02-17 17:19:43 +07:00
denozord c475762fde refactor(NetworkMapUnifi): improve layout computation by grouping server types and enhancing curve point calculations for better visualization
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m5s
2026-02-17 17:13:56 +07:00
denozord 965eb7467b feat(NetworkMapDashboard, SettingsPage): implement ping cache duration settings for network map and enhance ping request handling
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m5s
2026-02-17 17:08:57 +07:00
denozord 48ee2a962e feat(NetworkMapDashboard): update ping requests to include gateway IP for accurate tunnel interface identification
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m2s
2026-02-17 16:59:58 +07:00
denozord 7be656c3f2 feat(NetworkMapDashboard): improve ping functionality by utilizing internal tunnel IPs for enhanced connectivity checks
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m3s
2026-02-17 16:35:42 +07:00
denozord 683f837b36 feat(NetworkMapDashboard): add ping functionality for jumphost and home servers to enhance network connectivity checks
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m4s
2026-02-17 16:27:47 +07:00
denozord cfd7b53a54 feat(NetworkMapDashboard, NetworkMapUnifi): enhance network visualization with tunnel interface connections and draggable node layout functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m7s
2026-02-17 16:21:37 +07:00
denozord d3b21d3dc0 refactor(SettingsPage, TrafficDashboard): rename traffic interface state variables and enhance interface selection logic for improved clarity and functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m1s
2026-02-17 16:17:02 +07:00
denozord f8a50581e4 refactor(NetworkMapDashboard): replace GraphView with NetworkMapUnifi for improved network visualization
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m7s
2026-02-17 16:12:46 +07:00
denozord 5ede9e079d feat(App, GraphView, SettingsPage, CommandPalette): add Network Map dashboard, enhance GraphView with ping data, and update SettingsPage for jumphost management
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m0s
2026-02-17 16:06:16 +07:00
denozord d6fc877e90 feat(SettingsPage): implement traffic interface selection with loading state and error handling for enhanced user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m4s
2026-02-17 16:00:10 +07:00
denozord e0c7ec32d6 feat(SettingsPage, TrafficDashboard): add traffic interface settings and filtering for enhanced analytics functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2026-02-17 15:56:17 +07:00
denozord e071d5060f refactor(NotifyProvider, ToastContainer): update toast notifications to Tabler style with improved layout, animations, and accessibility features
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m2s
2026-02-17 15:49:47 +07:00
denozord c3d41a32d8 refactor(SettingsPage): enhance sidebar structure and layout for improved navigation and visual consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2026-02-17 15:41:26 +07:00
denozord d3ebe5b2b1 refactor(SettingsPage): adjust spacing in section layouts for improved visual consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m57s
2026-02-17 15:35:25 +07:00
denozord 17cd5a8f43 refactor(SettingsPage): streamline loading and success/error message display for improved clarity and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2026-02-17 15:29:48 +07:00
denozord 7b92d1eb70 refactor(SettingsPage): replace SectionCard with SectionHeading for simplified section layout
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s
2026-02-17 15:24:11 +07:00
denozord 94337c8980 refactor(SettingsPage): update sidebar layout to Tabler style with improved navigation and search functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s
2026-02-17 15:19:32 +07:00
denozord 820b73e622 feat(SettingsPage): enhance sidebar layout and content responsiveness for improved user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m4s
2026-02-17 15:08:50 +07:00
denozord 48f47d66ca feat(SettingsPage): restructure sidebar navigation into groups for improved organization and usability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2026-02-17 15:02:10 +07:00
denozord f38f682a6f refactor(SettingsPage): change container class from 'container-xl' to 'container-fluid' for improved responsiveness
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m1s
2026-02-17 14:58:19 +07:00
denozord 18926a7274 feat(SettingsPage): implement sidebar navigation with search functionality for improved user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s
2026-02-17 14:54:18 +07:00
denozord f67a8f888c refactor(SettingsPage): replace PTR zone icon with IconWorld for improved clarity
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m7s
2026-02-17 14:46:22 +07:00
denozord 73f03c21cd fix(App): correct navigation link for settings page by replacing anchor tag with Link component
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m1s
2026-02-17 14:33:20 +07:00
denozord 3514276855 feat(App): add settings page and integrate into navigation; update command palette with settings option
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 57s
2026-02-17 14:14:09 +07:00
denozord ba0557e877 refactor(ServerCard): update layout and styling of expanded information section to use Tabler datagrid format
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m4s
2026-02-17 13:58:58 +07:00
denozord 1959b1d1d0 feat(TrafficDashboard): add summary statistics and visual representation for traffic data across all systems
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2026-02-17 13:42:30 +07:00
denozord 8278017459 feat(TrafficDashboard): enhance traffic visualization with donut chart and updated color palette
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2026-02-17 13:25:50 +07:00
denozord 2b957c6d9a feat(TrafficDashboard): add traffic statistics route and integrate TrafficDashboard component into the application
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s
2026-02-17 13:19:16 +07:00
denozord 630ceb30b7 refactor(BillingCard): remove button size modifier for consistent button group styling
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m51s
2026-02-17 13:11:20 +07:00
denozord 4c5aaf60bd refactor(BillingCard): update button styles for consistent sizing and improved layout
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m59s
2026-02-17 13:00:21 +07:00
denozord 60f55e447f feat(ServerManager): fetch and integrate network configuration gateways for improved server gateway handling
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s
2026-02-17 12:53:54 +07:00
denozord a0b5eb910f refactor(ServerModal): simplify country selection and remove unused gateway handling logic
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s
2026-02-17 12:45:16 +07:00
denozord 06799406a3 refactor(ServerGroupCard): update button styles and event handling for improved user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m2s
2026-02-17 12:34:10 +07:00
denozord 49b62fbfb5 refactor(BillingCard): wrap action buttons in a button group for improved layout and accessibility
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m55s
2026-02-17 12:14:06 +07:00
denozord 7cd33384b7 feat(ServerManager): add group selection functionality for servers in ServerManager and ServerGroupCard components
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m51s
2026-02-17 12:10:49 +07:00
denozord e09976b64e feat(ServerGroupCard): implement group selection functionality and improve selection indicator logic
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m53s
2026-02-17 11:17:04 +07:00
denozord 804f132d42 refactor(ServerGroupCard): update layout and improve server information display in expanded view
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m53s
2026-02-17 10:57:46 +07:00
denozord 8f3f1bd8a4 feat(ServerGroupCard): enhance server group card layout and functionality with improved selection and expanded view
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2026-02-17 10:48:38 +07:00
denozord 9482ab6d70 feat(BulkActions): integrate FloatingBulkActionsBar for improved bulk action handling in ASNs and IPRanges managers
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m55s
2026-02-17 10:45:34 +07:00
denozord d799c38edf feat(ServerManager): implement server grouping in cards view and enhance server modal for groupKey input
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s
2026-02-17 10:37:23 +07:00
denozord 29fcee8ab1 fix(PingServicesManager): improve error handling during save operation and streamline etag management for UI settings
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m59s
2026-02-17 00:27:58 +07:00
denozord 4c05913694 refactor(PingServicesManager): replace PageHeaderActions with a custom button list for improved UI structure
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m59s
2026-02-17 00:22:24 +07:00
denozord 3f42c6226a refactor(brandIcons): remove unused Microsoft and Nginx icons from brand icons list for cleaner code
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m50s
2026-02-17 00:18:54 +07:00
denozord 89a86f7c72 feat(PingServices): add endpoint for retrieving ping services list and update dashboard to display dynamic service configurations
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m12s
2026-02-17 00:15:32 +07:00
denozord 17ab2a30ec feat(PingServices): add caching functionality for ping service results and enhance settings modal for cache duration configuration
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m41s
2026-02-17 00:05:30 +07:00
denozord 21bd829979 refactor(PingServices): refine interface selection logic for ping operations, accommodating home routers without gateways
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
2026-02-16 23:53:54 +07:00
denozord c0d35c2e51 refactor(PingServices): update server selection logic to include home routers and improve error handling for ping services
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m39s
2026-02-16 23:47:24 +07:00
denozord ee357ed59a feat(SettingsModal): enhance server selection for ping services with autocomplete input and load server list from API
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s
2026-02-16 23:40:33 +07:00
denozord 838ca6b220 feat(PingServices): implement UI settings for ping services configuration and enhance ping functionality through RouterOS
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m34s
2026-02-16 23:35:25 +07:00
denozord dfcbdb35be feat(PingServices): add endpoint and dashboard component for measuring RTT to key services (Google, Cloudflare, Yandex, Instagram)
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m39s
2026-02-16 23:24:51 +07:00
denozord a3023daac9 refactor(MikrotikConfig): extend MikroTik credentials handling to support home routers and update related error messages for improved clarity
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s
2026-02-16 23:04:43 +07:00
denozord 7af6719f4b refactor(App): adjust dropdown z-index to ensure proper layering above sidebar and below modals for improved user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s
2026-02-16 23:02:41 +07:00
denozord dd94f9f887 refactor(PageHeader): update actions positioning to display below the title for improved layout and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m55s
2026-02-16 22:51:35 +07:00
denozord 1f49b02ba0 refactor(PageHeaderActions): restructure actions layout and enhance button visibility for improved user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s
2026-02-16 22:45:06 +07:00
denozord 0935eacd3f refactor(ServerManager): enhance actions layout and button styling for improved user experience and interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s
2026-02-16 22:28:47 +07:00
denozord f2c3562000 refactor(PageHeader): update actions positioning and layout for improved visibility and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m55s
2026-02-16 22:23:27 +07:00
denozord 6fad114b22 refactor(CommunitiesManager): enhance button actions and layout for improved user interaction and functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m57s
2026-02-16 22:18:04 +07:00
denozord f380be5d21 refactor(CommunitiesManager): update tab navigation style to improve layout and user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m51s
2026-02-16 22:12:23 +07:00
denozord 87f8283191 refactor(CommunitiesManager): simplify layout structure and enhance notification handling for improved user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m49s
2026-02-16 22:08:02 +07:00
denozord f13b532f1a refactor(BillingManager): update button groups and styling for improved layout and user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m53s
2026-02-16 21:55:43 +07:00
denozord 915304f800 refactor(BillingManager): improve layout and styling of subscription section for better user interaction and visual consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m59s
2026-02-16 21:43:48 +07:00
denozord 3dbdb6f794 fix(BillingManager): close conditional rendering for modal windows to ensure proper display
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m51s
2026-02-16 21:30:55 +07:00
denozord 08a64b5be4 refactor(BillingManager): add tab navigation for subscriptions and payments, enhancing user experience and data visibility
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 58s
2026-02-16 21:28:58 +07:00
denozord 5af5abb02d refactor(FilterManager): simplify notification handling and improve layout structure for enhanced user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m0s
2026-02-16 21:19:05 +07:00
denozord 8ad4f23a49 refactor(FilterManager): streamline server selection UI and enhance button actions for improved user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m55s
2026-02-16 21:07:48 +07:00
denozord 5c57e160fd refactor(ServerManager): adjust offcanvas width and maxWidth for improved layout and responsiveness
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m0s
2026-02-16 19:48:59 +07:00
denozord 46c9515ed1 refactor(CommunitiesManager): integrate PageHeader component for enhanced layout and consistency in community management
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2026-02-16 19:46:56 +07:00
denozord a6cf9f7130 refactor(ServerManager): integrate PageHeaderActions for enhanced action handling and UI consistency in server management
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m57s
2026-02-16 19:39:28 +07:00
denozord cf6d8b61a3 refactor(PageHeader): standardize header component across various managers by adding icons for improved visual consistency and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m53s
2026-02-16 19:26:56 +07:00
denozord 9784ec581e refactor(ServerSidebar): enhance text handling in filters for improved readability and layout consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m50s
2026-02-16 19:21:40 +07:00
denozord 75d2366462 refactor(ServerManager): implement offcanvas for filters and update ServerSidebar for embedded usage to enhance UI flexibility and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m53s
2026-02-16 19:17:13 +07:00
denozord 700e6674e5 refactor(ASNsNewManager): enhance JSX formatting and structure for improved readability and consistency in component layout
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m50s
2026-02-16 19:02:37 +07:00
denozord 4ce3bf4a9f refactor(IPRangesManager): improve code readability and structure by standardizing JSX formatting and enhancing layout consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m4s
2026-02-16 18:52:37 +07:00
denozord f8713e0bf9 refactor(ASNsNewManager): replace fragment with div for improved structure and consistency in component rendering
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m0s
2026-02-16 18:22:50 +07:00
denozord 23c863714c refactor(IPRangesManager): replace fragment with div for improved structure and consistency in component rendering
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m0s
2026-02-16 18:19:46 +07:00
denozord 3fade5f1c3 refactor(ASNsNewManager): replace div with fragment for improved rendering efficiency and component structure
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 57s
2026-02-16 18:15:37 +07:00
denozord 7509d1c781 refactor(ASNsNewManager): replace fragment with div for improved structure and consistency in component rendering
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 54s
2026-02-16 18:11:46 +07:00
denozord 1c953ed9d8 refactor(Managers): replace modal implementation with FormModal for community editing in ASNs, Domains, and IPRanges managers to enhance code maintainability and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 54s
2026-02-16 18:07:48 +07:00
denozord 59082aa0d0 refactor(Managers): update card header layout in ASNs, Domains, and IPRanges managers for improved visual consistency and enhanced user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 55s
2026-02-16 17:58:47 +07:00
denozord 6efe497e18 refactor(Managers): implement modal editing for community fields in ASNs, Domains, and IPRanges managers to enhance user experience and streamline editing process
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m55s
2026-02-16 17:55:32 +07:00
denozord 57db189375 refactor(Managers): enhance button group styling in tables for improved visual consistency and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m53s
2026-02-16 17:50:48 +07:00
denozord 38ece61b31 refactor(Managers): standardize button group styling and layout across ASNs, Domains, and IPRanges managers for improved UI consistency and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m50s
2026-02-16 17:42:57 +07:00
denozord a80740b802 refactor(Managers): remove S3MetaBar component and standardize pagination placement in ASNs, Domains, and IPRanges managers for improved UI consistency and readability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m51s
2026-02-16 17:37:16 +07:00
denozord dbc94d71a0 refactor(Managers): apply flat card styling across ASNs, Domains, and IPRanges managers for enhanced visual consistency and improved user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m1s
2026-02-16 17:28:51 +07:00
denozord eca744999c refactor(Managers): integrate LastSaved component and optimize button layout for enhanced UI consistency and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m2s
2026-02-16 17:22:48 +07:00
denozord cfcb44d5f0 refactor(DomainsNewManager): integrate LastSaved component and streamline button placement for improved UI consistency and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m57s
2026-02-16 17:18:24 +07:00
denozord 571ec06522 refactor(App): remove mobile navbar for cleaner layout and improved readability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m3s
2026-02-16 17:06:35 +07:00
denozord 698d42ce31 refactor(Managers): remove Breadcrumbs component from various managers for cleaner UI and improved readability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m57s
2026-02-16 16:58:24 +07:00
denozord ebec5c613f refactor(App): update vertical sidebar footer layout and styling for improved accessibility and user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m55s
2026-02-16 16:44:16 +07:00
denozord 0e0f4380d7 refactor(App): enhance vertical sidebar layout and styling for improved usability and consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m19s
2026-02-16 16:38:26 +07:00
denozord 62387aa6d5 refactor(DomainsNewManager): remove unnecessary closing div for cleaner markup and improved readability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m55s
2026-02-16 16:25:00 +07:00
denozord a322de4129 refactor(Tables): implement flat table styling across ASNs, Domains, and IPRanges managers for improved visual consistency and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 58s
2026-02-16 16:20:01 +07:00
denozord 94efee905a refactor(Managers): integrate AddItemModal into ASNs, Domains, and IPRanges managers for improved item addition functionality and UI consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m57s
2026-02-16 16:09:05 +07:00
denozord 3a7578ce26 refactor(ServerSidebar): implement new filter section and sidebar styling for improved user interaction and organization
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2026-02-16 16:03:28 +07:00
denozord af8af84ab7 refactor(DataTable): enhance table styling and functionality with fixed action column, improved striped rows, and new props for UX customization
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s
2026-02-16 15:56:28 +07:00
denozord 1fe9cf4794 refactor(App): update vertical sidebar styling to a consistent dark theme, enhancing visual coherence and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m1s
2026-02-16 15:48:33 +07:00
denozord 01a15d97c6 refactor(App): improve vertical sidebar styling and dropdown behavior for enhanced user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m53s
2026-02-16 15:41:13 +07:00
denozord 336a5b42a6 refactor(workflows): replace hardcoded webhook URL with secret for improved security in Docker publish workflows
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m50s
2026-02-16 15:31:47 +07:00
denozord 5cc19b8c3e refactor(App): enhance vertical sidebar navigation with icon integration and improved dropdown item styling for better user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m41s
2026-02-16 15:21:46 +07:00
denozord b87af004ee refactor(App): update layout and styling for vertical sidebar navigation and page header, enhancing user experience and accessibility
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m32s
2026-02-16 15:16:13 +07:00
denozord 8a8a55e7b6 feat(MikrotikTools): add traceroute and ping mode selection with reset and run buttons for enhanced user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m53s
2026-02-13 00:17:55 +07:00
denozord 79ca6e2d05 refactor(MikrotikTools): replace gateway input with card-based target selection for improved UI and user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m30s
2026-02-13 00:09:50 +07:00
denozord 34a52ed826 refactor(MikrotikTools): enhance UI layout and styling for server and gateway selection, improving readability and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m38s
2026-02-13 00:01:44 +07:00
denozord c7f55dfee9 feat(MikrotikTools): add popular targets for ping/traceroute with icons for enhanced user selection
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m17s
2026-02-12 23:52:42 +07:00
denozord caa566aa8f feat(MikrotikTools): implement gateway selection feature with enhanced UI for improved user experience and server-specific filtering
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m24s
2026-02-12 23:43:44 +07:00
denozord 87064cb89d Revert "refactor(MikrotikTools): streamline parameters section layout and button actions for traceroute and ping, enhancing user interaction"
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m4s
This reverts commit 9f058e5811.
2026-02-12 23:34:36 +07:00
denozord 9f058e5811 refactor(MikrotikTools): streamline parameters section layout and button actions for traceroute and ping, enhancing user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m2s
2026-02-12 23:28:55 +07:00
denozord e7650b961b refactor(MikrotikTools): redesign header and action buttons for traceroute and ping modes, improving layout and user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m8s
2026-02-12 23:19:35 +07:00
denozord 0387491ada feat(MikrotikTools): add traceroute tab state management for improved user navigation between results and analysis
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m35s
2026-02-12 23:09:57 +07:00
denozord d79f831032 refactor(MikrotikTools): improve server search input layout and styling for better usability
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m11s
2026-02-12 23:01:24 +07:00
denozord a42e4189c3 refactor(MikrotikTools): update UI text and layout for server check functionality, enhancing user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m6s
2026-02-12 22:51:39 +07:00
denozord 73bc623fdf feat(MikrotikTools): add ping functionality alongside traceroute with mode selection and result handling
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m54s
2026-02-12 22:33:43 +07:00
denozord dd089453bc refactor(MikrotikTools): improve table layout and enhance row display with severity indicators for traceroute results
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m18s
2026-02-12 22:22:53 +07:00
denozord 2d6c90ab31 fix(MikrotikTools): update gateway card display to show country and provider in subtitle, adjust layout for improved readability
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m5s
2026-02-12 17:33:05 +07:00
denozord d76355b6ef feat(MikrotikTools): enhance gateway selection UI with card display and add address format toggle for traceroute results
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m56s
2026-02-12 17:06:38 +07:00
denozord 9fbda4f4e5 feat(MikrotikTools): implement server search functionality and enhance UI for jumphost selection in traceroute
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m5s
2026-02-12 16:48:26 +07:00
denozord 337a4ce944 feat(MikrotikTools): add configurable DNS usage option in traceroute for flexible user preferences
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m23s
2026-02-12 16:40:13 +07:00
denozord 56b264a724 feat(MikrotikTools): add toggle for DNS/IP display in traceroute results for enhanced user control
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m39s
2026-02-12 16:29:37 +07:00
denozord 635b791118 feat(MikrotikTools): enhance traceroute output with reverse DNS support and improved IP handling
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m28s
2026-02-12 16:14:06 +07:00
denozord 1e28fa33f5 feat(MikrotikTools): deduplicate traceroute results and handle empty rows for improved accuracy
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m40s
2026-02-12 16:06:22 +07:00
denozord 3b60a5885b feat(MikrotikTools): limit packet count per hop in traceroute functionality for improved control
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m31s
2026-02-12 15:56:00 +07:00
denozord ba006edec0 feat(MikrotikTools): enhance traceroute functionality to dynamically determine interface based on gateway IP
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m45s
2026-02-12 15:39:34 +07:00
denozord 6077d3bc5b feat(MikrotikTools): add traceroute functionality via MikroTik API and integrate new tools route in frontend
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m36s
2026-02-12 15:24:53 +07:00
denozord c75f5f6b82 feat(EasySwitchManager, FilterManager): implement location-based ping requests to refresh data on route changes
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m56s
2026-02-12 13:34:03 +07:00
denozord 219ca7450c feat(FilterManager): enhance server metadata retrieval to support short names for improved matching
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m24s
2026-02-12 12:09:58 +07:00
denozord c792645a55 feat(PingContext): integrate PingProvider for centralized ping management across components
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m5s
2026-02-12 11:57:10 +07:00
denozord 793b8acaea feat(FilterManager): enhance GatewayAutocomplete with ping functionality and prefetching for selected server
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m5s
2026-02-12 11:46:50 +07:00
denozord 6b8e060d54 feat(MikrotikBackupsManager): add large modal for displaying backup content with improved UI
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 56s
2026-02-11 16:00:48 +07:00
denozord bdc7dd2bb3 refactor(MikrotikBackupsManager): optimize diff computation and synchronize scrolling between panels
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m25s
2026-02-11 15:51:05 +07:00
denozord 562f09753b feat(MikrotikBackupsManager): implement diff modal for enhanced configuration comparison
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m48s
2026-02-11 15:42:05 +07:00
denozord 91fd3baccc refactor(MikrotikBackupsManager): enhance diff panel styles for better contrast and visibility
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m41s
2026-02-11 14:29:30 +07:00
denozord 9a51265a4f refactor(MikrotikBackupsManager): update diff panel styles for improved readability and visual clarity
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m45s
2026-02-11 14:18:03 +07:00
denozord a2910832be feat(MikrotikBackupsManager): add side-by-side diff functionality for configuration comparison
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m47s
2026-02-11 14:12:07 +07:00
denozord ba6c7b9618 refactor(mikrotikBackupScheduler): improve logging for backup scheduler and remove initial backup on container restart
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m27s
2026-02-11 13:20:24 +07:00
denozord 6a90f4079d feat(ping): add ping caching functionality with configurable duration in UI settings
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m42s
2026-02-11 11:48:32 +07:00
denozord 1651700609 feat(ping): implement dynamic ping target selection from UI settings or default value 2026-02-11 11:45:37 +07:00
denozord 896f1ee3a7 refactor(EasySwitchManager): enhance text display by removing truncation and adding word break for better readability
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m23s
2026-02-11 01:21:26 +07:00
denozord 497940b29f refactor(EasySwitchManager): update gateway display to include comments and improve layout consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m59s
2026-02-11 01:13:59 +07:00
denozord f6d32b3efa refactor(EasySwitchManager): simplify gateway information display by consolidating layout and enhancing clarity of comments
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m33s
2026-02-11 01:03:14 +07:00
denozord 583f9b255e refactor(EasySwitchManager): enhance gateway display with improved layout and additional information including IP and provider details
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m25s
2026-02-11 00:53:48 +07:00
denozord 343626c350 refactor(EasySwitchManager): optimize ping requests by using parallel execution and immediate UI updates for improved responsiveness
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m42s
2026-02-11 00:22:13 +07:00
denozord a0bf4a7a9f feat(ping): refine interface selection logic to prioritize 'ether1' and 'ether2' before falling back to other 'ether' interfaces
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m49s
2026-02-10 23:38:15 +07:00
denozord fc66c7db0b feat(ping): implement additional fallback for interface resolution by selecting the first 'ether' interface if none is specified
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m9s
2026-02-10 23:15:21 +07:00
denozord e914b0003c feat(ping): add fallback mechanism for interface resolution by querying MikroTik routes based on gateway IP
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m8s
2026-02-10 23:03:55 +07:00
denozord de6fd2dddb feat(ping): enhance interface resolution logic by integrating gateway checks for improved tunnel interface matching
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m54s
2026-02-10 22:54:50 +07:00
denozord 18e488a8e8 feat(ping): add logging for ping requests and CLI command for debugging purposes
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m22s
2026-02-10 22:39:38 +07:00
denozord b6df4af920 refactor(ping): remove srcAddress parameter from ping functions to simplify configuration
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m39s
2026-02-10 22:17:38 +07:00
denozord 78bf17920b fix(ping): return ping data directly in JSON format for frontend compatibility
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m6s
2026-02-10 22:00:05 +07:00
denozord b3739388e5 feat(EasySwitchManager): integrate network-config gateways into server data loading and filtering logic for improved accuracy
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m4s
2026-02-10 21:49:55 +07:00
denozord ecee4ab601 feat(Mikrotik): add ping endpoint and implement prefetching of ping data for gateways in EasySwitchManager
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m18s
2026-02-10 21:34:52 +07:00
denozord 8ae99a0d69 refactor(GatewayAutocomplete): remove selected gateway info display to streamline component and reduce clutter
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 4m0s
2026-02-10 20:54:28 +07:00
denozord 5bfd7ff5de fix(FilterManager): update serverCountry and serverProvider assignments to fallback on selectedServer properties for improved accuracy
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m28s
2026-02-10 19:11:32 +07:00
denozord 09c343a272 refactor(FilterManager): rename country and provider variables to serverCountry and serverProvider for clarity and consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m3s
2026-02-10 19:02:24 +07:00
denozord 4155963d97 feat(GatewayAutocomplete): simplify country flag rendering and display provider information for selected gateways
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m36s
2026-02-10 18:53:39 +07:00
denozord e2b03c7689 feat(GatewayAutocomplete): adjust layout for gateway cards and display additional gateway information to enhance user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 4m38s
2026-02-10 18:44:52 +07:00
denozord 56c181f697 feat(GatewayAutocomplete): add selected gateway info and free gateway warning to enhance user feedback
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m32s
2026-02-10 18:33:53 +07:00
denozord 4e2f4ed7a5 refactor(GatewayAutocomplete): remove commented code to streamline FilterManager component
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m42s
2026-02-10 18:28:04 +07:00
denozord 5dfe76f1df feat(GatewayAutocomplete): enhance UI layout and improve gateway card display for better user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m19s
2026-02-10 18:22:34 +07:00
denozord 5e0397f667 feat(GatewayAutocomplete): improve gateway filtering logic to show full list when a gateway is selected, enhancing user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m36s
2026-02-10 18:15:38 +07:00
denozord b2b3933d2d refactor(GatewayAutocomplete): remove unnecessary div to clean up layout and improve code readability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m40s
2026-02-10 17:54:40 +07:00
denozord 5ec477b23a feat(GatewayAutocomplete): replace dropdown with card-based selection for gateways, enhancing user experience and accessibility
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m6s
2026-02-10 17:38:35 +07:00
denozord 4afef9abf8 feat(MikrotikBackupsManager): enhance backup management UI with detailed backup table and improved server selection settings
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m27s
2026-02-10 17:27:47 +07:00
denozord 7b4b65053b feat(workflows): add container update webhook trigger to Docker publish workflows
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m5s
2026-02-10 16:01:48 +07:00
denozord ce13ca7845 feat(MikrotikBackupsManager): add server search functionality to enhance server selection UI
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 4m6s
2026-02-10 15:52:05 +07:00
denozord 7bfa7be02d feat(MikrotikBackupsManager): implement server selection UI with search, sorting, and pagination features
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m43s
2026-02-10 15:38:44 +07:00
denozord 0063e79a73 feat(mikrotikBackupRoutes): add encryption and decryption for backup content to enhance security
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m42s
2026-02-10 01:54:38 +07:00
denozord 77ba6c1fa3 refactor(mikrotikBackupRoutes, mikrotikApplyService): improve error handling and logging for backup and file retrieval processes
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m2s
2026-02-10 01:48:07 +07:00
denozord db31690df5 refactor(mikrotikApplyService): enhance file retrieval logic with improved error handling and support for large files
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m53s
2026-02-10 01:38:19 +07:00
denozord 0c40d2726b refactor(mikrotikBackupRoutes, mikrotikBackupScheduler, mikrotikApplyService): replace direct export command with file-based export method for improved reliability and error handling
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m59s
2026-02-10 01:29:39 +07:00
denozord f2372ef4c9 refactor(MikrotikBackupsManager): update condition to check for selectedServerId instead of selectedServer for improved clarity
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m10s
2026-02-10 01:21:44 +07:00
denozord 3d3afa4d3e refactor(MikrotikBackupsManager): rename selectedServer state to selectedServerId and update related logic for improved clarity and consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m23s
2026-02-10 01:05:07 +07:00
denozord 7f2c4c3c8f refactor(mikrotikBackupRoutes, mikrotikBackupScheduler): update backup export command to use /rest/execute with compact terse script for improved reliability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m19s
2026-02-09 20:51:43 +07:00
denozord 89d35cd7ef feat(mikrotikBackup): add endpoint and UI for manual backup execution
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 4m39s
2026-02-09 19:50:29 +07:00
denozord 5eecb8d070 feat(mikrotikBackupScheduler, MikrotikBackupsManager): enhance backup server management by integrating UI settings and adding server selection functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m58s
2026-02-09 17:06:49 +07:00
denozord 0d9542d7e9 refactor(App, MikrotikBackupsManager): remove unused IconDatabaseBackup and update icon reference for Mikrotik backups
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m28s
2026-02-09 16:51:21 +07:00
denozord bcbbdc19e4 fix(MikrotikBackupsManager): correct backup server description formatting for consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m10s
2026-02-09 16:38:59 +07:00
denozord 275f2f6929 feat(mikrotikBackup): add MikroTik backup functionality with S3 integration and implement backup scheduler
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m2s
2026-02-09 15:13:56 +07:00
denozord 3d9394e27d fix(mikrotikApplyService, ServerModal): update default port for MikroTik connections to 80 and ensure proper number conversion for port input
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m41s
2026-02-04 15:07:13 +07:00
denozord 21d2707a1b refactor(BillingManager): streamline next payment date calculation by integrating it into data normalization process
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m21s
2026-02-04 12:33:48 +07:00
denozord d8457b1497 feat(BillingManager): implement next payment date calculation based on payment history and exchange rates
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m50s
2026-02-04 11:29:34 +07:00
denozord e5b72ca56f feat(BillingManager): add functionality to calculate next payment date by adding one month to the last payment date
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 4m46s
2026-02-04 10:26:19 +07:00
denozord 61c87e69ef fix(mikrotikConfigRoutes): enhance script execution logic to find script ID before running
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m9s
2026-02-03 23:47:39 +07:00
denozord d4a1643a25 fix(mikrotikConfigRoutes): correct script execution command to use '.id' for script name in API call
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m54s
2026-02-03 22:17:49 +07:00
denozord 7815c5354f fix(mikrotikConfigRoutes): update script execution command to use the correct REST API endpoint for running scripts
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m3s
2026-02-03 22:11:28 +07:00
denozord 335af01116 feat(mikrotikConfigRoutes, FilterManager): add run-script endpoint and integrate BGP update functionality in FilterManager
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m7s
2026-02-03 21:56:30 +07:00
denozord 99c027f96f fix(mikrotikApplyService): filter routes by comment prefix when removing to ensure accurate route deletion
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m55s
2026-02-03 21:43:08 +07:00
denozord 6e4a0aae27 refactor(mikrotikInterfaceGenerator, NetworkConfigManager): improve comments for route removal and enhance request logging format in API responses
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m0s
2026-02-03 21:35:04 +07:00
denozord 1dfdfcfeec fix(mikrotikConfigRoutes, mikrotikApplyService): update default port for MikroTik API connections from 443 to 80 to align with REST API standards
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m59s
2026-02-03 21:26:47 +07:00
denozord 999bd14590 refactor(mikrotikApplyService): transition from node-routeros to REST API client for MikroTik configuration; enhance request handling and error management
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m10s
2026-02-03 21:19:01 +07:00
denozord e7c527f787 chore(mikrotikConfigRoutes): remove deprecated MikroTik configuration routes file
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m49s
2026-02-03 21:02:19 +07:00
denozord 76294ab2c9 refactor(NetworkConfigManager, mikrotikConfigRoutes): streamline logging and error handling in MikroTik configuration application; remove redundant debug log setup
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m8s
2026-02-03 20:50:31 +07:00
denozord ce107db7ea feat(MikrotikConfig, NetworkConfigManager): implement streaming logs for MikroTik configuration application and enhance error handling in response processing
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m2s
2026-02-03 20:40:32 +07:00
denozord b374faa593 feat(MikrotikConfig): add ping endpoint and enhance applyMikrotikConfig error handling with safe response method
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m57s
2026-02-03 20:22:19 +07:00
denozord b581c47c28 feat(MikrotikConfig): enhance applyMikrotikConfig and NetworkConfigManager with detailed request logging and error handling improvements
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m57s
2026-02-03 20:13:08 +07:00
denozord e5f093b65f refactor(MikrotikConfig): implement timeout handling and improve error management in applyMikrotikConfig function
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m51s
2026-02-03 20:05:05 +07:00
denozord f56150694b fix(NetworkConfigManager): update button click handler to use arrow function for improved readability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m15s
2026-02-03 19:55:40 +07:00
denozord 3319f9c329 refactor(NetworkConfigManager): update API call for applying MikroTik configuration to use fetch; improve error handling and response processing
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m55s
2026-02-03 19:48:05 +07:00
denozord 94314c383b feat(MikrotikConfig): add API endpoint for applying MikroTik configuration; update frontend to support configuration application with dry-run option and modal interface
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2026-02-03 19:26:49 +07:00
denozord 1a27217676 feat(MikrotikConnection): add MikroTik connection testing functionality in ServerModal; update API routes and README for new encryption key and connection endpoint
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m13s
2026-02-03 19:12:32 +07:00
denozord 0ea0f376a6 feat(MikrotikConfig): enhance MikroTik configuration generation with support for 'text' and 'json' formats; add new API endpoints for interface and recursive route generation
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m37s
2026-02-03 17:46:30 +07:00
denozord 5b96325184 feat(NetworkConfigManager): implement recursive gateway code generation for enhanced routing configuration, including error handling for missing gateways and parent references
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m45s
2026-01-25 21:43:53 +07:00
denozord ac34faaf48 feat(NetworkConfigManager, SettingsModal): add global PTR zone settings management, allowing users to define DNS replacements in both global and interface-specific contexts for enhanced configuration flexibility
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m47s
2026-01-25 21:35:33 +07:00
denozord f4f79a8f01 fix(NetworkConfigManager): refine IP conflict checking logic to ensure uniqueness of gateways on the same server, improving conflict notification accuracy
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m44s
2026-01-25 21:32:51 +07:00
denozord 49ee872e4a fix(NetworkConfigManager): correct comment formatting for IP conflict checking logic to enhance code clarity
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m54s
2026-01-25 21:24:43 +07:00
denozord e0163fa968 fix(NetworkConfigManager): update IP conflict checking logic to differentiate between gateway and interface IPs, ensuring accurate conflict notifications during gateway creation
Publish Fast Tabler Docker image / build-and-push-fast (push) Has been cancelled
2026-01-25 21:23:03 +07:00
denozord 2ee43d0538 feat(NetworkConfigManager): implement PTR zone replacement functionality, allowing dynamic DNS adjustments for improved server configuration and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Has been cancelled
2026-01-25 21:21:39 +07:00
denozord c7ebe0943b feat(NetworkConfigManager, GatewayAutocompleteInput): add serverId handling for gateway filtering, enhancing configuration accuracy and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m2s
2026-01-25 21:06:15 +07:00
denozord d583757882 feat(NetworkConfigManager): add IP normalization function to enhance gateway IP generation, ensuring correct handling of full and partial IP inputs
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m44s
2026-01-25 19:25:52 +07:00
denozord 80c91d9c54 feat(NetworkConfigManager): enhance gateway IP generation logic to support direct and recursive gateways, improving template-based configuration and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m43s
2026-01-25 19:17:20 +07:00
denozord 604d45ed92 feat(NetworkConfigManager): add gateway templates feature for streamlined gateway creation and configuration, enhancing user experience and efficiency
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m44s
2026-01-25 14:20:56 +07:00
denozord 335f7c9b58 refactor(NetworkConfigManager): replace action buttons with compact icon buttons wrapped in tooltips for improved UI clarity and user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s
2026-01-25 14:15:39 +07:00
denozord 4feb84338a refactor(NetworkConfigManager): improve layout and styling of server cards for better user experience and readability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m47s
2026-01-25 14:08:30 +07:00
denozord 3b858441a2 refactor(NetworkConfigManager): enhance UI structure and styling for server cards, improving readability and user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m42s
2026-01-25 14:00:54 +07:00
denozord cc77a21ba1 feat(NetworkConfigManager): add server-wide MikroTik code generation for interfaces, improving bulk configuration capabilities and user feedback
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s
2026-01-25 12:26:26 +07:00
denozord 6f1d307ee0 feat(NetworkConfigManager): implement category-based accordion view for gateways and interfaces, improving organization and user navigation
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m51s
2026-01-25 03:46:35 +07:00
denozord 0310147044 feat(NetworkConfigManager): add GRE interface list support for home routers, enhancing tunnel management and configuration clarity
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m45s
2026-01-25 03:16:02 +07:00
denozord 2bdad8445e feat(NetworkConfigManager): add support for local-address parameter in GRE tunnel configuration for home routers, improving network setup clarity
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m44s
2026-01-24 18:21:28 +07:00
denozord cd4b95bb8f feat(NetworkConfigManager): implement MikroTik code generation for individual gateways, enhancing user experience with detailed route generation and error handling
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m41s
2026-01-24 13:59:17 +07:00
denozord 6ced966274 fix(NetworkConfigManager): enhance validation for /30 networks by ensuring generated local and remote IPs are always checked against network and broadcast address conflicts, improving IP generation reliability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m43s
2026-01-24 01:18:05 +07:00
denozord 32255c6a3a fix(NetworkConfigManager): enhance validation for /30 networks by ensuring local and remote IPs do not conflict with network and broadcast addresses, improving IP generation accuracy
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m41s
2026-01-24 00:52:35 +07:00
denozord 4ff9ae0f46 fix(NetworkConfigManager): refine validation logic for /30 networks to ensure local IPs are correctly derived and within valid ranges, enhancing overall IP generation accuracy
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m48s
2026-01-24 00:32:51 +07:00
denozord 4c9cb7eab6 fix(NetworkConfigManager): improve validation for /30 networks by ensuring local and remote IPs do not conflict with network and broadcast addresses
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
2026-01-23 19:18:06 +07:00
denozord f60611e689 feat(NetworkConfigManager): implement MikroTik code generation for individual and selected interfaces, enhancing user interaction and flexibility in network configuration
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m37s
2026-01-23 15:59:11 +07:00
denozord b7a55e4241 fix(NetworkConfigManager): enhance IP generation logic for /30 networks to ensure valid address ranges and prevent conflicts with reserved IPs
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m39s
2026-01-23 14:24:36 +07:00
denozord cef571cc40 feat(NetworkConfigManager): add MTU field to interface configuration, allowing for better network performance tuning and flexibility in settings
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m19s
2026-01-23 00:15:02 +07:00
denozord 9b74b77391 refactor(NetworkConfigManager): remove unused MTU settings from interface creation logic to streamline code and improve clarity
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m24s
2026-01-23 00:01:25 +07:00
denozord 2157c6a3c7 feat(NetworkConfigManager): add MTU configuration support for interfaces, enhancing flexibility in tunnel settings and improving user input handling
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m9s
2026-01-22 23:59:01 +07:00
denozord 26f8a97c21 fix(NetworkConfigManager): update GRE tunnel configuration to use DNS domain for remote address if available, improving flexibility in server address handling
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m33s
2026-01-22 23:34:09 +07:00
denozord 3ab4c4095a feat(ServerManagement): add 'home' server type to server configurations and update related components to handle billing and filtering exclusions for home routers 2026-01-22 23:30:04 +07:00
denozord 5114e9dd81 refactor(NetworkConfigManager): clean up parentGateways and remove deprecated parentGatewayId field for improved data handling
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
2026-01-22 22:23:09 +07:00
denozord 0cfeaec417 feat(NetworkConfigManager): normalize interface names and descriptions to uppercase for consistency; enhance data handling during interface creation and updates
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m39s
2026-01-22 22:14:19 +07:00
denozord ded2116b43 feat(NetworkConfigManager): enhance IP generation logic to ensure unique local and remote IP pairs; improve handling of alternative IP generation for tunnel previews, ensuring better usability and error handling
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m42s
2026-01-22 22:05:16 +07:00
denozord fc27599222 feat(NetworkConfigManager): refine tunnel preview functionality to support multiple server pairs; enhance IP address generation logic for better handling of local and remote IPs, ensuring uniqueness and proper display in the UI
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
2026-01-22 21:28:02 +07:00
denozord ba0f0273e3 feat(NetworkConfigManager): improve IP address generation by passing used IPs to ensure uniqueness across tunnel creations; enhance error messaging for IP allocation failures
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m46s
2026-01-22 21:17:50 +07:00
denozord cd2c0d275e feat(NetworkConfigManager): enhance IP address generation logic to validate local and remote IPs against CIDR; ensure unique IPs and improve handling for /30 networks
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m39s
2026-01-22 21:04:24 +07:00
denozord ced727e702 feat(NetworkConfigManager): implement dynamic server pair management for multiple tunnel creation; enhance UI for server selection and interface naming, ensuring proper validation and error handling
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m43s
2026-01-22 20:49:27 +07:00
denozord 297ea4af34 feat(NetworkConfigManager): enhance tunnel creation logic to support multiple tunnels with dynamic server pairing and improved IP address generation; update UI to reflect changes in tunnel count and server selection
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m46s
2026-01-22 20:37:04 +07:00
denozord 84b4f7e795 refactor(NetworkConfigManager): remove unused IP generation and CIDR checking functions to streamline code and improve maintainability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s
2026-01-22 20:20:48 +07:00
denozord e57d1316d2 feat(NetworkConfigManager): add support for multiple tunnel creation; implement dynamic interface naming and IP address generation for specified tunnel count
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 54s
2026-01-22 20:14:27 +07:00
denozord 2f09c9ebbb feat(NetworkConfigManager): add IP pool selection for IP address generation; enhance IP address generation logic to utilize selected IP pools and improve user interface for IP pool management
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 54s
2026-01-22 20:10:41 +07:00
denozord 82c9ff8a04 feat(Docker, encryption): add mandatory ENCRYPTION_KEY environment variable for IPSec password encryption; update documentation and Docker configurations to reflect changes
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m18s
2026-01-22 20:07:29 +07:00
denozord 86e8b5fece fix(NetworkConfigManager, server): enhance IPSec password encryption and decryption error handling; add validation for encrypted password format and improve logging for better debugging
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m21s
2026-01-22 19:54:32 +07:00
denozord 999285801d refactor(NetworkConfigManager): update GRE tunnel creation to use ipsec-secret for IPSec configuration; streamline code generation for improved clarity
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m39s
2026-01-22 19:44:50 +07:00
denozord cb0c370c10 fix(NetworkConfigManager): improve IPSec password validation and handling; ensure non-empty passwords are processed and add warnings for empty responses
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s
2026-01-22 19:35:25 +07:00
denozord 76a8b867f8 feat(NetworkConfigManager): extend IPSec password handling for GRE tunnels; update modal behavior and enhance MikroTik code generation for GRE with optional IPSec support
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
2026-01-22 19:22:34 +07:00
denozord a77ba76ff8 feat(NetworkConfigManager, server): update IPSec password handling; return password ID in response and enhance modal behavior for better user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m19s
2026-01-22 19:17:24 +07:00
denozord d223981a93 feat(NetworkConfigManager, server): implement IPSec password management; add CRUD operations for IPSec passwords in the backend and integrate UI components for creating, editing, and deleting passwords in the frontend
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m21s
2026-01-22 19:07:34 +07:00
denozord 39f3510512 feat(NetworkConfigManager): add optional interface names for servers; enhance interface name generation logic with DNS parsing and user-defined names
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m41s
2026-01-22 18:56:50 +07:00
denozord 011740df1f feat(NetworkConfigManager): add interface template creation feature; implement modal for generating interfaces from server templates with conflict checking and preview functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m36s
2026-01-22 18:43:59 +07:00
denozord 566dd1d1bb refactor(NetworkConfigManager): adjust column layout for Local and Remote IP fields; change width from 4 to 3 for improved UI consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m35s
2026-01-22 18:38:12 +07:00
denozord 8f5cd700c3 feat(NetworkConfigManager): add IP Pool management features; implement filtering and rendering for IP Pools, including server grouping and enhanced UI elements
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
2026-01-22 18:33:40 +07:00
denozord b94b7c67e2 feat(NetworkConfigManager): enhance IP generation logic for /30 networks; support paired local and remote IP generation
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m39s
2026-01-22 17:49:47 +07:00
denozord 96d8e9bb67 feat(NetworkConfigManager): add PTR zone display for servers; enhance interface with additional server information
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s
2026-01-22 17:44:31 +07:00
denozord 5c2edac25a feat(NetworkConfigManager): implement IP suggestion feature for local and remote IPs; add functionality to generate free private IPs from defined ranges
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m39s
2026-01-22 17:40:14 +07:00
denozord 51999d485c refactor(NetworkConfigManager): streamline IP registry processing; enhance conflict detection and improve interface display with clearer server information
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
2026-01-22 17:33:31 +07:00
denozord a076f981bb feat(NetworkConfigManager): enhance IP registry with server information; display unique servers for each IP and improve interface management
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m36s
2026-01-22 17:28:00 +07:00
denozord 0442942b44 feat(NetworkConfigManager): add IP registry management features; implement IP conflict checking and filtering for better interface management
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m36s
2026-01-22 17:18:14 +07:00
denozord 0109d5ea5f refactor(NetworkConfigManager): adjust layout dimensions for improved consistency; simplify DNS display by removing unnecessary badges and buttons
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m39s
2026-01-22 15:50:53 +07:00
denozord 29e0679038 refactor(NetworkConfigManager): update server DNS display logic for improved clarity; localize indicator type for better user understanding
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m36s
2026-01-22 15:40:39 +07:00
denozord 849247002c refactor(NetworkConfigManager): migrate parentGatewayId to parentGateways array for recursive gateways; enhance data handling and UI updates for improved clarity and functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m39s
2026-01-22 15:23:18 +07:00
denozord 8f81cf9645 refactor(NetworkConfigManager): enhance layout and styling for improved responsiveness; adjust flex properties for better alignment and display of elements
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m46s
2026-01-22 15:05:49 +07:00
denozord e9a029e568 refactor(NetworkConfigManager): adjust card layout for items display; improve responsiveness by modifying Bootstrap grid column size
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m36s
2026-01-22 14:56:41 +07:00
denozord cc69bc53b9 refactor(NetworkConfigManager): update card layout for items display; enhance responsiveness with Bootstrap grid system
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m37s
2026-01-22 14:50:23 +07:00
denozord b79dced147 refactor(NetworkConfigManager): improve layout and styling of gateway and interface cards; enhance indicator display and text formatting for better clarity
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
2026-01-22 14:43:43 +07:00
denozord 00bf38fbb9 refactor(NetworkConfigManager): enhance gateway and interface card rendering with improved layout and indicator display; update MikroTik code generation for gateway values
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s
2026-01-22 14:31:08 +07:00
denozord 0bd2a07dd2 feat(NetworkConfigManager): implement code copying functionality for individual blocks and all generated MikroTik code; update server grouping display for improved clarity
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m39s
2026-01-22 14:05:52 +07:00
denozord 75a7778148 refactor(NetworkConfigManager): improve MikroTik code copying functionality and group blocks by server; enhance interface card rendering for dual server support
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
2026-01-22 13:55:06 +07:00
denozord 9328e205a0 refactor(NetworkConfigManager): enhance server display with card layout for improved UI; remove unused PreviewConfigModal import
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m39s
2026-01-22 13:42:47 +07:00
denozord b0ccbc5c54 refactor(NetworkConfigManager): update MikroTik code generation to return structured blocks for interface addresses and recursive routes; enhance modal display for generated code
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 58s
2026-01-22 13:28:47 +07:00
denozord fdc8b1f458 feat(NetworkConfigManager): add optional second interface name for dual server configurations; enhance UI to support interface naming for both servers
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
2026-01-22 13:18:46 +07:00
denozord cf1dbfce37 feat(NetworkConfigManager): add support for dual server configurations in network interfaces; enhance MikroTik code generation and UI to display both servers
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
2026-01-22 13:07:39 +07:00
denozord bf08e91d4b fix(NetworkConfigManager): update parent type handling and improve IP assignment for gateways and interfaces; enhance logging for missing parent configurations
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m33s
2026-01-22 12:41:20 +07:00
denozord ea6220d955 feat(NetworkConfigManager, FilterManager): replace select with GatewayAutocompleteInput for parent gateway selection; update display to include comments in gateway details
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m43s
2026-01-22 12:29:29 +07:00
denozord 64f4b76d3f feat(NetworkConfigManager, FilterManager): enhance gateway display and validation; update UI to show comments and improve IP handling in gateway configuration
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m43s
2026-01-22 12:16:08 +07:00
denozord d3bf307d6c feat(NetworkConfigManager, FilterManager): update gateway configuration to use IP as name; enhance filtering logic and UI to accommodate changes in gateway structure
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m44s
2026-01-22 12:05:07 +07:00
denozord bfae6def11 feat(NetworkConfigManager): remove name field from gateway configuration; add description field and update option display to include IP or description
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m41s
2026-01-22 11:26:55 +07:00
denozord 03a47e7346 feat(NetworkConfigManager, PreviewConfigModal): improve clipboard functionality for MikroTik code; add title and description props to preview modal
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m37s
2026-01-22 11:14:54 +07:00
denozord 682c2ce96b feat(NetworkConfigManager): add MikroTik code generation for recursive routes; implement modal for previewing and copying generated code
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m43s
2026-01-22 11:08:21 +07:00
denozord 1e6f5badec feat(NetworkConfigManager): enhance gateway configuration with new fields for country and type; add recursive gateway support and improve filtering options
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s
2026-01-22 10:59:50 +07:00
denozord e5f576d11e feat(FilterManager): integrate network configuration gateways for enhanced filtering; implement async loading of gateways from /network-config endpoint
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m37s
2026-01-22 10:45:03 +07:00
denozord c925a22a99 style(NetworkConfigManager): update badge styles for gateways, interfaces, and pools to reflect active tab state
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
2026-01-22 10:40:24 +07:00
denozord 48419d577a refactor(NetworkConfigManager): remove provider and country from gateway configuration; integrate server details for improved filtering and UI display
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
2026-01-22 10:32:54 +07:00
denozord c04b2d726b feat(NetworkConfigManager): enhance server management with serverId integration, improved filtering options, and UI updates for gateways and interfaces
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
2026-01-22 10:24:19 +07:00
denozord d949e011c9 feat(NetworkConfig): add network configuration routes and validation; integrate into server and frontend for managing IP, interfaces, and gateways
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m27s
2026-01-22 10:08:51 +07:00
denozord dace11a407 feat(FilterManager): add functionality to copy filter rules from another server; implement modal for server selection and enhance jumphost filtering logic
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m41s
2026-01-21 18:50:55 +07:00
denozord fe665ebc1f feat(server): expand server types to include BGP and DNS; enhance validation for required fields and tunnel requirements in server configuration
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m25s
2026-01-20 19:04:55 +07:00
denozord ff7c26f6f7 feat(ServerModal, serverUtils): implement conditional tunnel selection based on server type; add needsTunnel utility function for improved server configuration
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m41s
2026-01-20 18:49:25 +07:00
denozord a04dc72b00 feat(serverUtils): add BGP router option to server type selection for enhanced server management
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m39s
2026-01-20 17:39:11 +07:00
denozord 86d4ecb7e2 feat(BillingManager, ServerManager): implement automatic synchronization of billing entries for new servers; enhance server management modals for improved error handling and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m41s
2026-01-20 17:31:41 +07:00
denozord d173112bae feat(ServerManager): automate billing entry creation for new servers; synchronize existing servers with billing records
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m45s
2026-01-20 17:20:47 +07:00
denozord ed34332df8 feat(BillingManager): add view mode toggle for billing display; implement card and table layouts for improved user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
2026-01-20 17:13:12 +07:00
denozord 8f1668668c feat(FilterManager): enhance filter row display with priority indicators and update alert message for clarity
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m42s
2026-01-20 17:02:23 +07:00
denozord 1ec3b3cf24 feat(FilterManager): implement drag-and-drop functionality for filter reordering; integrate @dnd-kit libraries for enhanced user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s
2026-01-20 16:53:49 +07:00
denozord f1aecf7529 refactor(ServerCard): improve layout and styling; adjust spacing and font sizes for better readability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s
2026-01-20 16:44:30 +07:00
denozord cf3b1a2028 refactor(ServerManager, ServerCard): remove unused icons and streamline server card layout; enhance button styles and improve responsiveness
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
2026-01-20 16:29:28 +07:00
denozord 4b8fcef17a feat(server): add cache control headers for API responses to prevent browser caching
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m24s
2026-01-20 16:09:53 +07:00
denozord c6599efb21 feat(serverConfigs): enhance jumphost server filtering logic for backward compatibility
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m24s
2026-01-20 15:53:34 +07:00
denozord fda6e936aa feat(serverConfigs): implement GET and POST routes for jumphost server management, including validation and filtering logic
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m22s
2026-01-20 15:42:10 +07:00
denozord f2e237840b refactor: rename server-configs.json to servers.json and update related references
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m22s
2026-01-20 15:35:48 +07:00
denozord b6c314507d fix: remove Aktiven badge, add config migration with legacyId
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m43s
2026-01-20 15:26:06 +07:00
denozord 8db03ca5b9 fix: add hardcoded legacyId mapping for vpsville migration
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m43s
2026-01-20 15:14:04 +07:00
denozord 9a1991c23c feat: FilterManager auto-migrate filters with legacyId
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m42s
2026-01-20 15:06:41 +07:00
denozord 549c695117 feat(FilterManager): синхронизация с jumphost'ами из раздела Серверы
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m46s
2026-01-20 14:56:42 +07:00
denozord 660513aee6 fix(BulkActions): новые читаемые стили кнопок с кастомным CSS
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m41s
2026-01-20 14:34:35 +07:00
denozord 20db630395 fix(UI): улучшен hover на кнопках и читаемость bulk actions
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s
2026-01-20 14:24:25 +07:00
denozord 28a9d0efa8 feat(ServerCard): улучшена компоновка карточки сервера - визуальные группы
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s
2026-01-20 14:13:32 +07:00
denozord 75f44d2c4b fix: update gateway badges to use readable light styles
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m48s
2026-01-20 14:02:34 +07:00
denozord 8d34822d12 refactor: Simplify ServerCard component layout and styles for improved readability and user experience, including badge updates and streamlined conditional rendering.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m43s
2026-01-20 13:54:45 +07:00
denozord 6a96c368e3 feat: Update ServerCard and ServerSidebar components for improved UI and functionality, including enhanced filtering options, updated country flag handling, and refined layout for better user experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m50s
2026-01-20 13:47:42 +07:00
denozord acd4e80435 feat: Refactor ServerManager component to enhance server management features, including improved filtering, sorting, pagination, and bulk actions, while adding new server and connection management functionalities.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m33s
2026-01-20 13:33:03 +07:00
denozord 02bc19770f feat: Allow saving of empty URL list and update success messages accordingly, enhancing user feedback and functionality in AutoUrlManager.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m5s
2026-01-11 19:33:49 +07:00
denozord d7c769c655 chore: Remove unused testing dependencies and configuration files to streamline the project and reduce clutter in the codebase.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m33s
2025-12-24 16:22:57 +07:00
denozord bda9b4fc27 chore: Update Dockerfile for faster npm installation and enhance package.json with additional testing dependencies to improve development and testing capabilities.
Publish Fast Tabler Docker image / build-and-push-fast (push) Has been cancelled
2025-12-24 16:19:52 +07:00
denozord 248b78ce96 chore: Optimize Dockerfile for faster npm installation and update package.json to include additional build checks and dependencies for improved development workflow.
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m15s
2025-12-24 16:17:00 +07:00
denozord b46471c4b4 chore: Update TypeScript configuration to enable composite projects, generate declaration files, and include vitest.config.ts for improved type checking and module management.
Publish Fast Tabler Docker image / build-and-push-fast (push) Has been cancelled
2025-12-24 16:07:55 +07:00
denozord 8bd39c554e feat: Enhance API mutation hooks with optimistic updates for domains, IP ranges, ASNs, servers, communities, server filters, and billing, improving user experience by providing immediate feedback on data changes.
Publish Fast Tabler Docker image / build-and-push-fast (push) Has been cancelled
2025-12-24 16:02:26 +07:00
denozord 27dade3eda feat: Implement date formatting utility functions and update components to use formatted timestamps for improved readability and consistency across the application.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m47s
2025-12-24 15:52:01 +07:00
denozord 6e7a63dd28 refactor: Update Pagination component to ensure hooks are called unconditionally and improve early return logic for better performance and readability.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m49s
2025-12-24 15:35:06 +07:00
denozord 6dfcaa584d feat: Integrate Pagination component into ASNsNewManager, DomainsNewManager, IPRangesManager, and FilterManager for improved navigation and user experience across multiple pages of data.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m46s
2025-12-24 15:29:46 +07:00
denozord f4a6ceed08 feat: Add useShortValue prop to CommunityAutocompleteInput and related components for improved community input handling, allowing for shorter community values to be processed.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m50s
2025-12-24 15:15:43 +07:00
denozord 9cdcd054e7 feat: Add toggle for grouping communities by tags in EasySwitchManager, enhancing user control over community display and improving organization.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m3s
2025-12-07 21:36:40 +07:00
denozord 260985573c feat: Group communities by tags in EasySwitchManager for improved organization and visibility, enhancing user experience with clear categorization and display of communities without tags.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s
2025-12-07 19:46:09 +07:00
denozord 37b34f718c style: Further refine EasySwitchManager card layout by adjusting column classes for improved responsiveness and visual consistency across various screen sizes.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m57s
2025-12-07 15:10:50 +07:00
denozord e3ad529411 style: Adjust EasySwitchManager card layout to improve responsiveness by modifying column classes, enhancing visual consistency across different screen sizes.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m57s
2025-12-07 14:04:29 +07:00
denozord 367d4fd520 style: Refine EasySwitchManager card layout and styling for improved visual consistency, including adjustments to padding, font sizes, and element dimensions to enhance user experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m0s
2025-12-07 13:57:06 +07:00
denozord a54d5c9009 fix: Adjust key parsing logic in EasySwitchManager to correctly split serverId and community by the first colon, ensuring accurate data handling for gateway assignments.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m5s
2025-12-07 13:34:29 +07:00
denozord f1dfb13d8e fix: Update filter processing logic in EasySwitchManager to ensure all communities are tracked, improving data integrity and consistency in gateway assignments.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m2s
2025-12-07 13:29:13 +07:00
denozord c06294d9f2 fix: Correct layout in EasySwitchManager by adding a missing closing div, ensuring proper rendering of components and improving overall UI structure.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m2s
2025-12-07 13:21:35 +07:00
denozord ec6c348b3e feat: Enhance EasySwitchManager with initial state tracking and server expansion toggle, improving user interaction and state management for gateway selections.
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m15s
2025-12-07 13:18:08 +07:00
denozord cb32b1bd71 feat: Implement server filter updates in EasySwitchManager with success and error notifications, enhancing user feedback and improving error handling during filter application.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m4s
2025-12-07 13:09:02 +07:00
denozord 4a3426e04f refactor: Update EasySwitchManager layout for improved community and gateway display, enhancing visual structure and user interaction with better card organization and positioning.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m55s
2025-12-07 12:56:41 +07:00
denozord 7204c4045f feat: Add toggle for displaying only configured filters in EasySwitchManager, enhancing user control over community visibility and improving filtering logic for better user experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m1s
2025-12-07 04:51:51 +07:00
denozord ab8b30fbd4 style: Update EasySwitchManager UI for improved layout and visual consistency, including adjustments to card dimensions, padding, and font sizes for better user experience. 2025-12-07 04:50:47 +07:00
denozord 489bdedfbb feat: Refactor EasySwitchManager to streamline data loading and enhance community and gateway management, including improved error handling and UI updates for better user experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m54s
2025-12-07 04:42:38 +07:00
denozord b13f412b79 feat: Update Easy Switch functionality to improve server and gateway management, including enhanced grouping by servers, refined UI components, and updated data structure for better user experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m59s
2025-12-07 04:31:12 +07:00
denozord 7091660b80 feat: Add Easy Switch feature to the application, including new route and menu item for improved navigation and user experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m6s
2025-12-07 04:23:29 +07:00
denozord 2859947a34 feat: Enhance FilterManager display logic by adding detailed community and gateway information, improving user experience with additional context for selected filters.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2025-12-07 03:44:29 +07:00
denozord 7eb8af3b9e refactor: Remove debug logging and normalize community values in CommunityAutocomplete, ensuring consistent handling of baseAS and improving display logic for selected communities.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m51s
2025-12-07 03:38:08 +07:00
denozord 91b6c398ef feat: Enhance community search logic in CommunityAutocomplete by adding detailed logging and refining matching criteria for improved accuracy in filtering, including handling of baseAS and colon-separated values.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m55s
2025-12-07 03:29:24 +07:00
denozord 45bd395870 feat: Introduce baseAS state management in FilterManager and SettingsModal, enhancing community search normalization and user settings configuration for improved filtering accuracy.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m54s
2025-12-07 03:23:36 +07:00
denozord 2bdb67a715 feat: Improve community search functionality in CommunityAutocomplete by refining matching logic for partial values and colon-separated entries, enhancing user experience in filtering.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m2s
2025-12-07 03:00:07 +07:00
denozord f14b7989b5 feat: Enhance community filtering logic in CommunityAutocomplete to support partial matches and improved search capabilities, including handling values with colons.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m5s
2025-12-07 02:57:50 +07:00
denozord 584fd78f23 feat: Add CommunityAutocomplete component to FilterManager for enhanced community selection, featuring dynamic filtering, grouping by category, and improved user experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m1s
2025-12-07 02:44:51 +07:00
denozord f938dbb47a feat: Refactor gateway selection logic in FilterManager to exclude fallback gateways, improving accuracy in gateway management and enhancing user experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m12s
2025-12-07 02:27:25 +07:00
denozord aa76966f89 feat: Add GatewayAutocomplete component to FilterManager for improved gateway selection, enhancing user experience with dynamic filtering and selection capabilities.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m10s
2025-12-07 02:19:17 +07:00
denozord a8c6307123 feat: Refactor FilterManager to streamline gateway selection and remove inline editing, enhancing user experience and simplifying filter management.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m4s
2025-12-07 02:08:02 +07:00
denozord 53bf828797 feat: Implement gateway normalization and inventory server loading in FilterManager, enhancing gateway management and improving server configuration reliability.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m6s
2025-12-07 01:54:50 +07:00
denozord 9fb92c19cb feat: Enhance EditServerModal initialization logic by ensuring local state is synchronized with props only on modal open, improving state management and preventing unnecessary updates.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m11s
2025-12-07 01:41:22 +07:00
denozord 62ceb95233 feat: Refactor EditServerModal to enhance gateway management with improved state handling, including dedicated functions for updating, adding, and removing gateways, ensuring robust server configuration management.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m12s
2025-12-07 01:35:10 +07:00
denozord 15dc034088 feat: Safeguard gateway access in EditServerModal by ensuring localServer's gateways are properly initialized as an array, enhancing reliability in server configuration management.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m5s
2025-12-07 01:26:25 +07:00
denozord c9cb325eb1 feat: Safeguard gateway access in EditServerModal by ensuring localServer's gateways are properly initialized as an array, enhancing reliability in server configuration management.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m24s
2025-12-07 01:19:02 +07:00
denozord 866c474ac2 refactor: Remove unnecessary gateways initialization in ServerManager to streamline code and improve clarity in handling server configurations.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m16s
2025-12-07 01:13:17 +07:00
denozord cb657fe56e feat: Safeguard gateway addition in EditServerModal by implementing error handling during state updates, ensuring robust server configuration management.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m8s
2025-12-07 00:58:26 +07:00
denozord e14839ca21 feat: Refactor gateway addition logic in EditServerModal to ensure safe state updates and improve clarity in handling gateways, enhancing server configuration reliability.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m7s
2025-12-07 00:47:35 +07:00
denozord f246d5407a feat: Improve gateway handling in ServerManager by ensuring safe access to gateways array and enhancing the addition of new gateways, promoting better server configuration reliability.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m1s
2025-12-07 00:33:51 +07:00
denozord 7b6e7580fb feat: Enhance gateway initialization in EditServerModal by ensuring a default primary gateway is created when no gateways are specified, improving server configuration reliability.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2025-12-07 00:27:03 +07:00
denozord 83b3fd96aa feat: Add gateway management UI in EditServerModal, allowing users to add, edit, and remove gateways with validation for primary gateway selection, enhancing server configuration experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m59s
2025-12-07 00:19:41 +07:00
denozord cfa08dd32f feat: Improve gateway handling in ServerManager by enforcing validation for jumphost and exit server types, ensuring at least one primary gateway is specified during server addition and updates.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m5s
2025-12-07 00:13:40 +07:00
denozord 7bc5b48af7 feat: Enhance server validation in server.js and improve gateway management in ServerManager and FilterManager, ensuring proper gateway configuration for jumphost and exit server types.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m2s
2025-12-06 23:56:08 +07:00
denozord d39d9adb2d feat: Implement gateway management in FilterManager, adding functionality for creating, updating, and validating gateways, enhancing server configuration capabilities and user experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m41s
2025-12-06 23:38:31 +07:00
denozord 85e4f9c22d feat: Add country flag display for inventory servers in FilterManager to enhance visual representation and user experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m10s
2025-12-06 23:23:42 +07:00
denozord 8a99309773 feat: Add inventory server fetching and display logic in FilterManager to enhance server detail presentation and improve user experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2025-12-06 23:15:12 +07:00
denozord 6f1f8b368e refactor: Update FilterManager layout to utilize a grid system for server display, enhancing visual organization and responsiveness of server cards.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m4s
2025-12-06 22:59:43 +07:00
denozord c84d4367b1 refactor: Revise FilterManager layout to enhance server selection interface with improved button actions and card design, streamlining user interaction and visual presentation.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s
2025-12-06 22:12:32 +07:00
denozord a058df010a refactor: Revise FilterManager layout with enhanced column structure and card design for server details, improving user interaction and visual clarity.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s
2025-12-06 22:02:04 +07:00
denozord 2b8470a7ca refactor: Revamp FilterManager UI for server selection, enhancing layout and interaction with improved card design and search functionality.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m53s
2025-12-06 21:55:29 +07:00
denozord 312c442d7c refactor: Update FilterManager to improve query parameter handling and server selection logic, enhancing user experience with more precise state management.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m0s
2025-12-06 21:51:16 +07:00
denozord 1566d3f998 feat: Enhance CommunitiesManager and FilterManager with improved tab styling and query parameter prefill functionality for better user experience and server selection.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m2s
2025-12-06 20:35:15 +07:00
denozord cba1615cdf feat: Introduce normalization function for community values in CommunitiesManager to ensure consistent formatting and improve input validation across various fields.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m54s
2025-12-06 19:27:55 +07:00
denozord 64f83a239f fix: Update placeholder and help text in CommunitiesManager for clearer input format guidance, specifying valid formats for user entries.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2025-12-06 19:20:30 +07:00
denozord 2e6f3d9263 feat: Enhance CommunitiesManager with filter usage tracking and UI improvements, including a refresh button and search prefill from query parameters for better user experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m1s
2025-12-06 18:51:14 +07:00
denozord b79d733868 refactor: Clean up brace counting logic in Mikrotik config validation for improved readability and maintainability.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m0s
2025-12-06 15:00:13 +07:00
denozord 8d6d2bbe25 refactor: Update Mikrotik config validation logic to improve brace counting accuracy and enhance AutoUrlManager UI with consistent card layouts, larger icons, and refined button styles for better user experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m50s
2025-12-06 14:57:41 +07:00
denozord fee74c377f feat: Improve currency exchange rate fetching in BillingManager by adding a primary source from the Central Bank of Russia and a fallback to an alternative API, along with updates to currency formatting for better precision.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m17s
2025-12-06 14:50:45 +07:00
denozord ad75ca8ff5 refactor: Update BillingManager UI for better readability and consistency, including larger icons, improved card layouts, and enhanced button styles.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m0s
2025-12-06 03:10:36 +07:00
denozord e086d04131 refactor: Update BillingManager UI components for improved styling and user experience, including badge colors, button styles, and status selection method.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m3s
2025-12-06 03:00:35 +07:00
denozord d60eb96d00 feat: Revamp BillingManager with enhanced filtering, sorting, and payment management features, including urgency indicators and improved UI components for better user experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m54s
2025-12-06 02:48:18 +07:00
denozord f8f8be44ca feat: Introduce server type management in ServerManager, enhancing server configuration with type selection and validation, and update example-servers.json to include server types for improved data consistency.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m27s
2025-12-06 02:16:37 +07:00
denozord 7ccb3118aa feat: Add currency conversion functionality in BillingManager to calculate total payments and monthly costs in RUB, improving financial reporting accuracy.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m46s
2025-12-01 23:51:05 +07:00
denozord b18c681552 feat: Refactor GraphView to include highlighted edges and connection counts for improved visualization and user interaction, enhancing the overall graph experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m41s
2025-12-01 23:22:53 +07:00
denozord e922f3ec78 feat: Add search functionality and node selection in GraphView, enhancing user interaction with server filtering and improved edge highlighting for better visualization.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m43s
2025-12-01 23:17:33 +07:00
denozord edbf2f4299 feat: Enhance GraphView with localStorage integration for node position persistence and improve edge text styling for better visualization in the topology graph.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s
2025-12-01 16:56:27 +07:00
denozord 007c7514e1 feat: Implement server connections API and enhance GraphView with custom edge styling for improved visualization and user interaction in the topology graph.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m23s
2025-12-01 16:49:00 +07:00
denozord 840865e074 feat: Enhance GraphView and ServerManager components by adding node highlighting and a modal for quick connection creation, improving user interaction and visual feedback in the graph interface.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m37s
2025-12-01 16:36:32 +07:00
denozord e73d5e2721 feat: Replace status selection in BillingManager forms with button group for improved user interaction and visual clarity, enhancing the overall UI experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m35s
2025-12-01 16:28:40 +07:00
denozord ded3d8c618 feat: Replace purpose selection in BillingManager forms with PurposeSelectInput component, streamlining the UI and improving code maintainability by removing hardcoded options.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
2025-12-01 16:20:37 +07:00
denozord b3295ebfae feat: Integrate ServerAutocompleteInput in BillingManager forms for optional server selection, enhancing user experience with improved search functionality and contextual help.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m37s
2025-12-01 16:13:36 +07:00
denozord 4655fc67cc feat: Add server selection functionality to BillingManager, including fetching server data and integrating server options in add/edit forms for improved billing management.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s
2025-12-01 16:07:28 +07:00
denozord bf4b1b1554 feat: Enhance ServerManager component by adding optional external and internal IP fields, improving server addition and editing functionality, and updating validation messages for better user experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m45s
2025-12-01 15:57:53 +07:00
denozord c3e0f99ff1 refactor: Streamline BillingManager layout by consolidating filter and action buttons. Enhanced UI for better accessibility and user experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m42s
2025-12-01 13:51:51 +07:00
163 changed files with 43382 additions and 9652 deletions
+110
View File
@@ -0,0 +1,110 @@
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H5","location":"frontend/src/App.jsx:route-change","message":"Route changed in MainLayout","data":{"pathname":"/easy-switch","layout":"fluid"},"timestamp":1771918574971}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/alerts"},"timestamp":1771918574974}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/communities"},"timestamp":1771918574974}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/communities","status":200},"timestamp":1771918575149}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/servers"},"timestamp":1771918575150}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/alerts","status":200},"timestamp":1771918575158}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/network-config"},"timestamp":1771918575261}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/servers","status":200},"timestamp":1771918575260}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/network-config","status":200},"timestamp":1771918575342}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/server-configs"},"timestamp":1771918575343}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/server-configs","status":200},"timestamp":1771918575431}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/server-filters/srv-5"},"timestamp":1771918575432}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/server-filters/msk-vpsville"},"timestamp":1771918575432}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/server-filters/msk-ihor"},"timestamp":1771918575431}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/server-filters/msk-dc"},"timestamp":1771918575432}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/server-filters/nsk-servhost"},"timestamp":1771918575432}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/server-filters/msk-vpsville","status":200},"timestamp":1771918575519}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/server-filters/msk-ihor","status":200},"timestamp":1771918575641}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/server-filters/srv-5","status":200},"timestamp":1771918575645}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/server-filters/nsk-servhost","status":200},"timestamp":1771918575648}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/server-filters/msk-dc","status":200},"timestamp":1771918575649}
{"sessionId":"378b5f","runId":"post-fix","hypothesisId":"H6","location":"frontend/src/contexts/PingContext.jsx:requestPings","message":"Ping batch scheduled","data":{"scope":"easy-switch","count":20,"force":false},"timestamp":1771918575650}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575651}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575651}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575651}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575651}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575651}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575651}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575652}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575652}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575652}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575652}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575652}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575652}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575653}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575652}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575652}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575652}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575653}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575653}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575653}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575653}
{"sessionId":"378b5f","runId":"post-fix","hypothesisId":"H6","location":"frontend/src/contexts/PingContext.jsx:requestPings","message":"Ping batch scheduled","data":{"scope":"easy-switch","count":0,"force":false},"timestamp":1771918575667}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918581171}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918581264}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918585199}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918585287}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918589219}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918589300}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918590962}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918593250}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918593325}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918594992}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918596225}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918599017}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918600849}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918601366}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918603038}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918604878}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918606344}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918608918}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918610433}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918614512}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/alerts"},"timestamp":1771918634979}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/alerts","status":200},"timestamp":1771918635314}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/alerts"},"timestamp":1771918694983}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/alerts","status":200},"timestamp":1771918695315}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/alerts"},"timestamp":1771919865915}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/communities"},"timestamp":1771919865915}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H5","location":"frontend/src/App.jsx:route-change","message":"Route changed in MainLayout","data":{"pathname":"/easy-switch","layout":"fluid"},"timestamp":1771919865914}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/servers"},"timestamp":1771919865941}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/communities","status":200},"timestamp":1771919865941}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/alerts","status":200},"timestamp":1771919865942}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/servers","status":200},"timestamp":1771919865956}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/network-config"},"timestamp":1771919865956}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/network-config","status":200},"timestamp":1771919866039}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/server-configs"},"timestamp":1771919866040}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/server-filters/msk-ihor"},"timestamp":1771919866114}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/server-filters/msk-vpsville"},"timestamp":1771919866114}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/server-configs","status":200},"timestamp":1771919866113}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/server-filters/srv-5"},"timestamp":1771919866114}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/server-filters/msk-dc"},"timestamp":1771919866114}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/server-filters/nsk-servhost"},"timestamp":1771919866114}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/server-filters/msk-ihor","status":200},"timestamp":1771919866194}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/server-filters/msk-vpsville","status":200},"timestamp":1771919866308}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/server-filters/srv-5","status":200},"timestamp":1771919866314}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/server-filters/msk-dc","status":200},"timestamp":1771919866322}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/server-filters/nsk-servhost","status":200},"timestamp":1771919866323}
{"sessionId":"378b5f","runId":"post-fix","hypothesisId":"H6","location":"frontend/src/contexts/PingContext.jsx:requestPings","message":"Ping batch scheduled","data":{"scope":"easy-switch","count":20,"force":false},"timestamp":1771919866323}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866325}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866326}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866326}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866326}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866326}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866326}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866326}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866326}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866327}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866326}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866327}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866327}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866327}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866327}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866327}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866327}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866327}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866327}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866327}
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866327}
{"sessionId":"378b5f","runId":"post-fix","hypothesisId":"H6","location":"frontend/src/contexts/PingContext.jsx:requestPings","message":"Ping batch scheduled","data":{"scope":"easy-switch","count":0,"force":false},"timestamp":1771919866389}
@@ -0,0 +1,248 @@
---
name: Анализ и оптимизация Router Lists UI
overview: Комплексный анализ проекта Router Lists UI (RouterOS Manager) — управление BGP-списками, MikroTik и S3. План включает предложения по новым фичам и оптимизациям архитектуры, производительности и качества кода.
todos: []
isProject: false
---
# Анализ проекта Router Lists UI
## Текущее состояние проекта
**Router Lists UI** — fullstack-приложение для управления BGP-списками (домены, IP-диапазоны, ASN), фильтрами и конфигурациями MikroTik на базе S3 (Yandex Object Storage).
### Архитектура
```mermaid
flowchart TB
subgraph Frontend [Frontend - Vite + React]
App[App.jsx]
Pages[20+ страниц]
Hooks[useApiQuery, useErrorHandler]
API[api.js + axios]
end
subgraph Backend [Backend - Express]
Server[server.js]
Routes[30+ роутов]
S3Service[s3Service]
Schedulers[4 планировщика]
end
subgraph External [Внешние сервисы]
S3[S3 / Yandex Object Storage]
MikroTik[MikroTik RouterOS API]
BGP[BGP Background Service]
end
App --> Pages
Pages --> Hooks
Hooks --> API
API -->|REST| Server
Server --> Routes
Routes --> S3Service
S3Service --> S3
Routes --> MikroTik
Server --> Schedulers
```
### Технологический стек
| Слой | Технологии |
| -------- | ------------------------------------------------------------------------------- |
| Frontend | React 19, Vite 7, Tabler, TanStack Query, React Router, Recharts, @xyflow/react |
| Backend | Express, AWS SDK (S3), Pino, Prometheus, Helmet, Rate Limit |
| Инфра | Docker, Gitea Actions, S3 |
### Сильные стороны
- Продуманная обработка ошибок (retry, ErrorBoundary, NetworkErrorHandler)
- ETag/If-None-Match для условных GET и кеширования
- Soft-locks для предотвращения гонок при редактировании
- Prometheus-метрики, health/ready endpoints
- i18n (RU/EN), темы (light/dark), два варианта layout
- Command Palette, горячие клавиши
- Типы в `frontend/src/types/index.ts` (частичная типизация)
---
## Рекомендуемые фичи
### 1. Аутентификация и авторизация (высокий приоритет)
**Проблема:** API открыт без авторизации — любой с доступом к сети может изменять данные.
**Решение:**
- JWT или session-based auth (express-session + passport)
- Роли: `viewer` (только чтение), `editor` (CRUD), `admin` (всё + настройки)
- Middleware на backend для проверки токена/сессии
- Страница логина, защищённые роуты на frontend
### 2. Аудит-лог изменений
- Логирование всех мутаций (кто, когда, что изменил)
- Хранение в S3 (`audit/YYYY-MM-DD.jsonl`) или отдельная таблица
- UI: страница «История изменений» с фильтрами по ресурсу, пользователю, дате
### 3. Уведомления и алерты
- Расширить [AlertsContext](frontend/src/contexts/AlertsContext.jsx): email/Telegram при критичных событиях
- Настраиваемые триггеры: сервер недоступен, превышение лимита ресурсов
- Интеграция с webhook (например, Slack/Discord)
### 4. Экспорт/импорт в разных форматах
- Экспорт доменов/ASN/IP в CSV, JSON, plain text
- Импорт из CSV с маппингом колонок
- Bulk-операции: «заменить community для выбранных» уже есть — добавить «импорт из файла»
### 5. Расширенный поиск и фильтры
- Полнотекстовый поиск по доменам (при больших объёмах — индексация на backend)
- Фильтры: по community, по дате добавления (если добавить метаданные)
- Сохранённые фильтры (уже есть [SavedFilters](frontend/src/components/SavedFilters.jsx)) — синхронизация с backend
### 6. Дашборд и отчёты
- Кастомные виджеты на Dashboard (drag-and-drop)
- Отчёты: топ доменов по community, динамика изменений
- Scheduled reports (PDF/email по расписанию)
### 7. API-документация
- OpenAPI/Swagger для backend API
- Интерактивная документация (Swagger UI) на `/api-docs`
- Генерация клиента для внешних интеграций
### 8. Мобильная версия (PWA)
- Service Worker для офлайн-просмотра кешированных данных
- Адаптивные таблицы (уже используется Tabler)
- Установка как PWA (manifest.json)
---
## Оптимизации проекта
### Frontend
#### 1. Code splitting и lazy loading
**Проблема:** Все 20+ страниц импортируются синхронно в [App.jsx](frontend/src/App.jsx) — большой начальный бандл.
**Решение:**
```javascript
// Вместо: import Dashboard from './Dashboard';
const Dashboard = lazy(() => import('./Dashboard'));
// Обернуть Routes в <Suspense fallback={<PageSkeleton />}>
```
Применить для тяжёлых страниц: `TrafficDashboard`, `NetworkMapDashboard`, `RouteOptimizerPage`, `FilterManager`, `MikrotikTools`, `OspfToolsPage`.
#### 2. Виртуализация таблиц
- Используется `@tanstack/react-virtual` — проверить, что все большие списки (domains, ip-ranges, asns) используют виртуализацию
- В [DomainsNewManager](frontend/src/DomainsNewManager.jsx) — пагинация на backend (`offset`/`limit`), но при `limit=0` загружаются все данные — рассмотреть серверную пагинацию для больших объёмов
#### 3. Оптимизация API-запросов
- На Easy Switch при открытии идёт множество параллельных `POST /mikrotik/ping` — добавить batch endpoint `POST /mikrotik/ping-batch` для снижения нагрузки
- Prefetch при наведении на пункты меню (React Query `prefetchQuery`)
- Дедупликация запросов к `/communities` — уже кешируется в React Query, но вызывается из многих компонентов
#### 4. Унификация работы с данными
- Часть страниц использует [useApiQuery](frontend/src/hooks/useApiQuery.js) (domains, ipRanges, asns, servers, communities), часть — ручные `useState` + `api.get/post`
- Мигрировать `DomainsNewManager`, `IPRangesManager`, `ASNsNewManager` на единые хуки из `useApiQuery` для консистентности и оптимистичных обновлений
#### 5. Миграция на TypeScript
- Есть [types/index.ts](frontend/src/types/index.ts), но большинство компонентов — `.jsx`
- Поэтапная миграция: сначала `api.js`, `useApiQuery.js`, затем страницы
- Включить `strict` в tsconfig для новых файлов
### Backend
#### 1. Кеширование S3-ответов
- Добавить in-memory cache (например, `node-cache`) для часто читаемых объектов (`communities`, `servers`, `filters`) с TTL 30–60 сек
- Уменьшит количество обращений к S3 при активной работе
#### 2. Пул соединений и таймауты
- Проверить настройки AWS SDK: connection pooling, таймауты для S3
- Для долгих операций (address-lists, speed-test) — рассмотреть фоновые задачи (очередь jobs)
#### 3. Валидация и санитизация
- Использовать единую схему валидации (Ajv) для всех JSON-эндпоинтов
- Добавить rate limit на чувствительные операции (apply config, run script)
#### 4. Структура роутов
- [server.js](backend/server.js) — ~450 строк, много роутов в одном файле
- Вынести группы роутов в отдельные файлы: `textDataRoutes`, `jsonDataRoutes`, `mikrotikRoutes` и подключать через `app.use('/api/...', router)`
### Инфраструктура и DevOps
#### 1. Тестирование
- **Сейчас:** `npm test` — заглушка, vitest упоминается в tsconfig, но тестов нет
- Добавить unit-тесты для: `validators`, `apiErrorHandler`, `s3Helpers`, `mikrotikConfig`
- E2E (Playwright) для критичных сценариев: логин, добавление домена, применение фильтра
#### 2. CI/CD
- Добавить шаг `npm run lint` и `npm run test` в Gitea Actions перед сборкой Docker
- Отдельный workflow для frontend: `npm run build` + проверка размера бандла
#### 3. Мониторинг
- Prometheus уже есть — добавить алерты (например, в Alertmanager) на `http_errors_total`, `http_request_duration_seconds`
- Health check: проверка доступности S3 в `/ready`
#### 4. Безопасность
- Регулярное обновление зависимостей (`npm audit`)
- Секреты (ENCRYPTION_KEY, AWS keys) — только через env, не в коде
- Рассмотреть Vault или аналог для production
### Документация и DX
- README — хороший, но API описан текстом; добавить ссылку на OpenAPI
- CONTRIBUTING.md с правилами коммитов, кодстайла
- Архитектурная диаграмма в репозитории (C4 или подобная)
---
## Приоритизация
| Категория | Действие | Приоритет |
| ------------------ | -------------------------- | --------- |
| Безопасность | Аутентификация | Высокий |
| Производительность | Lazy loading страниц | Высокий |
| Производительность | Batch ping endpoint | Средний |
| Качество | Unit-тесты | Высокий |
| Качество | Миграция на TS | Средний |
| UX | Аудит-лог | Средний |
| UX | Расширенный экспорт/импорт | Низкий |
| Инфра | CI: lint + test | Высокий |
| Инфра | S3 in-memory cache | Средний |
---
## Следующие шаги
1. Выбрать 2–3 пункта из плана для первой итерации
2. Для аутентификации — определиться со стратегией (JWT vs session, хранение пользователей)
3. Для lazy loading — создать `PageSkeleton` и обновить роуты в App.jsx
4. Для тестов — настроить Vitest, написать первые тесты для `validators` и `apiErrorHandler`
-55
View File
@@ -1,55 +0,0 @@
name: Publish Fast Tabler Docker image
on:
push:
branches:
- tabler
- v3
jobs:
build-and-push-fast:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: |
image=moby/buildkit:v0.13.2
- name: Log in to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.shts.su
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT }}
- name: Build and push Fast Tabler Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.fast
push: true
platforms: linux/amd64
cache-from: |
type=registry,ref=git.shts.su/${{ gitea.repository }}:tabler-fast
cache-to: type=inline
tags: |
git.shts.su/${{ gitea.repository }}:tabler-fast
git.shts.su/${{ gitea.repository }}:tabler-fast-${{ gitea.sha }}
labels: |
org.opencontainers.image.title=S3 Lists Manager with Tabler UI (Fast Build)
org.opencontainers.image.description=Modern S3 Lists Manager with Tabler UI design - Fast Alpine build
org.opencontainers.image.version=tabler-fast
org.opencontainers.image.revision=${{ gitea.sha }}
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
provenance: false
- name: Cleanup old images
run: |
echo "Skipping container registry cleanup (inline cache in image). Keep 3 latest image tags logic can be added with regctl if needed."
+39 -5
View File
@@ -4,6 +4,11 @@ on:
push:
branches:
- main
- v4
- v5
- tabler
- v3
workflow_dispatch:
jobs:
build-and-push:
@@ -25,11 +30,35 @@ jobs:
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT }}
- name: Build and push Docker image
# Ветка v5: в реестр только теги v5 / v5-<sha> (без latest)
- name: Build and push Docker image (v5)
if: ${{ gitea.ref_name == 'v5' }}
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
file: ./Dockerfile.fast
push: true
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
git.shts.su/${{ gitea.repository }}:v5
git.shts.su/${{ gitea.repository }}:v5-${{ gitea.sha }}
labels: |
org.opencontainers.image.title=RouterOS Manager
org.opencontainers.image.description=RouterOS Manager for domains and AS management
org.opencontainers.image.version=v5
org.opencontainers.image.revision=${{ gitea.sha }}
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
build-args: |
BUILDKIT_INLINE_CACHE=1
- name: Build and push Docker image (main и прочие ветки)
if: ${{ gitea.ref_name != 'v5' }}
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.fast
push: true
platforms: linux/amd64
cache-from: type=gha
@@ -38,10 +67,15 @@ jobs:
git.shts.su/${{ gitea.repository }}:latest
git.shts.su/${{ gitea.repository }}:${{ gitea.sha }}
labels: |
org.opencontainers.image.title=S3 Lists Manager
org.opencontainers.image.description=S3 Lists Manager for domains and AS management
org.opencontainers.image.title=RouterOS Manager
org.opencontainers.image.description=RouterOS Manager for domains and AS management
org.opencontainers.image.version=latest
org.opencontainers.image.revision=${{ gitea.sha }}
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
build-args: |
BUILDKIT_INLINE_CACHE=1
BUILDKIT_INLINE_CACHE=1
- name: Trigger container update webhook
continue-on-error: true
run: |
curl -X POST -sS --fail "${{ secrets.portainer_webhook }}"
+482
View File
@@ -0,0 +1,482 @@
# AI-READY DEVELOPMENT SPEC
## 1. SYSTEM OVERVIEW (СИСТЕМНЫЙ КОНТЕКСТ)
Система `router-lists-ui` управляет сетевыми данными и конфигурациями RouterOS/MikroTik: домены, ASN, IP-диапазоны, фильтры, серверные конфиги, бэкапы и мониторинг доступности/ресурсов. UI (`React + Vite`) работает с API (`Express`) через `/api/*`.
Фактическая модель хранения гибридная:
- справочники BGP (domains/asns/ip-ranges/communities/auto-urls) обслуживаются через EvoBGP API (`evobgpClient`);
- локальные конфиги/кеш/версии хранятся в SQLite (`better-sqlite3`) через слой совместимости `s3Service`/`blobStorage`;
- `.rsc` backup MikroTik хранится в локальной ФС (`MIKROTIK_BACKUP_DIR`).
### Ключевые бизнес-процессы
- Управление BGP-справочниками (CRUD через EvoBGP).
- Управление фильтрами (`global/simple`) в `app_filter_rules`.
- Управление серверами, server-configs, server-filters.
- Генерация/применение MikroTik-конфигов и диагностические операции (ping/traceroute/speed-test).
- Планировщики: network-map, ping-services, uptime-monitor, backups.
- История/rollback для versioned blob-объектов.
### Основные сущности
- `DomainCommunity`: `{ domain, community }`
- `AsnCommunity`: `{ domain(asn), type(community) }`
- `IpRangeCommunity`: `{ ipRange, community }`
- `Server`: jumphost/exit + gateways + creds
- `FilterRule`: `{ community, gateway, description, ...extra }` + `scope`
- `BlobObject`: logical key + body + etag + versions
- `UiSettings`
- `MikrotikBackup`
### Критические ограничения (НЕ НАРУШАТЬ)
- Нельзя заменять EvoBGP-данные локальной SQLite-логикой для `domains-new/asns/ip-ranges/communities/auto-urls`.
- Нельзя удалять/ломать ETag-конкурентность (`If-None-Match`, `If-Match`, `412`) в read/write API.
- Нельзя ломать namespace/ключи blob-объектов (`servers.json`, `filter-manager/*`, `bgp_data/rt_ui_settings.json` и т.д.) без миграционного плана.
- Нельзя отключать `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout`.
- Нельзя менять формат ответа ошибок `{ code, message, details, requestId }`.
---
## 2. REPOSITORY MAP (КАРТА РЕПОЗИТОРИЯ)
> Примечание: в текущем репозитории нет отдельных top-level директорий `/db`, `/services`, `/api`, `/agents`; их роль реализована внутри `backend/`.
### `/frontend`
- Назначение: UI-приложение (`React 19`, `react-router-dom`, `@tanstack/react-query`, `axios`, Tabler).
- Ключевые зоны:
- `src/App.jsx`: роутинг, layout, providers (`QueryClientProvider`, `NotifyProvider`, `PingProvider`, `AlertsProvider`).
- `src/lib/api.js`: единый HTTP-клиент, retry, ETag-based GET-cache, нормализация ошибок.
- `src/hooks/useApiQuery.js`: реактивный data-layer + optimistic updates.
- Правила изменений:
- Любой новый экран регистрировать в `App.jsx` routes + navigation.
- Новые API-вызовы делать только через `src/lib/api.js`.
- Мутации должны инвалидацировать/обновлять react-query cache.
- Зависимости: backend `/api`, browser localStorage (theme/lang/layout), глобальный notifier.
### `/backend`
- Назначение: API, интеграции, storage abstraction, schedulers.
- Ключевые зоны:
- `server.js`: composition root, middleware, endpoint registration.
- `routes/*.js`: endpoint-level orchestration.
- `services/*.js`: storage/EvoBGP/MikroTik/scheduler логика.
- `db/sqliteDb.js` + `db/migrations/*.sql`: DB init/migrations.
- `middleware/*`: unified errors + soft locks.
- Правила изменений:
- Новый endpoint добавлять через route/service, не встраивать бизнес-логику в `server.js`.
- Для persistent данных использовать `s3Service`/`filterRulesStorage`/`backupFsService` по назначению.
- Сохранять совместимость legacy-имен (`s3*`) пока фронт/интеграции их используют.
- Зависимости: SQLite, EvoBGP HTTP API, локальная ФС для backups, env-конфиг.
### `/backend/db` (внутренний `/db`)
- Назначение: схема и поведение SQLite.
- Состав:
- `migrations/001_init.sql`: blobs/meta/cache/versioning.
- `migrations/002_filter_rules.sql`: `app_filter_rules`.
- Правила изменений:
- Только миграциями; прямые ручные правки существующих таблиц запрещены.
- Миграции должны быть backward-compatible и idempotent.
### `/backend/services` (внутренний `/services`)
- Назначение: слой бизнес-операций и инфраструктурных адаптеров.
- Ключевые модули:
- `evobgpClient.js` (внешний источник правды для BGP reference).
- `s3Service.js` + `blobStorage.js` (SQLite-backed blob API).
- `filterRulesStorage.js` (SQL-таблица фильтров).
- `mikrotik*`, `*Scheduler.js` (операции и периодические задачи).
- Правила изменений:
- Не смешивать transport-логику endpoint с data-access.
- Любая write-операция должна возвращать meta для `sendOk`.
### `/backend/routes` (внутренний `/api`)
- Назначение: HTTP contracts + валидация входа + orchestrator.
- Правила изменений:
- Вход валидируется до вызова сервиса.
- Ошибки наружу только через `sendError`.
- ETag/Last-Modified сохранять в GET/POST где уже реализовано.
### `/agents`
- В репозитории отсутствует.
---
## 3. ARCHITECTURE RULES (АРХИТЕКТУРНЫЕ ПРАВИЛА)
### Слои и границы
- DO: `Route -> Service -> Storage/External API`.
- DO: хранить валидацию запроса на route-слое, а доменную обработку в service.
- DON'T: вызывать SQLite напрямую из UI.
- DON'T: переносить доменную логику в `server.js`.
### Бизнес-логика
- DO: для filters использовать только `filterRulesStorage` (`app_filter_rules`).
- DO: для BGP-reference использовать только `evobgpClient`.
- DON'T: писать domains/asns/ip-ranges в `blobs`.
### State management (frontend)
- DO: network-state через React Query.
- DO: optimistic updates + rollback на error.
- DO: invalidation query keys после мутаций.
- DON'T: дублировать fetch/caching logic вне `api.js`/`useApiQuery.js`.
### Доступ к БД
- DO: через `s3Service`/`blobStorage`/`filterRulesStorage`.
- DO: учитывать `etag`, `lastModified`, `contentLength`.
- DON'T: менять ключевые `object_key` без миграции и impact map.
### Изменения высокой опасности
- Запрещено менять без полной цепочки рефакторинга:
- форматы payload для `/api/domains-new`, `/api/asns`, `/api/ip-ranges`;
- schema ключей blob-хранилища;
- `sendOk/sendError` формат;
- scheduler settings endpoints и их JSON-структуры;
- semantics `server-configs` (только jumphost в листинге).
---
## 4. BUSINESS LOGIC SPECIFICATION
### 4.1 Процесс: BGP Reference Sync (domains/asns/ip-ranges)
- Inputs: HTTP GET/POST, EvoBGP env (`EVOBGP_API_URL`, `EVOBGP_API_TOKEN`), payload массив.
- Outputs: данные справочников + `sendOk` metadata.
- States:
- `IDLE` -> `VALIDATE_INPUT` -> `CALL_EVOBGP` -> `NORMALIZE` -> `RESPOND_OK`
- ветка ошибки: `VALIDATE_INPUT -> RESPOND_400` или `CALL_EVOBGP -> RESPOND_5XX`.
- Errors: `E_BAD_REQUEST`, `E_SCHEMA`, `E_EVOBGP`, `E_EVOBGP_NOT_CONFIGURED`.
- Side effects: запись в EvoBGP, обновление remote reference set.
### 4.2 Процесс: Filter Rules Replace (`global/simple`)
- Inputs: `scope`, массив правил `{community,gateway,...}`.
- Outputs: новый snapshot правил + meta (`etag/lastModified`).
- States:
- `READ_SCOPE_META` -> `VALIDATE_ARRAY` -> `TX_BEGIN` -> `DELETE_SCOPE_ROWS` -> `INSERT_ROWS_ORDERED` -> `UPDATE_META_MTIME` -> `TX_COMMIT` -> `RESPOND_OK`
- rollback: `TX_BEGIN -> ERROR -> TX_ROLLBACK`.
- Errors: `E_BAD_REQUEST`, `E_SCHEMA`, `E_STORAGE`.
- Side effects: перезапись всех правил scope (full replace).
### 4.3 Процесс: Blob Object Write (legacy s3Service API)
- Inputs: `object_key`, `content`.
- Outputs: `sendOk(meta)`.
- States:
- `VALIDATE_KEY` -> `READ_EXISTING` -> `IF_VERSIONED_SAVE_OLD_VERSION` -> `UPSERT_BLOB` -> `TRIM_VERSIONS` -> `INVALIDATE_MEMORY_CACHE` -> `RESPOND_OK`.
- Errors: `E_STORAGE`.
- Side effects: изменение `blobs`, опционально `blob_versions`.
### 4.4 Процесс: Server Config Management
- Inputs: `serverId`, config text / filters json.
- Outputs: per-server config/filter data.
- States:
- `VALIDATE_SERVER_ID` -> `READ_OR_WRITE_BLOB_KEY` -> `RESPOND`.
- Для `/api/server-configs` list: `READ_SERVERS` -> `FILTER_JUMPHOST` -> `MAP_ID_NAME` -> `RESPOND`.
- Errors: `E_BAD_REQUEST`, `E_SCHEMA`, `E_S3/E_STORAGE`.
- Side effects: запись в `filter-manager/config-*.txt` и `filter-manager/server-filters-*.json`.
### 4.5 Процесс: Auto URLs Ingestion
- Inputs: список URL + community.
- Outputs: summary counters.
- States:
- `LOAD_URLS` -> `FETCH_CONTENT` -> `PARSE_LINES` -> `CLASSIFY(IP/CIDR/DOMAIN)` -> `DEDUP_WITH_CURRENT` -> `SAVE_TO_EVOBGP` -> `RESPOND`.
- Errors: `E_BAD_REQUEST`, `E_EVOBGP`.
- Side effects: расширение списков IP/domains в EvoBGP.
### 4.6 Процесс: Ping Services Cache Refresh
- Inputs: UI settings (`pingServicesSource`, cache TTL), services list.
- Outputs: `{ byId, history }`.
- States:
- `LOAD_UI_SETTINGS` -> (`ROUTER_MODE` | `WEB_MODE`) -> `MEASURE_RTT` -> `WRITE_CACHE` -> `APPEND_HISTORY` -> `RESPOND`.
- Errors: fallback на `ms: null` payload при деградации.
- Side effects: запись в blob-ключи `ping-services/cache_*`, `ping-services/history_*`.
### 4.7 Процесс: History Rollback
- Inputs: `resource`, `versionId`.
- Outputs: `sendOk(meta)` после rollback.
- States:
- `MAP_RESOURCE_TO_KEY` -> `VALIDATE_SUPPORT` -> `ROLLBACK_VERSION` -> `RESPOND`.
- Errors: `E_RESOURCE`, `E_HISTORY_EV`, `E_HISTORY_FILTERS_SQL`, `E_STORAGE`.
- Side effects: восстановление blob из `blob_versions`.
---
## 5. API CONTRACTS (ФОРМАЛЬНЫЕ КОНТРАКТЫ)
### 5.1 Глобальные правила API
- Base path: `/api`.
- Auth level: явной auth-схемы нет (network-level trust, rate-limit, CORS, security headers).
- Success write format: `{ ok, etag, lastModified, contentLength }`.
- Error format: `{ code, message, details, requestId }`.
- Конкурентность: `If-None-Match` для GET, `If-Match`/`etag` для отдельных write.
### 5.2 Контракты endpoint’ов
| Route | Method | Auth | Input schema (JSON) | Output schema | Side effects | Таблицы/ключи | Сервисы |
|---|---|---|---|---|---|---|---|
| `/health` | GET | public | - | `{ok:true}` | none | - | - |
| `/ready` | GET | public | - | `{ok:true}` | none | - | - |
| `/metrics` | GET | internal | - | Prometheus text | none | - | prom-client |
| `/api/version` | GET | public | - | `{version,gitSha,buildAt}` | none | - | env |
| `/api/domains` | GET/POST | public | POST: `{domains:[{domain,type}],etag?}` | GET std/raw list, POST `sendOk` | remote write/read | EvoBGP | evobgpClient |
| `/api/asns` | GET/POST | public | POST: `{domains:[{domain,type}],etag?}` | GET/POST аналогично | remote write/read | EvoBGP | evobgpClient |
| `/api/domains-new` | GET/POST | public | POST: `{domains:[{domain,community}],etag?}` | GET/POST аналогично | remote write/read | EvoBGP | evobgpClient |
| `/api/ip-ranges` | GET/POST | public | POST: `{ipRanges:[{ipRange,community}],etag?}` | GET/POST аналогично | remote write/read | EvoBGP | evobgpClient |
| `/api/servers` | GET/POST | public | POST: `{servers:[...],etag?}` | JSON list / `sendOk` | persist servers | `blobs:servers.json` | serversRoutes,s3Service |
| `/api/server-connections` | GET/POST | public | POST: `{domains:[{from,to,tunnelType,...}]}` | list / `sendOk` | persist graph links | blob key `server-connections.json` | jsonDataRoutes |
| `/api/billing` | GET/POST | public | POST: `{items:[...],etag?}` (route expects `domains` in factory; adapters normalize) | list / `sendOk` | persist billing | blob key `servers-billing.json` | jsonDataRoutes |
| `/api/filters` | GET/POST | public | POST: `{domains:[{community,gateway,...}]}` | list / `sendOk` | replace scope rules | `app_filter_rules(scope=global)` | filterRulesStorage |
| `/api/simple-filters` | GET/POST | public | POST: `{domains:[{community,gateway,...}]}` | list / `sendOk` | replace scope rules | `app_filter_rules(scope=simple)` | filterRulesStorage |
| `/api/network-config` | GET/POST | public | POST: `{domains:{gateways?,tunnelInterfaces?,ipPools?}}` | object / `sendOk` | persist network config | blob key `network-config.json` | jsonDataRoutes |
| `/api/communities` | GET/POST | public | POST: `{communities:[{value,name?,...}]}` | list / `sendOk` | EvoBGP write | EvoBGP | communitiesRoutes |
| `/api/communities/stats` | GET | public | - | `{stats:[{community,count}],total}` | none | EvoBGP + `app_filter_rules` | communitiesRoutes |
| `/api/filters/generate-config` | GET | public | query params | generated config | none/write temp | derived blobs | filtersRoutes |
| `/api/filters/export-config` | POST | public | export params | file/content json | write export | derived blobs/fs | filtersRoutes |
| `/api/server-filters/generate-config` | POST | public | `{serverId,...}` | generated config | none | per-server blobs | filtersRoutes |
| `/api/server-configs` | GET/POST | public | POST: `{servers:[jumphost...]}` | jumphost list / `sendOk` | rewrite servers subset | `servers.json` | serverConfigsRoutes |
| `/api/server-configs/:serverId` | GET/POST/DELETE | public | POST: `{config:string}` | `{config}` / `sendOk` | write/delete server config | `filter-manager/config-<id>.txt` | serverConfigsRoutes |
| `/api/server-configs/:serverId/complete` | DELETE | public | - | `{ok:true,...}` | delete config+filters | `filter-manager/config-*`, `server-filters-*` | serverConfigsRoutes |
| `/api/server-filters/:serverId` | GET/POST | public | POST: `{filters:[{community,gateway,...}],etag?}` | `{filters}` / `sendOk` | write server filters | `filter-manager/server-filters-<id>.json` | serverConfigsRoutes |
| `/api/locks/:resource` | GET/POST/DELETE | public | POST:`{owner?,ttlSeconds?}` | lock status/result | in-memory lock mutate | RAM only | lockManager |
| `/api/history/:resource` | GET | public | query:`countOnly,format` | versions list | none | `blob_versions` | miscRoutes+s3Service |
| `/api/history/:resource/rollback` | POST | public | `{versionId}` | `sendOk` | rollback snapshot | `blobs`,`blob_versions` | miscRoutes+s3Service |
| `/api/s3/last-modified` | GET | public | - | map metas | none | blobs/filter_rules | miscRoutes |
| `/api/auto-urls` | GET/POST | public | POST:`{urls:[{url,community,...}]}` | list / `sendOk` | EvoBGP write | EvoBGP | miscRoutes |
| `/api/auto-urls/process` | POST | public | optional params | process summary | pulls URLs, writes lists | EvoBGP | miscRoutes |
| `/api/servers/availability` | GET | public | query:`ttlSeconds?` | `{online,total,statuses}` | refresh memory cache | RAM | miscRoutes |
| `/api/update-bgp/background` | POST | public (rate limited) | passthrough body | upstream response | outbound HTTP POST | - | miscRoutes |
| `/api/ws/url` | GET | public | - | `{url}` | none | `bgp_data/rt_ui_settings.json` | miscRoutes |
| `/api/ui-settings` | GET/POST | public | POST:`{settings,etag?}` | settings / `sendOk` | write ui settings | `bgp_data/rt_ui_settings.json` | miscRoutes |
| `/api/ping-services-list` | GET | public | - | `{list:[...]}` | none | ui settings | miscRoutes |
| `/api/ping-services` | GET | public | query:`refresh|nocache` | `{byId,history}` | cache+history writes | `ping-services/cache_*`,`history_*` | miscRoutes |
| `/api/ipsec-passwords` | GET/POST | public | POST DTO ipsec secret | list / item / `sendOk` | secret storage update | blobs/config | ipsecPasswordsRoutes |
| `/api/ipsec-passwords/:id` | GET/PUT/DELETE | public | PUT DTO | item / `sendOk` | update/delete secret | blobs/config | ipsecPasswordsRoutes |
| `/api/mikrotik/generate` | POST | public | generation payload | config text/json | none | derived | mikrotikConfigRoutes |
| `/api/mikrotik/generate-interfaces` | GET | public | query | generated config | none | derived | mikrotikConfigRoutes |
| `/api/mikrotik/generate-recursive-routes` | GET | public | query | generated config | none | derived | mikrotikConfigRoutes |
| `/api/mikrotik/test-connection` | POST | public | connection payload | test result | network probe | - | mikrotikConfigRoutes |
| `/api/mikrotik/ping` | GET/POST | public | POST:`{serverId,gateway,target,...}` | health/ping result | RouterOS ping | - | mikrotikConfigRoutes |
| `/api/mikrotik/traceroute` | POST | public | traceroute payload | trace result | RouterOS command | - | mikrotikConfigRoutes |
| `/api/mikrotik/speed-test` | POST | public | speed-test payload | throughput metrics | RouterOS command | speed cache blobs | mikrotikConfigRoutes |
| `/api/mikrotik/apply` | POST | public | apply payload | apply report | config apply + backup | fs+blobs | mikrotikConfigRoutes |
| `/api/mikrotik/run-script` | POST | public | script payload | execution output | RouterOS script execution | - | mikrotikConfigRoutes |
| `/api/mikrotik/address-lists` | GET | public | query | list data | none | EvoBGP/derived | mikrotikConfigRoutes |
| `/api/mikrotik/ospf-interface-templates` | GET | public | query | templates | none | blobs/config | mikrotikConfigRoutes |
| `/api/mikrotik/ospf-interface-templates/apply` | POST | public | apply payload | `sendOk`/report | RouterOS mutation | - | mikrotikConfigRoutes |
| `/api/mikrotik/address-lists/apply-summary` | POST | public | summary payload | report | RouterOS mutation | - | mikrotikConfigRoutes |
| `/api/uptime/cache` | GET | public | query | uptime snapshot | none | uptime cache blob | mikrotikConfigRoutes |
| `/api/uptime/check` | POST | public | check payload | status report | ping/http checks | uptime cache blob | mikrotikConfigRoutes |
| `/api/traffic/interface-stats` | GET | public | query | traffic stats | optional cache update | cache blobs | trafficRoutes |
| `/api/resources/stats` | GET | public | query | cpu/ram/hdd stats | optional cache update | cache blobs | resourceStatsRoutes |
| `/api/alerts` | GET | public | query | alerts aggregate | none | combines caches | alertsRoutes |
| `/api/mikrotik/backups` | GET/POST | public | POST backup payload | list / backup result | write/read `.rsc` | local FS + metadata | mikrotikBackupRoutes |
| `/api/mikrotik/backups/item` | GET | public | query id/path | backup item | read FS | local FS | mikrotikBackupRoutes |
| `/api/mikrotik/backups/diff` | POST | public | `{from,to,...}` | diff result | none | local FS | mikrotikBackupRoutes |
| `/api/mikrotik/backups/run` | POST | public | optional | run-now result | scheduler trigger | local FS | mikrotikBackupRoutes |
| `/api/network-map-cache` | GET | public | query | cached map | none | cache blobs | schedulerRoutes |
| `/api/route-optimizer` | GET | public | query topology | optimization result | none | derived/cache | routeOptimizerRoutes |
| `/api/scheduler/network-map/settings` | GET/PATCH | public | PATCH settings DTO | settings/result | update scheduler config | blobs/ui settings | schedulerRoutes |
| `/api/scheduler/network-map/logs` | GET | public | query | logs | none | blobs/logs | schedulerRoutes |
| `/api/scheduler/network-map/run-now` | POST | public | optional | trigger result | immediate job | cache update | schedulerRoutes |
| `/api/scheduler/ping-services/settings` | GET/PATCH | public | PATCH settings DTO | settings/result | update scheduler config | ui settings | schedulerRoutes |
| `/api/scheduler/ping-services/run-now` | POST | public | optional | trigger result | immediate cache refresh | ping-services cache | schedulerRoutes |
| `/api/scheduler/uptime-monitor/settings` | GET/PATCH | public | PATCH settings DTO | settings/result | update scheduler config | ui settings | schedulerRoutes |
| `/api/scheduler/uptime-monitor/run-now` | POST | public | optional | trigger result | immediate uptime job | uptime cache | schedulerRoutes |
| `/api/mikrotik/validate` | POST | public | `{config:string}` | validation report | none | - | mikrotik-validator |
---
## 6. DATABASE BEHAVIOR MODEL
### 6.1 Критичные таблицы
| Таблица | Назначение | Критичность | Можно менять? |
|---|---|---|---|
| `blobs` | текущее состояние logical objects | very high | только через миграцию + compatibility layer |
| `blob_versions` | история версий для rollback | high | нельзя удалять без замены rollback механизма |
| `versioned_keys` | whitelist ключей с версионированием | high | расширять можно, удаление рискованно |
| `app_filter_rules` | глобальные/simple фильтры | very high | менять с миграцией + route/service sync |
| `app_meta` | meta timestamps/flags | medium | осторожно; используется для etag/mtime |
| `cache_entries` | TTL-кеш | medium | можно расширять, не ломая purge logic |
| `schema_migrations` | applied versions | critical | не редактировать вручную |
### 6.2 Cascade/relations
- `blob_versions.object_key -> blobs.object_key ON DELETE CASCADE`.
- Удаление blob удаляет историю версий автоматически.
### 6.3 Transactional boundaries
- `blobStorage.writeBlobTx`: атомарно сохраняет новую версию + snapshot old version + trim.
- `filterRulesStorage.replaceRules`: атомарно `DELETE scope` + `INSERT new set` + update mtime.
- Rollback версии выполняется внутри DB write flow.
### 6.4 Consistency rules
- `etag` вычисляется по SHA-256 body; должен соответствовать текущему blob содержимому.
- `lastModified` и `contentLength` обязательны для корректной cache-конкурентности UI/API.
- `app_filter_rules.position` определяет порядок; запись всегда full-replace.
- `SQLITE_PATH` должен быть persistent volume в production.
- `journal_mode=WAL` обязателен для конкурентного I/O.
---
## 7. FRONTEND RULES (UI ENGINEERING RULESET)
### Структура страниц
- Все маршруты регистрируются централизованно в `App.jsx`.
- Экран = manager/page компонент + общие UI-компоненты (`components/*`).
### State management rules
- Server-state: только React Query (`useQuery/useMutation`).
- UI-state: local component state + Context (`Language`, `Theme`, `Ping`, `Alerts`).
- Запрещено хранить API snapshot в произвольных singleton-объектах вне React Query.
### Data fetching rules
- Только через `src/lib/api.js`.
- Для GET использовать `If-None-Match`/ETag-механику клиента.
- Для write-путей с поддержкой ETag передавать `If-Match`/`etag`.
### Caching strategy
- React Query `staleTime` default 30s.
- Axios in-memory response cache для GET.
- Для `/communities` отключён условный GET (всегда fresh).
### Routing dependencies
- Навигация и route paths должны совпадать (sidebar/horizontal menu).
- Любой новый route требует:
- компонент страницы;
- запись в nav categories;
- route registration (оба layout блока).
### Запрещённые паттерны
- Прямой `fetch` в компонентах (мимо `api.js`).
- Изменение `queryClient` политики точечно без общего обоснования.
- UI-изменения, которые ломают optimistic rollback сценарии.
---
## 8. AI AGENT OPERATING PROTOCOL
### 8.1 Роли агентов
- `architect agent`: проверяет архитектурные границы, планирует миграции/контракты.
- `backend agent`: реализует route/service/storage изменения с сохранением backward compatibility.
- `frontend agent`: реализует UI/state/data-fetch изменения по правилам React Query.
- `refactor agent`: выполняет безопасные структурные изменения с impact map.
- `debug agent`: локализует баги, подтверждает root cause, готовит минимальный fix.
### 8.2 Правила работы агентов
- Перед правками читать: `README.md`, `backend/server.js`, целевой route/service, соответствующий frontend hook/page.
- Изменения планировать от контракта к реализации: API -> service -> storage -> UI.
- При любом endpoint-change проверять:
- формат success/error;
- ETag/headers;
- влияние на React Query keys и optimistic updates.
- Избегать конфликтов:
- не менять shared utility без cross-feature impact check;
- при рефакторинге route переносить логику в service, не наоборот.
### 8.3 SAFE EDITING RULE (обязательный)
Перед изменением кода агент обязан:
1. Найти все зависимости (routes/services/hooks/components/schedulers).
2. Проверить влияние на API (input/output, status codes, headers).
3. Проверить влияние на DB (таблицы, ключи blobs, миграции, транзакции).
4. Проверить UI impact (query keys, route path, optimistic update behavior).
5. Сформировать `plan diff` (что меняется, что не меняется, как верифицируется).
---
## 9. CHANGE MANAGEMENT PROTOCOL
### STEP 1 — ANALYZE
- Определить feature/bug scope.
- Идентифицировать owner-слой (frontend/backend/storage/external).
- Собрать baseline контрактов.
### STEP 2 — IMPACT MAP
- Построить карту зависимостей:
- endpoint -> service -> storage/external;
- endpoint -> frontend hooks -> pages/components.
- Классифицировать риск: low/medium/high.
### STEP 3 — IMPLEMENT PLAN
- Реализовать минимально-инвазивные изменения.
- Сохранять backward-compatible payload где возможно.
- Для high-risk сначала добавить адаптерный слой, потом migration.
### STEP 4 — APPLY PATCH
- Вносить правки атомарными логическими блоками.
- Не смешивать функциональные и косметические изменения.
- Фиксировать invariants в комментариях только при необходимости.
### STEP 5 — VERIFY CONSISTENCY
- Проверить lint/type/build.
- Проверить ключевые API сценарии (read/write/etag/error).
- Проверить UI critical path (list load -> edit -> save -> refresh).
- Проверить scheduler/side effect пути, если затронуты.
---
## 10. DEBUGGING & RECOVERY RULES
### Диагностика ошибок
- Backend:
- проверять structured logs (`pino-http`) с `requestId`;
- проверять метрики (`http_errors_total`, `http_request_duration_seconds`);
- валидировать env для EvoBGP/MikroTik.
- Frontend:
- проверять network ошибки через `apiErrorHandler` + notify payload;
- проверять retry/noRetry и cache behavior.
### Откат изменений
- Код: откат через VCS commit revert.
- Данные:
- blob keys: `/api/history/:resource/rollback` (если поддерживается).
- filters/evobgp resources: rollback через локальный history API не поддерживается; нужен внешний restore plan.
### Изоляция багов
- Сначала локализовать слой (UI/API/Service/Storage/External).
- Воспроизвести минимальный сценарий.
- Проверить контракт на границе слоя перед deep refactor.
### Логирование проблем
- Каждый баг-репорт должен включать:
- requestId/endpoint;
- входной payload (без секретов);
- фактический vs ожидаемый ответ;
- impacted таблицы/ключи.
---
## 11. EXTENSION GUIDE
### Добавление новой фичи (общий шаблон)
1. Добавить/расширить service (доменная логика).
2. Добавить route с валидацией и contract-consistent ответом.
3. Добавить frontend hook в `useApiQuery.js` или отдельный data hook.
4. Подключить UI page/component + route entry.
5. Добавить проверку impact на ETag/cache/retry.
### Расширение API
- Предпочитать additive changes (новые поля/роуты) вместо breaking.
- Для breaking change:
- ввести transitional endpoint или feature flag;
- обновить frontend и consumers синхронно;
- подготовить rollback plan.
### Добавление новой таблицы
- Только новой миграцией `00X_*.sql`.
- Определить:
- primary key + indexes;
- relation/cascade policy;
- transaction boundaries;
- read/write service API.
### Как не ломать текущую систему
- Не удалять legacy aliases без full usage audit.
- Не менять ключи объектов в `blobs` без map миграции.
- Не менять `sendError/sendOk` contract.
- Не обходить `evobgpClient` для BGP-reference данных.
---
## OPERATIONAL CHECKLIST FOR LLM AGENTS
- [ ] Прочитан `README.md` и целевые route/service/frontend файлы.
- [ ] Сформирован `plan diff` и impact map.
- [ ] Проверены API contract invariants (status/errors/headers).
- [ ] Проверены DB invariants (transactions/etag/versioning).
- [ ] Проверен UI flow (query cache + optimistic updates).
- [ ] Выполнены lint/build/ручная smoke-валидация критических сценариев.
+146 -141
View File
@@ -1,208 +1,213 @@
# Docker Images
Этот проект предоставляет Docker образы для S3 Lists Manager с двумя различными версиями интерфейса.
Образ собирается из [`Dockerfile.fast`](Dockerfile.fast) (многостадийная сборка: Vite frontend + `npm ci` backend, рантайм **distroless** Node 20). В Gitea Actions используется тот же файл (см. [`.gitea/workflows/docker-publish.yml`](.gitea/workflows/docker-publish.yml)).
## 🏷️ Доступные теги
## Теги в реестре `git.shts.su`
### Основная версия (main branch)
- `git.shts.su/[repository]:latest` - Стабильная версия с базовым интерфейсом
- `git.shts.su/[repository]:[commit-sha]` - Версия с конкретным коммитом
Замените `[repository]` на путь репозитория в Gitea (например `denozord/router-lists-ui`).
### Tabler версия (tabler branch)
- `git.shts.su/[repository]:tabler` - Версия с современным Tabler UI
- `git.shts.su/[repository]:tabler-[commit-sha]` - Tabler версия с конкретным коммитом
| Ветка (триггер push) | Образы в реестре |
|----------------------|-------------------|
| **main** (и v4, v3 и т.д. из workflow) | `git.shts.su/[repository]:latest` и `git.shts.su/[repository]:<полный-sha>` |
| **v5** | **только** `git.shts.su/[repository]:v5` и `git.shts.su/[repository]:v5-<полный-sha>` — тег **`latest` с ветки v5 не публикуется** |
| **tabler** | см. отдельный workflow `docker-publish-tabler.yml`, если включён |
## 🚀 Запуск
Пин на коммит: используйте тег с суффиксом `-<sha>`; «плавающий» тег ветки (`:v5` или `:latest`) всегда указывает на последнюю успешную сборку.
## Запуск (production)
### Общие рекомендации
1. **Данные на хосте** — смонтируйте том для SQLite и каталог бэкапов MikroTik, иначе при удалении контейнера пропадут БД и `.rsc`.
2. **Переменные** — минимум `ENCRYPTION_KEY`; для справочников и UI — `EVOBGP_API_URL` и `EVOBGP_API_TOKEN`; при необходимости `CORS_ORIGINS`, `PORT`.
3. **Рантайм distroless** — внутри образа **нет** `/bin/bash` и пакетного менеджера; отладка через `docker logs` / метрики. Интерактивная оболочка недоступна.
### Основная линия (`:latest`)
### Основная версия
```bash
docker run -d \
--name s3-lists-manager \
--name router-lists-ui \
-p 3001:3001 \
-e ENCRYPTION_KEY=<64-hex> \
-e EVOBGP_API_URL=https://evobgp.example/v1 \
-e EVOBGP_API_TOKEN=<token> \
-e SQLITE_PATH=/data/router-lists.db \
-e MIKROTIK_BACKUP_DIR=/data/backups/mikrotik \
-v router-lists-data:/data \
git.shts.su/[repository]:latest
```
### Tabler версия
Каталог `/data` на томе должен быть доступен пользователю **nonroot** (образ distroless); при ошибках прав создайте том заранее или используйте bind-mount с `chown` под UID образа (обычно `65532` у nonroot).
### Ветка v5 (только теги `v5` / `v5-<sha>`)
```bash
docker pull git.shts.su/[repository]:v5
docker run -d \
--name router-lists-ui-v5 \
-p 3002:3001 \
-e ENCRYPTION_KEY=<64-hex> \
-e EVOBGP_API_URL=https://evobgp.example/v1 \
-e EVOBGP_API_TOKEN=<token> \
-e SQLITE_PATH=/data/router-lists.db \
-e MIKROTIK_BACKUP_DIR=/data/backups/mikrotik \
-v router-lists-v5-data:/data \
git.shts.su/[repository]:v5
```
Чтобы зафиксировать конкретный коммит:
```bash
docker run -d ... git.shts.su/[repository]:v5-1c6e6ab...
```
### Tabler-ветка (если публикуется отдельным workflow)
```bash
docker run -d \
--name s3-lists-manager-tabler \
-p 3001:3001 \
--name router-lists-ui-tabler \
-p 3003:3001 \
--env-file ./backend/.env \
-v router-lists-tabler-data:/data \
git.shts.su/[repository]:tabler
```
## 🔄 Обновление
## Обновление образа
### latest
### Основная версия
```bash
docker pull git.shts.su/[repository]:latest
docker stop s3-lists-manager
docker rm s3-lists-manager
docker run -d \
--name s3-lists-manager \
-p 3001:3001 \
git.shts.su/[repository]:latest
docker stop router-lists-ui && docker rm router-lists-ui
# затем снова docker run ... как выше
```
### Tabler версия
### v5
```bash
docker pull git.shts.su/[repository]:tabler
docker stop s3-lists-manager-tabler
docker rm s3-lists-manager-tabler
docker run -d \
--name s3-lists-manager-tabler \
-p 3001:3001 \
git.shts.su/[repository]:tabler
docker pull git.shts.su/[repository]:v5
docker stop router-lists-ui-v5 && docker rm router-lists-ui-v5
docker run -d ... git.shts.su/[repository]:v5
```
## 🐳 Docker Compose
## Docker Compose
Создайте файл `docker-compose.yml`:
Пример **одной** службы на `latest` с томом данных (подставьте свой `image:` и при необходимости `env_file`):
```yaml
version: '3.8'
services:
s3-lists-manager:
router-lists-ui:
image: git.shts.su/[repository]:latest
container_name: s3-lists-manager
container_name: router-lists-ui
ports:
- "3001:3001"
restart: unless-stopped
environment:
- NODE_ENV=production
NODE_ENV: production
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
EVOBGP_API_URL: ${EVOBGP_API_URL}
EVOBGP_API_TOKEN: ${EVOBGP_API_TOKEN}
SQLITE_PATH: /data/router-lists.db
MIKROTIK_BACKUP_DIR: /data/backups/mikrotik
volumes:
- router-lists-data:/data
s3-lists-manager-tabler:
image: git.shts.su/[repository]:tabler
container_name: s3-lists-manager-tabler
ports:
- "3002:3001"
restart: unless-stopped
environment:
- NODE_ENV=production
volumes:
router-lists-data:
```
Запуск:
```bash
docker-compose up -d
Отдельный сервис для **v5** — тот же фрагмент, но `image: git.shts.su/[repository]:v5`, другой `container_name` и при желании отдельный volume.
## Переменные окружения (контейнер)
| Переменная | Описание | Обязательная |
|------------|----------|----------------|
| `ENCRYPTION_KEY` | AES-256 для IPSec и паролей MikroTik (64 hex) | **Да** |
| `EVOBGP_API_URL` | Базовый URL API EvoBGP (серверный прокси `/api/evobgp` и бэкенд-справочники) | По сценарию |
| `EVOBGP_API_TOKEN` | Bearer к EvoBGP | По сценарию |
| `SQLITE_PATH` | Путь к файлу SQLite внутри контейнера (персистентный том) | Рекомендуется явно задать, напр. `/data/router-lists.db` |
| `MIKROTIK_BACKUP_DIR` | Каталог бэкапов `.rsc` на диске | Рекомендуется с томом, напр. `/data/backups/mikrotik` |
| `PORT` | Порт HTTP внутри контейнера | Нет (по умолчанию `3001`) |
| `CORS_ORIGINS` | Список Origin через запятую | Нет |
| `LOG_LEVEL` | Уровень логов pino | Нет |
`ENCRYPTION_KEY` должен быть **один и тот же** между перезапусками, иначе не расшифровать уже сохранённые секреты.
### EvoBGP и reverse proxy (nginx)
Запросы UI идут на тот же origin: `/api/evobgp/…` обрабатывает **Node** внутри образа. Если перед приложением стоит **nginx**, нужно проксировать префикс `/api` на Node **без потери** сегмента `/api` в URI.
**Рекомендуемый вариант** (путь `/api/…` сохраняется):
```nginx
location /api/ {
proxy_pass http://127.0.0.1:3001/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
## 🔧 Переменные окружения
**Типичная ошибка**: `proxy_pass http://127.0.0.1:3001/;` — nginx срезает `/api`. В коде есть запасной маршрут на `/evobgp`, но лучше исправить nginx.
| Переменная | Описание | По умолчанию |
|------------|----------|--------------|
| `NODE_ENV` | Окружение Node.js | `production` |
| `PORT` | Порт для запуска сервера | `3001` |
**Проверка**: `GET http://<хост>:<порт>/api/evobgp` → JSON вида `{"ok":true,"proxy":true,"evobgpConfigured":true}`.
## 📊 Мониторинг
## Генерация `ENCRYPTION_KEY`
### Просмотр логов
```bash
# Основная версия
docker logs s3-lists-manager
# Tabler версия
docker logs s3-lists-manager-tabler
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
### Статистика контейнеров
## Мониторинг
```bash
docker stats s3-lists-manager s3-lists-manager-tabler
docker logs -f router-lists-ui
docker stats router-lists-ui
```
## 🔍 Отладка
## Локальная сборка (как в CI)
### Вход в контейнер
```bash
# Основная версия
docker exec -it s3-lists-manager /bin/bash
# Tabler версия
docker exec -it s3-lists-manager-tabler /bin/bash
docker build -f Dockerfile.fast -t router-lists-ui:local .
docker run --rm -p 3001:3001 --env-file ./backend/.env -v $(pwd)/data:/data \
-e SQLITE_PATH=/data/router-lists.db \
-e MIKROTIK_BACKUP_DIR=/data/backups/mikrotik \
router-lists-ui:local
```
### Проверка процессов
(PowerShell на Windows: вместо `$(pwd)` используйте путь к каталогу данных.)
## CI/CD (Gitea Actions)
- [`.gitea/workflows/docker-publish.yml`](.gitea/workflows/docker-publish.yml) — пуш в перечисленные ветки (включая **v5**): сборка и push в `git.shts.su`.
- Для **v5** в реестр уходят **только** теги `v5` и `v5-<sha>` (без `latest`).
- Остальные ветки из списка workflow — `latest` и тег по SHA.
Шаг **Trigger container update webhook** (если задан `secrets.portainer_webhook`) выполняется после сборки; при ошибке шаг не валит job (`continue-on-error: true`).
## Безопасность и сеть
```bash
docker exec s3-lists-manager ps aux
docker network create router-lists-net
docker run -d --network router-lists-net ... git.shts.su/[repository]:latest
```
## 🏗️ Сборка локально
Ограничение ресурсов:
### Основная версия
```bash
git checkout main
docker build -t s3-lists-manager:local .
docker run -d --memory=512m --cpus=0.5 ... git.shts.su/[repository]:latest
```
### Tabler версия
```bash
git checkout tabler
docker build -t s3-lists-manager-tabler:local .
```
## Устранение неполадок
## 📝 CI/CD
### Автоматическая сборка
- **Ветка `main`**: Автоматически создает образ с тегом `latest`
- **Ветка `tabler`**: Автоматически создает образ с тегом `tabler`
### Workflow файлы
- `.gitea/workflows/docker-publish.yml` - Сборка основной версии
- `.gitea/workflows/docker-publish-tabler.yml` - Сборка Tabler версии
## 🔒 Безопасность
### Проверка образа
```bash
docker scan git.shts.su/[repository]:latest
docker scan git.shts.su/[repository]:tabler
```
### Запуск в изолированной сети
```bash
docker network create s3-lists-network
docker run -d \
--name s3-lists-manager \
--network s3-lists-network \
-p 3001:3001 \
git.shts.su/[repository]:latest
```
## 📈 Производительность
### Ограничение ресурсов
```bash
docker run -d \
--name s3-lists-manager \
--memory=512m \
--cpus=0.5 \
-p 3001:3001 \
git.shts.su/[repository]:latest
```
### Мониторинг ресурсов
```bash
docker stats --no-stream s3-lists-manager
```
## 🆘 Устранение неполадок
### Проверка статуса контейнера
```bash
docker ps -a | grep s3-lists-manager
```
### Перезапуск контейнера
```bash
docker restart s3-lists-manager
```
### Очистка неиспользуемых образов
```bash
docker ps -a --filter name=router-lists-ui
docker restart router-lists-ui
docker image prune -f
```
---
**Примечание**: Замените `[repository]` на актуальное имя вашего репозитория в Gitea.
**Замените `[repository]`** на актуальный путь репозитория в Gitea (например `denozord/router-lists-ui`).
+14 -1
View File
@@ -11,7 +11,10 @@ RUN npm config set registry https://registry.npmjs.org/ \
&& npm config set fetch-retry-maxtimeout 120000
COPY frontend/package*.json ./
RUN --mount=type=cache,target=/root/.npm npm install --no-audit --no-fund
# npm install с кэшем для ускорения
RUN --mount=type=cache,target=/root/.npm \
npm install --no-audit --no-fund
COPY frontend/src/ ./src/
COPY frontend/public/ ./public/
@@ -45,8 +48,18 @@ COPY backend/middleware ./middleware
COPY backend/routes ./routes
COPY backend/services ./services
COPY backend/utils ./utils
COPY backend/db ./db
COPY --from=frontend-builder /app/frontend/dist ./public
# EvoBGP (справочник community / router-lists через прокси /api/evobgp, без CORS в браузере):
# EVOBGP_API_URL — например http://evobgp:8080 или http://192.168.1.10:8080 (к /v1 приведётся автоматически при необходимости)
# EVOBGP_API_TOKEN — Bearer-токен API EvoBGP
#
# ENCRYPTION_KEY должен быть установлен через переменные окружения при запуске контейнера
# Это обязательный параметр для работы с IPSec паролями
# Пример: docker run -e ENCRYPTION_KEY=your-64-hex-character-key ...
# Для генерации ключа: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
EXPOSE 3001
USER nonroot
CMD ["server.js"]
+269
View File
@@ -0,0 +1,269 @@
# Easy Switch - Быстрое переключение шлюзов
## Описание
Easy Switch - это новая страница для быстрого переключения шлюзов (gateways) внутри серверов на основе communities из фильтров. Интерфейс вдохновлен мобильными VPN приложениями и обеспечивает удобный способ управления маршрутизацией.
## Особенности
### 1. **Визуальный интерфейс**
- Компактные карточки шлюзов (уменьшенный размер)
- Карточки с флагами стран
- Отображение пинга и протокола подключения
- Метка "Fast" для самого быстрого шлюза в группе
- Активный шлюз выделен синим цветом
### 2. **Группировка по Серверам**
- Сервера группируются в отдельные блоки
- Каждый сервер показывает свои communities
- Для каждого community на сервере можно выбрать активный шлюз **этого сервера**
- Переключение происходит между шлюзами **внутри одного сервера**
- Отображается количество communities на каждом сервере
### 3. **Поиск и фильтрация**
- Поиск по community, описанию или имени шлюза
- Фильтрация по правилам
- Вкладки "Прокси" и "Провайдер прокси"
### 4. **Управление изменениями**
- Кнопка "Применить изменения" появляется при выборе нового шлюза
- Возможность обновить данные с сервера
- Отслеживание изменений
## Структура данных
### Источники данных
1. **Server Configs** (`/api/server-configs`)
- Список серверов с настройками
- ID сервера для загрузки фильтров
2. **Server Filters** (`/api/server-filters/{serverId}`)
- Фильтры для каждого сервера
- Community и gateway для каждого фильтра
3. **Inventory Servers** (`/api/servers`)
- Метаданные серверов (страна, провайдер)
- Используется для отображения флагов
### Формат данных фильтра
```json
{
"community": "65001:100",
"gateway": "SWE-HIPHOST",
"description": "Стриминг сервисы"
}
```
### Структура данных в приложении
После загрузки данные организованы следующим образом:
```javascript
servers = [
{
id: "server-001",
name: "SWE-HIPHOST",
description: "Шведский сервер",
communities: [
{
community: "65001:100",
description: "Стриминг сервисы",
gateways: [
{ name: "gateway1" },
{ name: "gateway2" },
{ name: "gateway3" }
]
},
// ... другие communities
]
},
// ... другие серверы
]
```
Активные шлюзы хранятся в формате:
```javascript
activeGateways = {
"server-001:65001:100": "gateway1",
"server-002:65001:100": "gateway4",
// ключ: "{serverId}:{community}", значение: "{gatewayName}"
}
```
## Интерфейс
### Заголовок
- Название страницы "Easy Switch"
- Кнопка "Обновить" для перезагрузки данных
- Кнопка "Применить изменения" (появляется при наличии изменений)
### Вкладки
- **Прокси** - основной список communities с шлюзами
- **Провайдер прокси** - дополнительная вкладка (в разработке)
### Панель фильтров
- Dropdown "Правило" - фильтрация по типу правила
- Поле поиска - поиск по ключевым словам
- Кнопки инструментов (настройки, быстрые действия)
### Блоки серверов
Каждый сервер отображается в отдельной карточке с:
- 🏴 **Флаг страны** сервера
- 🖥️ **Иконка сервера**
- **Название** сервера
- **Описание** сервера
- **Количество communities** на этом сервере
### Карточки шлюзов
Каждая карточка шлюза (компактная) содержит:
- ⚡ **Метка Fast** (для первого шлюза в списке)
- **Название** шлюза
- **Протокол** подключения (vless и т.д.)
- **Пинг** в миллисекундах
- ✓ **Галочка** для активного шлюза
Карточки имеют меньший размер (col-6 col-sm-4 col-md-3 col-lg-2) для компактности.
### Состояния карточки шлюза
- **Неактивная** - белый фон, серая рамка
- **Активная** - синий фон, белый текст, тень
- **Hover** - подъем карточки на 1px, легкая тень
## Использование
### Открытие страницы
Навигация: `Управление → Easy Switch`
URL: `/easy-switch`
### Переключение шлюза
1. Найдите нужный **сервер** в списке
2. В этом сервере найдите нужный **community**
3. Нажмите на карточку желаемого **шлюза этого сервера**
4. Карточка станет синей (активной)
5. Нажмите "Применить изменения" для сохранения
**Важно:** Переключение происходит между шлюзами **внутри одного сервера**. Каждый сервер имеет свой набор шлюзов для каждого community.
### Поиск
Введите в поле поиска:
- Название сервера (например, `SWE-HIPHOST`)
- Номер community (например, `65001:100`)
- Описание (например, `Стриминг`)
- Название шлюза (например, `gateway1`)
## Технические детали
### Компоненты
- **EasySwitchManager.jsx** - главный компонент страницы
### Зависимости
- React hooks (useState, useEffect)
- Tabler Icons для иконок
- API клиент для загрузки данных
### API Endpoints
```javascript
GET /api/server-configs // Список серверов
GET /api/server-filters/{id} // Фильтры сервера
GET /api/servers // Метаданные серверов
```
### Состояния
```javascript
- loading: boolean // Загрузка данных
- error: string // Текст ошибки
- servers: [] // Список серверов с communities и gateways
- inventoryServers: [] // Метаданные серверов (флаги, провайдеры)
- activeGateways: {} // Активные шлюзы (ключ: "serverId:community")
- hasChanges: boolean // Наличие изменений
- searchQuery: string // Поисковый запрос
- selectedRule: string // Выбранное правило фильтрации
- activeTab: string // Активная вкладка (proxies/providers)
```
## Важные изменения (v2)
### ✅ Исправлено в версии 2.0
- **Все communities отображаются для всех серверов** - больше нет различий в количестве communities между серверами
- **Загрузка названий communities** из справочника `/api/communities`
- **Все gateways сервера** отображаются для каждого community, а не только те, что настроены в filters
- **Бейдж "настроен"** показывает, для каких communities уже есть фильтры
- **Активный gateway берется из filters**, если настроен, иначе выбирается первый
### Логика работы v2
1. Загружается справочник communities → **все одинаковые для всех серверов**
2. Для каждого сервера загружаются его gateways (из `/api/servers` или `server-configs`)
3. Для каждого community показываются **все** gateways этого сервера
4. Из filters берется информация только об **активном** gateway для данного community
## Roadmap
### Планируется добавить
- [ ] Реальные метрики пинга (интеграция с мониторингом)
- [ ] Сохранение изменений на сервер
- [ ] История переключений
- [ ] Групповое переключение
- [ ] Автоматический выбор fastest
- [ ] Уведомления о недоступных серверах
- [ ] Статистика использования
- [ ] Экспорт конфигурации
### В разработке
- [ ] Вкладка "Провайдер прокси"
- [ ] Настройки отображения
- [ ] Быстрые действия
## Отладка
### Проверка данных в консоли
```javascript
// В DevTools Console
console.log('Active Gateways:', activeGateways);
console.log('Communities:', communities);
console.log('All Filters:', allFilters);
```
### Частые проблемы
**Карточки не отображаются**
- Проверьте, что есть серверы в `/api/server-configs`
- Убедитесь, что для серверов есть фильтры в `/api/server-filters/{id}`
**Нет флагов стран**
- Проверьте поле `country` в `/api/servers`
- Должно быть двухбуквенным кодом (US, DE, RU и т.д.)
**Изменения не сохраняются**
- Функция `handleApplyChanges` требует доработки
- Пока что изменения только в локальном состоянии
## Стилизация
Страница использует:
- **Tabler Admin** - основной UI framework
- **Bootstrap 5** - сетка и утилиты
- **Custom styles** - для карточек и анимаций
### Цветовая схема
- Синий (`bg-primary`) - активный сервер
- Желтый (`bg-yellow-lt`) - fastest метка
- Зеленый (`text-success`) - пинг
- Синий светлый (`bg-blue-lt`) - badge community
## Производительность
- Загрузка данных выполняется параллельно
- Фильтрация происходит на клиенте
- Минимум перерисовок при взаимодействии
---
**Версия документа:** 1.0
**Дата создания:** 2025-12-06
**Автор:** AI Assistant
+76 -288
View File
@@ -1,341 +1,129 @@
# Router Lists UI
Полноценный UI/Backend для управления списками BGP (домены, IP-диапазоны, ASNs), фильтрами и конфигурациями MikroTik на базе S3 (Yandex Object Storage). Интерфейс построен на Tabler, frontend — Vite + React, backend — Express.
UI/Backend для управления BGP-списками, фильтрами и конфигурациями MikroTik.
Текущая модель хранения: **SQLite + локальная файловая система** (для `.rsc` backup MikroTik).
## Актуальная архитектура хранения
- Основные данные и объекты приложения хранятся в SQLite (через `better-sqlite3`).
- MikroTik backup-файлы (`.rsc`) хранятся в локальной ФС в каталоге `MIKROTIK_BACKUP_DIR`.
- AWS SDK/S3 в runtime не используются.
- Часть имен API/сервисов (`s3*`) сохранена для обратной совместимости, но фактический backend storage — SQLite.
## Быстрый старт
### Требования
- Node.js 18+
- S3-совместимое хранилище (Yandex Object Storage)
- Доступы AWS: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `S3_BUCKET_NAME`, `AWS_REGION`
- npm
### Запуск backend
```powershell
cd backend
npm i
npm install
# создайте .env и заполните (пример ниже)
npm start
```
Сервис поднимется на `http://localhost:3001`.
Backend поднимется на `http://localhost:3001`.
### Запуск frontend
```bash
```powershell
cd frontend
npm i
npm install
npm run dev
```
Frontend доступен на `http://localhost:5173` (по умолчанию). Production-сборка: `npm run build`.
## Главные особенности
- Единая UX/UI библиотека Tabler, адаптивные панели действий и навигация.
- Онлайн-обновление (WebSocket) BGP c потоковым логом и фоновое обновление (HTTP POST) из UI.
- Блокировки (soft-lock) ресурсов с TTL, чтобы избежать гонок при одновременном редактировании.
- Версионирование данных (история/откат), если включено версии в бакете.
- Генерация конфигурации MikroTik из фильтров (`/api/filters/generate-config`) и экспорт в S3.
- Метрики Prometheus: `/metrics`, health/ready: `/health`, `/ready`.
Frontend доступен на `http://localhost:5173`.
### Важные переменные окружения
- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, `S3_BUCKET_NAME` — доступ к Object Storage
- `CORS_ORIGINS` — список разрешённых Origin через запятую (если пусто — разрешены все)
- `LOG_LEVEL` — уровень логов pino (`info` по умолчанию)
- `PORT` — порт backend (по умолчанию 3001)
- `BGP_BACKGROUND_URL` — адрес фонового обновления BGP, например:
- `http://77.232.38.173:8080/api/update_bgp/background?api_key=...`
- Используется эндпоинтом прокси `POST /api/update-bgp/background` для обхода CORS
## Важные переменные окружения
- `SQLITE_PATH` — путь к файлу SQLite (по умолчанию `backend/data/router-lists.db`).
- `MIKROTIK_BACKUP_DIR` — каталог для локальных `.rsc` backup-файлов MikroTik.
- `ENCRYPTION_KEY` — 64 hex-символа для шифрования секретов (IPSec/MikroTik).
- `EVOBGP_API_URL`, `EVOBGP_API_TOKEN` — если используете интеграцию EvoBGP.
- `CORS_ORIGINS`, `LOG_LEVEL`, `PORT` — эксплуатационные настройки сервиса.
- `BGP_BACKGROUND_URL` — URL фонового обновления BGP для прокси-эндпоинта `POST /api/update-bgp/background`.
Пример `.env`:
```dotenv
PORT=3001
LOG_LEVEL=info
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_REGION=ru-central1
S3_BUCKET_NAME=...
SQLITE_PATH=./data/router-lists.db
MIKROTIK_BACKUP_DIR=./data/backups/mikrotik
ENCRYPTION_KEY=<64-hex>
CORS_ORIGINS=http://localhost:5173
BGP_BACKGROUND_URL=http://77.232.38.173:8080/api/update_bgp/background?api_key=denozord2502
BGP_BACKGROUND_URL=http://77.232.38.173:8080/api/update_bgp/background?api_key=...
```
## API (backend)
Все ответы об ошибке имеют единый формат:
Формат ошибок:
```json
{ "code": "E_*", "message": "...", "details": {}, "requestId": "..." }
```
Успешные POST/PUT возвращают:
Формат успешных `POST/PUT`:
```json
{ "ok": true, "etag": "...", "lastModified": "ISO", "contentLength": 123 }
```
И всегда выставляют заголовки `ETag`, `Last-Modified`, `Content-Length-Source` (если есть). GET поддерживают `countOnly=true` там, где это логично.
### Данные
- GET `/api/domains-new` — список доменов `{ domain, community }`
- `?q=`, `?offset=`, `?limit=`, `?countOnly=true`, `?format=std`
- POST `/api/domains-new` — `{ domains: [{domain, community}], etag }`
### Примечание по legacy-терминам
- GET `/api/ip-ranges` — список `[{ ipRange, community }]`
- POST `/api/ip-ranges` — `{ ipRanges: [{ipRange, community}], etag }`
- `GET /api/s3/last-modified` и функции вида `readS3*`/`writeS3*` — это **legacy-названия**.
- Фактически эти операции работают с SQLite-backed хранилищем.
- GET `/api/asns` — список `[{ domain, type }]` (domain = AS, type = community)
- POST `/api/asns` — `{ domains: [{domain, type}], etag }`
### Основные эндпоинты
- GET `/api/communities` — справочник community (JSON)
- POST `/api/communities` — `{ communities: [...] }` (валидация уникальности value)
- Данные: `/api/domains-new`, `/api/ip-ranges`, `/api/asns`, `/api/communities`
- Фильтры/конфиги: `/api/filters`, `/api/server-configs`, `/api/server-filters`
- MikroTik: `/api/mikrotik/generate`, `/api/mikrotik/generate-interfaces`, `/api/mikrotik/generate-recursive-routes`, `/api/mikrotik/test-connection`, `/api/mikrotik/apply`
- Прочее: `/api/servers`, `/api/billing`, `/api/auto-urls`, `/api/servers/availability`, `/api/locks/:resource`, `/api/history/:resource`
### Фильтры и конфигурации
- GET `/api/filters` / POST `/api/filters` — фильтры для всех серверов.
- GET `/api/filters/generate-config` — сгенерировать конфиг MikroTik из `filters.json`.
- POST `/api/filters/export-config` — сохранить сгенерированный конфиг в S3 (`mikrotik-frouting-config.txt`).
## Docker
- GET `/api/server-configs` / POST `/api/server-configs` — список серверов (id, name, ...).
- GET `/api/server-configs/:serverId` / POST `/api/server-configs/:serverId` — конфиг конкретного сервера.
- DELETE `/api/server-configs/:serverId` — удалить конфиг.
- DELETE `/api/server-configs/:serverId/complete` — удалить конфиг и фильтры.
Полная инструкция: [DOCKER.md](DOCKER.md).
- GET `/api/server-filters/:serverId` — фильтры сервера.
- POST `/api/server-filters/:serverId` — сохранить фильтры сервера.
- POST `/api/server-filters/generate-config` — сгенерировать конфиг MikroTik на лету из переданных `{ filters }`.
Критично для production:
### Прочее
- GET `/api/servers` / POST `/api/servers` — список серверов.
- GET `/api/billing` / POST `/api/billing` — биллинг (ноды, статусы и т.п.).
- GET `/api/auto-urls` / POST `/api/auto-urls` — список авто-URL.
- POST `/api/auto-urls/process` — обработать авто-URL и добавить IP в `bgp_data/ips.txt`.
- GET `/api/servers/availability?ttlSeconds=60` — быстрый TCP‑чек доступности нод.
- GET `/api/s3/last-modified` — метаданные S3 (etag/lastModified/contentLength) по ключевым файлам.
- POST `/api/update-bgp/background` — прокси к фоновой задаче обновления BGP. Требует `BGP_BACKGROUND_URL` в `.env`.
- Locks: GET `/api/locks/:resource`, POST `/api/locks/:resource`, DELETE `/api/locks/:resource`.
- History: GET `/api/history/:resource`, POST `/api/history/:resource/rollback`.
- Обязательно смонтировать volume для пути с `SQLITE_PATH`.
- Обязательно смонтировать volume для `MIKROTIK_BACKUP_DIR`, иначе `.rsc` backup-файлы потеряются при пересоздании контейнера.
- Использовать стабильный `ENCRYPTION_KEY` между перезапусками.
## Frontend
- Vite + React, Tabler CSS/JS (`@tabler/core`).
- Общий компонент `PageHeaderActions` — единый toolbar на страницах данных.
- Модалка `WsUpdateModal` — поток логов online-обновления (ws).
- Доступность: роли, подписи, фокус-кольца, hot-path без мыши.
Пример запуска:
### Скрипты
```bash
npm run dev # dev-сервер
npm run build # продакшн сборка
```
## Структура репозитория
```
backend/ # Express API
frontend/ # Vite React UI
```
## Безопасность и эксплуатация
- Helmet, RateLimit, CORS, отключён слабый etag на JSON.
- Prometheus метрики по умолчанию.
- Для истории версий включите versioning в бакете S3.
## Лицензия
MIT
# 📂 S3 Lists Manager
Веб-интерфейс для удобного управления файлами в S3-совместимом хранилище Yandex Cloud. Приложение позволяет в реальном времени просматривать, добавлять, редактировать, удалять и массово изменять записи в следующих файлах:
- **domains.txt** - список доменов и их шлюзов
- **asns.txt** - список AS номеров и их шлюзов
- **servers.json** - список серверов с расширенной информацией (IP, DNS, страна, провайдер, тип туннеля)
## ✨ Возможности
- **Три режима работы:** Управление списками доменов, AS-номеров и серверов через вкладки.
- **CRUD операции:** Полный набор действий: создание, чтение, обновление и удаление записей.
- **Поиск в реальном времени:** Мгновенная фильтрация списков по мере ввода.
- **Массовая замена:** Быстрое обновление шлюзов для сотен записей в один клик.
- **Сохранение в S3:** Все изменения сохраняются непосредственно в файлах в бакете Yandex Cloud.
- **Docker-контейнеризация:** Готовый `Dockerfile` для сборки и запуска приложения в изолированном окружении.
- **CI/CD с Gitea Actions:** Автоматическая сборка и публикация Docker-образа в Gitea Registry при пуше в `main`.
- **Современный интерфейс:** Полностью интегрированный Tabler UI с официальными компонентами.
## 🛠️ Технологический стек
| Область | Технология |
|--------------|-----------------------------------------------------------------------------------------------------------|
| **Фронтенд** | [**React**](https://reactjs.org/) + [**Vite**](https://vitejs.dev/) |
| | [**@tabler/core**](https://tabler.io/) (UI-компоненты для Tabler версии) |
| | [**Tabler Icons**](https://tabler-icons.io/) (иконки) |
| | [**Axios**](https://axios-http.com/) (HTTP-клиент) |
| **Бэкенд** | [**Node.js**](https://nodejs.org/) + [**Express**](https://expressjs.com/) |
| | [**AWS SDK for JS**](https://aws.amazon.com/sdk-for-javascript/) (для работы с Yandex Cloud S3) |
| **CI/CD** | [**Docker**](https://www.docker.com/), [**Gitea Actions**](https://gitea.com/blog/2022/10/01/gitea-actions/) |
## 🏗️ Архитектура
Приложение состоит из двух основных частей: фронтенд на React и бэкенд на Node.js/Express, которые взаимодействуют через REST API.
```mermaid
graph TD
subgraph Browser
A[React Frontend]
end
subgraph Server
B(Node.js/Express API)
end
subgraph Yandex Cloud
C{S3 Bucket}
D1[domains.txt]
D2[asns.txt]
D3[servers.json]
end
A -- HTTP Requests --> B
B -- AWS SDK --> C
C --- D1
C --- D2
```
## 🚀 Установка и запуск
### Предварительные требования
- [Node.js](https://nodejs.org/) (v20.x или выше)
- [npm](https://www.npmjs.com/) или [yarn](https://yarnpkg.com/)
- Доступ к бакету Yandex Cloud S3 и сервисный аккаунт с правами на чтение и запись.
### 1. Настройка бэкенда
1. Перейдите в директорию `backend`:
```bash
cd backend
```
2. Создайте файл `.env` на основе примера `.env.example`. Заполните его вашими учетными данными от Yandex Cloud S3:
```env
# .env
S3_ACCESS_KEY_ID=ВАШ_КЛЮЧ_ДОСТУПА
S3_SECRET_ACCESS_KEY=ВАШ_СЕКРЕТНЫЙ_КЛЮЧ
S3_BUCKET_NAME=ИМЯ_ВАШЕГО_БАКЕТА
```
3. Установите зависимости:
```bash
npm install
```
### 2. Настройка фронтенда
1. Перейдите в директорию `frontend`:
```bash
cd ../frontend
```
2. Установите зависимости:
```bash
npm install
```
### 3. Запуск приложения
1. **Запустите бэкенд-сервер.** В директории `backend` выполните:
```bash
npm start
```
Сервер запустится на `http://localhost:3001`.
2. **Запустите фронтенд.** В новой вкладке терминала, в директории `frontend`, выполните:
```bash
npm run dev
```
Приложение будет доступно по адресу `http://localhost:5173` и будет автоматически проксировать API-запросы на бэкенд.
## 🐳 Docker
Приложение полностью готово к запуску в Docker с двумя вариантами интерфейса.
### Доступные образы
#### Основная версия (main branch)
```bash
git.shts.su/[repository]:latest
```
#### Tabler версия (tabler branch)
```bash
git.shts.su/[repository]:tabler
```
### Быстрый запуск
#### Основная версия
```bash
docker run -d \
--name s3-lists-manager \
-p 3001:3001 \
--env-file ./backend/.env \
```powershell
docker run -d `
--name router-lists-ui `
-p 3001:3001 `
--env-file ./backend/.env `
-e SQLITE_PATH=/data/router-lists.db `
-e MIKROTIK_BACKUP_DIR=/data/backups/mikrotik `
-v router-lists-data:/data `
git.shts.su/[repository]:latest
```
#### Tabler версия
```bash
docker run -d \
--name s3-lists-manager-tabler \
-p 3002:3001 \
--env-file ./backend/.env \
git.shts.su/[repository]:tabler
## Чек-лист проверки соответствия (S3 -> SQLite)
1. В `backend/package.json` нет зависимостей AWS SDK.
2. Приложение стартует с `SQLITE_PATH` и создает/использует файл БД.
3. CRUD по основным данным (`/api/domains-new`, `/api/ip-ranges`, `/api/asns`) работает после перезапуска контейнера с тем же volume.
4. Созданный MikroTik backup появляется как локальный `.rsc` файл в каталоге `MIKROTIK_BACKUP_DIR`.
5. После перезапуска контейнера с тем же volume backup-файл остается доступным.
6. `GET /api/s3/last-modified` возвращает метаданные, но интерпретируется как legacy endpoint поверх SQLite.
## Структура репозитория
```text
backend/ Express API
frontend/ Vite + React UI
```
### Локальная сборка
## Лицензия
Для сборки образа выполните команду в корневой директории проекта:
```bash
docker build -t s3-lists-manager .
```
### Запуск контейнера
Для запуска контейнера необходимо передать переменные окружения. Это можно сделать с помощью флага `-e` или через `--env-file`.
```bash
docker run --rm -p 3001:3001 --env-file ./backend/.env s3-lists-manager
```
После этого приложение будет доступно по адресу `http://localhost:3001`.
### Docker Compose
Создайте файл `docker-compose.yml`:
```yaml
version: '3.8'
services:
s3-lists-manager:
image: git.shts.su/[repository]:latest
container_name: s3-lists-manager
ports:
- "3001:3001"
restart: unless-stopped
env_file:
- ./backend/.env
s3-lists-manager-tabler:
image: git.shts.su/[repository]:tabler
container_name: s3-lists-manager-tabler
ports:
- "3002:3001"
restart: unless-stopped
env_file:
- ./backend/.env
```
Запуск:
```bash
docker-compose up -d
```
Подробная документация по Docker образам доступна в файле [DOCKER.md](DOCKER.md).
## ⚙️ API Endpoints
| Метод | Путь | Описание |
|--------|---------------|----------------------------------|
| `GET` | `/api/domains`| Получить список всех доменов. |
| `POST` | `/api/domains`| Сохранить изменения в `domains.txt`. |
| `GET` | `/api/asns` | Получить список всех AS. |
| `POST` | `/api/asns` | Сохранить изменения в `asns.txt`. |
| `GET` | `/api/servers`| Получить список всех серверов. |
| `POST` | `/api/servers`| Сохранить изменения в `servers.json`. |
MIT
+1 -1
View File
@@ -2,7 +2,7 @@
## Краткое описание
Проведена комплексная модернизация пользовательского интерфейса S3 Lists Manager с внедрением современных UX/UI практик, улучшением доступности и мобильного опыта.
Проведена комплексная модернизация пользовательского интерфейса RouterOS Manager с внедрением современных UX/UI практик, улучшением доступности и мобильного опыта.
---
Binary file not shown.
+68
View File
@@ -0,0 +1,68 @@
-- Router-lists local storage (SQLite). EvoBGP holds reference lists; this DB holds config + caches.
CREATE TABLE IF NOT EXISTS app_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS schema_migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version INTEGER NOT NULL UNIQUE,
applied_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS blobs (
object_key TEXT PRIMARY KEY,
namespace TEXT NOT NULL CHECK (namespace IN (
'config',
'derived',
'per_server',
'cache'
)),
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
body BLOB NOT NULL,
byte_size INTEGER NOT NULL,
etag TEXT NOT NULL,
updated_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_blobs_namespace_updated
ON blobs (namespace, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_blobs_prefix
ON blobs (object_key);
CREATE TABLE IF NOT EXISTS blob_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
object_key TEXT NOT NULL,
body BLOB NOT NULL,
byte_size INTEGER NOT NULL,
etag TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (object_key) REFERENCES blobs(object_key) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_blob_versions_key_time
ON blob_versions (object_key, created_at DESC);
CREATE TABLE IF NOT EXISTS versioned_keys (
object_key TEXT PRIMARY KEY
);
INSERT OR IGNORE INTO versioned_keys (object_key) VALUES
('filters.json'),
('servers.json');
CREATE TABLE IF NOT EXISTS cache_entries (
cache_key TEXT PRIMARY KEY,
namespace TEXT NOT NULL DEFAULT 'cache',
body BLOB NOT NULL,
byte_size INTEGER NOT NULL,
etag TEXT,
updated_at INTEGER NOT NULL,
expires_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_cache_expires
ON cache_entries (expires_at);
@@ -0,0 +1,16 @@
-- Нормализованные глобальные и simple-фильтры (вместо filters.json / simple-filters.json в blobs)
CREATE TABLE IF NOT EXISTS app_filter_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope TEXT NOT NULL CHECK (scope IN ('global', 'simple')),
position INTEGER NOT NULL,
community TEXT NOT NULL,
gateway TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
extra_json TEXT NOT NULL DEFAULT '{}',
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_app_filter_rules_scope_pos ON app_filter_rules(scope, position);
DELETE FROM versioned_keys WHERE object_key = 'filters.json';
+155
View File
@@ -0,0 +1,155 @@
/**
* Single-process SQLite connection, migrations, PRAGMAs.
* Designed so the same API can later move to a dedicated storage service process.
*/
const fs = require('fs');
const path = require('path');
const Database = require('better-sqlite3');
let _db = null;
let _cachePurgeTimer = null;
function defaultDbPath() {
return path.join(__dirname, '..', 'data', 'router-lists.db');
}
function getDbPath() {
const p = process.env.SQLITE_PATH || defaultDbPath();
const dir = path.dirname(p);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Ensure the file exists before better-sqlite3 opens it.
// This makes container startup resilient when SQLITE_PATH points to
// a persistent volume and database file is not created yet.
if (!fs.existsSync(p)) {
fs.closeSync(fs.openSync(p, 'a'));
}
try {
fs.accessSync(p, fs.constants.R_OK | fs.constants.W_OK);
} catch (err) {
const details = err && err.message ? err.message : String(err);
throw new Error(`SQLite file is not readable/writable at "${p}": ${details}`);
}
return p;
}
function applyMigration001(db) {
const sql = fs.readFileSync(path.join(__dirname, 'migrations', '001_init.sql'), 'utf8');
db.exec(sql);
}
function applyMigration002(db) {
const sql = fs.readFileSync(path.join(__dirname, 'migrations', '002_filter_rules.sql'), 'utf8');
db.exec(sql);
const migrateBlobToRules = (objectKey, scope) => {
const row = db.prepare('SELECT body FROM blobs WHERE object_key = ?').get(objectKey);
if (!row) return;
let bodyStr = '';
try {
bodyStr = Buffer.isBuffer(row.body) ? row.body.toString('utf8') : String(row.body);
} catch {
return;
}
let arr = [];
try {
const parsed = JSON.parse(bodyStr);
arr = Array.isArray(parsed) ? parsed : [];
} catch {
return;
}
const now = Date.now();
const delRules = db.prepare('DELETE FROM app_filter_rules WHERE scope = ?');
const ins = db.prepare(
`INSERT INTO app_filter_rules (scope, position, community, gateway, description, extra_json, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
);
const run = () => {
delRules.run(scope);
for (let i = 0; i < arr.length; i++) {
const it = arr[i] || {};
const community = String(it.community || '').trim();
const gateway = String(it.gateway || '').trim();
const description = String(it.description || '').trim();
const extra = { ...it };
delete extra.community;
delete extra.gateway;
delete extra.description;
ins.run(scope, i, community, gateway, description, JSON.stringify(extra), now);
}
db.prepare('INSERT OR REPLACE INTO app_meta (key, value) VALUES (?, ?)').run(
`filter_rules_mtime_${scope}`,
String(now),
);
db.prepare('DELETE FROM blob_versions WHERE object_key = ?').run(objectKey);
db.prepare('DELETE FROM blobs WHERE object_key = ?').run(objectKey);
};
db.transaction(run)();
};
migrateBlobToRules('filters.json', 'global');
migrateBlobToRules('filter-manager/simple-filters.json', 'simple');
}
/**
* @returns {import('better-sqlite3').Database}
*/
function openDatabase() {
if (_db) return _db;
const filePath = getDbPath();
const db = new Database(filePath);
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
db.pragma('foreign_keys = ON');
db.pragma(`busy_timeout = ${Number(process.env.SQLITE_BUSY_MS) || 8000}`);
const cacheKb = Number(process.env.SQLITE_CACHE_KB);
if (Number.isFinite(cacheKb) && cacheKb !== 0) {
db.pragma(`cache_size = ${-Math.abs(Math.floor(cacheKb))}`);
}
let userVersion = db.pragma('user_version', { simple: true });
if (userVersion < 1) {
applyMigration001(db);
db.pragma('user_version = 1');
const now = Date.now();
db.prepare('INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (1, ?)').run(now);
userVersion = 1;
}
if (userVersion < 2) {
applyMigration002(db);
db.pragma('user_version = 2');
const now2 = Date.now();
db.prepare('INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (2, ?)').run(now2);
}
_db = db;
if (!_cachePurgeTimer) {
const intervalMs = Math.max(60_000, Number(process.env.SQLITE_CACHE_PURGE_MS) || 300_000);
_cachePurgeTimer = setInterval(purgeExpiredCacheEntries, intervalMs);
_cachePurgeTimer.unref?.();
}
return db;
}
function purgeExpiredCacheEntries() {
const db = openDatabase();
const now = Date.now();
try {
db.prepare('DELETE FROM cache_entries WHERE expires_at IS NOT NULL AND expires_at < ?').run(now);
} catch (_) {}
}
module.exports = {
openDatabase,
getDbPath,
purgeExpiredCacheEntries,
};
+4 -1
View File
@@ -39,7 +39,10 @@ function validateMikrotikConfig(config) {
inBlock = true;
blockName = line.match(/^\/\w+(\s+\w+)*/)?.[0] || '';
currentBlockLine = lineNum;
braceBalance++;
// Считаем скобки только один раз для этой строки (чтобы не дублировать подсчёт ниже)
const openBraces = (line.match(/\{/g) || []).length;
const closeBraces = (line.match(/\}/g) || []).length;
braceBalance += openBraces - closeBraces;
continue;
}
+17
View File
@@ -195,6 +195,22 @@ function sanitizeString(str) {
return cleaned.trim();
}
/**
* Валидация идентификатора сервера для использования в S3-ключах и путях.
* Запрещает path traversal (.., /, \) и небезопасные символы.
* @param {string} serverId - идентификатор сервера
* @param {object} opts - { maxLength: number } (по умолчанию 128)
* @returns {boolean}
*/
function isValidServerId(serverId, opts = {}) {
if (!serverId || typeof serverId !== 'string') return false;
const maxLen = opts.maxLength != null ? opts.maxLength : 128;
const trimmed = serverId.trim();
if (trimmed.length === 0 || trimmed.length > maxLen) return false;
if (trimmed.includes('..') || trimmed.includes('/') || trimmed.includes('\\')) return false;
return /^[a-zA-Z0-9_.-]+$/.test(trimmed);
}
/**
* Проверка на SQL injection паттерны
* @param {string} str - строка для проверки
@@ -328,6 +344,7 @@ module.exports = {
isValidASN,
isValidCommunity,
isValidGateway,
isValidServerId,
sanitizeString,
isSafeSQLString,
isSafeXSSString,
+363 -2014
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -16,17 +16,17 @@
"license": "ISC",
"description": "",
"dependencies": {
"ajv": "^8.17.1",
"better-sqlite3": "^11.6.0",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^17.0.1",
"express": "^4.19.2",
"compression": "^1.7.4",
"ajv": "^8.17.1",
"helmet": "^7.1.0",
"express-rate-limit": "^6.11.2",
"helmet": "^7.1.0",
"pino": "^9.4.0",
"pino-http": "^10.3.0",
"prom-client": "^15.1.3",
"@aws-sdk/client-s3": "^3.687.0"
"prom-client": "^15.1.3"
},
"devDependencies": {
"nodemon": "^3.1.10"
+377
View File
@@ -0,0 +1,377 @@
/**
* Система оповещений: агрегирует проблемы из availability и resources/stats.
* Критерии и пороги задаются в ui-settings.alertSettings.
*/
const { sendError } = require('../middleware/errorHandler');
const { readServersFromS3 } = require('./serversRoutes');
const { checkOneServerFast, loadUiSettingsSync } = require('./miscRoutes');
const { getResourceStatsData } = require('./resourceStatsRoutes');
const { getUptimeCacheData } = require('./mikrotikConfigRoutes');
const { buildConnections } = require('../services/networkMapScheduler');
const { readS3TextObject } = require('../services/s3Service');
/** Значения по умолчанию для настроек оповещений. */
const DEFAULT_ALERT_SETTINGS = {
serverOffline: { enabled: true, offlineMinutes: 5 },
mikrotikUnreachable: { enabled: true, durationMinutes: 5 },
highCpu: { enabled: true, thresholdPercent: 85, durationMinutes: 10 },
highRam: { enabled: true, thresholdPercent: 85, durationMinutes: 10 },
highHdd: { enabled: true, thresholdPercent: 90, durationMinutes: 10 },
/** Глобальные пороги карты сети для UI-аналитики */
networkMapNodeThresholds: {
lowSpeedMbps: 40,
belowNormSpeedMbps: 80,
normalSpeedMbps: 120,
},
/** Индивидуальные пороги по туннелям (карта сети) */
tunnelThresholds: [],
};
function getAlertSettings(uiSettings) {
const raw = uiSettings?.alertSettings && typeof uiSettings.alertSettings === 'object' ? uiSettings.alertSettings : {};
return {
serverOffline: { ...DEFAULT_ALERT_SETTINGS.serverOffline, ...raw.serverOffline },
mikrotikUnreachable: { ...DEFAULT_ALERT_SETTINGS.mikrotikUnreachable, ...raw.mikrotikUnreachable },
highCpu: { ...DEFAULT_ALERT_SETTINGS.highCpu, ...raw.highCpu },
highRam: { ...DEFAULT_ALERT_SETTINGS.highRam, ...raw.highRam },
highHdd: { ...DEFAULT_ALERT_SETTINGS.highHdd, ...raw.highHdd },
networkMapNodeThresholds: {
...DEFAULT_ALERT_SETTINGS.networkMapNodeThresholds,
...(raw.networkMapNodeThresholds && typeof raw.networkMapNodeThresholds === 'object'
? raw.networkMapNodeThresholds
: {}),
},
tunnelThresholds: Array.isArray(raw.tunnelThresholds) ? raw.tunnelThresholds : [],
};
}
function edgePingKey(a, b) {
return [String(a), String(b)].sort().join(':');
}
function speedKey(key1, key2) {
return [String(key1), String(key2)].sort().join(':');
}
/**
* GET /api/alerts
* Возвращает список активных оповещений (учёт настроек из ui-settings.alertSettings).
*/
async function getAlerts(req, res) {
try {
const uiSettings = await loadUiSettingsSync();
const settings = getAlertSettings(uiSettings);
const servers = await readServersFromS3();
const alerts = [];
const now = new Date().toISOString();
// 1) Доступность серверов: jumphost/home — по правилам Uptime Monitor (кеш), остальные — TCP 80/443
if (settings.serverOffline.enabled) {
const routerTypes = ['jumphost', 'home'];
const { results: uptimeResults } = await getUptimeCacheData();
const nonRouterServers = servers.filter(
(s) => !routerTypes.includes(String(s.type || '').toLowerCase())
);
const availabilityChecks = await Promise.allSettled(
nonRouterServers.map((s) => checkOneServerFast(s))
);
servers.forEach((server) => {
const serverId = server.id || server.dns || server.ip;
const name = server.name || server.dns || server.ip || server.id || 'Сервер';
const isRouter = routerTypes.includes(String(server.type || '').toLowerCase());
let offline = false;
let description = '';
if (isRouter) {
const entry = uptimeResults[serverId];
const offlineMinutes = Math.max(1, Math.min(1440, Number(settings.serverOffline.offlineMinutes) || 5));
const offlineSinceMs = entry && entry.lastCheckTs ? (Date.now() - entry.lastCheckTs) : 0;
offline = entry && entry.ok === false && offlineSinceMs >= offlineMinutes * 60 * 1000;
description = offline
? `${name} недоступен более ${offlineMinutes} мин (по проверке Uptime Monitor).`
: '';
} else {
const idx = nonRouterServers.findIndex(
(s) => (s.id || s.dns || s.ip) === serverId
);
const online = idx >= 0 && availabilityChecks[idx].status === 'fulfilled' && availabilityChecks[idx].value;
offline = !online;
description = offline ? `${name} не отвечает на TCP (80/443).` : '';
}
if (offline && description) {
alerts.push({
id: `server-offline-${serverId}`,
type: 'server_offline',
severity: 'critical',
title: 'Сервер недоступен',
description,
entity: name,
entityId: serverId,
link: isRouter ? '/uptime-monitor' : '/servers',
at: now,
});
}
});
}
// 2) Ресурсы роутеров (MikroTik): ошибка доступа, высокое CPU/RAM/HDD
let resourceData;
try {
resourceData = await getResourceStatsData();
} catch (err) {
console.warn('alerts: getResourceStatsData failed', err?.message);
resourceData = { routers: [] };
}
const thCpu = Math.max(1, Math.min(100, Number(settings.highCpu.thresholdPercent) || 85));
const thRam = Math.max(1, Math.min(100, Number(settings.highRam.thresholdPercent) || 85));
const thHdd = Math.max(1, Math.min(100, Number(settings.highHdd.thresholdPercent) || 90));
const routers = resourceData.routers || [];
routers.forEach((router) => {
const { serverId, name, error, resource } = router;
const entityName = name || serverId;
if (error && settings.mikrotikUnreachable.enabled) {
alerts.push({
id: `mikrotik-error-${serverId}`,
type: 'mikrotik_unreachable',
severity: 'critical',
title: 'MikroTik недоступен',
description: `${entityName}: ${error}`,
entity: entityName,
entityId: serverId,
link: '/resource-stats',
at: now,
});
return;
}
if (!resource) return;
if (settings.highCpu.enabled && resource.cpuLoad != null && resource.cpuLoad >= thCpu) {
alerts.push({
id: `cpu-high-${serverId}`,
type: 'high_cpu',
severity: 'warning',
title: 'Высокое использование CPU',
description: `${entityName}: ${resource.cpuLoad}% (порог ${thCpu}%).`,
entity: entityName,
entityId: serverId,
value: resource.cpuLoad,
threshold: thCpu,
link: '/resource-stats',
at: now,
});
}
if (settings.highRam.enabled && resource.memoryUsagePercent != null && resource.memoryUsagePercent >= thRam) {
alerts.push({
id: `ram-high-${serverId}`,
type: 'high_ram',
severity: 'warning',
title: 'Высокое использование RAM',
description: `${entityName}: ${resource.memoryUsagePercent}% (порог ${thRam}%).`,
entity: entityName,
entityId: serverId,
value: resource.memoryUsagePercent,
threshold: thRam,
link: '/resource-stats',
at: now,
});
}
if (settings.highHdd.enabled && resource.hddUsagePercent != null && resource.hddUsagePercent >= thHdd) {
alerts.push({
id: `hdd-high-${serverId}`,
type: 'high_hdd',
severity: 'warning',
title: 'Высокое использование диска',
description: `${entityName}: ${resource.hddUsagePercent}% (порог ${thHdd}%).`,
entity: entityName,
entityId: serverId,
value: resource.hddUsagePercent,
threshold: thHdd,
link: '/resource-stats',
at: now,
});
}
});
// 3) Туннели (карта сети): индивидуальные пороги по пингу и скорости
const rawTunnelThresholds = Array.isArray(settings.tunnelThresholds)
? settings.tunnelThresholds
: [];
const tunnelThresholds = rawTunnelThresholds.filter(
(t) => t && (t.fromKey || t.toKey) && t.enabled !== false
);
if (tunnelThresholds.length > 0) {
let cache = null;
let connectionsData = null;
try {
const [rawCache, built] = await Promise.all([
readS3TextObject('network-map-cache/latest.json').catch(() => null),
buildConnections().catch(() => null),
]);
if (rawCache?.body) {
const parsed = JSON.parse(rawCache.body);
if (parsed && typeof parsed === 'object') cache = parsed;
}
if (built && typeof built === 'object') connectionsData = built;
} catch (e) {
// Ошибки при чтении кеша/конфигурации не должны ломать остальные оповещения
console.warn('alerts: network-map-cache read failed', e?.message || e);
}
if (cache && connectionsData && Array.isArray(connectionsData.connections)) {
const pingMap = cache.pingMap && typeof cache.pingMap === 'object' ? cache.pingMap : {};
const speedMap = cache.speedMap && typeof cache.speedMap === 'object' ? cache.speedMap : {};
const serversByIp = new Map();
servers.forEach((s) => {
if (s && s.ip) {
serversByIp.set(String(s.ip), s);
}
});
const thresholdsByKey = new Map();
const makeKey = (fromKey, toKey, iface) => {
const a = String(fromKey || '');
const b = String(toKey || '');
const pair = [a, b].sort().join('__');
return `${pair}::${iface || ''}`;
};
tunnelThresholds.forEach((t) => {
const key = makeKey(t.fromKey, t.toKey, t.interfaceName || '');
const maxPingMs =
typeof t.maxPingMs === 'number'
? t.maxPingMs
: t.thresholdMs != null
? Number(t.thresholdMs) || 0
: 0;
const minDownloadMbps =
typeof t.minDownloadMbps === 'number'
? t.minDownloadMbps
: t.minDownMbps != null
? Number(t.minDownMbps) || 0
: 0;
const minUploadMbps =
typeof t.minUploadMbps === 'number'
? t.minUploadMbps
: t.minUpMbps != null
? Number(t.minUpMbps) || 0
: 0;
thresholdsByKey.set(key, {
maxPingMs,
minDownloadMbps,
minUploadMbps,
});
});
connectionsData.connections.forEach((c) => {
const fromIp = String(c.from);
const toIp = String(c.to);
if (!fromIp || !toIp || fromIp === toIp) return;
const fromServer = serversByIp.get(fromIp) || null;
const toServer = serversByIp.get(toIp) || null;
const fromName = fromServer?.name || fromServer?.dns || fromIp;
const toName = toServer?.name || toServer?.dns || toIp;
const fromKey = c.fromKey || fromIp;
const toKey = c.toKey || toIp;
const key = makeKey(fromKey, toKey, c.interfaceName || '');
const th = thresholdsByKey.get(key);
if (!th) return;
const maxPingMs = Math.max(0, Number(th.maxPingMs) || 0);
const minDownMbps = Math.max(0, Number(th.minDownloadMbps) || 0);
const minUpMbps = Math.max(0, Number(th.minUploadMbps) || 0);
const pingMapKey = edgePingKey(fromIp, toIp);
const pingMs = typeof pingMap[pingMapKey] === 'number' ? pingMap[pingMapKey] : null;
if (maxPingMs > 0 && pingMs != null && pingMs >= maxPingMs) {
alerts.push({
id: `tunnel-ping-${pingMapKey}-${maxPingMs}`,
type: 'tunnel_high_ping',
severity: 'warning',
title: 'Высокий пинг по туннелю',
description: `Пинг между ${fromName} и ${toName}: ${pingMs} ms (порог ${maxPingMs} ms).`,
entity: `${fromName} ⇄ ${toName}`,
entityId: `${c.fromKey || fromIp}__${c.toKey || toIp}__${c.interfaceName || ''}`,
link: '/network-map',
at: now,
});
}
if ((minDownMbps > 0 || minUpMbps > 0) && c.fromKey && c.toKey) {
const sk = speedKey(c.fromKey, c.toKey);
const speed = speedMap[sk];
if (speed && typeof speed === 'object') {
const downMbps =
typeof speed.tcpDownloadBps === 'number' ? speed.tcpDownloadBps / 1e6 : null;
const upMbps =
typeof speed.tcpUploadBps === 'number' ? speed.tcpUploadBps / 1e6 : null;
const downTooLow = minDownMbps > 0 && (downMbps == null || downMbps < minDownMbps);
const upTooLow = minUpMbps > 0 && (upMbps == null || upMbps < minUpMbps);
if (downTooLow || upTooLow) {
const parts = [];
if (downTooLow) {
parts.push(
`↓ ${downMbps != null ? downMbps.toFixed(1) : '—'} Мбит/с (порог ${minDownMbps} Мбит/с)`
);
}
if (upTooLow) {
parts.push(
`↑ ${upMbps != null ? upMbps.toFixed(1) : '—'} Мбит/с (порог ${minUpMbps} Мбит/с)`
);
}
const ifaceLabel = c.interfaceName ? `, интерфейс ${c.interfaceName}` : '';
alerts.push({
id: `tunnel-speed-${sk}-${minDownMbps}-${minUpMbps}`,
type: 'tunnel_low_speed',
severity: 'warning',
title: 'Низкая скорость по туннелю',
description: `Туннель между ${fromName} и ${toName}${ifaceLabel}: ${parts.join(
'; '
)}.`,
entity: `${fromName} ⇄ ${toName}`,
entityId: `${c.fromKey || fromIp}__${c.toKey || toIp}__${c.interfaceName || ''}`,
link: '/network-map',
at: now,
});
}
}
}
});
}
}
return res.json({
alerts,
total: alerts.length,
at: now,
});
} catch (error) {
console.error('getAlerts:', error);
return sendError(
res,
500,
error.message || 'Ошибка загрузки оповещений',
'E_ALERTS'
);
}
}
module.exports = {
getAlerts,
};
+93 -99
View File
@@ -1,38 +1,52 @@
/**
* Роуты для работы с communities (справочник BGP Community)
* Роуты для работы с communities (справочник BGP Community) — данные из EvoBGP API
*/
const { s3, BUCKET_NAME, writeS3JsonObject, invalidateCacheForKey } = require('../services/s3Service');
const evobgpClient = require('../services/evobgpClient');
const filterRulesStorage = require('../services/filterRulesStorage');
const { sendError, sendOk } = require('../middleware/errorHandler');
const { GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
const { streamToString } = require('../services/s3Service');
const validators = require('../lib/validators');
const { getS3JsonWithHeaders } = require('../utils/s3Helpers');
const S3_KEY = 'bgp_data/communities.json';
function requireEvobgp(res) {
if (!evobgpClient.isConfigured()) {
return sendError(
res,
503,
'EvoBGP не настроен: задайте EVOBGP_API_URL и EVOBGP_API_TOKEN',
'E_EVOBGP_NOT_CONFIGURED',
);
}
return null;
}
// GET /api/communities
async function getCommunities(req, res) {
await getS3JsonWithHeaders(S3_KEY, req, res, {
transform: (communities) => {
// Нормализация
return communities
.filter((c) => c && typeof c.value === 'string' && c.value.trim().length > 0)
.map((c) => ({
value: String(c.value).trim(),
name: c.name ? String(c.name) : '',
description: c.description ? String(c.description) : '',
tags: Array.isArray(c.tags) ? c.tags.map(String) : [],
gatewayDefault: c.gatewayDefault ? String(c.gatewayDefault) : '',
color: c.color ? String(c.color) : ''
}));
},
defaultValue: []
});
const err = requireEvobgp(res);
if (err) return err;
try {
const communities = await evobgpClient.listCommunities();
const normalized = communities
.filter((c) => c && typeof c.value === 'string' && c.value.trim().length > 0)
.map((c) => ({
value: String(c.value).trim(),
name: c.name ? String(c.name) : '',
description: c.description ? String(c.description) : '',
tags: Array.isArray(c.tags) ? c.tags.map(String) : [],
color: c.color ? String(c.color) : '',
icon: c.icon ? String(c.icon) : '',
}));
res.json(normalized);
} catch (e) {
console.error('getCommunities', e);
return sendError(res, 500, e.message || 'EvoBGP error', 'E_EVOBGP');
}
}
// POST /api/communities
async function postCommunities(req, res) {
const err = requireEvobgp(res);
if (err) return err;
const { communities } = req.body;
if (!Array.isArray(communities)) {
@@ -42,130 +56,111 @@ async function postCommunities(req, res) {
const seen = new Set();
const normalized = [];
const validationErrors = [];
for (let i = 0; i < communities.length; i++) {
const entry = communities[i] || {};
const value = typeof entry.value === 'string' ? entry.value.trim() : '';
if (!value) {
validationErrors.push(`Community at index ${i} is missing required field: value`);
continue;
}
if (!validators.isValidCommunity(value)) {
validationErrors.push(`Community at index ${i} has invalid value: ${value}`);
continue;
}
if (seen.has(value)) {
validationErrors.push(`Duplicate community value at index ${i}: ${value}`);
continue;
}
seen.add(value);
normalized.push({
value,
name: entry.name ? String(entry.name) : '',
description: entry.description ? String(entry.description) : '',
tags: Array.isArray(entry.tags) ? entry.tags.map(String) : [],
gatewayDefault: entry.gatewayDefault ? String(entry.gatewayDefault) : '',
color: entry.color ? String(entry.color) : '',
icon: entry.icon ? String(entry.icon) : '',
category: entry.category ? String(entry.category) : '',
priority: typeof entry.priority === 'number' ? entry.priority : 0,
enabled: typeof entry.enabled === 'boolean' ? entry.enabled : true,
});
}
if (validationErrors.length > 0) {
return sendError(res, 400, 'Validation errors', 'E_VALIDATION', { errors: validationErrors });
}
try {
await s3.send(new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: S3_KEY,
Body: JSON.stringify(normalized, null, 2),
ContentType: 'application/json',
}));
invalidateCacheForKey(S3_KEY);
const { headMeta } = require('../services/s3Service');
const meta = await headMeta(S3_KEY);
return sendOk(res, meta);
await evobgpClient.saveCommunitiesData(normalized);
return sendOk(res, {
etag: null,
lastModified: new Date().toISOString(),
contentLength: null,
});
} catch (error) {
console.error('Error writing communities to S3:', error);
return sendError(res, 500, 'Error writing communities to S3', 'E_S3');
console.error('Error writing communities to EvoBGP:', error);
return sendError(res, 500, error.message || 'EvoBGP write failed', 'E_EVOBGP');
}
}
function countCommunityInLines(rows, getCommunity) {
const usage = new Map();
for (const row of rows || []) {
const c = getCommunity(row);
if (c) usage.set(c, (usage.get(c) || 0) + 1);
}
return usage;
}
// GET /api/communities/stats
async function getCommunityStats(req, res) {
const err = requireEvobgp(res);
if (err) return err;
try {
const [domainsRes, ipRangesRes, asnsRes, filtersRes] = await Promise.allSettled([
s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt' })),
s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/ips.txt' })),
s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/asns.txt' })),
s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'filter-manager/simple-filters.json' })),
const [domains, ipRanges, asns] = await Promise.allSettled([
evobgpClient.fetchDomainsData(),
evobgpClient.fetchIpRangesData(),
evobgpClient.fetchAsnsData(),
]);
const communityUsage = new Map();
// Подсчет использования в доменах
if (domainsRes.status === 'fulfilled') {
const text = await streamToString(domainsRes.value.Body);
text.split('\n').filter(Boolean).forEach(line => {
const parts = line.trim().split(/\s+/);
if (parts[1]) {
communityUsage.set(parts[1], (communityUsage.get(parts[1]) || 0) + 1);
}
});
if (domains.status === 'fulfilled') {
const m = countCommunityInLines(domains.value, (r) => r.community);
for (const [k, v] of m) communityUsage.set(k, (communityUsage.get(k) || 0) + v);
}
// Подсчет в IP ranges
if (ipRangesRes.status === 'fulfilled') {
const text = await streamToString(ipRangesRes.value.Body);
text.split('\n').filter(Boolean).forEach(line => {
const parts = line.trim().split(/\s+/);
if (parts[1]) {
communityUsage.set(parts[1], (communityUsage.get(parts[1]) || 0) + 1);
}
});
if (ipRanges.status === 'fulfilled') {
const m = countCommunityInLines(ipRanges.value, (r) => r.community);
for (const [k, v] of m) communityUsage.set(k, (communityUsage.get(k) || 0) + v);
}
// Подсчет в ASNs
if (asnsRes.status === 'fulfilled') {
const text = await streamToString(asnsRes.value.Body);
text.split('\n').filter(Boolean).forEach(line => {
const parts = line.trim().split(/\s+/);
if (parts[1]) {
communityUsage.set(parts[1], (communityUsage.get(parts[1]) || 0) + 1);
}
});
if (asns.status === 'fulfilled') {
const m = countCommunityInLines(asns.value, (r) => r.community);
for (const [k, v] of m) communityUsage.set(k, (communityUsage.get(k) || 0) + v);
}
// Подсчет в фильтрах
if (filtersRes.status === 'fulfilled') {
try {
const text = await streamToString(filtersRes.value.Body);
const filters = JSON.parse(text);
if (Array.isArray(filters)) {
filters.forEach(f => {
if (f.community) {
communityUsage.set(f.community, (communityUsage.get(f.community) || 0) + 1);
}
});
try {
const simpleFilters = filterRulesStorage.listRules('simple');
for (const f of simpleFilters) {
if (f.community) {
communityUsage.set(f.community, (communityUsage.get(f.community) || 0) + 1);
}
} catch {}
}
const stats = Array.from(communityUsage.entries()).map(([community, count]) => ({
community,
count,
})).sort((a, b) => b.count - a.count);
}
} catch (_) {}
const stats = Array.from(communityUsage.entries())
.map(([community, count]) => ({ community, count }))
.sort((a, b) => b.count - a.count);
res.json({ stats, total: stats.reduce((sum, s) => sum + s.count, 0) });
} catch (error) {
console.error('Error getting community stats:', error);
return sendError(res, 500, 'Error getting community stats', 'E_S3');
return sendError(res, 500, 'Error getting community stats', 'E_EVOBGP');
}
}
@@ -174,4 +169,3 @@ module.exports = {
postCommunities,
getCommunityStats,
};
+148
View File
@@ -0,0 +1,148 @@
const http = require('http');
const https = require('https');
const { URL } = require('url');
/**
* Нормализует базовый URL API EvoBGP до вида …/v1 (без завершающего слэша).
*/
function normalizeEvobgpBaseUrl(raw) {
if (!raw || !String(raw).trim()) return '';
const input = String(raw).trim();
try {
const u = new URL(input);
let path = (u.pathname || '').replace(/\/+$/, '');
if (!path || path === '/') {
u.pathname = '/v1';
} else if (!/\/v1$/i.test(path)) {
u.pathname = `${path}/v1`;
} else {
u.pathname = path;
}
return `${u.origin}${u.pathname}`.replace(/\/+$/, '');
} catch {
return '';
}
}
/**
* Прокси EvoBGP: браузер бьёт в тот же origin (/api/evobgp/…), сервер добавляет Bearer и ходит в EvoBGP по сети Docker (без CORS).
*/
function createEvobgpProxy({ logger = console } = {}) {
return function evobgpProxy(req, res) {
const base = normalizeEvobgpBaseUrl(process.env.EVOBGP_API_URL || '');
const token = String(process.env.EVOBGP_API_TOKEN || '').trim();
if (!base) {
return res.status(503).json({
code: 'E_EVOBGP_PROXY_NOT_CONFIGURED',
message:
'Прокси EvoBGP не настроен: задайте EVOBGP_API_URL (и при необходимости EVOBGP_API_TOKEN) в окружении контейнера.',
});
}
if (!token) {
return res.status(503).json({
code: 'E_EVOBGP_PROXY_NO_TOKEN',
message: 'Прокси EvoBGP: не задан EVOBGP_API_TOKEN в окружении сервера.',
});
}
const suffix = req.url && req.url.length ? req.url : '/';
const targetUrl = `${base}${suffix.startsWith('/') ? suffix : `/${suffix}`}`;
let parsed;
try {
parsed = new URL(targetUrl);
} catch {
return res.status(500).json({
code: 'E_EVOBGP_PROXY_BAD_URL',
message: 'Некорректный EVOBGP_API_URL',
});
}
const lib = parsed.protocol === 'https:' ? https : http;
const headers = { ...req.headers };
delete headers.host;
delete headers.connection;
delete headers['content-length'];
delete headers.cookie;
headers.authorization = `Bearer ${token}`;
headers.host = parsed.host;
const opts = {
hostname: parsed.hostname,
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
path: parsed.pathname + parsed.search,
method: req.method,
headers,
timeout: Math.min(120000, Number(process.env.EVOBGP_PROXY_TIMEOUT_MS) || 60000),
};
const clientReq = lib.request(opts, (clientRes) => {
res.status(clientRes.statusCode);
// Не отбрасываем content-encoding: иначе upstream может отдать gzip-тело,
// а клиент не узнает, что его нужно распаковать (в браузере будет «мусор»).
const skip = new Set(['connection', 'transfer-encoding']);
Object.keys(clientRes.headers).forEach((k) => {
if (!skip.has(k.toLowerCase())) {
res.setHeader(k, clientRes.headers[k]);
}
});
clientRes.pipe(res);
});
clientReq.on('error', (err) => {
try {
(req.log || logger).error({ err: err.message, target: targetUrl }, 'evobgp proxy');
} catch {
/* no-op */
}
if (!res.headersSent) {
res.status(502).json({
code: 'E_EVOBGP_PROXY_UPSTREAM',
message: err.message || 'Не удалось связаться с EvoBGP',
});
}
});
clientReq.on('timeout', () => {
clientReq.destroy();
if (!res.headersSent) {
res.status(504).json({
code: 'E_EVOBGP_PROXY_TIMEOUT',
message: 'Таймаут запроса к EvoBGP',
});
}
});
if (req.method === 'OPTIONS' || req.method === 'GET' || req.method === 'HEAD') {
clientReq.end();
return;
}
if (req.method === 'DELETE') {
clientReq.end();
return;
}
const body =
req.body !== undefined && req.body !== null && typeof req.body === 'object'
? JSON.stringify(req.body)
: typeof req.body === 'string'
? req.body
: '';
if (body) {
const buf = Buffer.from(body, 'utf8');
clientReq.setHeader('Content-Type', req.headers['content-type'] || 'application/json');
clientReq.setHeader('Content-Length', buf.length);
clientReq.end(buf);
} else {
clientReq.end();
}
};
}
module.exports = {
createEvobgpProxy,
normalizeEvobgpBaseUrl,
};
+257
View File
@@ -0,0 +1,257 @@
/**
* GET/POST для справочников из EvoBGP (ранее textDataRoutes + S3).
*/
const crypto = require('crypto');
const evobgpClient = require('../services/evobgpClient');
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
const countOnlyCache = { map: new Map(), ttlMs: 10_000 };
function getCountOnlyCache(cacheKey) {
const v = countOnlyCache.map.get(cacheKey);
if (!v) return null;
if (Date.now() > v.at + countOnlyCache.ttlMs) {
countOnlyCache.map.delete(cacheKey);
return null;
}
return v.value;
}
function setCountOnlyCache(cacheKey, value) {
countOnlyCache.map.set(cacheKey, { value, at: Date.now() });
}
function etagForJson(obj) {
const s = JSON.stringify(obj);
const h = crypto.createHash('sha256').update(Buffer.from(s, 'utf8')).digest('hex');
return `"${h}"`;
}
function filterPage(items, q, offset, limit) {
const qstr = q ? String(q).toLowerCase() : '';
const filtered = !qstr
? items
: items.filter((it) => JSON.stringify(it).toLowerCase().includes(qstr));
const total = filtered.length;
if (limit > 0) {
return { items: filtered.slice(offset, offset + limit), total };
}
return { items: filtered, total };
}
function createEvobgpListGET({ fetchItems, cachePrefix, validate }) {
return async (req, res) => {
const { q = '', offset, limit, countOnly, format } = req.query || {};
if (!evobgpClient.isConfigured()) {
return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
}
try {
if (countOnly === 'true') {
const cacheKey = `${cachePrefix}:count:${q}`;
const cached = getCountOnlyCache(cacheKey);
if (cached != null) {
return res.json(format === 'std' ? { items: [], total: cached, meta: {} } : { total: cached });
}
const all = await fetchItems();
const { total } = filterPage(all, q, 0, 0);
setCountOnlyCache(cacheKey, total);
return res.json(format === 'std' ? { items: [], total, meta: {} } : { total });
}
if (Number(limit) > 0) {
const all = await fetchItems();
if (!validate(all)) {
return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
}
const { items, total } = filterPage(all, q, Number(offset) || 0, Number(limit) || 0);
return res.json(format === 'std' ? { items, total, meta: {} } : { items, total });
}
const all = await fetchItems();
if (!validate(all)) {
return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
}
const etag = etagForJson(all);
res.set('ETag', etag);
res.set('Last-Modified', new Date().toUTCString());
res.set('Content-Length-Source', String(Buffer.byteLength(JSON.stringify(all), 'utf8')));
if (checkIfNoneMatch(req, res, etag)) return;
if (format === 'std') {
return res.json({ items: all, total: all.length, meta: {} });
}
return res.json(all);
} catch (e) {
console.error(cachePrefix, e);
return sendError(res, 500, e.message || 'EvoBGP error', 'E_EVOBGP');
}
};
}
function readIfMatch(req) {
const raw = req.headers['if-match'] || req.headers['If-Match'];
if (!raw) return null;
return String(raw).trim();
}
function createEvobgpDomainsNewPOST({ validate, validateItem }) {
return async (req, res) => {
const { domains: items, etag: bodyEtag } = req.body || {};
if (!Array.isArray(items)) {
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
}
if (!validate(items)) {
return sendError(res, 400, 'Invalid payload format', 'E_SCHEMA');
}
if (validateItem) {
const validationErrors = [];
for (let i = 0; i < items.length; i++) {
const errors = validateItem(items[i], i);
if (errors.length > 0) validationErrors.push(...errors);
}
if (validationErrors.length > 0) {
return sendError(res, 400, 'Validation errors', 'E_VALIDATION', {
errors: validationErrors.slice(0, 10),
});
}
}
if (!evobgpClient.isConfigured()) {
return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
}
try {
const current = await evobgpClient.fetchDomainsData();
const currentEtag = etagForJson(current);
const ifMatch = readIfMatch(req);
const expected = ifMatch || bodyEtag;
if (expected && String(expected).trim() !== currentEtag) {
return sendError(res, 412, 'ETag mismatch', 'E_ETAG_MISMATCH');
}
const normalized = items.map((d) => ({
domain: String(d.domain || '').trim().toLowerCase(),
community: String(d.community || '').trim(),
}));
await evobgpClient.saveDomainsData(normalized, current);
return sendOk(res, { etag: null, lastModified: new Date().toISOString(), contentLength: null });
} catch (e) {
console.error(e);
return sendError(res, 500, e.message || 'EvoBGP write failed', 'E_EVOBGP');
}
};
}
function createEvobgpAsnsPOST({ validate, validateItem }) {
return async (req, res) => {
const { domains: items, etag: bodyEtag } = req.body || {};
if (!Array.isArray(items)) {
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
}
if (!validate(items)) {
return sendError(res, 400, 'Invalid payload format', 'E_SCHEMA');
}
if (validateItem) {
const validationErrors = [];
for (let i = 0; i < items.length; i++) {
const errors = validateItem(items[i], i);
if (errors.length > 0) validationErrors.push(...errors);
}
if (validationErrors.length > 0) {
return sendError(res, 400, 'Validation errors', 'E_VALIDATION', {
errors: validationErrors.slice(0, 10),
});
}
}
if (!evobgpClient.isConfigured()) {
return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
}
try {
const current = await evobgpClient.fetchAsnsData();
const currentEtag = etagForJson(
current.map((a) => ({ domain: a.asn, type: a.community })),
);
const ifMatch = readIfMatch(req);
const expected = ifMatch || bodyEtag;
if (expected && String(expected).trim() !== currentEtag) {
return sendError(res, 412, 'ETag mismatch', 'E_ETAG_MISMATCH');
}
const normalized = items.map((a) => ({
asn: String(a.domain || a.asn || '').trim(),
community: String(a.type || a.community || '').trim(),
}));
await evobgpClient.saveAsnsData(normalized, current);
return sendOk(res, { etag: null, lastModified: new Date().toISOString(), contentLength: null });
} catch (e) {
console.error(e);
return sendError(res, 500, e.message || 'EvoBGP write failed', 'E_EVOBGP');
}
};
}
function createEvobgpIpRangesPOST({ validate, validateItem }) {
return async (req, res) => {
const payload = Array.isArray(req.body?.domains)
? req.body.domains
: Array.isArray(req.body?.ipRanges)
? req.body.ipRanges
: null;
const { etag: bodyEtag } = req.body || {};
if (!Array.isArray(payload)) {
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
}
if (!validate(payload)) {
return sendError(res, 400, 'Invalid payload format', 'E_SCHEMA');
}
if (validateItem) {
const validationErrors = [];
for (let i = 0; i < payload.length; i++) {
const errors = validateItem(payload[i], i);
if (errors.length > 0) validationErrors.push(...errors);
}
if (validationErrors.length > 0) {
return sendError(res, 400, 'Validation errors', 'E_VALIDATION', {
errors: validationErrors.slice(0, 10),
});
}
}
if (!evobgpClient.isConfigured()) {
return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
}
try {
const current = await evobgpClient.fetchIpRangesData();
const currentEtag = etagForJson(current);
const ifMatch = readIfMatch(req);
const expected = ifMatch || bodyEtag;
if (expected && String(expected).trim() !== currentEtag) {
return sendError(res, 412, 'ETag mismatch', 'E_ETAG_MISMATCH');
}
const normalized = payload.map((ip) => ({
ipRange: String(ip.ipRange || '').trim(),
community: String(ip.community || '').trim(),
}));
await evobgpClient.saveIpRangesData(normalized, current);
return sendOk(res, { etag: null, lastModified: new Date().toISOString(), contentLength: null });
} catch (e) {
console.error(e);
return sendError(res, 500, e.message || 'EvoBGP write failed', 'E_EVOBGP');
}
};
}
/** Legacy /api/domains: тот же модуль ASN, ответ в полях domain + type. */
function createEvobgpLegacyDomainsGET({ validate }) {
return createEvobgpListGET({
cachePrefix: 'domains',
validate,
fetchItems: async () => {
const rows = await evobgpClient.fetchAsnsData();
return rows.map((r) => ({ domain: r.asn, type: r.community }));
},
});
}
module.exports = {
createEvobgpListGET,
createEvobgpDomainsNewPOST,
createEvobgpAsnsPOST,
createEvobgpIpRangesPOST,
createEvobgpLegacyDomainsGET,
};
+53
View File
@@ -0,0 +1,53 @@
/**
* GET/POST /api/filters и /api/simple-filters — данные в app_filter_rules, не в blobs.
*/
const filterRulesStorage = require('../services/filterRulesStorage');
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
/**
* @param {'global'|'simple'} scope
* @param {(item: any, i: number) => string|null} validateItem
*/
function createFilterRulesRoutes(scope, validateItem) {
return {
get: async (req, res) => {
try {
const head = filterRulesStorage.headMetaForScope(scope);
if (head.etag) res.set('ETag', String(head.etag));
if (head.lastModified) {
res.set('Last-Modified', new Date(head.lastModified).toUTCString());
}
if (typeof head.contentLength === 'number') {
res.set('Content-Length-Source', String(head.contentLength));
}
if (checkIfNoneMatch(req, res, head.etag)) return;
res.json(filterRulesStorage.listRules(scope));
} catch (e) {
console.error('filterRules get', e);
return sendError(res, 500, 'Error reading storage', 'E_STORAGE');
}
},
post: async (req, res) => {
const { domains: items } = req.body;
if (!Array.isArray(items)) {
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
}
try {
const meta = filterRulesStorage.replaceRules(scope, items, { validateItem });
return sendOk(res, meta);
} catch (e) {
if (e.code === 'E_SCHEMA') {
return sendError(res, 400, e.message || 'Validation failed', 'E_SCHEMA');
}
console.error('filterRules post', e);
return sendError(res, 500, 'Error writing storage', 'E_STORAGE');
}
},
};
}
module.exports = {
createFilterRulesRoutes,
};
+32 -29
View File
@@ -2,23 +2,28 @@
* Роуты для фильтров и генерации конфигураций MikroTik
*/
const { s3, BUCKET_NAME, writeS3TextObject, headMeta } = require('../services/s3Service');
const { sendError, sendOk } = require('../middleware/errorHandler');
const { GetObjectCommand } = require('@aws-sdk/client-s3');
const { streamToString } = require('../services/s3Service');
const { writeS3TextObject } = require('../services/s3Service');
const filterRulesStorage = require('../services/filterRulesStorage');
const { sendError, checkIfNoneMatch } = require('../middleware/errorHandler');
const { generateMikrotikConfig } = require('../utils/mikrotikConfig');
const { getS3JsonWithHeaders } = require('../utils/s3Helpers');
// GET /api/filters/generate-config
// GET /api/filters/generate-config?format=text|json
async function generateConfig(req, res) {
try {
await getS3JsonWithHeaders('filters.json', req, res, {
transform: (filters) => {
const config = generateMikrotikConfig(filters);
return { config };
},
defaultValue: [],
});
const format = (req.query.format || 'text').toLowerCase();
if (!['text', 'json'].includes(format)) {
return sendError(res, 400, 'format must be "text" or "json"', 'E_BAD_REQUEST');
}
const head = filterRulesStorage.headMetaForScope('global');
if (head.etag) res.set('ETag', String(head.etag));
if (head.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString());
if (typeof head.contentLength === 'number') {
res.set('Content-Length-Source', String(head.contentLength));
}
if (checkIfNoneMatch(req, res, head.etag)) return;
const filters = filterRulesStorage.listRules('global');
const result = generateMikrotikConfig(filters, { format });
res.json(format === 'json' ? result : { config: result });
} catch (error) {
console.error(error);
return sendError(res, 500, 'Error generating configuration', 'E_S3');
@@ -28,17 +33,7 @@ async function generateConfig(req, res) {
// POST /api/filters/export-config
async function exportConfig(req, res) {
try {
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'filters.json' }));
const fileContent = await streamToString(data.Body);
let filters = [];
try {
filters = JSON.parse(fileContent);
if (!Array.isArray(filters)) filters = [];
} catch (parseError) {
console.error('Error parsing filters.json:', parseError);
filters = [];
}
const filters = filterRulesStorage.listRules('global');
if (filters.length === 0) {
return res.json({ success: false, message: 'Нет фильтров для экспорта' });
@@ -46,18 +41,26 @@ async function exportConfig(req, res) {
const config = generateMikrotikConfig(filters);
await writeS3TextObject('mikrotik-frouting-config.txt', config);
res.json({ success: true, message: 'Конфигурация экспортирована в S3' });
res.json({ success: true, message: 'Конфигурация экспортирована в локальное хранилище' });
} catch (error) {
console.error(error);
return sendError(res, 500, 'Error exporting configuration', 'E_S3');
return sendError(res, 500, 'Error exporting configuration', 'E_STORAGE');
}
}
// POST /api/server-filters/generate-config
// Body: { filters, format?: 'text'|'json' }
async function generateServerFilterConfig(req, res) {
const { filters } = req.body;
const config = generateMikrotikConfig(filters);
res.json({ config });
const { filters, format = 'text' } = req.body || {};
const fmt = String(format || 'text').toLowerCase();
if (!['text', 'json'].includes(fmt)) {
return sendError(res, 400, 'format must be "text" or "json"', 'E_BAD_REQUEST');
}
const result = generateMikrotikConfig(Array.isArray(filters) ? filters : [], { format: fmt });
if (fmt === 'json') {
return res.json(result);
}
res.json({ config: result });
}
module.exports = {
+315
View File
@@ -0,0 +1,315 @@
/**
* Роуты для управления IPSec паролями
* Пароли хранятся в S3 в зашифрованном виде
*/
const { sendError, sendOk } = require('../middleware/errorHandler');
const { readS3TextObject, writeS3JsonObject, deleteS3Object } = require('../services/s3Service');
const { encrypt, decrypt } = require('../utils/encryption');
const S3_KEY = 'network-config/ipsec-passwords.json';
/**
* GET /api/ipsec-passwords - получить список паролей (без расшифровки)
*/
async function getIpsecPasswords(req, res) {
try {
const data = await readS3TextObject(S3_KEY).catch(() => ({ body: '[]' }));
let passwords = [];
try {
passwords = JSON.parse(data.body || '[]');
if (!Array.isArray(passwords)) {
passwords = [];
}
} catch (parseError) {
passwords = [];
}
// Возвращаем список паролей БЕЗ расшифровки (только метаданные)
const result = passwords.map(p => ({
id: p.id,
name: p.name || '',
description: p.description || '',
createdAt: p.createdAt || null,
updatedAt: p.updatedAt || null,
// НЕ возвращаем зашифрованный пароль
}));
res.json(result);
} catch (error) {
console.error('Error reading IPSec passwords:', error);
return sendError(res, 500, 'Error reading IPSec passwords', 'E_S3');
}
}
/**
* GET /api/ipsec-passwords/:id - получить конкретный пароль (расшифрованный)
*/
async function getIpsecPassword(req, res) {
const { id } = req.params;
try {
const data = await readS3TextObject(S3_KEY).catch(() => ({ body: '[]' }));
let passwords = [];
try {
passwords = JSON.parse(data.body || '[]');
if (!Array.isArray(passwords)) {
passwords = [];
}
} catch (parseError) {
passwords = [];
}
const password = passwords.find(p => p.id === id);
if (!password) {
return sendError(res, 404, 'IPSec password not found', 'E_NOT_FOUND');
}
// Проверяем наличие зашифрованного пароля
if (!password.encryptedPassword) {
console.error(`IPSec password ${id} has no encryptedPassword field`);
console.error('Password object:', JSON.stringify(password, null, 2));
return sendError(res, 500, 'Password has no encrypted data', 'E_DECRYPT');
}
// Расшифровываем пароль для возврата
let decryptedPassword = '';
try {
if (typeof password.encryptedPassword !== 'string') {
console.error(`IPSec password ${id} encryptedPassword is not a string:`, typeof password.encryptedPassword);
return sendError(res, 500, 'Invalid encrypted password format', 'E_DECRYPT');
}
decryptedPassword = decrypt(password.encryptedPassword);
if (!decryptedPassword) {
console.error(`IPSec password ${id} decrypted to empty string`);
return sendError(res, 500, 'Password decrypted to empty string', 'E_DECRYPT');
}
} catch (decryptError) {
console.error('Error decrypting password:', decryptError);
console.error('Password ID:', id);
console.error('Encrypted password length:', password.encryptedPassword?.length);
console.error('Encrypted password preview:', password.encryptedPassword?.substring(0, 50));
console.error('Encrypted password format check:', password.encryptedPassword?.split(':').length, 'parts');
// Проверяем, возможно это старый формат или другой ключ
const errorMessage = decryptError.message || 'Unknown decryption error';
return sendError(res, 500, `Error decrypting password: ${errorMessage}. Check if ENCRYPTION_KEY is correct.`, 'E_DECRYPT');
}
res.json({
id: password.id,
name: password.name || '',
description: password.description || '',
password: decryptedPassword, // Расшифрованный пароль
createdAt: password.createdAt || null,
updatedAt: password.updatedAt || null,
});
} catch (error) {
console.error('Error reading IPSec password:', error);
return sendError(res, 500, 'Error reading IPSec password', 'E_S3');
}
}
/**
* POST /api/ipsec-passwords - создать новый пароль
*/
async function createIpsecPassword(req, res) {
const { name, password, description } = req.body;
if (!name || !password) {
return sendError(res, 400, 'Name and password are required', 'E_BAD_REQUEST');
}
try {
// Читаем текущие пароли
const data = await readS3TextObject(S3_KEY).catch(() => ({ body: '[]' }));
let passwords = [];
try {
passwords = JSON.parse(data.body || '[]');
if (!Array.isArray(passwords)) {
passwords = [];
}
} catch (parseError) {
passwords = [];
}
// Проверяем, нет ли уже пароля с таким именем
if (passwords.some(p => p.name === name)) {
return sendError(res, 400, 'Password with this name already exists', 'E_DUPLICATE');
}
// Шифруем пароль
let encryptedPassword;
try {
encryptedPassword = encrypt(password);
if (!encryptedPassword || encryptedPassword.trim() === '') {
console.error('Failed to encrypt password: result is empty');
return sendError(res, 500, 'Failed to encrypt password', 'E_ENCRYPT');
}
// Проверяем формат зашифрованного пароля
const parts = encryptedPassword.split(':');
if (parts.length !== 3) {
console.error('Invalid encrypted password format:', encryptedPassword.substring(0, 50));
return sendError(res, 500, 'Failed to encrypt password: invalid format', 'E_ENCRYPT');
}
} catch (encryptError) {
console.error('Error encrypting password:', encryptError);
return sendError(res, 500, `Failed to encrypt password: ${encryptError.message}`, 'E_ENCRYPT');
}
// Создаем новый пароль
const newPassword = {
id: `ipsec-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
name: String(name).trim(),
description: String(description || '').trim(),
encryptedPassword,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
passwords.push(newPassword);
// Сохраняем в S3
const meta = await writeS3JsonObject(S3_KEY, passwords);
// Возвращаем без расшифрованного пароля, но с ID
res.json({
id: newPassword.id,
name: newPassword.name,
description: newPassword.description,
createdAt: newPassword.createdAt,
updatedAt: newPassword.updatedAt,
});
} catch (error) {
console.error('Error creating IPSec password:', error);
return sendError(res, 500, 'Error creating IPSec password', 'E_S3');
}
}
/**
* PUT /api/ipsec-passwords/:id - обновить пароль
*/
async function updateIpsecPassword(req, res) {
const { id } = req.params;
const { name, password, description } = req.body;
try {
// Читаем текущие пароли
const data = await readS3TextObject(S3_KEY).catch(() => ({ body: '[]' }));
let passwords = [];
try {
passwords = JSON.parse(data.body || '[]');
if (!Array.isArray(passwords)) {
passwords = [];
}
} catch (parseError) {
passwords = [];
}
const index = passwords.findIndex(p => p.id === id);
if (index === -1) {
return sendError(res, 404, 'IPSec password not found', 'E_NOT_FOUND');
}
// Проверяем уникальность имени (если изменилось)
if (name && name !== passwords[index].name) {
if (passwords.some(p => p.id !== id && p.name === name)) {
return sendError(res, 400, 'Password with this name already exists', 'E_DUPLICATE');
}
}
// Обновляем пароль
if (name !== undefined) {
passwords[index].name = String(name).trim();
}
if (description !== undefined) {
passwords[index].description = String(description || '').trim();
}
if (password !== undefined) {
let encryptedPassword;
try {
encryptedPassword = encrypt(password);
if (!encryptedPassword || encryptedPassword.trim() === '') {
console.error('Failed to encrypt password: result is empty');
return sendError(res, 500, 'Failed to encrypt password', 'E_ENCRYPT');
}
// Проверяем формат зашифрованного пароля
const parts = encryptedPassword.split(':');
if (parts.length !== 3) {
console.error('Invalid encrypted password format:', encryptedPassword.substring(0, 50));
return sendError(res, 500, 'Failed to encrypt password: invalid format', 'E_ENCRYPT');
}
passwords[index].encryptedPassword = encryptedPassword;
} catch (encryptError) {
console.error('Error encrypting password:', encryptError);
return sendError(res, 500, `Failed to encrypt password: ${encryptError.message}`, 'E_ENCRYPT');
}
}
passwords[index].updatedAt = new Date().toISOString();
// Сохраняем в S3
const meta = await writeS3JsonObject(S3_KEY, passwords);
// Возвращаем без расшифрованного пароля
res.json({
id: passwords[index].id,
name: passwords[index].name,
description: passwords[index].description,
createdAt: passwords[index].createdAt,
updatedAt: passwords[index].updatedAt,
});
} catch (error) {
console.error('Error updating IPSec password:', error);
return sendError(res, 500, 'Error updating IPSec password', 'E_S3');
}
}
/**
* DELETE /api/ipsec-passwords/:id - удалить пароль
*/
async function deleteIpsecPassword(req, res) {
const { id } = req.params;
try {
// Читаем текущие пароли
const data = await readS3TextObject(S3_KEY).catch(() => ({ body: '[]' }));
let passwords = [];
try {
passwords = JSON.parse(data.body || '[]');
if (!Array.isArray(passwords)) {
passwords = [];
}
} catch (parseError) {
passwords = [];
}
const filtered = passwords.filter(p => p.id !== id);
if (filtered.length === passwords.length) {
return sendError(res, 404, 'IPSec password not found', 'E_NOT_FOUND');
}
// Сохраняем в S3
const meta = await writeS3JsonObject(S3_KEY, filtered);
res.json({ success: true });
} catch (error) {
console.error('Error deleting IPSec password:', error);
return sendError(res, 500, 'Error deleting IPSec password', 'E_S3');
}
}
module.exports = {
getIpsecPasswords,
getIpsecPassword,
createIpsecPassword,
updateIpsecPassword,
deleteIpsecPassword,
};
+64 -42
View File
@@ -2,46 +2,58 @@
* Общие роуты для JSON данных (servers, filters, billing и т.д.)
*/
const { s3, BUCKET_NAME, writeS3JsonObject } = require('../services/s3Service');
const { writeS3JsonObject, headMeta, readS3TextObject } = require('../services/s3Service');
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
const { streamToString } = require('../services/s3Service');
/**
* Создать GET эндпоинт для JSON данных
* @param {string} s3Key - Ключ объекта (логический, как раньше в S3)
* @param {Object} options - Опции
* @param {boolean} options.singleObject - Если true, возвращает объект вместо массива
* @param {any} options.defaultValue - Значение по умолчанию ([] для массива, {} для объекта)
*/
function createJsonDataGET(s3Key) {
function createJsonDataGET(s3Key, options = {}) {
const { singleObject = false, defaultValue } = options;
const fallback = defaultValue !== undefined ? defaultValue : (singleObject ? {} : []);
return async (req, res) => {
try {
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null);
const etag = head?.ETag || null;
const head = await headMeta(s3Key);
const etag = head?.etag || null;
if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
if (head?.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString());
if (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
if (checkIfNoneMatch(req, res, etag)) return;
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key }));
const fileContent = await streamToString(data.Body);
let items = [];
let fileContent;
try {
items = JSON.parse(fileContent);
if (!Array.isArray(items)) {
items = [];
const data = await readS3TextObject(s3Key);
fileContent = data.body;
} catch (e) {
if (e?.code === 'NoSuchKey') {
return res.json(fallback);
}
throw e;
}
let items = fallback;
try {
const parsed = JSON.parse(fileContent);
if (singleObject) {
items = typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : fallback;
} else {
items = Array.isArray(parsed) ? parsed : fallback;
}
} catch (parseError) {
console.error(`Error parsing ${s3Key}:`, parseError);
items = [];
items = fallback;
}
res.json(items);
} catch (error) {
if (error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
res.json([]);
} else {
console.error(error);
return sendError(res, 500, 'Error reading from S3', 'E_S3');
}
console.error(error);
return sendError(res, 500, 'Error reading storage', 'E_STORAGE');
}
};
}
@@ -49,22 +61,36 @@ function createJsonDataGET(s3Key) {
/**
* Создать POST эндпоинт для JSON данных
*/
function createJsonDataPOST(s3Key, validateItem = null) {
function createJsonDataPOST(s3Key, validateItem = null, options = {}) {
const { singleObject = false } = options;
return async (req, res) => {
const { domains: items } = req.body; // Используем 'domains' для обратной совместимости
if (!Array.isArray(items)) {
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
}
// Валидация элементов, если предоставлена
if (validateItem) {
for (let i = 0; i < items.length; i++) {
const error = validateItem(items[i], i);
const { domains: items } = req.body;
if (singleObject) {
if (typeof items !== 'object' || items === null || Array.isArray(items)) {
return sendError(res, 400, 'Data must be an object', 'E_BAD_REQUEST');
}
if (validateItem) {
const error = validateItem(items);
if (error) {
return sendError(res, 400, error, 'E_SCHEMA');
}
}
} else {
if (!Array.isArray(items)) {
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
}
if (validateItem) {
for (let i = 0; i < items.length; i++) {
const error = validateItem(items[i], i);
if (error) {
return sendError(res, 400, error, 'E_SCHEMA');
}
}
}
}
try {
@@ -72,18 +98,15 @@ function createJsonDataPOST(s3Key, validateItem = null) {
return sendOk(res, meta);
} catch (error) {
console.error(error);
return sendError(res, 500, 'Error writing to S3', 'E_S3');
return sendError(res, 500, 'Error writing storage', 'E_STORAGE');
}
};
}
/**
* Создать роуты для JSON данных (GET + POST)
*/
function createJsonDataRoutes(s3Key, validateItem = null) {
function createJsonDataRoutes(s3Key, validateItem = null, options = {}) {
return {
get: createJsonDataGET(s3Key),
post: createJsonDataPOST(s3Key, validateItem)
get: createJsonDataGET(s3Key, options),
post: createJsonDataPOST(s3Key, validateItem, options),
};
}
@@ -92,4 +115,3 @@ module.exports = {
createJsonDataGET,
createJsonDataPOST,
};
+329
View File
@@ -0,0 +1,329 @@
/**
* Роуты для бэкапов конфигурации MikroTik
* Хранят сырый текст конфигурации (например, вывод /export) на локальной ФС.
*
* Логические ключи: backups/mikrotik/{serverId}/{timestamp}-{rand}.rsc
*
* Задачи:
* - создать бэкап (ручной или из планировщика)
* - получить список бэкапов для сервера
* - прочитать конкретный бэкап
* - (опционально) отдать две версии для сравнения и отката
*/
const crypto = require('crypto');
const { sendError, sendOk } = require('../middleware/errorHandler');
const { writeBackupFile, readBackupFile, listBackupObjects } = require('../services/backupFsService');
const { readServersFromS3 } = require('./serversRoutes');
const { createRosClient, fetchExportViaFile } = require('../services/mikrotikApplyService');
const { encrypt, decrypt } = require('../utils/encryption');
const BACKUP_PREFIX = 'backups/mikrotik';
const ENCRYPTED_PREFIX = 'ENC:';
/** Расшифровать содержимое бэкапа (поддерживает старые незашифрованные) */
function decryptBackupContent(raw) {
if (!raw || typeof raw !== 'string') return '';
if (raw.startsWith(ENCRYPTED_PREFIX)) {
try {
return decrypt(raw.slice(ENCRYPTED_PREFIX.length));
} catch (e) {
console.error('Backup decryption failed:', e?.message);
throw new Error(`Failed to decrypt backup: ${e?.message || 'decryption error'}`);
}
}
return raw;
}
function buildBackupKey(serverId, createdAt, suffix) {
const safeId = String(serverId || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_');
const ts = createdAt.toISOString().replace(/[:-]/g, '').replace(/\.\d+Z$/, 'Z');
const rand = suffix || crypto.randomBytes(3).toString('hex');
return `${BACKUP_PREFIX}/${safeId}/${ts}-${rand}.rsc`;
}
/**
* Внутренняя утилита: сохранить бэкап в S3.
* Используется как HTTP-роутом, так и планировщиком.
*/
async function saveBackupForServer(serverId, config, { source, comment } = {}) {
const now = new Date();
const key = buildBackupKey(serverId, now);
const headerLines = [
'# MikroTik configuration backup',
`# Server: ${serverId}`,
`# CreatedAt: ${now.toISOString()}`,
source ? `# Source: ${String(source)}` : null,
comment ? `# Comment: ${String(comment).replace(/\r?\n/g, ' ')}` : null,
'',
].filter(Boolean);
let content = `${headerLines.join('\n')}\n${String(config || '').replace(/^\uFEFF/, '')}`;
// Шифруем содержимое файла, если задан ENCRYPTION_KEY
if (process.env.ENCRYPTION_KEY) {
try {
content = ENCRYPTED_PREFIX + encrypt(content);
} catch (e) {
console.error('Backup encryption failed:', e?.message);
throw new Error(`Failed to encrypt backup: ${e?.message || 'encryption error'}`);
}
}
const meta = await writeBackupFile(key, content, 'text/plain');
return { key, createdAt: now.toISOString(), storage: meta };
}
/**
* POST /api/mikrotik/backups
* Body: { serverId: string, config: string, source?: string, comment?: string }
*
* Сохраняет текст конфигурации (обычно вывод /export) в каталог бэкапов на диске.
* Автоматический бэкап можно организовать внешним планировщиком, дергающим этот эндпоинт.
*/
async function createBackup(req, res) {
try {
const { serverId, config, source, comment } = req.body || {};
if (!serverId || typeof serverId !== 'string') {
return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
}
if (!config || typeof config !== 'string') {
return sendError(res, 400, 'config (MikroTik export text) is required', 'E_BAD_REQUEST');
}
const { key, createdAt, storage } = await saveBackupForServer(serverId, config, { source, comment });
return res.json({
ok: true,
key,
serverId,
createdAt,
storage,
});
} catch (error) {
console.error('createBackup error:', error);
return sendError(res, 500, error.message || 'Error creating backup', 'E_BACKUP_CREATE');
}
}
/**
* GET /api/mikrotik/backups?serverId=...
*
* Возвращает список бэкапов для сервера (без содержимого).
*/
async function listBackups(req, res) {
try {
const serverId = req.query.serverId;
if (!serverId || typeof serverId !== 'string') {
return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
}
const safeId = String(serverId).replace(/[^a-zA-Z0-9._-]/g, '_');
const objects = await listBackupObjects(safeId, 200);
const items = objects
.map((o) => ({
key: o.key,
serverId,
createdAt: o.lastModified,
size: o.size,
etag: o.etag,
}))
.sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
return res.json({ ok: true, items });
} catch (error) {
console.error('listBackups error:', error);
return sendError(res, 500, error.message || 'Error listing backups', 'E_BACKUP_LIST');
}
}
/**
* GET /api/mikrotik/backups/item?key=...
*
* Возвращает содержимое конкретного бэкапа.
*/
async function getBackup(req, res) {
try {
const key = req.query.key;
if (!key || typeof key !== 'string') {
return sendError(res, 400, 'key is required', 'E_BAD_REQUEST');
}
if (!key.startsWith(`${BACKUP_PREFIX}/`)) {
return sendError(res, 400, 'Invalid backup key', 'E_BAD_REQUEST');
}
const data = await readBackupFile(key);
const config = decryptBackupContent(data.body || '');
return res.json({
ok: true,
key,
config,
etag: data.etag,
lastModified: data.lastModified,
contentLength: data.contentLength,
});
} catch (error) {
console.error('getBackup error:', error);
return sendError(res, 500, error.message || 'Error reading backup', 'E_BACKUP_READ');
}
}
/**
* POST /api/mikrotik/backups/diff
* Body: { keyA: string, keyB: string }
*
* Простой текстовый diff: возвращает оба варианта и флаг same.
* Визуальное сравнение (подсветка отличий) можно сделать на frontend.
*/
async function diffBackups(req, res) {
try {
const { keyA, keyB } = req.body || {};
if (!keyA || !keyB) {
return sendError(res, 400, 'keyA and keyB are required', 'E_BAD_REQUEST');
}
if (!String(keyA).startsWith(`${BACKUP_PREFIX}/`) || !String(keyB).startsWith(`${BACKUP_PREFIX}/`)) {
return sendError(res, 400, 'Invalid backup keys', 'E_BAD_REQUEST');
}
const [a, b] = await Promise.all([
readBackupFile(keyA),
readBackupFile(keyB),
]);
const bodyA = decryptBackupContent(a.body || '');
const bodyB = decryptBackupContent(b.body || '');
return res.json({
ok: true,
same: bodyA === bodyB,
a: {
key: keyA,
etag: a.etag,
lastModified: a.lastModified,
contentLength: a.contentLength,
},
b: {
key: keyB,
etag: b.etag,
lastModified: b.lastModified,
contentLength: b.contentLength,
},
// Сырые тексты отдаем целиком — UI сам покажет diff
configA: bodyA,
configB: bodyB,
});
} catch (error) {
console.error('diffBackups error:', error);
return sendError(res, 500, error.message || 'Error diffing backups', 'E_BACKUP_DIFF');
}
}
/**
* Вспомогательная функция: получить MikroTik REST-клиент по serverId
*/
async function getMikrotikClientForServerId(serverId) {
const servers = await readServersFromS3();
const server = servers.find((s) => (s.id || s.dns || s.ip) === serverId);
if (!server || server.type !== 'jumphost') {
const err = new Error('Jumphost server not found');
err.code = 'E_NOT_FOUND';
throw err;
}
const host = server.mikrotikHost || server.ip || server.dns;
if (!host) {
const err = new Error('MikroTik host is not configured for this server');
err.code = 'E_HOST';
throw err;
}
let password = '';
if (server.encryptedMikrotikPassword) {
try {
password = decrypt(server.encryptedMikrotikPassword);
} catch (e) {
const err = new Error('Failed to decrypt MikroTik password');
err.code = 'E_CREDENTIALS';
throw err;
}
} else {
const err = new Error('MikroTik credentials not configured for this server');
err.code = 'E_CREDENTIALS';
throw err;
}
const port = server.mikrotikPort;
const restPort = port === 80 || port === 443 || port === 8443 ? port : 80;
const user = server.mikrotikUser || 'admin';
const creds = { host, port: restPort, user, password, secure: false };
return createRosClient(creds);
}
/**
* POST /api/mikrotik/backups/run
* Body: { serverId: string }
*
* Немедленно делает /rest/export compact на указанном MikroTik и сохраняет результат на диск.
*/
async function runBackupNow(req, res) {
try {
const { serverId } = req.body || {};
if (!serverId || typeof serverId !== 'string') {
return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
}
let client;
try {
client = await getMikrotikClientForServerId(serverId);
} catch (e) {
if (e.code === 'E_NOT_FOUND') {
return sendError(res, 400, e.message, e.code);
}
if (e.code === 'E_HOST' || e.code === 'E_CREDENTIALS') {
return sendError(res, 400, e.message, e.code);
}
throw e;
}
// API не возвращает текст /export напрямую — экспортируем в файл, читаем, удаляем
const config = await fetchExportViaFile(client);
if (!config || !config.trim()) {
return sendError(res, 502, 'Empty export result from MikroTik', 'E_EMPTY_EXPORT');
}
const { key, createdAt, storage } = await saveBackupForServer(serverId, config, {
source: 'manual',
comment: 'Manual backup from UI (/api/mikrotik/backups/run)',
});
return res.json({
ok: true,
key,
serverId,
createdAt,
storage,
});
} catch (error) {
console.error('runBackupNow error:', error?.message, error?.stack, error?.response?.data);
let msg = error?.message || 'Error running backup now';
const detail = error?.response?.data;
if (detail && typeof detail === 'object') {
const d = detail.detail || detail.message;
if (d) msg += ` (${d})`;
} else if (typeof detail === 'string') {
msg += ` (${detail})`;
}
return sendError(res, 500, msg, 'E_BACKUP_RUN', process.env.NODE_ENV !== 'production' ? { stack: error?.stack } : undefined);
}
}
module.exports = {
createBackup,
listBackups,
getBackup,
diffBackups,
saveBackupForServer,
runBackupNow,
};
File diff suppressed because it is too large Load Diff
+387 -160
View File
@@ -2,10 +2,18 @@
* Разные утилитарные роуты (auto-urls, history, s3 meta, availability, etc.)
*/
const { s3, BUCKET_NAME, writeS3TextObject, headMeta, streamToString } = require('../services/s3Service');
const {
writeS3TextObject,
writeS3JsonObject,
readS3TextObject,
headMeta,
listBlobVersionsForKey,
rollbackBlobVersion,
} = require('../services/s3Service');
const evobgpClient = require('../services/evobgpClient');
const filterRulesStorage = require('../services/filterRulesStorage');
const { sendError, sendOk } = require('../middleware/errorHandler');
const { resourceToKey, toIso } = require('../utils/helpers');
const { GetObjectCommand, PutObjectCommand, ListObjectVersionsCommand, CopyObjectCommand } = require('@aws-sdk/client-s3');
const validators = require('../lib/validators');
const net = require('net');
const http = require('http');
@@ -19,66 +27,97 @@ async function getS3LastModified(req, res) {
{ name: 'domainsNew', key: 'bgp_data/domains_community.txt' },
{ name: 'asns', key: 'bgp_data/asns.txt' },
{ name: 'servers', key: 'servers.json' },
{ name: 'filters', key: 'filters.json' },
{ name: 'filters', key: null },
{ name: 'ipRanges', key: 'bgp_data/ips.txt' },
{ name: 'uiSettings', key: 'bgp_data/rt_ui_settings.json' }
];
const results = await Promise.allSettled(
keys.map(k => headMeta(k.key))
);
const out = {};
results.forEach((r, idx) => {
const name = keys[idx].name;
if (r.status === 'fulfilled' && r.value) {
out[name] = r.value;
} else {
out[name] = null;
for (const k of keys) {
if (k.name === 'filters') {
try {
const head = filterRulesStorage.headMetaForScope('global');
out.filters = {
etag: head.etag,
lastModified: head.lastModified,
contentLength: head.contentLength,
};
} catch {
out.filters = null;
}
continue;
}
});
if (['domainsNew', 'asns', 'ipRanges'].includes(k.name) && evobgpClient.isConfigured()) {
try {
await evobgpClient.fetchRouterListsCatalog();
out[k.name] = { etag: null, lastModified: new Date().toISOString(), contentLength: null };
} catch {
out[k.name] = null;
}
continue;
}
out[k.name] = await headMeta(k.key).catch(() => null);
}
res.json(out);
} catch (error) {
console.error('Error fetching last modified dates from S3:', error);
return sendError(res, 500, 'Error fetching last modified dates from S3', 'E_S3');
console.error('Error fetching last modified dates:', error);
return sendError(res, 500, 'Error fetching last modified dates', 'E_STORAGE');
}
}
const EVOBGP_ONLY_HISTORY_RESOURCES = new Set(['domains-new', 'asns', 'ip-ranges', 'filters']);
// GET /api/history/:resource
async function getHistory(req, res) {
const { resource } = req.params;
const { countOnly, format } = req.query || {};
if (EVOBGP_ONLY_HISTORY_RESOURCES.has(resource)) {
const note =
resource === 'filters'
? 'Фильтры в SQL-таблице app_filter_rules; локальной истории отката через этот API нет'
: 'Версии хранятся в EvoBGP, локальной истории нет';
if (countOnly === 'true') {
return res.json(format === 'std' ? { items: [], total: 0, meta: { noBlobHistory: true } } : { total: 0 });
}
return res.json(
format === 'std'
? { items: [], total: 0, meta: { noBlobHistory: true, note } }
: { items: [], total: 0 },
);
}
const key = resourceToKey(resource);
if (!key) return sendError(res, 400, 'Unknown resource', 'E_RESOURCE');
try {
const out = await s3.send(new ListObjectVersionsCommand({
Bucket: BUCKET_NAME,
Prefix: key,
MaxKeys: 50
const rows = listBlobVersionsForKey(key, 50);
const versionsAll = rows.map((r) => ({
id: r.id,
object_key: r.object_key,
byte_size: r.byte_size,
etag: r.etag,
created_at: r.created_at,
}));
const versionsAll = (out.Versions || []).filter(v => v.Key === key);
if (countOnly === 'true') {
const total = versionsAll.length;
return res.json(format === 'std' ? { items: [], total, meta: {} } : { total });
}
const versions = versionsAll.slice(0, 10).map(v => ({
versionId: v.VersionId,
isLatest: v.IsLatest,
lastModified: toIso(v.LastModified),
size: v.Size,
etag: v.ETag
const versions = versionsAll.slice(0, 10).map((v, idx) => ({
versionId: String(v.id),
isLatest: idx === 0,
lastModified: toIso(v.created_at),
size: v.byte_size,
etag: v.etag,
}));
return res.json(format === 'std' ? { items: versions, total: versionsAll.length, meta: {} } : { items: versions });
} catch (e) {
console.error('history error', e);
return sendError(res, 500, 'Error reading history', 'E_S3', { error: String(e?.message || e) });
return sendError(res, 500, 'Error reading history', 'E_STORAGE', { error: String(e?.message || e) });
}
}
@@ -86,125 +125,76 @@ async function getHistory(req, res) {
async function postRollback(req, res) {
const { resource } = req.params;
const { versionId } = req.body || {};
if (EVOBGP_ONLY_HISTORY_RESOURCES.has(resource)) {
const msg =
resource === 'filters'
? 'Откат версий для фильтров не поддерживается (данные в SQL, не в blob_versions)'
: 'Откат версий для этого справочника не поддерживается (данные в EvoBGP)';
return sendError(res, 400, msg, resource === 'filters' ? 'E_HISTORY_FILTERS_SQL' : 'E_HISTORY_EV');
}
const key = resourceToKey(resource);
if (!key || !versionId) {
return sendError(res, 400, 'Bad request', 'E_BAD_REQUEST');
}
try {
await s3.send(new CopyObjectCommand({
Bucket: BUCKET_NAME,
CopySource: `/${BUCKET_NAME}/${encodeURIComponent(key)}?versionId=${encodeURIComponent(versionId)}`,
Key: key
}));
rollbackBlobVersion(key, versionId);
const meta = await headMeta(key);
return sendOk(res, meta);
} catch (e) {
console.error('rollback error', e);
return sendError(res, 500, 'Error rollback', 'E_S3', { error: String(e?.message || e) });
return sendError(res, 500, 'Error rollback', 'E_STORAGE', { error: String(e?.message || e) });
}
}
// GET /api/auto-urls
async function getAutoUrls(req, res) {
const { checkIfNoneMatch } = require('../middleware/errorHandler');
try {
const { HeadObjectCommand } = require('@aws-sdk/client-s3');
const head = await s3.send(new HeadObjectCommand({
Bucket: BUCKET_NAME,
Key: 'bgp_data/auto_url/urls.txt'
})).catch(() => null);
const etag = head?.ETag || null;
if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
if (checkIfNoneMatch(req, res, etag)) return;
const data = await s3.send(new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: 'bgp_data/auto_url/urls.txt'
}));
const fileContent = await streamToString(data.Body);
const urls = fileContent.split('\n').filter(line => line).map(line => {
const parts = line.trim().split(/\s+/);
return { url: parts[0] || '', community: parts[1] || '' };
});
res.json(urls);
} catch (error) {
if (error.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
res.json([]);
} else {
console.error(error);
return sendError(res, 500, 'Error reading auto URLs from S3', 'E_S3');
if (!evobgpClient.isConfigured()) {
return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
}
const rows = await evobgpClient.fetchAutoUrlsData();
res.json(rows);
} catch (error) {
console.error(error);
return sendError(res, 500, 'Error reading auto URLs', 'E_EVOBGP');
}
}
// POST /api/auto-urls
async function postAutoUrls(req, res) {
const { urls } = req.body;
const fileContent = urls.map(u => `${u.url} ${u.community}`).join('\n');
if (!Array.isArray(urls)) {
return sendError(res, 400, 'urls must be an array', 'E_BAD_REQUEST');
}
try {
const meta = await writeS3TextObject('bgp_data/auto_url/urls.txt', fileContent);
return sendOk(res, meta);
if (!evobgpClient.isConfigured()) {
return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
}
await evobgpClient.saveAutoUrlsData(urls);
return sendOk(res, { etag: null, lastModified: new Date().toISOString(), contentLength: null });
} catch (error) {
console.error(error);
return sendError(res, 500, 'Error writing auto URLs to S3', 'E_S3');
return sendError(res, 500, 'Error writing auto URLs', 'E_EVOBGP');
}
}
// POST /api/auto-urls/process
async function processAutoUrls(req, res) {
try {
// Загрузка URL списков
let urls = [];
try {
const urlsData = await s3.send(new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: 'bgp_data/auto_url/urls.txt'
}));
const urlsContent = await streamToString(urlsData.Body);
urls = urlsContent.split('\n').filter(line => line).map(line => {
const parts = line.trim().split(/\s+/);
return { url: parts[0] || '', community: parts[1] || '' };
});
} catch (error) {
if (error.code !== 'NoSuchKey') throw error;
if (!evobgpClient.isConfigured()) {
return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
}
const urls = await evobgpClient.fetchAutoUrlsData();
if (urls.length === 0) {
return sendError(res, 400, 'No URLs to process', 'E_BAD_REQUEST');
}
// Загрузка текущих IP и доменов
let currentIps = [];
try {
const ipsData = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/ips.txt' }));
const ipsContent = await streamToString(ipsData.Body);
currentIps = ipsContent.split('\n').filter(line => line).map(line => {
const parts = line.trim().split(/\s+/);
return { ipRange: parts[0] || '', community: parts[1] || '' };
});
} catch (error) {
if (error.code !== 'NoSuchKey') throw error;
}
let currentDomains = [];
try {
const dData = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt' }));
const dContent = await streamToString(dData.Body);
currentDomains = dContent.split('\n').filter(line => line).map(line => {
const parts = line.trim().split(/\s+/);
return { domain: (parts[0] || '').toLowerCase(), community: parts[1] || '' };
});
} catch (error) {
if (error.code !== 'NoSuchKey') throw error;
}
const currentIps = await evobgpClient.fetchIpRangesData();
const currentDomains = await evobgpClient.fetchDomainsData();
// Обработка URL
const newIps = [];
@@ -256,22 +246,8 @@ async function processAutoUrls(req, res) {
const uniqueNewDomains = newDomains.filter(d => !existingDomains.has(d.domain));
const allDomains = [...currentDomains, ...uniqueNewDomains];
// Сохранение
const updatedIpsContent = allIps.map(i => `${i.ipRange} ${i.community}`).join('\n');
await s3.send(new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: 'bgp_data/ips.txt',
Body: updatedIpsContent,
ContentType: 'text/plain'
}));
const updatedDomainsContent = allDomains.map(d => `${d.domain} ${d.community}`).join('\n');
await s3.send(new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: 'bgp_data/domains_community.txt',
Body: updatedDomainsContent,
ContentType: 'text/plain'
}));
await evobgpClient.saveIpRangesData(allIps, currentIps);
await evobgpClient.saveDomainsData(allDomains, currentDomains);
const msg = `Обработано ${urls.length} URL, добавлено ${uniqueNewIps.length} IP и ${uniqueNewDomains.length} доменов`;
return res.json({
@@ -285,7 +261,7 @@ async function processAutoUrls(req, res) {
});
} catch (error) {
console.error('Error processing auto URLs:', error);
return sendError(res, 500, 'Error processing auto URLs', 'E_S3');
return sendError(res, 500, 'Error processing auto URLs', 'E_EVOBGP');
}
}
@@ -309,6 +285,254 @@ function tcpCheck(host, port, timeoutMs) {
});
}
/** Измерить RTT (мс) до host:port по TCP. Возвращает число мс или null при ошибке/таймауте. */
function measureTcpRtt(host, port = 443, timeoutMs = 6000) {
return new Promise((resolve) => {
const start = Date.now();
const socket = new net.Socket();
let settled = false;
const settle = (ms) => {
if (!settled) {
settled = true;
try { socket.destroy(); } catch (_) {}
resolve(ms);
}
};
socket.setTimeout(timeoutMs, () => settle(null));
socket.once('error', () => settle(null));
socket.connect(port, host, () => {
const ms = Math.round(Date.now() - start);
settle(ms);
});
});
}
/** Список целей для пинга по умолчанию (id, name, host, port, icon, color) */
const PING_SERVICES_DEFAULT = [
{ id: 'google', name: 'Google', host: '8.8.8.8', port: 443, icon: 'IconBrandGoogle', color: 'blue' },
{ id: 'cloudflare', name: 'Cloudflare', host: '1.1.1.1', port: 443, icon: 'IconBrandCloudflare', color: 'orange' },
{ id: 'yandex', name: 'Yandex', host: 'ya.ru', port: 443, icon: 'IconBrandYandex', color: 'red' },
{ id: 'instagram', name: 'Instagram', host: 'instagram.com', port: 443, icon: 'IconBrandInstagram', color: 'pink' },
];
function getPingServicesListFromSettings(uiSettings) {
const raw = uiSettings.pingServicesList;
if (Array.isArray(raw) && raw.length > 0) {
return raw.map((s) => ({
id: String(s?.id ?? '').trim() || `svc-${Math.random().toString(36).slice(2, 9)}`,
name: String(s?.name ?? '').trim() || 'Сервис',
host: String(s?.host ?? '').trim() || '0.0.0.0',
port: Math.max(1, parseInt(s?.port, 10) || 443),
icon: typeof s?.icon === 'string' ? s.icon : 'IconWorld',
color: typeof s?.color === 'string' ? s.color : 'primary',
})).filter((s) => s.host !== '0.0.0.0');
}
return PING_SERVICES_DEFAULT;
}
/** Загрузить UI-настройки из S3 (для pingServicesSource и др.) */
async function loadUiSettingsSync() {
try {
const data = await readS3TextObject('bgp_data/rt_ui_settings.json');
const parsed = JSON.parse(data.body || '{}');
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
const PING_SERVICES_CACHE_KEY_PREFIX = 'ping-services/cache_';
const PING_SERVICES_HISTORY_KEY_PREFIX = 'ping-services/history_';
const PING_HISTORY_MAX = 30;
function pingServicesStorageSuffix(viaRouter, serverId, gatewayIp) {
return (viaRouter ? 'router' : 'web') + '_' + String(serverId || '').replace(/[^a-zA-Z0-9.-]/g, '_') + '_' + String(gatewayIp || '').replace(/[^a-zA-Z0-9.]/g, '_');
}
/**
* Прочитать историю замеров пингов из S3. Формат: { byId: { [serviceId]: number[] } }.
*/
async function readPingServicesHistory(suffix) {
const key = PING_SERVICES_HISTORY_KEY_PREFIX + suffix;
try {
const raw = await readS3TextObject(key).catch(() => null);
if (!raw?.body) return {};
const parsed = JSON.parse(raw.body);
return parsed?.byId && typeof parsed.byId === 'object' ? parsed.byId : {};
} catch {
return {};
}
}
/**
* Записать историю замеров пингов в S3.
*/
async function writePingServicesHistory(suffix, byId) {
const key = PING_SERVICES_HISTORY_KEY_PREFIX + suffix;
await writeS3JsonObject(key, { byId }).catch((err) => console.warn('[ping-services] history write failed:', err?.message));
}
/**
* Обновить кеш пинг-сервисов: выполнить пинги по списку из настроек и записать результат в S3.
* Используется планировщиком и при промахе кеша в getPingServices.
* @returns {Promise<Record<string, { id: string, name: string, host: string, ms: number|null }>>} byId
*/
async function refreshPingServicesCache() {
const uiSettings = await loadUiSettingsSync();
const servicesList = getPingServicesListFromSettings(uiSettings);
const fallbackPayload = {};
servicesList.forEach((s) => { fallbackPayload[s.id] = { id: s.id, name: s.name, host: s.host, ms: null }; });
const viaRouter = String(uiSettings.pingServicesSource || 'web').toLowerCase() === 'router';
const cacheSeconds = Math.max(0, parseInt(uiSettings.pingServicesCacheSeconds, 10) || 0);
let serverId = null;
let gatewayIp = null;
if (viaRouter) {
const { readServersFromS3 } = require('./serversRoutes');
const servers = await readServersFromS3();
const routerServers = servers.filter(
(s) => s && (String(s.type || '').toLowerCase() === 'jumphost' || String(s.type || '').toLowerCase() === 'home')
);
serverId = (uiSettings.pingServicesServerId && String(uiSettings.pingServicesServerId).trim()) || (routerServers[0] && (routerServers[0].id || routerServers[0].dns || routerServers[0].ip));
gatewayIp = (uiSettings.pingServicesGatewayIp && String(uiSettings.pingServicesGatewayIp).trim()) || null;
}
const suffix = pingServicesStorageSuffix(viaRouter, serverId, gatewayIp);
const cacheKey = cacheSeconds > 0 ? PING_SERVICES_CACHE_KEY_PREFIX + suffix : null;
if (viaRouter) {
const { runPingViaRouter } = require('./mikrotikConfigRoutes');
const { readServersFromS3 } = require('./serversRoutes');
const servers = await readServersFromS3();
const routerServers = servers.filter(
(s) => s && (String(s.type || '').toLowerCase() === 'jumphost' || String(s.type || '').toLowerCase() === 'home')
);
let gatewayIpResolved = gatewayIp;
if (!gatewayIpResolved && serverId && routerServers.length > 0) {
const server = routerServers.find((s) => (s.id || s.dns || s.ip) === serverId) || routerServers[0];
const gateways = Array.isArray(server.gateways) ? server.gateways : [];
const primary = gateways.find((g) => g && g.primary) || gateways[0];
gatewayIpResolved = primary && (primary.ip || primary.remoteIp) ? (primary.ip || primary.remoteIp) : null;
}
if (!serverId) {
console.warn('[ping-services] router mode: no serverId (no jumphost/home in settings or in servers list)');
return fallbackPayload;
}
const results = await Promise.all(
servicesList.map(async (svc) => {
try {
const result = await runPingViaRouter(serverId, gatewayIpResolved || null, svc.host, 3);
const ms = typeof result.avgMs === 'number' ? Math.round(result.avgMs) : null;
return { id: svc.id, name: svc.name, host: svc.host, ms };
} catch (err) {
console.warn('[ping-services] runPingViaRouter failed for', svc.host, err?.message || err);
return { id: svc.id, name: svc.name, host: svc.host, ms: null };
}
})
);
const byId = {};
results.forEach((r) => { byId[r.id] = r; });
if (cacheKey) {
writeS3JsonObject(cacheKey, { byId, cachedAt: Date.now() }).catch((err) => console.warn('[ping-services] cache write failed:', err?.message));
}
const history = await readPingServicesHistory(suffix);
Object.keys(byId).forEach((id) => {
const ms = byId[id]?.ms;
if (typeof ms !== 'number') return;
const list = Array.isArray(history[id]) ? history[id] : [];
history[id] = [...list, ms].slice(-PING_HISTORY_MAX);
});
await writePingServicesHistory(suffix, history);
return byId;
}
const results = await Promise.all(
servicesList.map(async (svc) => {
const ms = await measureTcpRtt(svc.host, svc.port, 6000);
return { id: svc.id, name: svc.name, host: svc.host, ms };
})
);
const byId = {};
results.forEach((r) => { byId[r.id] = r; });
if (cacheKey) {
writeS3JsonObject(cacheKey, { byId, cachedAt: Date.now() }).catch((err) => console.warn('[ping-services] cache write failed:', err?.message));
}
const history = await readPingServicesHistory(suffix);
Object.keys(byId).forEach((id) => {
const ms = byId[id]?.ms;
if (typeof ms !== 'number') return;
const list = Array.isArray(history[id]) ? history[id] : [];
history[id] = [...list, ms].slice(-PING_HISTORY_MAX);
});
await writePingServicesHistory(suffix, history);
return byId;
}
// GET /api/ping-services-list — список сервисов для пинга из настроек (для дашборда и редактора)
async function getPingServicesList(req, res) {
try {
const uiSettings = await loadUiSettingsSync();
const list = getPingServicesListFromSettings(uiSettings);
return res.json({ list });
} catch (e) {
console.error('ping-services-list error', e);
return res.json({ list: PING_SERVICES_DEFAULT });
}
}
// GET /api/ping-services — RTT до сервисов из списка (веб или через RouterOS по настройке)
async function getPingServices(req, res) {
try {
const uiSettings = await loadUiSettingsSync();
const servicesList = getPingServicesListFromSettings(uiSettings);
const fallbackPayload = {};
servicesList.forEach((s) => { fallbackPayload[s.id] = { id: s.id, name: s.name, host: s.host, ms: null }; });
const viaRouter = String(uiSettings.pingServicesSource || 'web').toLowerCase() === 'router';
const cacheSeconds = Math.max(0, parseInt(uiSettings.pingServicesCacheSeconds, 10) || 0);
let serverId = null;
let gatewayIp = null;
if (viaRouter) {
const { readServersFromS3 } = require('./serversRoutes');
const servers = await readServersFromS3();
const routerServers = servers.filter(
(s) => s && (String(s.type || '').toLowerCase() === 'jumphost' || String(s.type || '').toLowerCase() === 'home')
);
serverId = (uiSettings.pingServicesServerId && String(uiSettings.pingServicesServerId).trim()) || (routerServers[0] && (routerServers[0].id || routerServers[0].dns || routerServers[0].ip));
gatewayIp = (uiSettings.pingServicesGatewayIp && String(uiSettings.pingServicesGatewayIp).trim()) || null;
}
const suffix = pingServicesStorageSuffix(viaRouter, serverId, gatewayIp);
const cacheKey = cacheSeconds > 0 ? PING_SERVICES_CACHE_KEY_PREFIX + suffix : null;
const skipCache = ['1', 'true', 'yes'].includes(String(req.query?.refresh || req.query?.nocache || '').toLowerCase());
if (cacheKey && !skipCache) {
try {
const raw = await readS3TextObject(cacheKey).catch(() => null);
if (raw?.body) {
const cached = JSON.parse(raw.body);
const cachedAt = typeof cached.cachedAt === 'number' ? cached.cachedAt : 0;
const ttlMs = cacheSeconds * 1000;
if (cachedAt && Date.now() - cachedAt < ttlMs && cached.byId && typeof cached.byId === 'object') {
const history = await readPingServicesHistory(suffix);
return res.json({ byId: cached.byId, history });
}
}
} catch (_) {}
}
const byId = await refreshPingServicesCache();
const history = await readPingServicesHistory(suffix);
return res.json({ byId, history });
} catch (e) {
console.error('ping-services error', e);
const fallback = {};
PING_SERVICES_DEFAULT.forEach((s) => { fallback[s.id] = { id: s.id, name: s.name, host: s.host, ms: null }; });
res.status(500).json({ byId: fallback, history: {} });
}
}
function anyTrue(promises) {
return new Promise((resolve) => {
if (!Array.isArray(promises) || promises.length === 0) return resolve(false);
@@ -349,10 +573,10 @@ async function getServersAvailability(req, res) {
return res.json({ ...availabilityCache.data, cached: true });
}
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'servers.json' }));
let servers = [];
try {
servers = JSON.parse(await streamToString(data.Body));
const data = await readS3TextObject('servers.json');
servers = JSON.parse(data.body);
if (!Array.isArray(servers)) servers = [];
} catch {
servers = [];
@@ -434,12 +658,13 @@ async function updateBgpBackground(req, res) {
// GET /api/ws/url
async function getWsUrl(req, res) {
try {
const settings = await s3.send(new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: 'bgp_data/rt_ui_settings.json'
})).then(async (d) => {
try { return JSON.parse(await streamToString(d.Body)); } catch { return {}; }
}).catch(() => ({}));
let settings = {};
try {
const d = await readS3TextObject('bgp_data/rt_ui_settings.json');
settings = JSON.parse(d.body || '{}');
} catch (_) {
settings = {};
}
const url = settings?.wsUpdateUrl || process.env.WS_UPDATE_URL || '';
return res.json({ url });
@@ -451,25 +676,22 @@ async function getWsUrl(req, res) {
// GET/POST /api/ui-settings
async function getUiSettings(req, res) {
const { checkIfNoneMatch } = require('../middleware/errorHandler');
try {
const { HeadObjectCommand } = require('@aws-sdk/client-s3');
const head = await s3.send(new HeadObjectCommand({
Bucket: BUCKET_NAME,
Key: 'bgp_data/rt_ui_settings.json'
})).catch(() => null);
const etag = head?.ETag || null;
const head = await headMeta('bgp_data/rt_ui_settings.json');
const etag = head?.etag || null;
if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
if (head?.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString());
if (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
if (checkIfNoneMatch(req, res, etag)) return;
const data = await s3.send(new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: 'bgp_data/rt_ui_settings.json'
}));
const jsonText = await streamToString(data.Body);
let jsonText = '{}';
try {
const data = await readS3TextObject('bgp_data/rt_ui_settings.json');
jsonText = data.body;
} catch (e) {
if (e?.code !== 'NoSuchKey') throw e;
}
let settings = {};
try {
const parsed = JSON.parse(jsonText);
@@ -479,11 +701,11 @@ async function getUiSettings(req, res) {
}
return res.json(settings);
} catch (error) {
if (error.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
if (error?.code === 'NoSuchKey') {
return res.json({});
}
console.error('Error reading ui settings from S3:', error);
return sendError(res, 500, 'Error reading UI settings from S3', 'E_S3');
console.error('Error reading ui settings:', error);
return sendError(res, 500, 'Error reading UI settings', 'E_STORAGE');
}
}
@@ -507,8 +729,8 @@ async function postUiSettings(req, res) {
const meta = await writeS3JsonObject('bgp_data/rt_ui_settings.json', payload);
return sendOk(res, meta);
} catch (error) {
console.error('Error writing UI settings to S3:', error);
return sendError(res, 500, 'Error writing UI settings to S3', 'E_S3', { error: String(error?.message || error) });
console.error('Error writing UI settings:', error);
return sendError(res, 500, 'Error writing UI settings', 'E_STORAGE', { error: String(error?.message || error) });
}
}
@@ -524,5 +746,10 @@ module.exports = {
getWsUrl,
getUiSettings,
postUiSettings,
getPingServicesList,
getPingServices,
refreshPingServicesCache,
checkOneServerFast,
loadUiSettingsSync,
};
+158
View File
@@ -0,0 +1,158 @@
/**
* Роуты для сбора статистики ресурсов (RAM, CPU, HDD) с MikroTik RouterOS.
* GET /api/resources/stats — system/resource по каждому jumphost/home с учётами MikroTik.
*/
const { sendError } = require('../middleware/errorHandler');
const { decrypt } = require('../utils/encryption');
const { readServersFromS3 } = require('./serversRoutes');
const { createRosClient } = require('../services/mikrotikApplyService');
function getMikrotikCredentials(server) {
if (!server || (server.type !== 'jumphost' && server.type !== 'home')) return null;
const host = server.mikrotikHost || server.ip || server.dns;
if (!host) return null;
const port = server.mikrotikPort;
const restPort = (port === 80 || port === 443 || port === 8443) ? port : 80;
const user = server.mikrotikUser || 'admin';
let password = '';
if (server.encryptedMikrotikPassword) {
try {
password = decrypt(server.encryptedMikrotikPassword);
} catch (_) {
return null;
}
}
return { host, port: restPort, user, password, secure: false };
}
/**
* Парсинг system/resource в нормализованный объект для UI.
* MikroTik REST возвращает: total-memory, free-memory (bytes), cpu-load (%),
* total-hdd-space, free-hdd-space (bytes), uptime, version, board-name и др.
*/
function parseResource(raw) {
if (!raw || typeof raw !== 'object') return null;
const num = (v) => (v != null && v !== '' ? parseInt(String(v), 10) : null);
const totalMemory = num(raw['total-memory']);
const freeMemory = num(raw['free-memory']);
const usedMemory = totalMemory != null && freeMemory != null ? totalMemory - freeMemory : null;
const totalHdd = num(raw['total-hdd-space']);
const freeHdd = num(raw['free-hdd-space']);
const usedHdd = totalHdd != null && freeHdd != null ? totalHdd - freeHdd : null;
const cpuLoad = raw['cpu-load'] != null ? parseFloat(String(raw['cpu-load'])) : null;
return {
// RAM (bytes и проценты)
totalMemory: totalMemory,
freeMemory: freeMemory,
usedMemory: usedMemory,
memoryUsagePercent:
totalMemory != null && totalMemory > 0 && usedMemory != null
? Math.round((usedMemory / totalMemory) * 100)
: null,
// CPU (%)
cpuLoad: cpuLoad != null ? Math.round(cpuLoad) : null,
cpuCount: num(raw['cpu-count']) ?? null,
cpuFrequency: raw['cpu-frequency'] != null ? String(raw['cpu-frequency']) : null,
cpu: raw.cpu != null ? String(raw.cpu) : null,
// HDD (bytes и проценты)
totalHddSpace: totalHdd,
freeHddSpace: freeHdd,
usedHddSpace: usedHdd,
hddUsagePercent:
totalHdd != null && totalHdd > 0 && usedHdd != null
? Math.round((usedHdd / totalHdd) * 100)
: null,
// прочее
uptime: raw.uptime != null ? String(raw.uptime) : null,
version: raw.version != null ? String(raw.version) : null,
boardName: raw['board-name'] != null ? String(raw['board-name']) : null,
architectureName: raw['architecture-name'] != null ? String(raw['architecture-name']) : null,
platform: raw.platform != null ? String(raw.platform) : null,
};
}
/**
* Внутренняя функция: возвращает данные по ресурсам роутеров (без HTTP).
*/
async function getResourceStatsData() {
const servers = await readServersFromS3();
const routers = (Array.isArray(servers) ? servers : []).filter(
(s) =>
s &&
(String(s.type || '').toLowerCase() === 'jumphost' ||
String(s.type || '').toLowerCase() === 'home')
);
const results = await Promise.all(
routers.map(async (server) => {
const serverId = server.id || server.dns || server.ip;
const name = server.name || server.dns || server.ip || serverId;
const host = server.mikrotikHost || server.ip || server.dns;
const creds = getMikrotikCredentials(server);
if (!creds) {
return {
serverId,
name,
host,
groupKey: server.groupKey || null,
error: 'MikroTik API не настроен или нет пароля',
resource: null,
};
}
try {
const client = createRosClient(creds);
const apiRes = await client.print('system/resource');
const raw = apiRes?.data;
const single = Array.isArray(raw) ? raw[0] : raw;
const resource = parseResource(single);
return {
serverId,
name,
host,
groupKey: server.groupKey || null,
error: null,
resource,
};
} catch (err) {
const msg = err.response?.data?.message || err.message || 'Ошибка запроса к RouterOS';
return {
serverId,
name,
host,
groupKey: server.groupKey || null,
error: msg,
resource: null,
};
}
})
);
return { routers: results };
}
/**
* GET /api/resources/stats
* По каждому jumphost/home с MikroTik — запрос system/resource, ответ с группировкой по роутерам.
*/
async function getResourceStats(req, res) {
try {
const data = await getResourceStatsData();
return res.json(data);
} catch (error) {
console.error('getResourceStats:', error);
return sendError(
res,
500,
error.message || 'Ошибка сбора статистики ресурсов',
'E_RESOURCE_STATS'
);
}
}
module.exports = {
getResourceStats,
getResourceStatsData,
};
+859
View File
@@ -0,0 +1,859 @@
/**
* Rule-based "local AI" optimizer for route selection.
* Returns best paths and probability of optimality for all candidates.
*/
const { sendError } = require('../middleware/errorHandler');
const { readS3TextObject, listS3Objects } = require('../services/s3Service');
const evobgpClient = require('../services/evobgpClient');
const { readServersFromS3 } = require('./serversRoutes');
const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json';
const NETWORK_CONFIG_KEY = 'network-config.json';
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
const DEFAULT_AI_SETTINGS = {
latencyWeight: 0.55,
bandwidthWeight: 0.35,
freshnessWeight: 0.1,
downloadWeight: 0.5,
uploadWeight: 0.5,
combineHomeToJumphostWeight: 0.45,
combineJumphostToExitWeight: 0.55,
probabilityScale: 5,
minProbabilityGainForSwitch: 10,
noPingScore: 0.2,
noSpeedScore: 0.15,
staleScore: 0.35,
freshnessExcellentSeconds: 120,
freshnessGoodSeconds: 600,
freshnessFairSeconds: 1800,
distancePenaltyPerStep: 0.03,
pinnedCommunityGateways: [],
};
function asObject(v) {
return v && typeof v === 'object' ? v : {};
}
function toNumber(v) {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function clamp(n, min, max) {
return Math.max(min, Math.min(max, n));
}
function positiveNumber(v, fallback) {
const n = Number(v);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
function nonNegativeNumber(v, fallback) {
const n = Number(v);
return Number.isFinite(n) && n >= 0 ? n : fallback;
}
function makeServerKey(server) {
if (!server || typeof server !== 'object') return '';
return String(server.id || server.dns || server.ip || '').trim();
}
function resolveServerRef(ref, byRef) {
const key = String(ref || '').trim();
if (!key) return null;
return byRef.get(key) || null;
}
function pairKey(a, b) {
return [String(a || ''), String(b || '')].sort().join(':');
}
function pairProbability(items, scoreGetter) {
if (!Array.isArray(items) || items.length === 0) return [];
const values = items.map((it) => Number(scoreGetter(it) || 0));
const max = Math.max(...values);
const exps = values.map((v) => Math.exp(v - max));
const sum = exps.reduce((acc, x) => acc + x, 0) || 1;
return exps.map((x) => (x / sum) * 100);
}
function latencyScore(pingMs, aiSettings) {
if (typeof pingMs !== 'number') return aiSettings.noPingScore;
const normalized = 1 / (1 + pingMs / 35);
return clamp(normalized, 0, 1);
}
function normalizeBandwidthValue(speedMbps, aiSettings) {
if (typeof speedMbps !== 'number' || speedMbps <= 0) return aiSettings.noSpeedScore;
// log-scale to avoid dominance by very high channels
const normalized = Math.log10(1 + speedMbps) / Math.log10(1001);
return clamp(normalized, 0, 1);
}
function freshnessScore(cacheUpdatedAt, aiSettings) {
if (typeof cacheUpdatedAt !== 'number') return aiSettings.staleScore;
const ageMs = Math.max(0, Date.now() - cacheUpdatedAt);
const excellentMs = aiSettings.freshnessExcellentSeconds * 1000;
const goodMs = aiSettings.freshnessGoodSeconds * 1000;
const fairMs = aiSettings.freshnessFairSeconds * 1000;
if (ageMs <= excellentMs) return 1;
if (ageMs <= goodMs) return 0.8;
if (ageMs <= fairMs) return 0.6;
return aiSettings.staleScore;
}
function bandwidthScore({ speedDownloadMbps, speedUploadMbps, aiSettings }) {
// DL/UL considered separately with configurable weights.
const dl = normalizeBandwidthValue(speedDownloadMbps, aiSettings);
const ul = normalizeBandwidthValue(speedUploadMbps, aiSettings);
return clamp(dl * aiSettings.downloadWeight + ul * aiSettings.uploadWeight, 0, 1);
}
function buildSegmentScore({
pingMs,
speedMbps,
speedDownloadMbps,
speedUploadMbps,
cacheUpdatedAt,
aiSettings,
}) {
const l = latencyScore(pingMs, aiSettings);
const b = bandwidthScore({ speedDownloadMbps, speedUploadMbps, aiSettings });
const f = freshnessScore(cacheUpdatedAt, aiSettings);
const hasAnySpeed =
typeof speedMbps === 'number' ||
typeof speedDownloadMbps === 'number' ||
typeof speedUploadMbps === 'number';
const metricPresence = (typeof pingMs === 'number' ? 1 : 0) + (hasAnySpeed ? 1 : 0);
const confidence = metricPresence === 2 ? 1 : metricPresence === 1 ? 0.65 : 0.35;
const score =
l * aiSettings.latencyWeight +
b * aiSettings.bandwidthWeight +
f * aiSettings.freshnessWeight;
return { score: clamp(score, 0, 1), confidence };
}
function compactServer(server) {
return {
id: server?.id || null,
ip: server?.ip || null,
dns: server?.dns || null,
type: server?.type || null,
country: server?.country || null,
label: server?.dns || server?.ip || server?.id || 'unknown',
};
}
function normalizeRef(value) {
return String(value || '').trim();
}
function buildServerRefSet(server) {
return new Set(collectServerRefs(server).map(normalizeRef).filter(Boolean));
}
function findPinnedCommunityGateway(pinnedCommunityGateways, jumphost, community) {
const targetCommunity = normalizeRef(community);
if (!targetCommunity || !Array.isArray(pinnedCommunityGateways)) return null;
const jumphostRefs = buildServerRefSet(jumphost);
for (const entry of pinnedCommunityGateways) {
const entryCommunity = normalizeRef(entry?.community);
if (!entryCommunity || entryCommunity !== targetCommunity) continue;
const pinnedGateway = normalizeRef(entry?.gateway);
if (!pinnedGateway) continue;
const pinnedJumphost = normalizeRef(entry?.jumphost);
if (!pinnedJumphost) return { community: entryCommunity, gateway: pinnedGateway, jumphost: null };
if (jumphostRefs.has(pinnedJumphost)) {
return { community: entryCommunity, gateway: pinnedGateway, jumphost: pinnedJumphost };
}
}
return null;
}
async function loadCommunitiesIndex() {
try {
if (!evobgpClient.isConfigured()) return new Map();
const arr = await evobgpClient.listCommunities();
const m = new Map();
if (!Array.isArray(arr)) return m;
for (const c of arr) {
const value = String(c?.value || '').trim();
if (!value) continue;
m.set(value, {
value,
name: c?.name ? String(c.name) : '',
description: c?.description ? String(c.description) : '',
tags: Array.isArray(c?.tags) ? c.tags.map(String) : [],
});
}
return m;
} catch (_) {
return new Map();
}
}
function parseServerIdFromFilterKey(key) {
const m = String(key || '').match(/^filter-manager\/server-filters-(.+)\.json$/);
return m ? m[1] : null;
}
async function loadServerFiltersByServerId() {
const out = new Map();
try {
const objects = await listS3Objects('filter-manager/server-filters-', { maxKeys: 500 });
for (const obj of objects) {
const key = obj?.key;
const serverId = parseServerIdFromFilterKey(key);
if (!serverId) continue;
try {
const raw = await readS3TextObject(key).catch(() => null);
const parsed = raw?.body ? JSON.parse(raw.body || '[]') : [];
const list = Array.isArray(parsed) ? parsed : [];
out.set(serverId, list.filter((f) => f && f.community && f.gateway).map((f) => ({
community: String(f.community),
gateway: String(f.gateway),
description: f.description ? String(f.description) : '',
})));
} catch (_) {
out.set(serverId, []);
}
}
} catch (_) {}
return out;
}
async function loadNetworkMapCache() {
try {
const raw = await readS3TextObject(NETWORK_MAP_CACHE_KEY).catch(() => null);
if (!raw?.body) return { pingMap: {}, speedMap: {}, updatedAt: null };
const parsed = JSON.parse(raw.body || '{}');
return {
pingMap: asObject(parsed.pingMap),
speedMap: asObject(parsed.speedMap),
updatedAt: toNumber(parsed.updatedAt),
};
} catch (_) {
return { pingMap: {}, speedMap: {}, updatedAt: null };
}
}
async function loadNetworkConfig() {
try {
const raw = await readS3TextObject(NETWORK_CONFIG_KEY).catch(() => null);
if (!raw?.body) return {};
const parsed = JSON.parse(raw.body || '{}');
return asObject(parsed);
} catch (_) {
return {};
}
}
async function loadAiSettings() {
try {
const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => null);
const parsed = raw?.body ? JSON.parse(raw.body || '{}') : {};
const src = asObject(parsed?.aiRouteOptimizer);
const latencyWeight = positiveNumber(src.latencyWeight, DEFAULT_AI_SETTINGS.latencyWeight);
const bandwidthWeight = positiveNumber(src.bandwidthWeight, DEFAULT_AI_SETTINGS.bandwidthWeight);
const freshnessWeight = positiveNumber(src.freshnessWeight, DEFAULT_AI_SETTINGS.freshnessWeight);
const sum = latencyWeight + bandwidthWeight + freshnessWeight || 1;
const downloadWeight = nonNegativeNumber(src.downloadWeight, DEFAULT_AI_SETTINGS.downloadWeight);
const uploadWeight = nonNegativeNumber(src.uploadWeight, DEFAULT_AI_SETTINGS.uploadWeight);
const sumDu = downloadWeight + uploadWeight;
const hjW = positiveNumber(
src.combineHomeToJumphostWeight,
DEFAULT_AI_SETTINGS.combineHomeToJumphostWeight
);
const jeW = positiveNumber(
src.combineJumphostToExitWeight,
DEFAULT_AI_SETTINGS.combineJumphostToExitWeight
);
const sum2 = hjW + jeW || 1;
const pinnedCommunityGateways = Array.isArray(src.pinnedCommunityGateways)
? src.pinnedCommunityGateways
.map((x) => ({
community: normalizeRef(x?.community),
gateway: normalizeRef(x?.gateway),
jumphost: normalizeRef(x?.jumphost),
}))
.filter((x) => x.community && x.gateway)
: [];
return {
latencyWeight: latencyWeight / sum,
bandwidthWeight: bandwidthWeight / sum,
freshnessWeight: freshnessWeight / sum,
downloadWeight: sumDu > 0 ? downloadWeight / sumDu : DEFAULT_AI_SETTINGS.downloadWeight,
uploadWeight: sumDu > 0 ? uploadWeight / sumDu : DEFAULT_AI_SETTINGS.uploadWeight,
combineHomeToJumphostWeight: hjW / sum2,
combineJumphostToExitWeight: jeW / sum2,
probabilityScale: clamp(
positiveNumber(src.probabilityScale, DEFAULT_AI_SETTINGS.probabilityScale),
0.5,
20
),
minProbabilityGainForSwitch: clamp(
positiveNumber(
src.minProbabilityGainForSwitch,
DEFAULT_AI_SETTINGS.minProbabilityGainForSwitch
),
0,
100
),
noPingScore: clamp(positiveNumber(src.noPingScore, DEFAULT_AI_SETTINGS.noPingScore), 0, 1),
noSpeedScore: clamp(positiveNumber(src.noSpeedScore, DEFAULT_AI_SETTINGS.noSpeedScore), 0, 1),
staleScore: clamp(positiveNumber(src.staleScore, DEFAULT_AI_SETTINGS.staleScore), 0, 1),
freshnessExcellentSeconds: clamp(
positiveNumber(src.freshnessExcellentSeconds, DEFAULT_AI_SETTINGS.freshnessExcellentSeconds),
10,
86400
),
freshnessGoodSeconds: clamp(
positiveNumber(src.freshnessGoodSeconds, DEFAULT_AI_SETTINGS.freshnessGoodSeconds),
10,
86400
),
freshnessFairSeconds: clamp(
positiveNumber(src.freshnessFairSeconds, DEFAULT_AI_SETTINGS.freshnessFairSeconds),
10,
86400
),
distancePenaltyPerStep: clamp(
positiveNumber(src.distancePenaltyPerStep, DEFAULT_AI_SETTINGS.distancePenaltyPerStep),
0,
1
),
pinnedCommunityGateways,
};
} catch (_) {
return { ...DEFAULT_AI_SETTINGS };
}
}
function buildInterfaceCandidates({ servers, tunnelInterfaces, pingMap, speedMap, cacheUpdatedAt, aiSettings }) {
const byRef = new Map();
servers.forEach((s) => {
const refs = [s.id, s.ip, s.dns].filter(Boolean).map((v) => String(v));
refs.forEach((ref) => byRef.set(ref, s));
});
const homeToJh = [];
const jhToExit = [];
for (const iface of tunnelInterfaces) {
if (!iface || !iface.serverId || !iface.serverId2) continue;
const s1 = resolveServerRef(iface.serverId, byRef);
const s2 = resolveServerRef(iface.serverId2, byRef);
if (!s1 || !s2) continue;
const t1 = String(s1.type || '').toLowerCase();
const t2 = String(s2.type || '').toLowerCase();
const k1 = makeServerKey(s1);
const k2 = makeServerKey(s2);
const pkey = pairKey(s1.ip, s2.ip);
const skey = pairKey(k1, k2);
const pingMs = toNumber(pingMap[pkey]);
const speedEntry = asObject(speedMap[skey]);
const downBps = toNumber(speedEntry.tcpDownloadBps);
const upBps = toNumber(speedEntry.tcpUploadBps);
const speedDownloadMbps = downBps != null ? downBps / 1e6 : null;
const speedUploadMbps = upBps != null ? upBps / 1e6 : null;
const speedMbps =
speedDownloadMbps != null && speedUploadMbps != null
? (speedDownloadMbps + speedUploadMbps) / 2
: speedDownloadMbps != null
? speedDownloadMbps
: speedUploadMbps != null
? speedUploadMbps
: null;
const scoring = buildSegmentScore({
pingMs,
speedMbps,
speedDownloadMbps,
speedUploadMbps,
cacheUpdatedAt,
aiSettings,
});
const base = {
interfaceName: iface.name || null,
localIp: iface.localIp || null,
remoteIp: iface.remoteIp || null,
pingMs,
speedMbps: speedMbps != null ? Number(speedMbps.toFixed(2)) : null,
speedDownloadMbps: speedDownloadMbps != null ? Number(speedDownloadMbps.toFixed(2)) : null,
speedUploadMbps: speedUploadMbps != null ? Number(speedUploadMbps.toFixed(2)) : null,
score: Number(scoring.score.toFixed(4)),
confidence: Number(scoring.confidence.toFixed(4)),
};
if ((t1 === 'home' && t2 === 'jumphost') || (t1 === 'jumphost' && t2 === 'home')) {
const home = t1 === 'home' ? s1 : s2;
const jumphost = t1 === 'jumphost' ? s1 : s2;
homeToJh.push({
id: `${makeServerKey(home)}->${makeServerKey(jumphost)}::${base.interfaceName || 'iface'}`,
homeKey: makeServerKey(home),
jumphostKey: makeServerKey(jumphost),
home: compactServer(home),
jumphost: compactServer(jumphost),
...base,
});
continue;
}
if ((t1 === 'jumphost' && t2 === 'exit') || (t1 === 'exit' && t2 === 'jumphost')) {
const jumphost = t1 === 'jumphost' ? s1 : s2;
const exit = t1 === 'exit' ? s1 : s2;
const gatewayIpForJumphost = t1 === 'jumphost' ? (iface.remoteIp || null) : (iface.localIp || null);
jhToExit.push({
id: `${makeServerKey(jumphost)}->${makeServerKey(exit)}::${base.interfaceName || 'iface'}`,
jumphostKey: makeServerKey(jumphost),
exitKey: makeServerKey(exit),
gatewayIpForJumphost,
jumphost: compactServer(jumphost),
exit: compactServer(exit),
...base,
});
}
}
return { homeToJh, jhToExit };
}
function enrichProbabilities(candidates, scoreField = 'score', aiSettings = DEFAULT_AI_SETTINGS) {
if (!Array.isArray(candidates) || candidates.length === 0) return [];
const probs = pairProbability(candidates, (c) => c[scoreField] * aiSettings.probabilityScale);
return candidates.map((c, i) => ({
...c,
probabilityOptimal: Number(probs[i].toFixed(2)),
}));
}
function groupBy(items, keyGetter) {
const m = new Map();
for (const item of items) {
const key = keyGetter(item);
if (!m.has(key)) m.set(key, []);
m.get(key).push(item);
}
return m;
}
function collectServerRefs(server) {
return [server?.id, server?.ip, server?.dns]
.filter(Boolean)
.map((x) => String(x).trim());
}
function buildServerIndex(servers) {
const byRef = new Map();
(Array.isArray(servers) ? servers : []).forEach((s) => {
collectServerRefs(s).forEach((ref) => byRef.set(ref, s));
});
return byRef;
}
function getServerByAnyRef(ref, byRef) {
const r = String(ref || '').trim();
if (!r) return null;
return byRef.get(r) || null;
}
function gatewayBelongsToJumphost(gw, jumphost, byRef) {
if (!gw || !jumphost) return false;
const gwServer = getServerByAnyRef(gw.serverId, byRef);
if (!gwServer) return false;
const jhRefs = new Set(collectServerRefs(jumphost));
return collectServerRefs(gwServer).some((r) => jhRefs.has(r));
}
function resolveParentGatewayRef(parentId, networkConfig) {
const gateways = Array.isArray(networkConfig?.gateways) ? networkConfig.gateways : [];
const interfaces = Array.isArray(networkConfig?.tunnelInterfaces)
? networkConfig.tunnelInterfaces
: [];
const gw = gateways.find((x) => x && x.id === parentId);
if (gw) return { type: 'gateway', value: gw };
const iface = interfaces.find((x) => x && x.id === parentId);
if (iface) return { type: 'interface', value: iface };
return null;
}
function getInterfaceGatewayIpForJumphost(iface, jumphost, byRef) {
const s1 = getServerByAnyRef(iface?.serverId, byRef);
const s2 = getServerByAnyRef(iface?.serverId2, byRef);
if (!s1 || !s2 || !jumphost) return null;
const jhRefs = new Set(collectServerRefs(jumphost));
const s1isJh = collectServerRefs(s1).some((r) => jhRefs.has(r));
const s2isJh = collectServerRefs(s2).some((r) => jhRefs.has(r));
if (s1isJh) return iface.remoteIp || null;
if (s2isJh) return iface.localIp || null;
return null;
}
function computeDistanceAdjustedScore(score, distance, aiSettings) {
const d = Number.isFinite(Number(distance)) ? Number(distance) : 1;
const penaltySteps = Math.max(0, d - 1);
return clamp(Number(score || 0) - penaltySteps * aiSettings.distancePenaltyPerStep, 0, 1);
}
function buildRecursiveGatewayOptionsForJumphost({
jumphost,
jumphostCandidates,
networkConfig,
servers,
aiSettings,
}) {
const byRef = buildServerIndex(servers);
const gateways = Array.isArray(networkConfig?.gateways) ? networkConfig.gateways : [];
const recursive = gateways.filter(
(gw) =>
gw &&
String(gw.type || '').toLowerCase() === 'recursive' &&
gatewayBelongsToJumphost(gw, jumphost, byRef)
);
if (recursive.length === 0) return [];
const options = [];
for (const rgw of recursive) {
const parentRefs =
Array.isArray(rgw.parentGateways) && rgw.parentGateways.length > 0
? rgw.parentGateways
: rgw.parentGatewayId
? [{ id: rgw.parentGatewayId, distance: undefined }]
: [];
const mapped = [];
for (const pref of parentRefs) {
const resolved = resolveParentGatewayRef(pref?.id, networkConfig);
if (!resolved) continue;
const distance = Number.isFinite(Number(pref?.distance)) ? Number(pref.distance) : 1;
let physicalGatewayIp = null;
if (resolved.type === 'gateway') {
physicalGatewayIp = resolved.value?.ip || null;
} else if (resolved.type === 'interface') {
physicalGatewayIp = getInterfaceGatewayIpForJumphost(
resolved.value,
jumphost,
byRef
);
}
if (!physicalGatewayIp) continue;
const candidate = (Array.isArray(jumphostCandidates) ? jumphostCandidates : []).find(
(c) => String(c.gatewayIpForJumphost || '') === String(physicalGatewayIp)
);
if (!candidate) continue;
mapped.push({
distance,
physicalGatewayIp,
candidate,
scoreWithDistance: computeDistanceAdjustedScore(candidate.score, distance, aiSettings),
});
}
if (mapped.length === 0) continue;
mapped.sort((a, b) => b.scoreWithDistance - a.scoreWithDistance);
const best = mapped[0];
options.push({
recursiveGateway: rgw.ip || null,
recursiveGatewayId: rgw.id || null,
recursiveDescription: rgw.description || '',
tunnelGatewayIp: best.physicalGatewayIp,
interfaceName: best.candidate.interfaceName || null,
distance: best.distance,
exit: best.candidate.exit,
baseScore: best.candidate.score,
score: Number(best.scoreWithDistance.toFixed(4)),
pingMs: best.candidate.pingMs,
speedMbps: best.candidate.speedMbps,
sourceCandidateId: best.candidate.id,
});
}
return options.sort((a, b) => b.score - a.score);
}
function buildCommunityOptimization({
exitsByJh,
serverFiltersByServerId,
communitiesIndex,
networkConfig,
servers,
aiSettings,
}) {
const jumphostByCommunity = [];
for (const [jumphostKey, rawCandidates] of exitsByJh.entries()) {
const candidates = enrichProbabilities([...rawCandidates].sort((a, b) => b.score - a.score), 'score', aiSettings);
const jumphost = candidates[0]?.jumphost || null;
const recursiveOptionsRaw = buildRecursiveGatewayOptionsForJumphost({
jumphost,
jumphostCandidates: candidates,
networkConfig,
servers,
aiSettings,
});
const recursiveOptions = enrichProbabilities(recursiveOptionsRaw, 'score', aiSettings);
const gatewayBestMap = new Map();
for (const c of candidates) {
const gw = String(c.gatewayIpForJumphost || '').trim();
if (!gw) continue;
if (!gatewayBestMap.has(gw) || gatewayBestMap.get(gw).score < c.score) {
gatewayBestMap.set(gw, c);
}
}
const recursiveByIp = new Map();
const recursiveById = new Map();
recursiveOptions.forEach((o) => {
if (o.recursiveGateway) recursiveByIp.set(String(o.recursiveGateway), o);
if (o.recursiveGatewayId) recursiveById.set(String(o.recursiveGatewayId), o);
});
const filters = serverFiltersByServerId.get(jumphostKey) || [];
if (filters.length === 0) {
jumphostByCommunity.push({
jumphost,
candidates,
recursiveGatewayOptions: recursiveOptions,
recommendations: [],
});
continue;
}
const recommendations = filters.map((f) => {
const currentGateway = String(f.gateway || '').trim();
const pinned = findPinnedCommunityGateway(
aiSettings.pinnedCommunityGateways,
jumphost,
f.community
);
const currentCandidate =
recursiveByIp.get(currentGateway) ||
recursiveById.get(currentGateway) ||
gatewayBestMap.get(currentGateway) ||
null;
const pinnedCandidate = pinned
? recursiveByIp.get(pinned.gateway) ||
recursiveById.get(pinned.gateway) ||
gatewayBestMap.get(pinned.gateway) ||
null
: null;
const recommendedCandidate = pinned
? pinnedCandidate
: (recursiveOptions.length > 0 ? recursiveOptions[0] : null) || candidates[0] || null;
const communityInfo = communitiesIndex.get(String(f.community)) || null;
const recommendedGatewayRef = normalizeRef(
recommendedCandidate?.recursiveGateway || recommendedCandidate?.gatewayIpForJumphost
);
const currentGatewayRef = normalizeRef(
currentCandidate?.recursiveGateway || currentCandidate?.gatewayIpForJumphost || currentGateway
);
const shouldSwitch = pinned
? Boolean(recommendedGatewayRef && recommendedGatewayRef !== currentGatewayRef)
: Boolean(
recommendedCandidate &&
currentCandidate &&
recommendedGatewayRef !== currentGatewayRef &&
(recommendedCandidate.probabilityOptimal - currentCandidate.probabilityOptimal) >=
aiSettings.minProbabilityGainForSwitch
);
return {
community: String(f.community),
communityInfo: communityInfo || {
value: String(f.community),
name: '',
description: f.description || '',
tags: [],
},
currentGateway: currentGateway || null,
current: currentCandidate ? {
gateway: currentCandidate.recursiveGateway || currentCandidate.gatewayIpForJumphost || null,
tunnelGateway: currentCandidate.tunnelGatewayIp || currentCandidate.gatewayIpForJumphost || null,
interfaceName: currentCandidate.interfaceName || null,
distance: currentCandidate.distance != null ? currentCandidate.distance : null,
exit: currentCandidate.exit,
score: currentCandidate.score,
probabilityOptimal: currentCandidate.probabilityOptimal,
pingMs: currentCandidate.pingMs,
speedMbps: currentCandidate.speedMbps,
speedDownloadMbps: currentCandidate.speedDownloadMbps,
speedUploadMbps: currentCandidate.speedUploadMbps,
} : null,
recommended: recommendedCandidate ? {
gateway: recommendedCandidate.recursiveGateway || recommendedCandidate.gatewayIpForJumphost || null,
tunnelGateway: recommendedCandidate.tunnelGatewayIp || recommendedCandidate.gatewayIpForJumphost || null,
interfaceName: recommendedCandidate.interfaceName || null,
distance: recommendedCandidate.distance != null ? recommendedCandidate.distance : null,
exit: recommendedCandidate.exit,
score: recommendedCandidate.score,
probabilityOptimal: recommendedCandidate.probabilityOptimal,
pingMs: recommendedCandidate.pingMs,
speedMbps: recommendedCandidate.speedMbps,
speedDownloadMbps: recommendedCandidate.speedDownloadMbps,
speedUploadMbps: recommendedCandidate.speedUploadMbps,
} : null,
pinnedGateway: pinned?.gateway || null,
pinnedBySettings: Boolean(pinned),
pinnedGatewayResolved: Boolean(pinned && recommendedCandidate),
shouldSwitch,
};
});
jumphostByCommunity.push({
jumphost,
candidates,
recursiveGatewayOptions: recursiveOptions,
recommendations,
});
}
return jumphostByCommunity;
}
async function getRouteOptimizer(req, res) {
try {
const [servers, networkConfig, networkMapCache, communitiesIndex, serverFiltersByServerId, aiSettings] = await Promise.all([
readServersFromS3(),
loadNetworkConfig(),
loadNetworkMapCache(),
loadCommunitiesIndex(),
loadServerFiltersByServerId(),
loadAiSettings(),
]);
const tunnelInterfaces = Array.isArray(networkConfig.tunnelInterfaces)
? networkConfig.tunnelInterfaces
: [];
const { homeToJh, jhToExit } = buildInterfaceCandidates({
servers: Array.isArray(servers) ? servers : [],
tunnelInterfaces,
pingMap: networkMapCache.pingMap,
speedMap: networkMapCache.speedMap,
cacheUpdatedAt: networkMapCache.updatedAt,
aiSettings,
});
const homeGroups = groupBy(homeToJh, (x) => x.homeKey);
const exitsByJh = groupBy(jhToExit, (x) => x.jumphostKey);
const homes = [];
for (const [homeKey, hjListRaw] of homeGroups.entries()) {
const hjList = enrichProbabilities(hjListRaw.sort((a, b) => b.score - a.score), 'score', aiSettings);
const bestHomeToJumphost = hjList[0] || null;
const fullRoutesRaw = [];
for (const hj of hjList) {
const exitCandidates = exitsByJh.get(hj.jumphostKey) || [];
for (const jhExit of exitCandidates) {
const combinedScore = clamp(
hj.score * aiSettings.combineHomeToJumphostWeight +
jhExit.score * aiSettings.combineJumphostToExitWeight,
0,
1
);
fullRoutesRaw.push({
id: `${hj.id}>>>${jhExit.id}`,
home: hj.home,
jumphost: hj.jumphost,
exit: jhExit.exit,
homeToJumphost: {
interfaceName: hj.interfaceName,
pingMs: hj.pingMs,
speedMbps: hj.speedMbps,
speedDownloadMbps: hj.speedDownloadMbps,
speedUploadMbps: hj.speedUploadMbps,
score: hj.score,
confidence: hj.confidence,
},
jumphostToExit: {
interfaceName: jhExit.interfaceName,
pingMs: jhExit.pingMs,
speedMbps: jhExit.speedMbps,
speedDownloadMbps: jhExit.speedDownloadMbps,
speedUploadMbps: jhExit.speedUploadMbps,
score: jhExit.score,
confidence: jhExit.confidence,
},
score: Number(combinedScore.toFixed(4)),
confidence: Number(((hj.confidence + jhExit.confidence) / 2).toFixed(4)),
});
}
}
const fullRoutes = enrichProbabilities(fullRoutesRaw.sort((a, b) => b.score - a.score), 'score', aiSettings);
const bestFullRoute = fullRoutes[0] || null;
homes.push({
home: bestHomeToJumphost?.home || null,
bestHomeToJumphost,
bestFullRoute,
homeToJumphostCandidates: hjList,
fullRouteCandidates: fullRoutes,
});
}
// Also expose jumphost->exit view for transparency
const jumphostSummaries = [];
for (const [jumphostKey, exitListRaw] of exitsByJh.entries()) {
const candidates = enrichProbabilities(exitListRaw.sort((a, b) => b.score - a.score), 'score', aiSettings);
jumphostSummaries.push({
jumphost: candidates[0]?.jumphost || null,
bestCandidate: candidates[0] || null,
candidates,
});
}
const communityOptimization = buildCommunityOptimization({
exitsByJh,
serverFiltersByServerId,
communitiesIndex,
networkConfig,
servers,
aiSettings,
});
homes.sort((a, b) => {
const pa = a.bestFullRoute?.probabilityOptimal ?? 0;
const pb = b.bestFullRoute?.probabilityOptimal ?? 0;
return pb - pa;
});
return res.json({
ok: true,
generatedAt: Date.now(),
metricsUpdatedAt: networkMapCache.updatedAt || null,
aiSettingsUsed: aiSettings,
homes,
jumphostToExitByJumphost: jumphostSummaries,
communityOptimizationByJumphost: communityOptimization,
totals: {
homes: homes.length,
homeToJumphostCandidates: homeToJh.length,
jumphostToExitCandidates: jhToExit.length,
jumphostsWithCommunityFilters: communityOptimization.filter((x) => (x.recommendations || []).length > 0).length,
},
});
} catch (error) {
console.error('[route-optimizer] getRouteOptimizer:', error);
return sendError(
res,
500,
error?.message || 'Ошибка расчёта оптимального маршрута',
'E_ROUTE_OPTIMIZER'
);
}
}
module.exports = {
getRouteOptimizer,
};
+259
View File
@@ -0,0 +1,259 @@
/**
* Роуты планировщика карты сети: настройки, логи, ручной запуск, кеш.
*/
const { sendError, sendOk } = require('../middleware/errorHandler');
const { readS3TextObject, writeS3JsonObject } = require('../services/s3Service');
const networkMapScheduler = require('../services/networkMapScheduler');
const pingServicesScheduler = require('../services/pingServicesScheduler');
const uptimeMonitorScheduler = require('../services/uptimeMonitorScheduler');
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json';
const SCHEDULER_KEYS = [
'networkMapSchedulerEnabled',
'networkMapSchedulerIntervalMinutes',
'networkMapSchedulerRunPing',
'networkMapSchedulerRunSpeedTest',
'networkMapSchedulerLogMaxLines',
];
const PING_SERVICES_SCHEDULER_KEYS = [
'pingServicesSchedulerEnabled',
'pingServicesSchedulerIntervalMinutes',
];
const UPTIME_MONITOR_SCHEDULER_KEYS = [
'uptimeMonitorSchedulerEnabled',
'uptimeMonitorSchedulerIntervalMinutes',
];
async function readUiSettings() {
try {
const data = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
const parsed = JSON.parse(data?.body || '{}');
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (_) {
return {};
}
}
/** GET /api/scheduler/network-map/settings */
async function getNetworkMapSchedulerSettings(req, res) {
try {
const ui = await readUiSettings();
const settings = networkMapScheduler.getSettingsFromUiSettings(ui);
const nextRunAt = networkMapScheduler.getNextRunAt(settings);
return res.json({ ...settings, nextRunAt: nextRunAt || undefined });
} catch (e) {
console.error('[scheduler] getSettings', e);
return sendError(res, 500, 'Не удалось прочитать настройки', 'E_SCHEDULER');
}
}
/** PATCH /api/scheduler/network-map/settings - обновить только ключи планировщика в ui-settings */
async function patchNetworkMapSchedulerSettings(req, res) {
try {
const body = req.body || {};
const current = await readUiSettings();
for (const key of SCHEDULER_KEYS) {
if (Object.prototype.hasOwnProperty.call(body, key)) {
current[key] = body[key];
}
}
if (Object.prototype.hasOwnProperty.call(body, 'networkMapSchedulerLogMaxLines')) {
networkMapScheduler.setLogMaxLines(body.networkMapSchedulerLogMaxLines);
}
await writeS3JsonObject(UI_SETTINGS_KEY, current);
const settings = networkMapScheduler.getSettingsFromUiSettings(current);
return res.json(settings);
} catch (e) {
console.error('[scheduler] patchSettings', e);
return sendError(res, 500, 'Не удалось сохранить настройки', 'E_SCHEDULER');
}
}
/** GET /api/scheduler/network-map/logs */
async function getNetworkMapSchedulerLogs(req, res) {
try {
const lines = networkMapScheduler.getLogs();
return res.json({ lines });
} catch (e) {
console.error('[scheduler] getLogs', e);
return sendError(res, 500, 'Не удалось прочитать логи', 'E_SCHEDULER');
}
}
/** POST /api/scheduler/network-map/run-now - один запуск по текущим настройкам */
async function runNetworkMapSchedulerNow(req, res) {
try {
const port = Number(process.env.PORT) || 3001;
const baseUrl = `http://127.0.0.1:${port}`;
const ui = await readUiSettings();
const settings = networkMapScheduler.getSettingsFromUiSettings(ui);
networkMapScheduler.runJob({
baseUrl,
runPing: settings.runPing,
runSpeedTest: settings.runSpeedTest,
trigger: 'manual',
}).catch((err) => {
console.error('[scheduler] run-now failed', err);
const msg = err?.message || String(err);
try {
networkMapScheduler.pushLog(`Ошибка выполнения: ${msg}`);
} catch (_) {}
});
return res.json({ ok: true, message: 'Запуск выполняется в фоне' });
} catch (e) {
console.error('[scheduler] run-now', e);
const msg = e?.message || String(e);
try {
networkMapScheduler.pushLog(`Ошибка запуска: ${msg}`);
} catch (_) {}
return sendError(res, 500, 'Не удалось запустить', 'E_SCHEDULER');
}
}
/** GET /api/network-map-cache - кеш пингов и скоростей для карты сети */
async function getNetworkMapCache(req, res) {
try {
const data = await readS3TextObject(NETWORK_MAP_CACHE_KEY).catch(() => null);
if (!data?.body) {
return res.json({ pingMap: {}, speedMap: {}, updatedAt: null });
}
const parsed = JSON.parse(data.body);
return res.json({
pingMap: parsed.pingMap && typeof parsed.pingMap === 'object' ? parsed.pingMap : {},
speedMap: parsed.speedMap && typeof parsed.speedMap === 'object' ? parsed.speedMap : {},
updatedAt: typeof parsed.updatedAt === 'number' ? parsed.updatedAt : null,
});
} catch (e) {
console.error('[network-map-cache] get', e);
return res.json({ pingMap: {}, speedMap: {}, updatedAt: null });
}
}
// === Ping services scheduler ===
/** GET /api/scheduler/ping-services/settings */
async function getPingServicesSchedulerSettings(req, res) {
try {
const ui = await readUiSettings();
const settings = pingServicesScheduler.getSettingsFromUiSettings(ui);
const nextRunAt = pingServicesScheduler.getNextRunAt(settings);
return res.json({ ...settings, nextRunAt: nextRunAt || undefined });
} catch (e) {
console.error('[scheduler] getPingServicesSettings', e);
return sendError(res, 500, 'Не удалось прочитать настройки', 'E_SCHEDULER');
}
}
/** PATCH /api/scheduler/ping-services/settings */
async function patchPingServicesSchedulerSettings(req, res) {
try {
const body = req.body || {};
const current = await readUiSettings();
for (const key of PING_SERVICES_SCHEDULER_KEYS) {
if (Object.prototype.hasOwnProperty.call(body, key)) {
current[key] = body[key];
}
}
await writeS3JsonObject(UI_SETTINGS_KEY, current);
const settings = pingServicesScheduler.getSettingsFromUiSettings(current);
return res.json(settings);
} catch (e) {
console.error('[scheduler] patchPingServicesSettings', e);
return sendError(res, 500, 'Не удалось сохранить настройки', 'E_SCHEDULER');
}
}
/** POST /api/scheduler/ping-services/run-now */
async function runPingServicesSchedulerNow(req, res) {
try {
const { refreshPingServicesCache } = require('./miscRoutes');
refreshPingServicesCache().catch((err) => {
console.error('[scheduler] ping-services run-now failed', err);
});
return res.json({ ok: true, message: 'Обновление кеша пинг-сервисов запущено в фоне' });
} catch (e) {
console.error('[scheduler] ping-services run-now', e);
return sendError(res, 500, 'Не удалось запустить', 'E_SCHEDULER');
}
}
// === Uptime monitor scheduler ===
/** GET /api/scheduler/uptime-monitor/settings */
async function getUptimeMonitorSchedulerSettings(req, res) {
try {
const ui = await readUiSettings();
const settings = uptimeMonitorScheduler.getSettingsFromUiSettings(ui);
const nextRunAt = uptimeMonitorScheduler.getNextRunAt(settings);
return res.json({ ...settings, nextRunAt: nextRunAt || undefined });
} catch (e) {
console.error('[scheduler] getUptimeMonitorSettings', e);
return sendError(res, 500, 'Не удалось прочитать настройки', 'E_SCHEDULER');
}
}
/** PATCH /api/scheduler/uptime-monitor/settings */
async function patchUptimeMonitorSchedulerSettings(req, res) {
try {
const body = req.body || {};
const current = await readUiSettings();
for (const key of UPTIME_MONITOR_SCHEDULER_KEYS) {
if (Object.prototype.hasOwnProperty.call(body, key)) {
current[key] = body[key];
}
}
await writeS3JsonObject(UI_SETTINGS_KEY, current);
const settings = uptimeMonitorScheduler.getSettingsFromUiSettings(current);
return res.json(settings);
} catch (e) {
console.error('[scheduler] patchUptimeMonitorSettings', e);
return sendError(res, 500, 'Не удалось сохранить настройки', 'E_SCHEDULER');
}
}
/** POST /api/scheduler/uptime-monitor/run-now */
async function runUptimeMonitorSchedulerNow(req, res) {
try {
const port = Number(process.env.PORT) || 3001;
const baseUrl = `http://127.0.0.1:${port}`;
const resServers = await fetch(`${baseUrl}/api/servers`).catch(() => null);
const data = resServers?.ok ? await resServers.json() : [];
const servers = Array.isArray(data) ? data : [];
const jumphosts = servers.filter(
(s) => s && ['jumphost', 'home'].includes(String(s.type || '').toLowerCase())
);
const serverIds = jumphosts.map((s) => s.id || s.dns || s.ip).filter(Boolean);
for (const serverId of serverIds) {
fetch(`${baseUrl}/api/uptime/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverId }),
}).catch((err) => console.warn('[scheduler] uptime check', serverId, err?.message));
}
return res.json({ ok: true, message: 'Проверки Uptime Monitor запущены в фоне' });
} catch (e) {
console.error('[scheduler] uptime-monitor run-now', e);
return sendError(res, 500, 'Не удалось запустить', 'E_SCHEDULER');
}
}
module.exports = {
getNetworkMapSchedulerSettings,
patchNetworkMapSchedulerSettings,
getNetworkMapSchedulerLogs,
runNetworkMapSchedulerNow,
getNetworkMapCache,
getPingServicesSchedulerSettings,
patchPingServicesSchedulerSettings,
runPingServicesSchedulerNow,
getUptimeMonitorSchedulerSettings,
patchUptimeMonitorSchedulerSettings,
runUptimeMonitorSchedulerNow,
};
+159 -16
View File
@@ -1,25 +1,163 @@
/**
* Роуты для управления конфигурациями серверов
*
* ВАЖНО: /api/server-configs возвращает только jumphost серверы из servers.json
* Это нужно для EasySwitch, который работает только с jumphost серверами
*/
const { s3, BUCKET_NAME, writeS3TextObject, deleteS3Object, headMeta } = require('../services/s3Service');
const { sendError, sendOk } = require('../middleware/errorHandler');
const { GetObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
const { streamToString } = require('../services/s3Service');
const { createJsonDataRoutes } = require('./jsonDataRoutes');
const { writeS3TextObject, deleteS3Object, headMeta, writeS3JsonObject, readS3TextObject } = require('../services/s3Service');
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
const { getS3TextWithHeaders, getS3JsonWithHeaders } = require('../utils/s3Helpers');
const { isValidServerId } = require('../lib/validators');
// GET /api/server-configs (список серверов)
const serverConfigsListRoutes = createJsonDataRoutes('server-configs.json', (server, i) => {
if (!server.id || !server.name) {
return `Server at index ${i} is missing required fields`;
const S3_KEY = 'servers.json';
// Валидация jumphost сервера
function validateJumphostServer(server, i) {
if (!server.ip || !server.dns || !server.country || !server.provider || !server.tunnel) {
return `Server at index ${i} is missing required fields (ip, dns, country, provider, tunnel)`;
}
if (String(server.type || '').toLowerCase() !== 'jumphost') {
return `Server at index ${i} must have type 'jumphost'`;
}
if (!Array.isArray(server.gateways) || server.gateways.length === 0) {
return `Server at index ${i} must have at least one gateway`;
}
const primaries = server.gateways.filter(g => g && g.primary);
if (primaries.length !== 1) {
return `Server at index ${i} must have exactly one primary gateway`;
}
for (let j = 0; j < server.gateways.length; j++) {
const gw = server.gateways[j] || {};
if (!gw.name || String(gw.name).trim().length === 0) {
return `Server at index ${i} gateway at index ${j} is missing name`;
}
}
return null;
});
}
// GET /api/server-configs (список только jumphost серверов)
async function getServerConfigsList(req, res) {
try {
const head = await headMeta(S3_KEY);
const etag = head?.etag || null;
if (etag) res.set('ETag', String(etag));
if (head?.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString());
if (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
if (checkIfNoneMatch(req, res, etag)) return;
let fileContent = '[]';
try {
const data = await readS3TextObject(S3_KEY);
fileContent = data.body;
} catch (e) {
if (e?.code !== 'NoSuchKey') throw e;
}
let allServers = [];
try {
allServers = JSON.parse(fileContent);
if (!Array.isArray(allServers)) {
allServers = [];
}
} catch (parseError) {
console.error(`Error parsing ${S3_KEY}:`, parseError);
allServers = [];
}
// Фильтруем только jumphost серверы и добавляем id/name для совместимости с фронтендом
// Fallback: если type не указан, но есть gateways - считаем jumphost (обратная совместимость)
const jumphostServers = allServers
.filter(s => {
const serverType = String(s.type || '').toLowerCase();
// Явно jumphost
if (serverType === 'jumphost') return true;
// Явно exit - пропускаем
if (serverType === 'exit') return false;
// Тип не указан, но есть gateways - считаем jumphost (обратная совместимость)
if (!serverType && Array.isArray(s.gateways) && s.gateways.length > 0) return true;
return false;
})
.map(s => ({
...s,
// Для совместимости с EasySwitchManager добавляем id и name из dns
id: s.id || s.dns,
name: s.name || s.dns
}));
res.json(jumphostServers);
} catch (error) {
if (error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
res.json([]);
} else {
console.error(error);
return sendError(res, 500, 'Error reading from S3', 'E_S3');
}
}
}
// POST /api/server-configs (сохранить jumphost серверы, сохраняя остальные)
async function postServerConfigsList(req, res) {
const { servers: jumphostServers } = req.body;
if (!Array.isArray(jumphostServers)) {
return sendError(res, 400, 'Servers must be an array', 'E_BAD_REQUEST');
}
// Валидация jumphost серверов
for (let i = 0; i < jumphostServers.length; i++) {
const error = validateJumphostServer(jumphostServers[i], i);
if (error) {
return sendError(res, 400, error, 'E_SCHEMA');
}
}
try {
// Читаем текущие данные
let allServers = [];
try {
const data = await readS3TextObject(S3_KEY);
allServers = JSON.parse(data.body);
if (!Array.isArray(allServers)) allServers = [];
} catch (e) {
allServers = [];
}
// Оставляем только НЕ-jumphost серверы (exit и другие)
const nonJumphostServers = allServers.filter(s =>
String(s.type || '').toLowerCase() !== 'jumphost'
);
// Объединяем: jumphost из запроса + остальные из файла
const updatedServers = [...jumphostServers, ...nonJumphostServers];
const meta = await writeS3JsonObject(S3_KEY, updatedServers);
return sendOk(res, meta);
} catch (error) {
console.error(error);
return sendError(res, 500, 'Error writing to S3', 'E_S3');
}
}
// Объект с GET и POST для совместимости с server.js
const serverConfigsListRoutes = {
get: getServerConfigsList,
post: postServerConfigsList
};
function validateServerIdParam(req, res) {
const serverId = req.params?.serverId;
if (!serverId || !isValidServerId(serverId)) {
sendError(res, 400, 'Invalid serverId: only alphanumeric, dot, dash, underscore allowed', 'E_BAD_REQUEST');
return null;
}
return serverId;
}
// GET /api/server-configs/:serverId (конкретная конфигурация)
async function getServerConfig(req, res) {
const { serverId } = req.params;
const serverId = validateServerIdParam(req, res);
if (serverId === null) return;
await getS3TextWithHeaders(
`filter-manager/config-${serverId}.txt`,
req,
@@ -33,7 +171,8 @@ async function getServerConfig(req, res) {
// POST /api/server-configs/:serverId (сохранить конфигурацию)
async function postServerConfig(req, res) {
const { serverId } = req.params;
const serverId = validateServerIdParam(req, res);
if (serverId === null) return;
const { config } = req.body;
if (!config) {
@@ -51,7 +190,8 @@ async function postServerConfig(req, res) {
// DELETE /api/server-configs/:serverId (удалить только конфигурацию)
async function deleteServerConfig(req, res) {
const { serverId } = req.params;
const serverId = validateServerIdParam(req, res);
if (serverId === null) return;
try {
await deleteS3Object(`filter-manager/config-${serverId}.txt`);
@@ -65,7 +205,8 @@ async function deleteServerConfig(req, res) {
// DELETE /api/server-configs/:serverId/complete (удалить конфигурацию и фильтры)
async function deleteServerComplete(req, res) {
const { serverId } = req.params;
const serverId = validateServerIdParam(req, res);
if (serverId === null) return;
try {
await Promise.allSettled([
@@ -82,7 +223,8 @@ async function deleteServerComplete(req, res) {
// GET /api/server-filters/:serverId
async function getServerFilters(req, res) {
const { serverId } = req.params;
const serverId = validateServerIdParam(req, res);
if (serverId === null) return;
await getS3JsonWithHeaders(
`filter-manager/server-filters-${serverId}.json`,
req,
@@ -93,7 +235,8 @@ async function getServerFilters(req, res) {
// POST /api/server-filters/:serverId
async function postServerFilters(req, res) {
const { serverId } = req.params;
const serverId = validateServerIdParam(req, res);
if (serverId === null) return;
const { filters } = req.body;
if (!Array.isArray(filters)) {
+152
View File
@@ -0,0 +1,152 @@
/**
* Кастомные роуты для серверов с поддержкой зашифрованных MikroTik учётных данных
* Для jumphost и home (входной роутер): mikrotikHost, mikrotikPort, mikrotikUser, encryptedMikrotikPassword
*/
const { readS3TextObject, writeS3JsonObject, headMeta } = require('../services/s3Service');
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
const { encrypt, decrypt } = require('../utils/encryption');
const S3_KEY = 'servers.json';
const SERVER_TYPES = ['jumphost', 'exit', 'bgp', 'dns', 'home'];
const TYPES_NEED_TUNNEL = ['jumphost', 'exit'];
const TYPES_NEED_GATEWAYS = ['jumphost', 'exit'];
function validateServer(server, i) {
if (!server.ip || !server.dns || !server.country || !server.provider) {
return `Server at index ${i} is missing required fields (ip, dns, country, provider)`;
}
const normalizedType = String(server.type || '').toLowerCase();
if (!SERVER_TYPES.includes(normalizedType)) {
return `Server at index ${i} has invalid type (allowed: ${SERVER_TYPES.join(', ')})`;
}
if (TYPES_NEED_TUNNEL.includes(normalizedType) && !server.tunnel) {
return `Server at index ${i} (${normalizedType}) requires tunnel type`;
}
if (TYPES_NEED_GATEWAYS.includes(normalizedType)) {
if (!Array.isArray(server.gateways) || server.gateways.length === 0) {
return `Server at index ${i} must have gateways for type ${normalizedType}`;
}
const primaries = server.gateways.filter((g) => g && g.primary);
if (primaries.length !== 1) {
return `Server at index ${i} must have exactly one primary gateway`;
}
for (let j = 0; j < server.gateways.length; j++) {
const gw = server.gateways[j] || {};
if (!gw.name || String(gw.name).trim().length === 0) {
return `Server at index ${i} gateway at index ${j} is missing name`;
}
}
}
server.type = normalizedType;
return null;
}
async function readServersFromS3() {
try {
const data = await readS3TextObject(S3_KEY);
const parsed = JSON.parse(data.body || '[]');
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
if (e?.code === 'NoSuchKey') return [];
throw e;
}
}
async function writeServersToS3(servers) {
await writeS3JsonObject(S3_KEY, servers);
}
/**
* GET /api/servers — возвращает серверы, для jumphost заменяет encryptedMikrotikPassword на hasMikrotikPassword
*/
async function getServers(req, res) {
try {
const head = await headMeta(S3_KEY);
const etag = head?.etag || null;
if (etag) res.set('ETag', String(etag));
if (head?.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString());
if (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
if (checkIfNoneMatch(req, res, etag)) return;
const servers = await readServersFromS3();
const sanitized = servers.map((s) => {
const out = { ...s };
const hasMikrotikApi = s.type === 'jumphost' || s.type === 'home';
if (hasMikrotikApi && out.encryptedMikrotikPassword) {
out.hasMikrotikPassword = true;
delete out.encryptedMikrotikPassword;
} else if (hasMikrotikApi) {
out.hasMikrotikPassword = false;
}
return out;
});
res.json(sanitized);
} catch (error) {
if (error?.code === 'NoSuchKey') {
return res.json([]);
}
console.error(error);
return sendError(res, 500, 'Error reading servers', 'E_STORAGE');
}
}
/**
* POST /api/servers — сохраняет серверы, шифрует mikrotikPassword при наличии
*/
async function postServers(req, res) {
const items = req.body?.domains ?? req.body?.servers;
if (!Array.isArray(items)) {
return sendError(res, 400, 'Data must be an array (domains or servers)', 'E_BAD_REQUEST');
}
for (let i = 0; i < items.length; i++) {
const err = validateServer(items[i], i);
if (err) return sendError(res, 400, err, 'E_SCHEMA');
}
try {
const currentServers = await readServersFromS3();
const findCurrent = (srv) =>
currentServers.find(
(c) => (srv.id && c.id === srv.id) || (srv.dns && c.dns === srv.dns) || (srv.ip && c.ip === srv.ip),
);
const toSave = items.map((srv) => {
const current = findCurrent(srv);
if (srv.type === 'jumphost' || srv.type === 'home') {
const out = { ...srv };
if (srv.mikrotikPassword !== undefined && srv.mikrotikPassword !== null && String(srv.mikrotikPassword).trim() !== '') {
try {
out.encryptedMikrotikPassword = encrypt(String(srv.mikrotikPassword).trim());
} catch (encErr) {
console.error('MikroTik password encryption failed:', encErr);
}
delete out.mikrotikPassword;
} else if (current?.encryptedMikrotikPassword) {
out.encryptedMikrotikPassword = current.encryptedMikrotikPassword;
}
return out;
}
return srv;
});
await writeServersToS3(toSave);
const meta = await headMeta(S3_KEY);
return sendOk(res, {
etag: meta?.etag || null,
lastModified: meta?.lastModified || null,
contentLength: typeof meta?.contentLength === 'number' ? meta.contentLength : null,
});
} catch (error) {
console.error(error);
return sendError(res, 500, error.message || 'Error saving servers', 'E_STORAGE');
}
}
module.exports = {
getServers,
postServers,
readServersFromS3,
};
+34 -69
View File
@@ -1,14 +1,10 @@
/**
* Общие роуты для текстовых данных (domains, asns, ip-ranges)
* Уменьшают дублирование кода для похожих эндпоинтов
*/
const { streamPaginatedText, headS3ObjectEtag, writeS3TextObject } = require('../services/s3Service');
const { streamPaginatedText, headS3ObjectEtag, writeS3TextObject, headMeta, readS3TextObject } = require('../services/s3Service');
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
const { splitWhitespace } = require('../utils/helpers');
const { s3, BUCKET_NAME } = require('../services/s3Service');
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
const { streamToString } = require('../services/s3Service');
// Кэш для countOnly запросов
const countOnlyCache = { map: new Map(), ttlMs: 10_000 };
@@ -27,17 +23,10 @@ function setCountOnlyCache(cacheKey, value) {
countOnlyCache.map.set(cacheKey, { value, at: Date.now() });
}
/**
* Создать GET эндпоинт для текстовых данных
* @param {string} s3Key - Ключ S3 файла
* @param {function} mapLine - Функция маппинга строки в объект
* @param {function} validate - Функция валидации данных
* @param {string} cachePrefix - Префикс для кэша
*/
function createTextDataGET(s3Key, mapLine, validate, cachePrefix) {
return async (req, res) => {
const { q = '', offset, limit, countOnly, format } = req.query || {};
try {
if (countOnly === 'true') {
const cacheKey = `${cachePrefix}:count:${q}`;
@@ -54,24 +43,32 @@ function createTextDataGET(s3Key, mapLine, validate, cachePrefix) {
q,
offset: Number(offset) || 0,
limit: Number(limit) || 0,
mapLine
mapLine,
});
if (!validate(items)) {
return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
}
return res.json(format === 'std' ? { items, total, meta: {} } : { items, total });
} else {
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null);
const etag = head?.ETag || null;
const head = await headMeta(s3Key);
const etag = head?.etag || null;
if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
if (head?.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString());
if (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
if (checkIfNoneMatch(req, res, etag)) return;
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key }));
const fileContent = await streamToString(data.Body);
const items = fileContent.split('\n').filter(line => line).map(line => mapLine(line.trim()));
let fileContent;
try {
const data = await readS3TextObject(s3Key);
fileContent = data.body;
} catch (e) {
if (e?.code === 'NoSuchKey') {
return res.json([]);
}
throw e;
}
const items = fileContent.split('\n').filter((line) => line).map((line) => mapLine(line.trim()));
if (!validate(items)) {
return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
}
@@ -85,24 +82,16 @@ function createTextDataGET(s3Key, mapLine, validate, cachePrefix) {
res.json([]);
} else {
console.error(error);
return sendError(res, 500, 'Error reading from S3', 'E_S3', { error: String(error?.message || error) });
return sendError(res, 500, 'Error reading storage', 'E_STORAGE', { error: String(error?.message || error) });
}
}
};
}
/**
* Создать POST эндпоинт для текстовых данных
* @param {string} s3Key - Ключ S3 файла
* @param {function} formatLine - Функция форматирования объекта в строку
* @param {function} validate - Функция валидации данных
* @param {function} validateItem - Функция валидации отдельного элемента (опционально)
*/
function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) {
return async (req, res) => {
const { domains, ipRanges, items, etag } = req.body; // 'domains' основной ключ, но поддерживаем и альтернативные
// Поддержка обратной совместимости: принимаем domains | ipRanges | items
const { domains, ipRanges, items, etag } = req.body;
const payload = Array.isArray(domains)
? domains
: Array.isArray(ipRanges)
@@ -114,13 +103,11 @@ function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) {
if (!Array.isArray(payload)) {
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
}
// Валидация с помощью AJV
if (!validate(payload || [])) {
return sendError(res, 400, 'Invalid payload format', 'E_SCHEMA');
}
// Дополнительная валидация элементов, если предоставлена
if (validateItem) {
const validationErrors = [];
for (let i = 0; i < payload.length; i++) {
@@ -129,62 +116,41 @@ function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) {
validationErrors.push(...errors);
}
}
if (validationErrors.length > 0) {
return sendError(res, 400, 'Validation errors', 'E_VALIDATION', {
errors: validationErrors.slice(0, 10)
errors: validationErrors.slice(0, 10),
});
}
}
const fileContent = (payload || []).map(formatLine).filter(Boolean).join('\n');
try {
// Проверка ETag для optimistic concurrency
let current = null;
const ifMatch = req.headers['if-match'] ? String(req.headers['if-match']).replace(/\"/g,'"') : null;
const ifMatch = req.headers['if-match'] ? String(req.headers['if-match']).replace(/\"/g, '"') : null;
try {
current = await headS3ObjectEtag(s3Key);
} catch {}
} catch (_) {}
if (current && (ifMatch || etag) && current !== String(ifMatch || etag)) {
const { headMeta } = require('../services/s3Service');
const meta = await headMeta(s3Key);
return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', {
currentEtag: current,
meta
});
return sendError(res, 412, 'ETag mismatch', 'E_ETAG_MISMATCH');
}
} catch {}
try {
const meta = await writeS3TextObject(s3Key, fileContent);
return sendOk(res, meta);
} catch (error) {
console.error(error);
return sendError(res, 500, 'Error writing to S3', 'E_S3', {
error: String(error?.message || error)
});
return sendError(res, 500, 'Error writing storage', 'E_STORAGE');
}
};
}
/**
* Создать роуты для текстовых данных (GET + POST)
*/
function createTextDataRoutes(config) {
const {
s3Key,
mapLine,
formatLine,
validate,
validateItem,
cachePrefix
} = config;
const { s3Key, mapLine, formatLine, validate, validateItem, cachePrefix } = config;
return {
get: createTextDataGET(s3Key, mapLine, validate, cachePrefix),
post: createTextDataPOST(s3Key, formatLine, validate, validateItem)
post: createTextDataPOST(s3Key, formatLine, validate, validateItem),
};
}
@@ -193,4 +159,3 @@ module.exports = {
createTextDataGET,
createTextDataPOST,
};
+128
View File
@@ -0,0 +1,128 @@
/**
* Роуты для сбора статистики трафика с MikroTik RouterOS (интерфейсы по jumphost).
* GET /api/traffic/interface-stats — статистика rx/tx по каждому интерфейсу для всех jumphost.
*/
const { sendError } = require('../middleware/errorHandler');
const { decrypt } = require('../utils/encryption');
const { readServersFromS3 } = require('./serversRoutes');
const { createRosClient } = require('../services/mikrotikApplyService');
/** Получить MikroTik credentials из сервера (jumphost или home) */
function getMikrotikCredentials(server) {
if (!server || (server.type !== 'jumphost' && server.type !== 'home')) return null;
const host = server.mikrotikHost || server.ip || server.dns;
if (!host) return null;
const port = server.mikrotikPort;
const restPort = (port === 80 || port === 443 || port === 8443) ? port : 80;
const user = server.mikrotikUser || 'admin';
let password = '';
if (server.encryptedMikrotikPassword) {
try {
password = decrypt(server.encryptedMikrotikPassword);
} catch (_) {
return null;
}
}
return { host, port: restPort, user, password, secure: false };
}
// UI-настройки (общие с остальным UI)
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
const INTERFACE_SPEED_CACHE_PREFIX = 'interface-speed-cache/';
async function loadUiSettings() {
try {
const data = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
const parsed = JSON.parse(data?.body || '{}');
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (_) {
return {};
}
}
function interfaceSpeedCacheKey(serverId, interfaceName) {
const key = `${serverId || ''}|${interfaceName || ''}`;
// Хэш не нужен: ключи короткие, но на всякий случай экранируем странные символы
return (
INTERFACE_SPEED_CACHE_PREFIX +
String(key)
.trim()
.replace(/[^a-zA-Z0-9_.:-]+/g, '_') +
'.json'
);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* GET /api/traffic/interface-stats
* Возвращает по каждому jumphost список интерфейсов с rx-byte, tx-byte (из RouterOS REST /interface).
*/
async function getInterfaceStats(req, res) {
try {
const servers = await readServersFromS3();
const jumphosts = (Array.isArray(servers) ? servers : []).filter(
(s) => s && (String(s.type || '').toLowerCase() === 'jumphost' || String(s.type || '').toLowerCase() === 'home')
);
const results = await Promise.all(
jumphosts.map(async (server) => {
const creds = getMikrotikCredentials(server);
if (!creds) {
return {
serverId: server.id,
name: server.name || server.dns || server.ip || server.id,
host: server.mikrotikHost || server.ip || server.dns,
error: 'MikroTik API не настроен или нет пароля',
interfaces: [],
};
}
try {
const client = createRosClient(creds);
const apiRes = await client.print('interface');
const raw = apiRes?.data;
const list = Array.isArray(raw) ? raw : raw ? [raw] : [];
const interfaces = list.map((iface) => {
const rx = iface['rx-byte'] != null ? parseInt(String(iface['rx-byte']), 10) : 0;
const tx = iface['tx-byte'] != null ? parseInt(String(iface['tx-byte']), 10) : 0;
const total = rx + tx;
return {
name: iface.name != null ? String(iface.name) : '',
type: iface.type != null ? String(iface.type) : '',
rxBytes: Number.isNaN(rx) ? 0 : rx,
txBytes: Number.isNaN(tx) ? 0 : tx,
totalBytes: Number.isNaN(total) ? 0 : total,
};
}).filter((i) => i.name && i.totalBytes >= 0);
return {
serverId: server.id,
name: server.name || server.dns || server.ip || server.id,
host: server.mikrotikHost || server.ip || server.dns,
interfaces,
};
} catch (err) {
const msg = err.response?.data?.message || err.message || 'Ошибка запроса к RouterOS';
return {
serverId: server.id,
name: server.name || server.dns || server.ip || server.id,
host: server.mikrotikHost || server.ip || server.dns,
error: msg,
interfaces: [],
};
}
})
);
return res.json({ jumphosts: results });
} catch (error) {
console.error('getInterfaceStats:', error);
return sendError(res, 500, error.message || 'Ошибка сбора статистики трафика', 'E_TRAFFIC');
}
}
module.exports = {
getInterfaceStats,
};
+264 -100
View File
@@ -15,9 +15,16 @@ const crypto = require('crypto');
// Импорт модулей
const { sendError, sendOk, errorHandler } = require('./middleware/errorHandler');
const { getLockStatus, acquireLock, releaseLock } = require('./middleware/lockManager');
const { createTextDataRoutes } = require('./routes/textDataRoutes');
const { createJsonDataRoutes } = require('./routes/jsonDataRoutes');
const { splitWhitespace } = require('./utils/helpers');
const { createFilterRulesRoutes } = require('./routes/filterRulesRoutes');
const evobgpClient = require('./services/evobgpClient');
const {
createEvobgpListGET,
createEvobgpDomainsNewPOST,
createEvobgpAsnsPOST,
createEvobgpIpRangesPOST,
createEvobgpLegacyDomainsGET,
} = require('./routes/evobgpReferenceRoutes');
const validators = require('./lib/validators');
const mikrotikValidator = require('./lib/mikrotik-validator');
@@ -26,6 +33,19 @@ const communitiesRoutes = require('./routes/communitiesRoutes');
const filtersRoutes = require('./routes/filtersRoutes');
const serverConfigsRoutes = require('./routes/serverConfigsRoutes');
const miscRoutes = require('./routes/miscRoutes');
const ipsecPasswordsRoutes = require('./routes/ipsecPasswordsRoutes');
const mikrotikConfigRoutes = require('./routes/mikrotikConfigRoutes');
const mikrotikBackupRoutes = require('./routes/mikrotikBackupRoutes');
const trafficRoutes = require('./routes/trafficRoutes');
const resourceStatsRoutes = require('./routes/resourceStatsRoutes');
const alertsRoutes = require('./routes/alertsRoutes');
const routeOptimizerRoutes = require('./routes/routeOptimizerRoutes');
const { initMikrotikBackupScheduler } = require('./services/mikrotikBackupScheduler');
const { initNetworkMapScheduler } = require('./services/networkMapScheduler');
const { initPingServicesScheduler } = require('./services/pingServicesScheduler');
const { initUptimeMonitorScheduler } = require('./services/uptimeMonitorScheduler');
const schedulerRoutes = require('./routes/schedulerRoutes');
const { createEvobgpProxy, normalizeEvobgpBaseUrl } = require('./routes/evobgpProxyRoutes');
const app = express();
const port = Number(process.env.PORT) || 3001;
@@ -87,6 +107,12 @@ app.set('etag', false);
app.use((req, res, next) => {
res.setHeader('Access-Control-Expose-Headers', 'ETag, Last-Modified, Content-Length-Source');
// Запрещаем браузеру кешировать API ответы
if (req.path.startsWith('/api/')) {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
}
next();
});
@@ -138,6 +164,23 @@ app.get('/api/version', (req, res) => {
});
});
// === EvoBGP reverse proxy (токен и URL только на сервере; без CORS в браузере) ===
function evobgpProxyHealth(req, res) {
const base = normalizeEvobgpBaseUrl(process.env.EVOBGP_API_URL || '');
const hasToken = Boolean(String(process.env.EVOBGP_API_TOKEN || '').trim());
res.json({
ok: true,
proxy: true,
evobgpConfigured: Boolean(base && hasToken),
});
}
// Точное совпадение до app.use: GET /api/evobgp и GET /evobgp — проверка, что запрос доходит до Node
app.get('/api/evobgp', evobgpProxyHealth);
app.get('/evobgp', evobgpProxyHealth);
app.use('/api/evobgp', createEvobgpProxy({ logger }));
// Если nginx делает proxy_pass http://upstream/ и срезает префикс /api, до приложения приходит /evobgp/…
app.use('/evobgp', createEvobgpProxy({ logger }));
// === Cache TTL ===
const DEFAULT_CACHE_TTL = Math.max(0, Math.min(300, Number(process.env.CACHE_TTL_SECONDS) || 30));
app.use((req, res, next) => {
@@ -195,115 +238,124 @@ const validateDomainsNew = ajv.compile(schemaDomainsNew);
const validateAsns = ajv.compile(schemaAsns);
const validateIpRanges = ajv.compile(schemaIpRanges);
// === Domains (старая версия) ===
const domainsOldRoutes = createTextDataRoutes({
s3Key: 'bgp_data/domains.txt',
mapLine: (line) => {
const parts = splitWhitespace(line);
return { domain: parts[0] || '', type: parts[1] || '' };
},
formatLine: (d) => `${String(d.domain || '').trim()} ${String(d.type || '').trim()}`.trim(),
validate: validateAsns,
cachePrefix: 'domains'
});
// === Domains (старая версия, поля domain+type — тот же источник, что ASNs) ===
const asnValidateItem = (asn, i) => {
const errors = [];
if (!validators.isValidASN(asn.domain)) {
errors.push(`Элемент ${i}: неверный ASN "${asn.domain}"`);
}
if (!validators.isValidCommunity(asn.type)) {
errors.push(`Элемент ${i}: неверный community "${asn.type}"`);
}
return errors;
};
const domainsOldRoutes = {
get: createEvobgpLegacyDomainsGET({ validate: validateAsns }),
post: createEvobgpAsnsPOST({ validate: validateAsns, validateItem: asnValidateItem }),
};
app.get('/api/domains', domainsOldRoutes.get);
app.post('/api/domains', domainsOldRoutes.post);
app.post('/api/domains', writeLimiter, domainsOldRoutes.post);
// === ASNs ===
const asnsRoutes = createTextDataRoutes({
s3Key: 'bgp_data/asns.txt',
mapLine: (line) => {
const parts = splitWhitespace(line);
return { domain: parts[0] || '', type: parts[1] || '' };
},
formatLine: (a) => `${String(a.domain || '').trim().toUpperCase()} ${String(a.type || '').trim()}`.trim(),
validate: validateAsns,
validateItem: (asn, i) => {
const errors = [];
if (!validators.isValidASN(asn.domain)) {
errors.push(`Элемент ${i}: неверный ASN "${asn.domain}"`);
}
if (!validators.isValidCommunity(asn.type)) {
errors.push(`Элемент ${i}: неверный community "${asn.type}"`);
}
return errors;
},
cachePrefix: 'asns'
});
// === ASNs (EvoBGP) ===
const asnsRoutes = {
get: createEvobgpListGET({
cachePrefix: 'asns',
validate: validateAsns,
fetchItems: async () => {
const rows = await evobgpClient.fetchAsnsData();
return rows.map((r) => ({ domain: r.asn, type: r.community }));
},
}),
post: createEvobgpAsnsPOST({ validate: validateAsns, validateItem: asnValidateItem }),
};
app.get('/api/asns', asnsRoutes.get);
app.post('/api/asns', writeLimiter, asnsRoutes.post);
// === Domains New ===
const domainsNewRoutes = createTextDataRoutes({
s3Key: 'bgp_data/domains_community.txt',
mapLine: (line) => {
const parts = splitWhitespace(line);
return { domain: parts[0] || '', community: parts[1] || '' };
},
formatLine: (d) => `${String(d.domain || '').trim().toLowerCase()} ${String(d.community || '').trim()}`.trim(),
validate: validateDomainsNew,
validateItem: (d, i) => {
const errors = [];
if (!validators.isValidDomain(d.domain) && !validators.isValidWildcardDomain(d.domain)) {
errors.push(`Элемент ${i}: неверный домен "${d.domain}"`);
}
if (!validators.isValidCommunity(d.community)) {
errors.push(`Элемент ${i}: неверный community "${d.community}"`);
}
if (!validators.isSafeXSSString(d.domain)) {
errors.push(`Элемент ${i}: домен содержит потенциально опасные символы`);
}
return errors;
},
cachePrefix: 'domains-new'
});
// === Domains New (EvoBGP) ===
const domainsNewRoutes = {
get: createEvobgpListGET({
cachePrefix: 'domains-new',
validate: validateDomainsNew,
fetchItems: async () => {
const rows = await evobgpClient.fetchDomainsData();
return rows.map(({ domain, community }) => ({ domain, community }));
},
}),
post: createEvobgpDomainsNewPOST({
validate: validateDomainsNew,
validateItem: (d, i) => {
const errors = [];
if (!validators.isValidDomain(d.domain) && !validators.isValidWildcardDomain(d.domain)) {
errors.push(`Элемент ${i}: неверный домен "${d.domain}"`);
}
if (!validators.isValidCommunity(d.community)) {
errors.push(`Элемент ${i}: неверный community "${d.community}"`);
}
if (!validators.isSafeXSSString(d.domain)) {
errors.push(`Элемент ${i}: домен содержит потенциально опасные символы`);
}
return errors;
},
}),
};
app.get('/api/domains-new', domainsNewRoutes.get);
app.post('/api/domains-new', writeLimiter, domainsNewRoutes.post);
// === IP Ranges ===
const ipRangesRoutes = createTextDataRoutes({
s3Key: 'bgp_data/ips.txt',
mapLine: (line) => {
const parts = splitWhitespace(line);
return { ipRange: parts[0] || '', community: parts[1] || '' };
},
formatLine: (ip) => `${String(ip.ipRange || '').trim()} ${String(ip.community || '').trim()}`.trim(),
validate: validateIpRanges,
validateItem: (ip, i) => {
const errors = [];
const ipStr = String(ip.ipRange || '').trim();
const isValidCIDR = validators.isValidCIDRv4(ipStr) || validators.isValidCIDRv6(ipStr);
const isValidIP = validators.isValidIPv4(ipStr) || validators.isValidIPv6(ipStr);
if (!isValidCIDR && !isValidIP) {
errors.push(`Элемент ${i}: неверный IP/CIDR "${ipStr}"`);
}
if (!validators.isValidCommunity(ip.community)) {
errors.push(`Элемент ${i}: неверный community "${ip.community}"`);
}
return errors;
},
cachePrefix: 'ip-ranges'
});
// === IP Ranges (EvoBGP) ===
const ipRangesRoutes = {
get: createEvobgpListGET({
cachePrefix: 'ip-ranges',
validate: validateIpRanges,
fetchItems: async () => {
const rows = await evobgpClient.fetchIpRangesData();
return rows.map(({ ipRange, community }) => ({ ipRange, community }));
},
}),
post: createEvobgpIpRangesPOST({
validate: validateIpRanges,
validateItem: (ip, i) => {
const errors = [];
const ipStr = String(ip.ipRange || '').trim();
const isValidCIDR = validators.isValidCIDRv4(ipStr) || validators.isValidCIDRv6(ipStr);
const isValidIP = validators.isValidIPv4(ipStr) || validators.isValidIPv6(ipStr);
if (!isValidCIDR && !isValidIP) {
errors.push(`Элемент ${i}: неверный IP/CIDR "${ipStr}"`);
}
if (!validators.isValidCommunity(ip.community)) {
errors.push(`Элемент ${i}: неверный community "${ip.community}"`);
}
return errors;
},
}),
};
app.get('/api/ip-ranges', ipRangesRoutes.get);
app.post('/api/ip-ranges', writeLimiter, ipRangesRoutes.post);
// === JSON DATA ROUTES (используют фабрики) ===
// Servers
const serversRoutes = createJsonDataRoutes('servers.json', (server, i) => {
if (!server.ip || !server.dns || !server.country || !server.provider || !server.tunnel) {
return `Server at index ${i} is missing required fields`;
// Servers (кастомные роуты с поддержкой зашифрованных MikroTik учётных данных для jumphost)
const serversRoutes = require('./routes/serversRoutes');
app.get('/api/servers', serversRoutes.getServers);
app.post('/api/servers', serversRoutes.postServers);
// Server connections for topology graph
const serverConnectionsRoutes = createJsonDataRoutes('server-connections.json', (conn, i) => {
if (!conn.from || !conn.to || !conn.tunnelType) {
return `Connection at index ${i} is missing required fields: from, to, tunnelType`;
}
if (conn.from === conn.to) {
return `Connection at index ${i} has identical from/to values`;
}
return null;
});
app.get('/api/servers', serversRoutes.get);
app.post('/api/servers', serversRoutes.post);
app.get('/api/server-connections', serverConnectionsRoutes.get);
app.post('/api/server-connections', serverConnectionsRoutes.post);
// Billing
const billingRoutes = createJsonDataRoutes('servers-billing.json', (item, i) => {
@@ -316,28 +368,49 @@ const billingRoutes = createJsonDataRoutes('servers-billing.json', (item, i) =>
app.get('/api/billing', billingRoutes.get);
app.post('/api/billing', billingRoutes.post);
// Filters
const filtersRoutes2 = createJsonDataRoutes('filters.json', (filter, i) => {
// Filters (SQL app_filter_rules, scope global)
const filterItemValidator = (filter, i) => {
if (!filter.community || !filter.gateway) {
return `Filter at index ${i} is missing required fields`;
}
return null;
});
};
const filtersRoutes2 = createFilterRulesRoutes('global', filterItemValidator);
app.get('/api/filters', filtersRoutes2.get);
app.post('/api/filters', filtersRoutes2.post);
// Simple Filters
const simpleFiltersRoutes = createJsonDataRoutes('filter-manager/simple-filters.json', (filter, i) => {
if (!filter.community || !filter.gateway) {
return `Filter at index ${i} is missing required fields`;
}
return null;
});
// Simple Filters (app_filter_rules, scope simple)
const simpleFiltersRoutes = createFilterRulesRoutes('simple', filterItemValidator);
app.get('/api/simple-filters', simpleFiltersRoutes.get);
app.post('/api/simple-filters', simpleFiltersRoutes.post);
// Network Config (справочник IP, интерфейсов и gateway)
const networkConfigRoutes = createJsonDataRoutes('network-config.json', (config) => {
// Валидация структуры конфига
if (typeof config !== 'object' || config === null) {
return 'Network config must be an object';
}
// Валидация gateways
if (config.gateways && !Array.isArray(config.gateways)) {
return 'gateways must be an array';
}
// Валидация tunnelInterfaces
if (config.tunnelInterfaces && !Array.isArray(config.tunnelInterfaces)) {
return 'tunnelInterfaces must be an array';
}
// Валидация ipPools
if (config.ipPools && !Array.isArray(config.ipPools)) {
return 'ipPools must be an array';
}
return null;
}, { singleObject: true });
app.get('/api/network-config', networkConfigRoutes.get);
app.post('/api/network-config', networkConfigRoutes.post);
// === COMMUNITIES ROUTES ===
app.get('/api/communities', communitiesRoutes.getCommunities);
app.post('/api/communities', writeLimiter, communitiesRoutes.postCommunities);
@@ -397,6 +470,77 @@ app.post('/api/update-bgp/background', bgpUpdateLimiter, miscRoutes.updateBgpBac
app.get('/api/ws/url', miscRoutes.getWsUrl);
app.get('/api/ui-settings', miscRoutes.getUiSettings);
app.post('/api/ui-settings', miscRoutes.postUiSettings);
app.get('/api/ping-services-list', miscRoutes.getPingServicesList);
app.get('/api/ping-services', miscRoutes.getPingServices);
// === IPSEC PASSWORDS ===
app.get('/api/ipsec-passwords', ipsecPasswordsRoutes.getIpsecPasswords);
app.get('/api/ipsec-passwords/:id', ipsecPasswordsRoutes.getIpsecPassword);
app.post('/api/ipsec-passwords', writeLimiter, ipsecPasswordsRoutes.createIpsecPassword);
app.put('/api/ipsec-passwords/:id', writeLimiter, ipsecPasswordsRoutes.updateIpsecPassword);
app.delete('/api/ipsec-passwords/:id', writeLimiter, ipsecPasswordsRoutes.deleteIpsecPassword);
// === MIKROTIK CONFIG GENERATION (text + json) ===
app.post('/api/mikrotik/generate', mikrotikConfigRoutes.generateMikrotikConfig);
app.get('/api/mikrotik/generate-interfaces', mikrotikConfigRoutes.generateInterfaces);
app.get('/api/mikrotik/generate-recursive-routes', mikrotikConfigRoutes.generateRecursiveRoutes);
app.post('/api/mikrotik/test-connection', mikrotikConfigRoutes.testMikrotikConnection);
// Простой health-check (используется NetworkConfigManager при открытии модалки применения)
app.get('/api/mikrotik/ping', (req, res) => res.json({ ok: true, ts: Date.now() }));
// Реальный ping через RouterOS по интерфейсу/шлюзу
app.post('/api/mikrotik/ping', writeLimiter, mikrotikConfigRoutes.pingViaInterface);
// Traceroute через RouterOS с выбором сервера и шлюза
app.post('/api/mikrotik/traceroute', writeLimiter, mikrotikConfigRoutes.tracerouteViaGateway);
// Speed-test (TCP upload/download) между MikroTik по туннельному интерфейсу
app.post('/api/mikrotik/speed-test', writeLimiter, mikrotikConfigRoutes.speedTestViaTunnel);
app.post('/api/mikrotik/apply', mikrotikConfigRoutes.applyMikrotikConfig);
app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript);
app.get('/api/mikrotik/address-lists', mikrotikConfigRoutes.getAddressLists);
app.get('/api/mikrotik/ospf-interface-templates', mikrotikConfigRoutes.getOspfInterfaceTemplates);
app.post('/api/mikrotik/ospf-interface-templates/apply', writeLimiter, mikrotikConfigRoutes.applyOspfInterfaceTemplates);
app.post('/api/mikrotik/address-lists/apply-summary', writeLimiter, mikrotikConfigRoutes.applyAddressListSummary);
// === UPTIME MONITOR (проверка доступности: http / internal-ping / external-ping из настроек) ===
app.get('/api/uptime/cache', mikrotikConfigRoutes.getUptimeCache);
app.post('/api/uptime/check', writeLimiter, mikrotikConfigRoutes.uptimeCheck);
// === TRAFFIC STATS (MikroTik interfaces by jumphost) ===
app.get('/api/traffic/interface-stats', trafficRoutes.getInterfaceStats);
// === RESOURCE STATS (RAM/CPU/HDD по роутерам) ===
app.get('/api/resources/stats', resourceStatsRoutes.getResourceStats);
// === ALERTS (агрегация проблем: серверы, ресурсы роутеров) ===
app.get('/api/alerts', alertsRoutes.getAlerts);
// === MIKROTIK BACKUPS (локальная ФС) ===
app.post('/api/mikrotik/backups', writeLimiter, mikrotikBackupRoutes.createBackup);
app.get('/api/mikrotik/backups', mikrotikBackupRoutes.listBackups);
app.get('/api/mikrotik/backups/item', mikrotikBackupRoutes.getBackup);
app.post('/api/mikrotik/backups/diff', mikrotikBackupRoutes.diffBackups);
app.post('/api/mikrotik/backups/run', writeLimiter, mikrotikBackupRoutes.runBackupNow);
// === NETWORK MAP CACHE (для быстрой загрузки карты сети) ===
app.get('/api/network-map-cache', schedulerRoutes.getNetworkMapCache);
// === LOCAL AI ROUTE OPTIMIZER (rule-based) ===
app.get('/api/route-optimizer', routeOptimizerRoutes.getRouteOptimizer);
// === SCHEDULER (карта сети: пинг и скорость по расписанию) ===
app.get('/api/scheduler/network-map/settings', schedulerRoutes.getNetworkMapSchedulerSettings);
app.patch('/api/scheduler/network-map/settings', writeLimiter, schedulerRoutes.patchNetworkMapSchedulerSettings);
app.get('/api/scheduler/network-map/logs', schedulerRoutes.getNetworkMapSchedulerLogs);
app.post('/api/scheduler/network-map/run-now', writeLimiter, schedulerRoutes.runNetworkMapSchedulerNow);
// === SCHEDULER (пинг сервисов: обновление кеша по расписанию, по умолчанию каждые 2 мин) ===
app.get('/api/scheduler/ping-services/settings', schedulerRoutes.getPingServicesSchedulerSettings);
app.patch('/api/scheduler/ping-services/settings', writeLimiter, schedulerRoutes.patchPingServicesSchedulerSettings);
app.post('/api/scheduler/ping-services/run-now', writeLimiter, schedulerRoutes.runPingServicesSchedulerNow);
// === SCHEDULER (Uptime Monitor: проверки по расписанию, по умолчанию каждые 2 мин) ===
app.get('/api/scheduler/uptime-monitor/settings', schedulerRoutes.getUptimeMonitorSchedulerSettings);
app.patch('/api/scheduler/uptime-monitor/settings', writeLimiter, schedulerRoutes.patchUptimeMonitorSchedulerSettings);
app.post('/api/scheduler/uptime-monitor/run-now', writeLimiter, schedulerRoutes.runUptimeMonitorSchedulerNow);
// === MIKROTIK VALIDATION ===
app.post('/api/mikrotik/validate', async (req, res) => {
@@ -425,5 +569,25 @@ app.use(errorHandler);
app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`);
try {
initMikrotikBackupScheduler(logger);
} catch (e) {
logger.error({ component: 'mikrotik-backup', err: e && e.message }, 'Failed to start backup scheduler');
}
try {
initNetworkMapScheduler(logger, port);
} catch (e) {
logger.error({ component: 'network-map-scheduler', err: e && e.message }, 'Failed to start network map scheduler');
}
try {
initPingServicesScheduler(logger);
} catch (e) {
logger.error({ component: 'ping-services-scheduler', err: e && e.message }, 'Failed to start ping services scheduler');
}
try {
initUptimeMonitorScheduler(logger, port);
} catch (e) {
logger.error({ component: 'uptime-monitor-scheduler', err: e && e.message }, 'Failed to start uptime monitor scheduler');
}
});
+103
View File
@@ -0,0 +1,103 @@
/**
* Бэкапы MikroTik на локальной ФС (вместо S3).
* Логические ключи сохраняют вид backups/mikrotik/{serverId}/{ts}-{rand}.rsc
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const BACKUP_PREFIX = 'backups/mikrotik';
function backupRootDir() {
const raw = process.env.MIKROTIK_BACKUP_DIR || path.join(__dirname, '..', 'data', 'backups', 'mikrotik');
if (!fs.existsSync(raw)) {
fs.mkdirSync(raw, { recursive: true });
}
return raw;
}
function parseBackupKey(key) {
const m = String(key || '').match(/^backups\/mikrotik\/([^/]+)\/(.+)$/);
if (!m) return null;
return { safeId: m[1], fileName: m[2] };
}
function fsPathForKey(key) {
const p = parseBackupKey(key);
if (!p) return null;
return path.join(backupRootDir(), p.safeId, p.fileName);
}
function buildBackupKey(serverId, createdAt, suffix) {
const safeId = String(serverId || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_');
const ts = createdAt.toISOString().replace(/[:-]/g, '').replace(/\.\d+Z$/, 'Z');
const rand = suffix || crypto.randomBytes(3).toString('hex');
return `${BACKUP_PREFIX}/${safeId}/${ts}-${rand}.rsc`;
}
function etagForContent(content) {
const h = crypto.createHash('sha256').update(Buffer.from(String(content), 'utf8')).digest('hex');
return `"${h}"`;
}
async function writeBackupFile(key, content, contentType = 'text/plain') {
const p = parseBackupKey(key);
if (!p) throw new Error('Invalid backup key');
const dir = path.join(backupRootDir(), p.safeId);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const fp = path.join(dir, p.fileName);
await fs.promises.writeFile(fp, String(content), 'utf8');
const st = await fs.promises.stat(fp);
const etag = etagForContent(String(content));
return {
etag,
lastModified: st.mtime.toISOString(),
contentLength: st.size,
};
}
async function readBackupFile(key) {
const fp = fsPathForKey(key);
if (!fp) throw new Error('Invalid backup key');
const body = await fs.promises.readFile(fp, 'utf8');
const st = await fs.promises.stat(fp);
const etag = etagForContent(body);
return {
body,
etag,
lastModified: st.mtime.toISOString(),
contentLength: st.size,
};
}
async function listBackupObjects(serverId, maxKeys = 200) {
const safeId = String(serverId || '').replace(/[^a-zA-Z0-9._-]/g, '_');
const dir = path.join(backupRootDir(), safeId);
if (!fs.existsSync(dir)) return [];
const names = await fs.promises.readdir(dir);
const out = [];
for (const name of names) {
if (out.length >= maxKeys) break;
const fp = path.join(dir, name);
const st = await fs.promises.stat(fp);
if (!st.isFile()) continue;
const key = `${BACKUP_PREFIX}/${safeId}/${name}`;
out.push({
key,
size: st.size,
lastModified: st.mtime.toISOString(),
etag: etagForContent(await fs.promises.readFile(fp, 'utf8')),
});
}
return out;
}
module.exports = {
BACKUP_PREFIX,
buildBackupKey,
writeBackupFile,
readBackupFile,
listBackupObjects,
backupRootDir,
};
+167
View File
@@ -0,0 +1,167 @@
/**
* SQLite blob store (logical S3 keys → rows in `blobs`).
*/
const crypto = require('crypto');
const { openDatabase } = require('../db/sqliteDb');
function sha256Etag(buf) {
const h = crypto.createHash('sha256').update(buf).digest('hex');
return `"${h}"`;
}
function namespaceFromKey(objectKey) {
const k = String(objectKey || '');
if (k.startsWith('ping-cache/') || k.startsWith('speed-test-cache/') || k.startsWith('network-map-cache/') ||
k.startsWith('uptime-monitor-cache/') || k.startsWith('ping-services/')) {
return 'cache';
}
if (k.startsWith('filter-manager/config-') || k.startsWith('filter-manager/server-filters-')) {
return 'per_server';
}
if (k === 'mikrotik-frouting-config.txt') {
return 'derived';
}
return 'config';
}
function isVersionedKey(db, objectKey) {
const row = db.prepare('SELECT 1 FROM versioned_keys WHERE object_key = ?').get(objectKey);
return Boolean(row);
}
function trimVersions(db, objectKey, keep = 50) {
db.prepare(
`DELETE FROM blob_versions WHERE object_key = ? AND id NOT IN (
SELECT id FROM blob_versions WHERE object_key = ? ORDER BY created_at DESC, id DESC LIMIT ?
)`
).run(objectKey, objectKey, keep);
}
/**
* @param {Buffer|string} body
*/
function writeBlobTx(db, objectKey, body, contentType) {
const buf = Buffer.isBuffer(body) ? body : Buffer.from(String(body), 'utf8');
const ns = namespaceFromKey(objectKey);
const etag = sha256Etag(buf);
const now = Date.now();
const size = buf.length;
const existing = db.prepare('SELECT body, content_type, etag, updated_at, created_at FROM blobs WHERE object_key = ?').get(objectKey);
const run = () => {
if (existing && isVersionedKey(db, objectKey)) {
const oldBuf = existing.body;
db.prepare(
`INSERT INTO blob_versions (object_key, body, byte_size, etag, created_at)
VALUES (?, ?, ?, ?, ?)`
).run(objectKey, oldBuf, oldBuf.length, existing.etag, existing.updated_at);
trimVersions(db, objectKey);
}
if (existing) {
db.prepare(
`UPDATE blobs SET namespace = ?, content_type = ?, body = ?, byte_size = ?, etag = ?, updated_at = ?
WHERE object_key = ?`
).run(ns, contentType, buf, size, etag, now, objectKey);
} else {
db.prepare(
`INSERT INTO blobs (object_key, namespace, content_type, body, byte_size, etag, updated_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
).run(objectKey, ns, contentType, buf, size, etag, now, now);
}
};
db.transaction(run)();
return {
etag,
lastModified: new Date(now).toISOString(),
contentLength: size,
};
}
function readBlob(objectKey) {
const db = openDatabase();
const row = db.prepare(
'SELECT body, etag, updated_at, byte_size FROM blobs WHERE object_key = ?'
).get(objectKey);
if (!row) return null;
const bodyStr = row.body.toString('utf8');
return {
body: bodyStr,
etag: row.etag,
lastModified: new Date(row.updated_at).toISOString(),
contentLength: row.byte_size,
};
}
function headMetaRow(objectKey) {
const db = openDatabase();
const row = db.prepare('SELECT etag, updated_at, byte_size FROM blobs WHERE object_key = ?').get(objectKey);
if (!row) {
return { etag: null, lastModified: null, contentLength: null };
}
return {
etag: row.etag,
lastModified: new Date(row.updated_at).toISOString(),
contentLength: row.byte_size,
};
}
function headEtag(objectKey) {
const m = headMetaRow(objectKey);
return m.etag || undefined;
}
function deleteBlob(objectKey) {
const db = openDatabase();
db.prepare('DELETE FROM blobs WHERE object_key = ?').run(objectKey);
}
function listByPrefix(prefix, maxKeys = 100) {
const db = openDatabase();
const rows = db.prepare(
`SELECT object_key AS key, byte_size AS size, updated_at, etag FROM blobs
WHERE object_key >= ? AND object_key < ? ORDER BY object_key LIMIT ?`
).all(prefix, prefix + '\uffff', maxKeys);
return rows.map((r) => ({
key: r.key,
size: r.size,
lastModified: new Date(r.updated_at).toISOString(),
etag: r.etag,
}));
}
function listVersions(objectKey, maxKeys = 50) {
const db = openDatabase();
return db.prepare(
`SELECT id, object_key, byte_size, etag, created_at FROM blob_versions
WHERE object_key = ? ORDER BY created_at DESC, id DESC LIMIT ?`
).all(objectKey, maxKeys);
}
function rollbackToVersionId(objectKey, versionId) {
const db = openDatabase();
const vid = Number(versionId);
if (!Number.isFinite(vid)) throw new Error('Invalid versionId');
const ver = db.prepare('SELECT body, etag, created_at FROM blob_versions WHERE id = ? AND object_key = ?').get(vid, objectKey);
if (!ver) throw new Error('Version not found');
const buf = ver.body;
const contentType = objectKey.endsWith('.json') ? 'application/json' : 'text/plain';
writeBlobTx(db, objectKey, buf, contentType);
return headMetaRow(objectKey);
}
module.exports = {
namespaceFromKey,
writeBlobTx,
readBlob,
headMetaRow,
headEtag,
deleteBlob,
listByPrefix,
listVersions,
rollbackToVersionId,
sha256Etag,
};
+426
View File
@@ -0,0 +1,426 @@
/**
* Серверный клиент EvoBGP API (Bearer, base /v1). Логика выборки совместима с frontend/src/lib/evobgpData.js
*/
const { normalizeEvobgpBaseUrl } = require('../routes/evobgpProxyRoutes');
const PAGE_LIMIT = 500;
const DEFAULT_TIMEOUT_MS = Number(process.env.EVOBGP_CLIENT_TIMEOUT_MS) || 30000;
function getBaseAndToken() {
const base = normalizeEvobgpBaseUrl(process.env.EVOBGP_API_URL || '');
const token = String(process.env.EVOBGP_API_TOKEN || '').trim();
return { base, token };
}
function isConfigured() {
const { base, token } = getBaseAndToken();
return Boolean(base && token);
}
async function evobgpRequest(method, pathname, { query, body } = {}) {
const { base, token } = getBaseAndToken();
if (!base || !token) {
const err = new Error('EvoBGP not configured');
err.code = 'E_EVOBGP_NOT_CONFIGURED';
throw err;
}
const u = new URL(pathname.startsWith('/') ? pathname.slice(1) : pathname, base.endsWith('/') ? base : `${base}/`);
if (query && typeof query === 'object') {
for (const [k, v] of Object.entries(query)) {
if (v !== undefined && v !== null) u.searchParams.set(k, String(v));
}
}
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), DEFAULT_TIMEOUT_MS);
try {
const res = await fetch(u.toString(), {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: ctrl.signal,
});
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch (_) {
data = text;
}
if (!res.ok) {
const err = new Error(typeof data === 'object' && data?.message ? data.message : res.statusText);
err.code = 'E_EVOBGP_HTTP';
err.status = res.status;
err.body = data;
throw err;
}
return data;
} finally {
clearTimeout(t);
}
}
async function paginate(path, params = {}) {
const items = [];
let cursor = null;
while (true) {
const res = await evobgpRequest('GET', path, {
query: { ...params, limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) },
});
const chunk = Array.isArray(res?.items) ? res.items : [];
items.push(...chunk);
if (!res?.has_more || !res?.next_cursor) break;
cursor = res.next_cursor;
}
return items;
}
async function fetchRouterListsCatalog() {
return evobgpRequest('GET', '/router-lists/catalog');
}
function getCatalogItems(section) {
if (!section || typeof section !== 'object') return [];
return Array.isArray(section.items) ? section.items : [];
}
async function listCommunities() {
let communities = [];
try {
const catalog = await fetchRouterListsCatalog();
communities = getCatalogItems(catalog.communities);
} catch {
communities = await paginate('/communities');
}
return communities.map((c) => ({
id: c.id,
value: String(c.community || ''),
name: String(c.title || ''),
description: '',
tags: [],
color: '',
icon: '',
}));
}
async function getOrCreateModule(moduleType, defaultName) {
const modules = await paginate('/modules', { type: moduleType });
if (modules.length > 0) return modules[0];
const created = await evobgpRequest('POST', '/modules', {
body: { type: moduleType, name: defaultName, enabled: true },
});
return created?.data || created;
}
function normalizeList(items, keyName) {
const out = [];
const seen = new Set();
for (const item of items || []) {
const key = String(item?.[keyName] || '').trim().toLowerCase();
if (!key || seen.has(key)) continue;
seen.add(key);
out.push({ ...item, [keyName]: key });
}
return out;
}
async function buildCommunityMaps() {
const communities = await listCommunities();
const byId = new Map(communities.map((c) => [c.id, c.value]));
const byValue = new Map(communities.map((c) => [c.value, c.id]));
return { communities, byId, byValue };
}
function mapCommunityToValue(communityId, byId) {
if (!communityId) return '';
return byId.get(communityId) || String(communityId);
}
async function syncEntries({
moduleType,
moduleName,
listPath,
keyName,
createBody,
patchBody,
currentItems,
originalItems,
requireCommunity = false,
}) {
const module = await getOrCreateModule(moduleType, moduleName);
const moduleId = module.id;
const { byId, byValue } = await buildCommunityMaps();
const existing = await paginate(`/modules/${moduleId}/${listPath}`);
const existingByKey = new Map();
for (const row of existing) {
const entryKey = String(row?.[keyName] || '').trim().toLowerCase();
if (!entryKey) continue;
existingByKey.set(entryKey, row);
}
const normalizedCurrent = normalizeList(currentItems, keyName);
const normalizedOriginal = normalizeList(originalItems, keyName);
const currentMap = new Map(normalizedCurrent.map((i) => [String(i[keyName]).toLowerCase(), i]));
const originalKeys = new Set(normalizedOriginal.map((i) => String(i[keyName]).toLowerCase()));
for (const key of originalKeys) {
if (!currentMap.has(key) && existingByKey.has(key)) {
const row = existingByKey.get(key);
await evobgpRequest('DELETE', `/modules/${moduleId}/${listPath}/${row.id}`);
existingByKey.delete(key);
}
}
for (const [key, item] of currentMap.entries()) {
const rawCommunity = String(item.community || '').trim();
const communityId = byValue.get(rawCommunity) || null;
if (requireCommunity && !communityId) {
throw new Error(`Community "${rawCommunity}" не найдена в справочнике`);
}
const existingRow = existingByKey.get(key);
if (!existingRow) {
await evobgpRequest('POST', `/modules/${moduleId}/${listPath}`, { body: createBody(item, communityId) });
continue;
}
const currentCommunity = mapCommunityToValue(existingRow.community_id, byId);
if (currentCommunity !== rawCommunity) {
await evobgpRequest('PATCH', `/modules/${moduleId}/${listPath}/${existingRow.id}`, {
body: patchBody(item, communityId),
});
}
}
}
async function fetchDomainsData() {
try {
const catalog = await fetchRouterListsCatalog();
const communities = getCatalogItems(catalog.communities);
const byId = new Map(communities.map((c) => [c.id, String(c.community || '')]));
const rows = getCatalogItems(catalog.domains);
return rows
.map((row) => ({
id: row?.entry?.id || row?.id || '',
domain: String(row?.entry?.fqdn || row?.fqdn || '')
.trim()
.toLowerCase(),
community: mapCommunityToValue(row?.entry?.community_id || row?.community_id || null, byId),
}))
.filter((row) => row.domain);
} catch {
const module = await getOrCreateModule('DOMAINS', 'Domains');
const { byId } = await buildCommunityMaps();
const rows = await paginate(`/modules/${module.id}/domain-entries`);
return rows.map((row) => ({
id: row.id,
domain: String(row.fqdn || '')
.trim()
.toLowerCase(),
community: mapCommunityToValue(row.community_id, byId),
}));
}
}
async function fetchIpRangesData() {
try {
const catalog = await fetchRouterListsCatalog();
const communities = getCatalogItems(catalog.communities);
const byId = new Map(communities.map((c) => [c.id, String(c.community || '')]));
const rows = getCatalogItems(catalog.ip_ranges);
return rows
.map((row) => ({
id: row?.entry?.id || row?.id || '',
ipRange: String(row?.entry?.prefix || row?.prefix || '').trim(),
community: mapCommunityToValue(row?.entry?.community_id || row?.community_id || null, byId),
}))
.filter((row) => row.ipRange);
} catch {
const module = await getOrCreateModule('IP_RANGES', 'IP ranges');
const { byId } = await buildCommunityMaps();
const rows = await paginate(`/modules/${module.id}/ip-range-entries`);
return rows.map((row) => ({
id: row.id,
ipRange: String(row.prefix || '').trim(),
community: mapCommunityToValue(row.community_id, byId),
}));
}
}
async function fetchAsnsData() {
try {
const catalog = await fetchRouterListsCatalog();
const communities = getCatalogItems(catalog.communities);
const byId = new Map(communities.map((c) => [c.id, String(c.community || '')]));
const rows = getCatalogItems(catalog.asns);
return rows
.map((row) => ({
id: row?.entry?.id || row?.id || '',
asn: String(row?.entry?.asn || row?.asn || '').trim(),
community: mapCommunityToValue(row?.entry?.community_id || row?.community_id || null, byId),
}))
.filter((row) => row.asn);
} catch {
const module = await getOrCreateModule('AS_PREFIXES', 'ASNs');
const { byId } = await buildCommunityMaps();
const rows = await paginate(`/modules/${module.id}/as-entries`);
return rows.map((row) => ({
id: row.id,
asn: String(row.asn || '').trim(),
community: mapCommunityToValue(row.community_id, byId),
}));
}
}
/** Auto-URL: секция каталога или модуль AUTO_URLS / URL_LIST (настраивается env). */
async function fetchAutoUrlsData() {
try {
const catalog = await fetchRouterListsCatalog();
for (const name of ['auto_urls', 'auto_url', 'urls', 'url_list']) {
const sec = catalog[name];
const items = getCatalogItems(sec);
if (items.length) {
const communities = getCatalogItems(catalog.communities);
const byId = new Map(communities.map((c) => [c.id, String(c.community || '')]));
return items.map((row) => {
const url = String(row?.entry?.url || row?.entry?.fqdn || row?.url || row?.fqdn || '').trim();
const community = mapCommunityToValue(
row?.entry?.community_id || row?.community_id || null,
byId,
);
return { url, community };
}).filter((r) => r.url);
}
}
} catch (_) {}
const modType = process.env.EVOBGP_AUTO_URL_MODULE_TYPE || 'AUTO_URLS';
try {
const module = await getOrCreateModule(modType, 'Auto URLs');
const listPath = process.env.EVOBGP_AUTO_URL_LIST_PATH || 'url-entries';
const rows = await paginate(`/modules/${module.id}/${listPath}`);
const { byId } = await buildCommunityMaps();
return rows.map((row) => ({
url: String(row.url || row.fqdn || '').trim(),
community: mapCommunityToValue(row.community_id, byId),
})).filter((r) => r.url);
} catch {
return [];
}
}
async function saveDomainsData(currentItems, originalItems) {
await syncEntries({
moduleType: 'DOMAINS',
moduleName: 'Domains',
listPath: 'domain-entries',
keyName: 'domain',
currentItems,
originalItems,
createBody: (item, communityId) => ({ fqdn: item.domain, community_id: communityId }),
patchBody: (item, communityId) => ({ fqdn: item.domain, community_id: communityId }),
});
}
async function saveIpRangesData(currentItems, originalItems) {
await syncEntries({
moduleType: 'IP_RANGES',
moduleName: 'IP ranges',
listPath: 'ip-range-entries',
keyName: 'ipRange',
currentItems,
originalItems,
requireCommunity: true,
createBody: (item, communityId) => ({ prefix: item.ipRange, community_id: communityId }),
patchBody: (item, communityId) => ({ prefix: item.ipRange, community_id: communityId }),
});
}
async function saveAsnsData(currentItems, originalItems) {
await syncEntries({
moduleType: 'AS_PREFIXES',
moduleName: 'ASNs',
listPath: 'as-entries',
keyName: 'asn',
currentItems,
originalItems,
createBody: (item, communityId) => ({ asn: Number(item.asn), community_id: communityId }),
patchBody: (item, communityId) => ({ asn: Number(item.asn), community_id: communityId }),
});
}
async function saveCommunitiesData(currentItems) {
const existing = await paginate('/communities');
const existingByValue = new Map(existing.map((c) => [String(c.community || ''), c]));
const nextByValue = new Map();
for (const item of currentItems || []) {
const value = String(item?.value || '').trim();
if (!value) continue;
nextByValue.set(value, item);
}
for (const [value, row] of existingByValue.entries()) {
if (!nextByValue.has(value)) {
await evobgpRequest('DELETE', `/communities/${row.id}`);
}
}
for (const [value, item] of nextByValue.entries()) {
const title = String(item?.name || '').trim();
const row = existingByValue.get(value);
if (!row) {
await evobgpRequest('POST', '/communities', { body: { community: value, title } });
continue;
}
if (String(row.title || '') !== title) {
await evobgpRequest('PATCH', `/communities/${row.id}`, { body: { community: value, title } });
}
}
}
async function saveAutoUrlsData(urls) {
const originalItems = await fetchAutoUrlsData();
const list = (urls || []).map((u) => ({
url: String(u?.url || '').trim(),
community: String(u?.community || '').trim(),
})).filter((u) => u.url && u.community);
const modType = process.env.EVOBGP_AUTO_URL_MODULE_TYPE || 'AUTO_URLS';
const listPath = process.env.EVOBGP_AUTO_URL_LIST_PATH || 'url-entries';
const keyName = process.env.EVOBGP_AUTO_URL_KEY_FIELD || 'url';
const currentItems = list.map((u) => ({ [keyName]: u.url, community: u.community }));
const origMapped = originalItems.map((u) => ({ [keyName]: u.url, community: u.community }));
await syncEntries({
moduleType: modType,
moduleName: 'Auto URLs',
listPath,
keyName,
currentItems,
originalItems: origMapped,
requireCommunity: true,
createBody: (item, communityId) => ({ url: item[keyName], community_id: communityId }),
patchBody: (item, communityId) => ({ url: item[keyName], community_id: communityId }),
});
}
module.exports = {
isConfigured,
evobgpRequest,
paginate,
fetchRouterListsCatalog,
listCommunities,
fetchDomainsData,
fetchIpRangesData,
fetchAsnsData,
fetchAutoUrlsData,
saveCommunitiesData,
saveDomainsData,
saveIpRangesData,
saveAsnsData,
saveAutoUrlsData,
buildCommunityMaps,
mapCommunityToValue,
};
+124
View File
@@ -0,0 +1,124 @@
/**
* Глобальные и simple-фильтры в таблице app_filter_rules (не в blobs JSON).
*/
const crypto = require('crypto');
const { openDatabase } = require('../db/sqliteDb');
/** @typedef {'global'|'simple'} FilterScope */
function assertScope(scope) {
if (scope !== 'global' && scope !== 'simple') throw new Error('Invalid filter scope');
}
function mtimeMetaKey(scope) {
return `filter_rules_mtime_${scope}`;
}
function sha256EtagFromString(s) {
const h = crypto.createHash('sha256').update(Buffer.from(s, 'utf8')).digest('hex');
return `"${h}"`;
}
function rowToItem(row) {
let extra = {};
try {
extra = JSON.parse(row.extra_json || '{}');
if (!extra || typeof extra !== 'object' || Array.isArray(extra)) extra = {};
} catch {
extra = {};
}
const out = {
community: row.community,
gateway: row.gateway,
description: row.description || '',
};
for (const k of Object.keys(extra).sort()) {
if (k === 'community' || k === 'gateway' || k === 'description') continue;
out[k] = extra[k];
}
return out;
}
/**
* @param {FilterScope} scope
*/
function listRules(scope) {
assertScope(scope);
const db = openDatabase();
const rows = db
.prepare(
`SELECT community, gateway, description, extra_json FROM app_filter_rules WHERE scope = ? ORDER BY position ASC`,
)
.all(scope);
return rows.map(rowToItem);
}
/**
* @param {FilterScope} scope
* @returns {{ etag: string, lastModified: string|null, contentLength: number }}
*/
function headMetaForScope(scope) {
assertScope(scope);
const items = listRules(scope);
const body = JSON.stringify(items);
const etag = sha256EtagFromString(body);
const contentLength = Buffer.byteLength(body, 'utf8');
const db = openDatabase();
const row = db.prepare('SELECT value FROM app_meta WHERE key = ?').get(mtimeMetaKey(scope));
const lastModified = row?.value ? new Date(Number(row.value)).toISOString() : null;
return { etag, lastModified, contentLength };
}
/**
* @param {FilterScope} scope
* @param {any[]} items
* @param {{ validateItem?: (item: any, i: number) => string|null }} [opts]
*/
function replaceRules(scope, items, opts = {}) {
assertScope(scope);
const { validateItem } = opts;
if (!Array.isArray(items)) throw new Error('items must be an array');
if (validateItem) {
for (let i = 0; i < items.length; i++) {
const err = validateItem(items[i], i);
if (err) {
const e = new Error(err);
e.code = 'E_SCHEMA';
throw e;
}
}
}
const db = openDatabase();
const now = Date.now();
const del = db.prepare('DELETE FROM app_filter_rules WHERE scope = ?');
const ins = db.prepare(
`INSERT INTO app_filter_rules (scope, position, community, gateway, description, extra_json, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
);
const metaUpsert = db.prepare('INSERT OR REPLACE INTO app_meta (key, value) VALUES (?, ?)');
const run = () => {
del.run(scope);
for (let i = 0; i < items.length; i++) {
const it = items[i] || {};
const community = String(it.community || '').trim();
const gateway = String(it.gateway || '').trim();
const description = String(it.description || '').trim();
const extra = { ...it };
delete extra.community;
delete extra.gateway;
delete extra.description;
ins.run(scope, i, community, gateway, description, JSON.stringify(extra), now);
}
metaUpsert.run(mtimeMetaKey(scope), String(now));
};
db.transaction(run)();
return headMetaForScope(scope);
}
module.exports = {
listRules,
headMetaForScope,
replaceRules,
};
+460
View File
@@ -0,0 +1,460 @@
/**
* Сервис применения конфигурации MikroTik через RouterOS REST API
* Идемпотентная логика: create / update / skip
* Требует RouterOS 7.1+ с www-ssl (HTTPS, порт 443) или www (HTTP, порт 80)
*/
const http = require('http');
const https = require('https');
/**
* Создать REST API клиент для MikroTik
* Поддерживает HTTP (порт 80) и HTTPS (порт 443)
* @param {object} opts - { host, port?, user, password, secure? }
* @returns {object} client с методами print, add, set, remove, command
*/
function createRosClient(opts) {
const { host, port = 80, user, password, secure = false } = opts;
// Порт может прийти строкой из JSON — приводим к числу
const portNumber = Number(port) || 80;
// REST API по умолчанию висит на HTTP:80, HTTPS обычно на 443/8443
const useHttp = portNumber === 80;
const protocol = useHttp ? http : https;
const makeRequest = (method, urlPath, body = null) => {
return new Promise((resolve, reject) => {
const auth = Buffer.from(`${user}:${password || ''}`).toString('base64');
const cleanPath = (urlPath.startsWith('/') ? urlPath.slice(1) : urlPath).replace(/\\/g, '/');
const pathStr = `/rest/${cleanPath}`.replace(/\/+/g, '/');
const bodyStr = (body !== null && body !== undefined) ? JSON.stringify(body) : '';
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Basic ${auth}`,
};
if (bodyStr) headers['Content-Length'] = Buffer.byteLength(bodyStr, 'utf8');
const options = {
hostname: host,
port: portNumber,
path: pathStr,
method,
headers,
rejectUnauthorized: secure,
};
const req = protocol.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
if ([200, 201, 204].includes(res.statusCode)) {
const parsed = data ? (() => { try { return JSON.parse(data); } catch { return data; } })() : null;
resolve({ data: parsed, code: res.statusCode });
} else {
let errMsg = res.statusMessage || 'Request failed';
try {
const errBody = JSON.parse(data);
errMsg = errBody.detail || errBody.message || errMsg;
} catch (_) {}
const err = new Error(errMsg);
err.response = { status: res.statusCode, data };
reject(err);
}
});
});
req.on('error', reject);
if (bodyStr) req.write(bodyStr);
req.end();
});
};
return {
print: (p) => makeRequest('GET', p),
add: (p, body) => makeRequest('PUT', p, body),
set: (p, body) => makeRequest('PATCH', p, body),
remove: (p) => makeRequest('DELETE', p),
command: (p, body) => makeRequest('POST', p, body),
};
}
/**
* Нормализовать path: убрать ведущий слэш
*/
function normalizePath(path) {
return (path || '').replace(/^\//, '');
}
/**
* Выполнить print с фильтром
* @param {object} client - ros-rest client
* @param {string} path - e.g. '/interface/gre' или 'interface/gre'
* @param {object} filter - e.g. { name: 'gre1' } или { '~comment': 'Recursive' }
*/
async function rosPrint(client, path, filter = {}) {
const pathClean = normalizePath(path);
const entries = Object.entries(filter).filter(([, v]) => v != null && v !== '');
let fullPath = pathClean;
if (entries.length > 0) {
const queryParts = entries.map(([k, v]) => {
const key = k.startsWith('~') ? `~${k.slice(1)}` : k;
return `${encodeURIComponent(key)}=${encodeURIComponent(String(v))}`;
});
fullPath = `${pathClean}?${queryParts.join('&')}`;
}
try {
const res = await client.print(fullPath);
const data = res?.data;
return Array.isArray(data) ? data : (data ? [data] : []);
} catch (err) {
if (err?.response?.status === 404) return [];
throw err;
}
}
/**
* Выполнить print с .query (для сложных фильтров вроде ~comment)
*/
async function rosPrintWithQuery(client, path, queryList) {
const pathClean = normalizePath(path);
const fullPath = `${pathClean}/print`;
try {
const res = await client.command(fullPath, { '.query': queryList });
const data = res?.data;
return Array.isArray(data) ? data : (data ? [data] : []);
} catch (err) {
if (err?.response?.status === 404) return [];
throw err;
}
}
/**
* Выполнить add
*/
async function rosAdd(client, path, params) {
const pathClean = normalizePath(path);
const body = params && typeof params === 'object'
? Object.fromEntries(
Object.entries(params).filter(([, v]) => v != null && v !== '').map(([k, v]) => [k, String(v)])
)
: {};
const res = await client.add(pathClean, body);
return res?.data;
}
/**
* Выполнить set
*/
async function rosSet(client, path, id, params) {
const pathClean = normalizePath(path);
const fullPath = id ? `${pathClean}/${id}` : pathClean;
const body = params && typeof params === 'object'
? Object.fromEntries(
Object.entries(params).filter(([, v]) => v != null && v !== '').map(([k, v]) => [k, String(v)])
)
: {};
const res = await client.set(fullPath, body);
return res?.data;
}
/**
* Выполнить remove
*/
async function rosRemove(client, path, id) {
const pathClean = normalizePath(path);
const fullPath = id ? `${pathClean}/${id}` : pathClean;
await client.remove(fullPath);
}
/**
* Сравнить объект из RouterOS с желаемыми params (только ключевые поля)
*/
function paramsMatch(rosItem, params, keysToCompare) {
if (!rosItem || !params) return false;
for (const k of keysToCompare) {
const rosVal = rosItem[k];
const wantVal = params[k];
if (wantVal == null) continue;
if (String(rosVal || '').trim() !== String(wantVal || '').trim()) return false;
}
return true;
}
/**
* Применить одну операцию с idempotent логикой
*/
async function applyOperation(client, op, dryRun) {
const { path, action, params, meta } = op;
const result = { path, action, params: { ...params }, status: null, details: null, error: null };
if (path === '/interface/list' && action === 'add' && meta?.ensureExists) {
const existing = await rosPrint(client, '/interface/list', { name: params.name });
if (existing.length > 0) {
result.status = 'skip';
result.details = 'Interface list already exists';
return result;
}
if (dryRun) {
result.status = 'would_create';
result.details = `Would create interface list ${params.name}`;
return result;
}
await rosAdd(client, '/interface/list', params);
result.status = 'created';
return result;
}
if (path === '/interface/gre' && action === 'add') {
const name = params.name;
const existing = await rosPrint(client, '/interface/gre', { name });
const compareKeys = ['remote-address', 'local-address', 'mtu', 'ipsec-secret', 'keepalive', 'allow-fast-path'];
if (existing.length > 0) {
const match = paramsMatch(existing[0], params, compareKeys);
if (match) {
result.status = 'skip';
result.details = `Interface ${name} already configured`;
return result;
}
if (dryRun) {
result.status = 'would_update';
result.details = `Would update interface ${name}`;
return result;
}
await rosSet(client, '/interface/gre', existing[0]['.id'], params);
result.status = 'updated';
return result;
}
if (dryRun) {
result.status = 'would_create';
result.details = `Would create GRE interface ${name}`;
return result;
}
await rosAdd(client, '/interface/gre', params);
result.status = 'created';
return result;
}
if (path === '/interface/list/member' && action === 'add') {
const iface = params.interface;
const list = params.list;
const existing = await rosPrint(client, '/interface/list/member', { list, interface: iface });
if (existing.length > 0) {
result.status = 'skip';
result.details = `Member ${iface} already in list ${list}`;
return result;
}
if (dryRun) {
result.status = 'would_create';
result.details = `Would add ${iface} to list ${list}`;
return result;
}
await rosAdd(client, '/interface/list/member', params);
result.status = 'created';
return result;
}
if (path === '/ip/address' && action === 'add') {
const addr = params.address;
const iface = params.interface;
const existing = await rosPrint(client, '/ip/address', { interface: iface });
const match = existing.find(e => (e.address || '').startsWith(addr.split('/')[0]));
if (match) {
result.status = 'skip';
result.details = `Address ${addr} already on ${iface}`;
return result;
}
if (dryRun) {
result.status = 'would_create';
result.details = `Would add ${addr} to ${iface}`;
return result;
}
await rosAdd(client, '/ip/address', params);
result.status = 'created';
return result;
}
if (path === '/ip/route') {
if (action === 'remove' && meta?.findComment) {
// REST API может игнорировать regex в .query и возвращать все маршруты — фильтруем на нашей стороне
const raw = await rosPrintWithQuery(client, '/ip/route', [`~comment=${meta.findComment}`]);
const toRemove = raw.filter(r => (r.comment || '').startsWith('Recursive: '));
if (toRemove.length === 0) {
result.status = 'skip';
result.details = 'No matching routes to remove';
return result;
}
if (dryRun) {
result.status = 'would_remove';
result.details = `Would remove ${toRemove.length} route(s)`;
return result;
}
for (const r of toRemove) {
const id = r['.id'];
if (id) await rosRemove(client, '/ip/route', id);
}
result.status = 'removed';
result.details = `${toRemove.length} route(s) removed`;
return result;
}
if (action === 'add') {
const dst = params['dst-address'];
const gw = params.gateway;
const existing = await rosPrint(client, '/ip/route', { 'dst-address': dst });
const match = existing.find(e => (e.gateway || '').includes((gw || '').split('%')[0]));
if (match) {
result.status = 'skip';
result.details = `Route to ${dst} already exists`;
return result;
}
if (dryRun) {
result.status = 'would_create';
result.details = `Would add route ${dst} via ${gw}`;
return result;
}
await rosAdd(client, '/ip/route', params);
result.status = 'created';
return result;
}
}
result.status = 'skipped';
result.details = `Unsupported operation: ${path} ${action}`;
return result;
}
/**
* Применить блок операций к MikroTik
*/
async function applyBlock(client, block, dryRun) {
const results = [];
const ops = block.operations || [];
for (const op of ops) {
try {
const r = await applyOperation(client, op, dryRun);
results.push(r);
} catch (err) {
results.push({
path: op.path,
action: op.action,
params: op.params,
status: 'error',
error: err.message || String(err),
});
}
}
return results;
}
/**
* Получить полный экспорт конфигурации MikroTik через REST API.
* API не возвращает текст /export напрямую — только при export file=... сохраняется в файл.
* Мы экспортируем во временный файл, читаем его, удаляем.
*
* @param {object} client - ros-rest client (createRosClient)
* @returns {Promise<string>} - текст конфигурации (export compact)
*/
async function fetchExportViaFile(client) {
const crypto = require('crypto');
const basename = `backup_${Date.now()}_${crypto.randomBytes(4).toString('hex')}.rsc`;
// Явно указываем flash/ — экспорт сохраняется в flash
const filePath = `flash/${basename}`;
try {
await client.command('export', { compact: '', file: filePath });
} catch (err) {
// Попробуем без flash/ — старые версии RouterOS могут сохранять иначе
try {
await client.command('export', { compact: '', file: basename });
} catch (err2) {
const msg = err?.message || err2?.message || String(err);
const detail = err?.response?.data || err2?.response?.data;
console.error('[fetchExportViaFile] export failed:', msg, detail);
throw new Error(`Export failed: ${msg}${detail ? ` (${JSON.stringify(detail).slice(0, 200)})` : ''}`);
}
}
// POST file/print — получаем все файлы и ищем по имени (надёжнее GET с query)
let list = [];
try {
const printRes = await client.command('file/print', {
'.proplist': ['.id', 'name', 'contents'],
});
const data = printRes?.data;
const raw = Array.isArray(data) ? data : (data ? [data] : []);
const found = raw.find(
(f) =>
(f.name || '') === basename ||
(f.name || '') === filePath ||
(f.name || '').endsWith('/' + basename) ||
(f.name || '').endsWith(basename),
);
if (found) list = [found];
} catch (err) {
console.error('[fetchExportViaFile] file/print failed:', err?.message, err?.response?.data);
throw new Error(`file/print failed: ${err?.message || String(err)}`);
}
const file = list[0];
if (!file) {
throw new Error('Export file not found after export (check write permissions on router)');
}
let config = String(file.contents || '');
// Для файлов >60KB API не возвращает contents в print — читаем через file/read
if (!config && file.name) {
const chunkSize = 32768;
let offset = 0;
const chunks = [];
try {
for (;;) {
const readRes = await client.command('file/read', {
file: file.name,
offset: String(offset),
'chunk-size': String(chunkSize),
});
const rd = readRes?.data;
const chunk = Array.isArray(rd) ? (rd[0]?.data ?? rd[0]) : (rd?.data ?? rd);
const str = typeof chunk === 'string' ? chunk : (chunk ? String(chunk) : '');
if (!str) break;
chunks.push(str);
offset += str.length;
if (str.length < chunkSize) break;
}
config = chunks.join('');
} catch (err) {
console.error('[fetchExportViaFile] file/read failed:', err?.message);
try {
const id = file['.id'];
if (id) await client.remove(`file/${id}`);
} catch (_) {}
throw new Error(`file/read failed: ${err?.message || String(err)}`);
}
}
if (!config) {
try {
const id = file['.id'];
if (id) await client.remove(`file/${id}`);
} catch (_) {}
throw new Error('File/print and file/read returned no contents (router may restrict file access)');
}
try {
const id = file['.id'];
if (id) await client.remove(`file/${id}`);
} catch (_) {}
return config;
}
module.exports = {
createRosClient,
rosPrint,
rosPrintWithQuery,
rosAdd,
rosSet,
rosRemove,
applyOperation,
applyBlock,
fetchExportViaFile,
};
+185
View File
@@ -0,0 +1,185 @@
/**
* Простейший планировщик автоматических бэкапов MikroTik.
*
* Работает внутри Node-процесса backend:
* - периодически обходит jumphost-сервера из servers.json
* - вызывает RouterOS REST API /rest/export (compact)
* - сохраняет конфиг на диск через saveBackupForServer
*
* Управляется через переменные окружения:
* - MIKROTIK_BACKUP_ENABLED=true|false (по умолчанию true)
* - MIKROTIK_BACKUP_INTERVAL_MINUTES=60 (интервал между запусками)
* - MIKROTIK_BACKUP_SERVERS="id1,id2" (если не указано — все jumphost)
*/
const { readServersFromS3 } = require('../routes/serversRoutes');
const { createRosClient, fetchExportViaFile } = require('./mikrotikApplyService');
const { saveBackupForServer } = require('../routes/mikrotikBackupRoutes');
const { decrypt } = require('../utils/encryption');
const { readS3TextObject } = require('../services/s3Service');
const DEFAULT_INTERVAL_MIN = 60;
function parseServerIds(envValue) {
if (!envValue) return null;
const parts = String(envValue)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return parts.length > 0 ? parts : null;
}
/**
* Прочитать список серверов для автобэкапов из UI-настроек (rt_ui_settings.json).
* Если поле mikrotikBackupServers отсутствует или пустое — вернуть null.
*/
async function getServerIdsFromUiSettings(logger) {
try {
const data = await readS3TextObject('bgp_data/rt_ui_settings.json').catch(() => null);
if (!data || !data.body) return null;
const parsed = JSON.parse(data.body || '{}');
const list = parsed && Array.isArray(parsed.mikrotikBackupServers)
? parsed.mikrotikBackupServers
: null;
if (!list || list.length === 0) return null;
return list.map((v) => String(v)).filter(Boolean);
} catch (e) {
(logger || console).warn(
{ component: 'mikrotik-backup', err: e && e.message },
'Failed to read mikrotikBackupServers from ui-settings',
);
return null;
}
}
async function fetchRouterExport(client) {
// API не возвращает текст /export напрямую — экспортируем в файл, читаем, удаляем
return fetchExportViaFile(client);
}
async function runBackupOnce(logger) {
const log = logger || console;
try {
const allServers = await readServersFromS3();
const uiIds = await getServerIdsFromUiSettings(log);
const envIds = parseServerIds(process.env.MIKROTIK_BACKUP_SERVERS);
const wantedIds = (uiIds && uiIds.length > 0) ? uiIds : envIds;
const jumphosts = (allServers || []).filter((s) => {
if (!s || String(s.type || '').toLowerCase() !== 'jumphost') return false;
if (!wantedIds) return true;
const id = s.id || s.dns || s.ip;
return id && wantedIds.includes(String(id));
});
if (jumphosts.length === 0) {
log.info({ component: 'mikrotik-backup' }, 'No jumphost servers found for backup');
return;
}
for (const server of jumphosts) {
const id = server.id || server.dns || server.ip || 'unknown';
try {
const host = server.mikrotikHost || server.ip || server.dns;
const port = server.mikrotikPort || 80;
const user = server.mikrotikUser || 'admin';
if (!host) {
log.warn({ component: 'mikrotik-backup', serverId: id }, 'Skip backup: no MikroTik host');
continue;
}
let password = '';
if (server.encryptedMikrotikPassword) {
try {
password = decrypt(server.encryptedMikrotikPassword);
} catch (e) {
log.error(
{ component: 'mikrotik-backup', serverId: id, err: e && e.message },
'Failed to decrypt MikroTik password, skipping backup',
);
continue;
}
} else {
log.warn(
{ component: 'mikrotik-backup', serverId: id },
'Skip backup: no encryptedMikrotikPassword configured',
);
continue;
}
const creds = {
host,
port: Number(port) || 80,
user,
password,
secure: false,
};
const client = createRosClient(creds);
log.info({ component: 'mikrotik-backup', serverId: id, host: creds.host }, 'Starting automatic backup via REST /export');
const configText = await fetchRouterExport(client);
if (!configText || !configText.trim()) {
log.warn({ component: 'mikrotik-backup', serverId: id }, 'Skip backup: empty export result');
continue;
}
const result = await saveBackupForServer(id, configText, {
source: 'scheduler',
comment: 'Automatic MikroTik backup (REST /export)',
});
log.info(
{ component: 'mikrotik-backup', serverId: id, key: result.key },
'Automatic backup saved to disk',
);
} catch (err) {
log.error(
{ component: 'mikrotik-backup', serverId: id, err: err && err.message },
'Failed to create automatic backup',
);
}
}
} catch (err) {
const msg = err && err.message ? err.message : String(err);
(logger || console).error({ component: 'mikrotik-backup', err: msg }, 'Backup run failed');
}
}
function initMikrotikBackupScheduler(logger) {
const enabledEnv = String(process.env.MIKROTIK_BACKUP_ENABLED || 'true').toLowerCase();
const enabled = enabledEnv !== 'false' && enabledEnv !== '0' && enabledEnv !== 'off';
const log = logger || console;
if (!enabled) {
log.info({ component: 'mikrotik-backup' }, 'Automatic MikroTik backup scheduler is disabled');
return;
}
const intervalMin =
Number(process.env.MIKROTIK_BACKUP_INTERVAL_MINUTES || DEFAULT_INTERVAL_MIN) || DEFAULT_INTERVAL_MIN;
const intervalMs = Math.max(5, intervalMin) * 60 * 1000;
const intervalMinutes = intervalMs / 60000;
log.info(
{
component: 'mikrotik-backup',
intervalMinutes,
nextRunInMinutes: intervalMinutes,
servers: process.env.MIKROTIK_BACKUP_SERVERS || 'all jumphost',
},
'Automatic MikroTik backup scheduler started (no backup on startup, first run in %d min)',
intervalMinutes,
);
// Только по расписанию — без бэкапа при перезапуске контейнера
setInterval(() => {
runBackupOnce(log);
}, intervalMs);
}
module.exports = {
initMikrotikBackupScheduler,
runBackupOnce,
};
+530
View File
@@ -0,0 +1,530 @@
/**
* Планировщик пингов и замеров скорости для карты сети.
* Периодически вызывает пинг и speed-test по связям из network-config,
* сохраняет результаты в S3 (кеш по рёбрам + агрегат network-map-cache/latest.json).
* Логи пишет в кольцевой буфер для просмотра в UI.
*/
const { readServersFromS3 } = require('../routes/serversRoutes');
const { loadNetworkConfig } = require('../routes/mikrotikConfigRoutes');
const { readS3TextObject, writeS3JsonObject } = require('./s3Service');
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json';
const NEXT_RUN_AT_KEY = 'networkMapSchedulerNextRunAt';
const DEFAULT_INTERVAL_MINUTES = 5;
const DEFAULT_LOG_MAX_LINES = 500;
const RESTART_RECOVERY_DELAY_MS = 10 * 60 * 1000;
/** Кольцевой буфер логов */
class LogBuffer {
constructor(maxLines = 500) {
this.maxLines = Math.max(100, maxLines);
this.lines = [];
this.index = 0;
}
push(line) {
const ts = new Date().toISOString();
const entry = `${ts} ${line}`;
if (this.lines.length < this.maxLines) {
this.lines.push(entry);
} else {
this.lines[this.index] = entry;
this.index = (this.index + 1) % this.maxLines;
}
}
getLines() {
if (this.lines.length < this.maxLines) return [...this.lines];
return [...this.lines.slice(this.index), ...this.lines.slice(0, this.index)];
}
setMaxLines(n) {
this.maxLines = Math.max(100, n);
if (this.lines.length > this.maxLines) {
this.lines = this.getLines().slice(-this.maxLines);
this.index = 0;
}
}
}
const logBuffer = new LogBuffer(DEFAULT_LOG_MAX_LINES);
async function loadUiSettings() {
try {
const data = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
const parsed = JSON.parse(data?.body || '{}');
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (_) {
return {};
}
}
/** Построить список связей как в NetworkMapDashboard (tunnelInterfaces → connections) */
async function buildConnections() {
const [serversList, config, uiSettings] = await Promise.all([
readServersFromS3(),
loadNetworkConfig(),
loadUiSettings(),
]);
const servers = Array.isArray(serversList) ? serversList : [];
const tunnelInterfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : [];
const getServer = (serverId) =>
servers.find((s) => s.id === serverId || s.ip === serverId || s.dns === serverId);
const connList = [];
for (const iface of tunnelInterfaces) {
if (!iface.serverId || !iface.serverId2) continue;
const s1 = getServer(iface.serverId);
const s2 = getServer(iface.serverId2);
if (!s1 || !s2 || s1.ip === s2.ip) continue;
const s1Key = s1.id || s1.dns || s1.ip;
const s2Key = s2.id || s2.dns || s2.ip;
if (!s1Key || !s2Key) continue;
const isJumphost1 = s1 && ['jumphost', 'home'].includes(String(s1.type || '').toLowerCase());
const isJumphost2 = s2 && ['jumphost', 'home'].includes(String(s2.type || '').toLowerCase());
connList.push({
from: s1.ip,
to: s2.ip,
fromKey: s1Key,
toKey: s2Key,
internalFromTo: iface.remoteIp || null,
internalToFrom: iface.localIp || null,
speedTestServerId: isJumphost1 ? s1Key : isJumphost2 ? s2Key : null,
interfaceName: iface.name || null,
});
}
return { connections: connList, servers, uiSettings };
}
function edgePingKey(a, b) {
return [String(a), String(b)].sort().join(':');
}
function speedKey(key1, key2) {
return [String(key1), String(key2)].sort().join(':');
}
/** Параллельно с лимитом */
async function runWithLimit(tasks, limit = 4) {
const results = [];
let index = 0;
async function runNext() {
const i = index++;
if (i >= tasks.length) return;
try {
results[i] = { value: await tasks[i]() };
} catch (err) {
results[i] = { error: err };
}
await runNext();
}
const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => runNext());
await Promise.all(workers);
return results;
}
/** Один тест на ноду в момент времени */
async function runWithOnePerKey(tasks, getKey) {
const n = tasks.length;
const keyToIndices = new Map();
for (let i = 0; i < n; i++) {
const k = String(getKey(i));
if (!keyToIndices.has(k)) keyToIndices.set(k, []);
keyToIndices.get(k).push(i);
}
const results = [];
for (let i = 0; i < n; i++) results[i] = null;
const keys = Array.from(keyToIndices.keys());
let round = 0;
while (true) {
const batch = [];
for (const k of keys) {
const indices = keyToIndices.get(k);
if (round < indices.length) batch.push(indices[round]);
}
if (batch.length === 0) break;
await Promise.all(
batch.map(async (i) => {
try {
results[i] = { value: await tasks[i]() };
} catch (err) {
results[i] = { error: err };
}
})
);
round++;
}
return results;
}
/**
* Выполнить одну итерацию: пинг и speed-test по связям, записать кеш и логи.
* @param {object} opts - { baseUrl, runPing, runSpeedTest, log }
*/
async function runJob(opts) {
const { baseUrl, runPing = true, runSpeedTest = true, log = (msg) => logBuffer.push(msg), trigger = 'scheduled' } = opts;
const pingMap = {};
const speedMap = {};
log(trigger === 'manual' ? '--- Ручной запуск ---' : '--- Запуск по расписанию ---');
try {
const { connections, servers } = await buildConnections();
if (connections.length === 0) {
log('Нет связей в network-config (tunnelInterfaces).');
await writeCache(pingMap, speedMap);
return { pingMap, speedMap };
}
log(`Связей: ${connections.length}. Пинг: ${runPing ? 'да' : 'нет'}, Скорость: ${runSpeedTest ? 'да' : 'нет'}`);
if (runPing) {
const pingTasks = [];
for (const c of connections) {
const from = String(c.from);
const to = String(c.to);
if (!from || !to || from === to) continue;
const srcFrom = servers.find((s) => s.ip === from);
const srcTo = servers.find((s) => s.ip === to);
const canPingFrom = srcFrom && ['jumphost', 'home'].includes(String(srcFrom.type || '').toLowerCase()) && c.internalFromTo;
const canPingTo = srcTo && ['jumphost', 'home'].includes(String(srcTo.type || '').toLowerCase()) && c.internalToFrom;
if (canPingFrom) {
pingTasks.push(async () => {
const ekey = edgePingKey(from, to);
try {
const res = await fetch(`${baseUrl}/api/mikrotik/ping`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: c.fromKey,
target: c.internalFromTo,
gatewayIp: c.internalFromTo,
count: 3,
forceRefresh: true,
}),
});
const data = await res.json().catch(() => ({}));
const ms = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null;
pingMap[ekey] = ms;
const errMsg = !res.ok ? (data?.message || `HTTP ${res.status}`) : null;
log(`Пинг ${from} → ${to}: ${ms != null ? ms + ' ms' : errMsg || 'ошибка'}`);
return ms;
} catch (e) {
pingMap[ekey] = null;
log(`Пинг ${from} → ${to}: ошибка ${e?.message || e}`);
return null;
}
});
} else if (canPingTo) {
pingTasks.push(async () => {
const ekey = edgePingKey(from, to);
try {
const res = await fetch(`${baseUrl}/api/mikrotik/ping`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: c.toKey,
target: c.internalToFrom,
gatewayIp: c.internalToFrom,
count: 3,
forceRefresh: true,
}),
});
const data = await res.json().catch(() => ({}));
const ms = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null;
pingMap[ekey] = ms;
const errMsg2 = !res.ok ? (data?.message || `HTTP ${res.status}`) : null;
log(`Пинг ${to} → ${from}: ${ms != null ? ms + ' ms' : errMsg2 || 'ошибка'}`);
return ms;
} catch (e) {
pingMap[ekey] = null;
log(`Пинг ${to} → ${from}: ошибка ${e?.message || e}`);
return null;
}
});
}
}
await runWithLimit(pingTasks, 4);
}
if (runSpeedTest) {
// Таймаут запроса: замер может долго идти на медленных каналах (download + upload по durationSeconds каждый)
const SPEED_TEST_REQUEST_MS = 300000; // 5 мин
const withSpeed = connections.filter((c) => c.speedTestServerId && c.interfaceName);
const speedTasks = withSpeed.map((c) => async () => {
const key = speedKey(c.fromKey, c.toKey);
const server = servers.find((s) => (s.id || s.dns || s.ip) === c.speedTestServerId);
const serverLabel = server ? (server.dns || server.ip || server.id) : c.speedTestServerId;
const speedLabel = `${serverLabel} / ${c.interfaceName}`;
const ac = new AbortController();
const timeoutId = setTimeout(() => ac.abort(), SPEED_TEST_REQUEST_MS);
try {
const res = await fetch(`${baseUrl}/api/mikrotik/speed-test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: c.speedTestServerId,
interfaceName: c.interfaceName,
forceRefresh: true,
}),
signal: ac.signal,
});
clearTimeout(timeoutId);
const data = await res.json().catch(() => ({}));
if (data?.ok && (data.tcpDownloadBps != null || data.tcpUploadBps != null)) {
speedMap[key] = {
tcpDownloadBps: data.tcpDownloadBps,
tcpUploadBps: data.tcpUploadBps,
cached: data.cached === true,
durationSeconds: data.durationSeconds,
};
const down = data.tcpDownloadBps != null ? (data.tcpDownloadBps / 1e6).toFixed(1) : '—';
const up = data.tcpUploadBps != null ? (data.tcpUploadBps / 1e6).toFixed(1) : '—';
log(`Скорость ${speedLabel}: ↓${down} ↑${up} Mbps`);
return speedMap[key];
}
speedMap[key] = null;
const speedErr = !res.ok ? (data?.message || `HTTP ${res.status}`) : 'нет данных';
log(`Скорость ${speedLabel}: ${speedErr}`);
return null;
} catch (e) {
clearTimeout(timeoutId);
speedMap[key] = null;
const msg = e?.message || e;
const isAbort = String(msg).toLowerCase().includes('abort');
log(`Скорость ${speedLabel}: ошибка ${isAbort ? 'таймаут или отмена запроса' : msg}`);
return null;
}
});
// В один момент — только один тест на один сервер назначения (удалённый конец туннеля),
// иначе несколько замеров к одному хостингу делят канал и дают заниженные результаты
await runWithOnePerKey(speedTasks, (i) => {
const c = withSpeed[i];
return c.speedTestServerId === c.fromKey ? c.toKey : c.fromKey;
});
}
await writeCache(pingMap, speedMap);
log('Кеш карты сети обновлён.');
} catch (err) {
log(`Ошибка: ${err?.message || err}`);
}
return { pingMap, speedMap };
}
async function writeCache(pingMap, speedMap) {
try {
await writeS3JsonObject(NETWORK_MAP_CACHE_KEY, {
pingMap: pingMap || {},
speedMap: speedMap || {},
updatedAt: Date.now(),
});
} catch (e) {
logBuffer.push(`Запись кеша в S3 не удалась: ${e?.message || e}`);
}
}
let schedulerTimer = null;
let schedulerStartTimeout = null;
/** Время последнего запуска по расписанию (для расчёта следующего) */
let lastScheduledRunAt = null;
/** Реально запланированное время следующего запуска */
let nextScheduledRunAt = null;
/** Интервал активного таймера (мс) */
let activeIntervalMs = null;
async function persistNextRunAt(nextRunAt) {
try {
const ui = await loadUiSettings();
ui[NEXT_RUN_AT_KEY] = typeof nextRunAt === 'number' ? Math.round(nextRunAt) : null;
await writeS3JsonObject(UI_SETTINGS_KEY, ui);
} catch (e) {
logBuffer.push(`Не удалось сохранить nextRunAt: ${e?.message || e}`);
}
}
function getSettingsFromUiSettings(uiSettings) {
const persistedNextRunAtRaw = uiSettings?.[NEXT_RUN_AT_KEY];
const persistedNextRunAt = Number(persistedNextRunAtRaw);
return {
enabled: Boolean(uiSettings.networkMapSchedulerEnabled),
intervalMinutes: Math.max(1, Math.min(1440, parseInt(uiSettings.networkMapSchedulerIntervalMinutes, 10) || DEFAULT_INTERVAL_MINUTES)),
runPing: uiSettings.networkMapSchedulerRunPing !== false,
runSpeedTest: uiSettings.networkMapSchedulerRunSpeedTest !== false,
logMaxLines: Math.max(100, Math.min(2000, parseInt(uiSettings.networkMapSchedulerLogMaxLines, 10) || DEFAULT_LOG_MAX_LINES)),
persistedNextRunAt: Number.isFinite(persistedNextRunAt) ? persistedNextRunAt : null,
};
}
/**
* Запустить планировщик (setInterval).
* @param {object} logger - pino logger
* @param {number} port - порт сервера для вызова API
*/
function initNetworkMapScheduler(logger, port) {
const log = logger || console;
const baseUrl = `http://127.0.0.1:${port}`;
async function tick(source = 'interval') {
if (source === 'interval') {
lastScheduledRunAt = Date.now();
if (activeIntervalMs) {
nextScheduledRunAt = lastScheduledRunAt + activeIntervalMs;
persistNextRunAt(nextScheduledRunAt);
}
}
let uiSettings;
try {
const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
uiSettings = JSON.parse(raw?.body || '{}') || {};
} catch (_) {
return;
}
const settings = getSettingsFromUiSettings(uiSettings);
if (!settings.enabled) return;
logBuffer.setMaxLines(settings.logMaxLines);
logBuffer.push('--- Запуск по расписанию ---');
runJob({
baseUrl,
runPing: settings.runPing,
runSpeedTest: settings.runSpeedTest,
trigger: source === 'startup-delay' ? 'startup-delay' : 'scheduled',
log: (msg) => {
logBuffer.push(msg);
log.info({ component: 'network-map-scheduler' }, msg);
},
}).catch((err) => {
logBuffer.push(`Ошибка: ${err?.message || err}`);
log.error({ component: 'network-map-scheduler', err: err?.message }, 'Scheduler run failed');
});
}
async function schedule() {
let uiSettings;
try {
const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
uiSettings = JSON.parse(raw?.body || '{}') || {};
} catch (_) {
return;
}
const settings = getSettingsFromUiSettings(uiSettings);
if (!settings.enabled) {
activeIntervalMs = null;
nextScheduledRunAt = null;
persistNextRunAt(null);
log.info({ component: 'network-map-scheduler' }, 'Scheduler disabled in settings');
return;
}
logBuffer.setMaxLines(settings.logMaxLines);
const intervalMs = settings.intervalMinutes * 60 * 1000;
activeIntervalMs = intervalMs;
const now = Date.now();
const persisted = settings.persistedNextRunAt;
let firstDelayMs = intervalMs;
if (typeof persisted === 'number' && Number.isFinite(persisted)) {
if (persisted > now) {
// Плановая дата ещё не наступила — сохраняем исходный план.
firstDelayMs = Math.max(1000, persisted - now);
} else {
// Плановая дата уже прошла (контейнер был перезапущен/простаивал) —
// первый запуск через 10 минут от старта контейнера.
firstDelayMs = RESTART_RECOVERY_DELAY_MS;
}
}
nextScheduledRunAt = now + firstDelayMs;
persistNextRunAt(nextScheduledRunAt);
log.info(
{
component: 'network-map-scheduler',
intervalMinutes: settings.intervalMinutes,
firstDelayMs,
recoveryDelayMs: RESTART_RECOVERY_DELAY_MS,
},
'Network map scheduler started (first run in %d sec, then every %d min)',
Math.round(firstDelayMs / 1000),
settings.intervalMinutes
);
schedulerStartTimeout = setTimeout(() => {
tick('interval');
schedulerTimer = setInterval(() => tick('interval'), intervalMs);
}, firstDelayMs);
}
schedule();
}
function stopNetworkMapScheduler() {
if (schedulerStartTimeout) {
clearTimeout(schedulerStartTimeout);
schedulerStartTimeout = null;
}
if (schedulerTimer) {
clearInterval(schedulerTimer);
schedulerTimer = null;
}
activeIntervalMs = null;
nextScheduledRunAt = null;
persistNextRunAt(null);
}
function getLogs() {
return logBuffer.getLines();
}
function getLogMaxLines() {
return logBuffer.maxLines;
}
function setLogMaxLines(n) {
const num = Math.max(100, Math.min(2000, parseInt(n, 10) || 500));
logBuffer.setMaxLines(num);
}
/** Добавить строку в лог (для вывода ошибок из роутов) */
function pushLog(msg) {
logBuffer.push(msg);
}
/**
* Время следующего запуска по расписанию (timestamp или null если выключено).
* @param {object} settings - результат getSettingsFromUiSettings()
* @returns {number|null}
*/
function getNextRunAt(settings) {
if (!settings || !settings.enabled) return null;
if (typeof nextScheduledRunAt === 'number' && Number.isFinite(nextScheduledRunAt)) {
return nextScheduledRunAt;
}
if (typeof lastScheduledRunAt === 'number' && Number.isFinite(lastScheduledRunAt)) {
const intervalMs = activeIntervalMs || (settings.intervalMinutes || DEFAULT_INTERVAL_MINUTES) * 60 * 1000;
return lastScheduledRunAt + intervalMs;
}
if (typeof settings?.persistedNextRunAt === 'number' && Number.isFinite(settings.persistedNextRunAt)) {
const now = Date.now();
if (settings.persistedNextRunAt > now) return settings.persistedNextRunAt;
return now + RESTART_RECOVERY_DELAY_MS;
}
return null;
}
module.exports = {
runJob,
initNetworkMapScheduler,
stopNetworkMapScheduler,
getLogs,
getLogMaxLines,
setLogMaxLines,
pushLog,
getSettingsFromUiSettings,
getNextRunAt,
loadUiSettings: loadUiSettings,
buildConnections,
LogBuffer,
};
+96
View File
@@ -0,0 +1,96 @@
/**
* Планировщик пинга сервисов из списка настроек.
* Периодически вызывает refreshPingServicesCache и обновляет кеш для GET /api/ping-services.
*/
const { readS3TextObject } = require('./s3Service');
const { refreshPingServicesCache } = require('../routes/miscRoutes');
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
const DEFAULT_INTERVAL_MINUTES = 2;
let schedulerTimer = null;
let lastScheduledRunAt = null;
function getSettingsFromUiSettings(uiSettings) {
return {
enabled: uiSettings.pingServicesSchedulerEnabled !== false,
intervalMinutes: Math.max(1, Math.min(1440, parseInt(uiSettings.pingServicesSchedulerIntervalMinutes, 10) || DEFAULT_INTERVAL_MINUTES)),
};
}
/**
* Время следующего запуска по расписанию (timestamp или null если выключено).
*/
function getNextRunAt(settings) {
if (!settings || !settings.enabled) return null;
const intervalMs = (settings.intervalMinutes || DEFAULT_INTERVAL_MINUTES) * 60 * 1000;
const from = lastScheduledRunAt || Date.now();
return from + intervalMs;
}
/**
* Запустить планировщик пинг-сервисов.
* @param {object} logger - pino logger
*/
function initPingServicesScheduler(logger) {
const log = logger || console;
async function tick() {
lastScheduledRunAt = Date.now();
let uiSettings;
try {
const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
uiSettings = JSON.parse(raw?.body || '{}') || {};
} catch (_) {
return;
}
const settings = getSettingsFromUiSettings(uiSettings);
if (!settings.enabled) return;
log.info({ component: 'ping-services-scheduler' }, 'Running ping services refresh');
refreshPingServicesCache().catch((err) => {
log.error({ component: 'ping-services-scheduler', err: err?.message }, 'Ping services refresh failed');
});
}
async function schedule() {
let uiSettings;
try {
const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
uiSettings = JSON.parse(raw?.body || '{}') || {};
} catch (_) {
return;
}
const settings = getSettingsFromUiSettings(uiSettings);
if (!settings.enabled) {
log.info({ component: 'ping-services-scheduler' }, 'Ping services scheduler disabled in settings');
return;
}
const intervalMs = settings.intervalMinutes * 60 * 1000;
log.info(
{ component: 'ping-services-scheduler', intervalMinutes: settings.intervalMinutes },
'Ping services scheduler started (first run in 15 s, then every %d min)',
settings.intervalMinutes
);
schedulerTimer = setInterval(tick, intervalMs);
setTimeout(() => tick(), 15 * 1000);
}
schedule();
}
function stopPingServicesScheduler() {
if (schedulerTimer) {
clearInterval(schedulerTimer);
schedulerTimer = null;
}
}
module.exports = {
initPingServicesScheduler,
stopPingServicesScheduler,
getSettingsFromUiSettings,
getNextRunAt,
};
+111 -197
View File
@@ -1,58 +1,35 @@
/**
* Сервис для работы с S3 (Yandex Object Storage)
* Централизованные операции чтения/записи/кэширования
* Локальное хранилище объектов (SQLite). Сохранены имена экспортов readS3TextObject / writeS3JsonObject и т.д. для совместимости с роутами.
* Справочники EvoBGP не хранятся здесь — см. evobgpClient.js
*/
const { S3Client, GetObjectCommand, HeadObjectCommand, PutObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
const { NodeHttpHandler } = require('@smithy/node-http-handler');
const http = require('http');
const https = require('https');
const { openDatabase } = require('../db/sqliteDb');
const blobStorage = require('./blobStorage');
// S3 Client setup
const s3 = new S3Client({
endpoint: 'https://storage.yandexcloud.net',
region: process.env.AWS_REGION,
forcePathStyle: true,
maxAttempts: 3,
requestHandler: new NodeHttpHandler({
httpAgent: new http.Agent({ keepAlive: true }),
httpsAgent: new https.Agent({ keepAlive: true })
}),
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || process.env.S3_SECRET_ACCESS_KEY,
}
});
openDatabase();
const BUCKET_NAME = process.env.S3_BUCKET_NAME;
const BUCKET_NAME = process.env.SQLITE_BUCKET_LABEL || 'sqlite';
// In-memory cache
const cache = {
text: new Map(),
head: new Map(),
max: 100,
ttlMs: 30_000
ttlMs: 30_000,
};
/**
* Конвертирует stream в строку
*/
async function streamToString(stream) {
if (!stream) return '';
if (typeof stream.transformToString === 'function') {
return await stream.transformToString();
}
return await new Promise((resolve, reject) => {
let chunks = [];
stream.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(String(c))));
stream.once('error', reject);
stream.once('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
});
if (!stream) return '';
if (typeof stream.transformToString === 'function') {
return await stream.transformToString();
}
return await new Promise((resolve, reject) => {
const chunks = [];
stream.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(String(c))));
stream.once('error', reject);
stream.once('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
});
}
/**
* Получить значение из кэша
*/
function getCache(map, key) {
const v = map.get(key);
if (!v) return null;
@@ -63,9 +40,6 @@ function getCache(map, key) {
return v.value;
}
/**
* Установить значение в кэш
*/
function setCache(map, key, value) {
if (map.size >= cache.max) {
const firstKey = map.keys().next().value;
@@ -74,177 +48,115 @@ function setCache(map, key, value) {
map.set(key, { value, at: Date.now() });
}
/**
* Инвалидировать кэш для ключа
*/
function invalidateCacheForKey(key) {
try { cache.text.delete(key); } catch {}
try { cache.head.delete(key); } catch {}
}
/**
* Прочитать текстовый объект из S3
*/
async function readS3TextObject(key, s3Duration = null) {
const cached = getCache(cache.text, key);
if (cached) return cached;
const s3Start = Date.now();
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
if (s3Duration) {
try { s3Duration.labels('getObject').observe((Date.now() - s3Start)/1000); } catch {}
}
const out = {
body: await streamToString(data.Body),
etag: data.ETag || undefined,
lastModified: data.LastModified ? data.LastModified.toISOString() : undefined,
contentLength: typeof data.ContentLength === 'number' ? data.ContentLength : undefined
};
setCache(cache.text, key, out);
return out;
}
/**
* Получить ETag объекта из S3
*/
async function headS3ObjectEtag(key, s3Duration = null) {
const cached = getCache(cache.head, key);
if (cached && cached.etag) return cached.etag;
const s3Start = Date.now();
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
if (s3Duration) {
try { s3Duration.labels('headObject').observe((Date.now() - s3Start)/1000); } catch {}
}
setCache(cache.head, key, { etag: head.ETag || undefined });
return head.ETag || undefined;
}
/**
* Получить метаданные объекта (etag, lastModified, contentLength)
*/
async function headMeta(key) {
try {
const h = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
return {
etag: h.ETag || null,
lastModified: h.LastModified ? new Date(h.LastModified).toISOString() : null,
contentLength: typeof h.ContentLength === 'number' ? h.ContentLength : null,
};
} catch (e) {
return { etag: null, lastModified: null, contentLength: null };
cache.text.delete(key);
} catch (_) {}
try {
cache.head.delete(key);
} catch (_) {}
}
async function readS3TextObject(key) {
const cached = getCache(cache.text, key);
if (cached) return cached;
const row = blobStorage.readBlob(key);
if (!row) {
const err = new Error('NoSuchKey');
err.code = 'NoSuchKey';
throw err;
}
const out = {
body: row.body,
etag: row.etag,
lastModified: row.lastModified,
contentLength: row.contentLength,
};
setCache(cache.text, key, out);
return out;
}
async function headS3ObjectEtag(key) {
const cached = getCache(cache.head, key);
if (cached && cached.etag) return cached.etag;
const etag = blobStorage.headEtag(key);
if (etag) setCache(cache.head, key, { etag });
return etag;
}
async function headMeta(key) {
return blobStorage.headMetaRow(key);
}
/**
* Записать текстовый объект в S3
*/
async function writeS3TextObject(key, content, contentType = 'text/plain') {
await s3.send(new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: key,
Body: content,
ContentType: contentType,
}));
const db = openDatabase();
blobStorage.writeBlobTx(db, key, content, contentType);
invalidateCacheForKey(key);
return await headMeta(key);
return headMeta(key);
}
/**
* Записать JSON объект в S3
*/
async function writeS3JsonObject(key, data) {
return await writeS3TextObject(key, JSON.stringify(data, null, 2), 'application/json');
return writeS3TextObject(key, JSON.stringify(data, null, 2), 'application/json');
}
/**
* Удалить объект из S3
*/
async function deleteS3Object(key) {
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
blobStorage.deleteBlob(key);
invalidateCacheForKey(key);
}
/**
* Потоковое чтение с пагинацией больших текстовых файлов
*/
async function streamPaginatedText({ key, mapLine, q, offset = 0, limit = 0 }) {
return new Promise(async (resolve, reject) => {
let total = 0;
const items = [];
let sent = 0;
let buffered = '';
const matchesQuery = (line) => {
if (!q) return true;
return line.toLowerCase().includes(String(q).toLowerCase());
};
try {
const resp = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
const stream = resp.Body;
if (!stream || typeof stream.on !== 'function') {
const text = await streamToString(resp.Body);
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = String(lines[i] || '').trim();
if (!line) continue;
if (!matchesQuery(line)) continue;
total++;
const pos = total - 1;
if (limit > 0) {
if (pos >= offset && sent < limit) { items.push(mapLine(line)); sent++; }
} else {
items.push(mapLine(line));
}
}
return resolve({ items, total });
}
stream.on('data', (chunk) => {
buffered += chunk.toString('utf-8');
let lines = buffered.split('\n');
buffered = lines.pop();
for (const lnRaw of lines) {
const line = lnRaw.trim();
if (!line) continue;
if (!matchesQuery(line)) continue;
total++;
const pos = total - 1;
if (limit > 0) {
if (pos >= offset && sent < limit) { items.push(mapLine(line)); sent++; }
} else {
items.push(mapLine(line));
}
}
});
stream.on('end', () => {
const last = (buffered || '').trim();
if (last) {
if (!q || last.toLowerCase().includes(String(q).toLowerCase())) {
total++;
if (limit > 0) {
const pos = total - 1;
if (pos >= offset && items.length < limit) items.push(mapLine(last));
} else {
items.push(mapLine(last));
}
}
}
resolve({ items, total });
});
stream.on('error', reject);
} catch (e) {
reject(e);
}
});
async function listS3Objects(prefix, { maxKeys = 100 } = {}) {
return blobStorage.listByPrefix(prefix, maxKeys);
}
async function streamPaginatedText({ key, mapLine, q, offset = 0, limit = 0 }) {
let text = '';
try {
const row = await readS3TextObject(key);
text = row.body || '';
} catch (e) {
if (e?.code === 'NoSuchKey') {
return { items: [], total: 0 };
}
throw e;
}
const lines = text.split('\n');
let total = 0;
const items = [];
const qstr = q ? String(q).toLowerCase() : '';
const matchesQuery = (line) => !qstr || line.toLowerCase().includes(qstr);
for (let i = 0; i < lines.length; i++) {
const line = String(lines[i] || '').trim();
if (!line) continue;
if (!matchesQuery(line)) continue;
total++;
const pos = total - 1;
if (limit > 0) {
if (pos >= offset && items.length < limit) items.push(mapLine(line));
} else {
items.push(mapLine(line));
}
}
return { items, total };
}
function listBlobVersionsForKey(key, maxKeys = 50) {
return blobStorage.listVersions(key, maxKeys);
}
function rollbackBlobVersion(key, versionId) {
openDatabase();
return blobStorage.rollbackToVersionId(key, versionId);
}
/** Заглушка: прямой вызов S3 SDK больше не используется. */
const s3 = {
send() {
throw new Error('S3 SDK removed: use readS3TextObject / writeS3TextObject or evobgpClient');
},
};
module.exports = {
s3,
BUCKET_NAME,
@@ -257,5 +169,7 @@ module.exports = {
deleteS3Object,
streamPaginatedText,
invalidateCacheForKey,
listS3Objects,
listBlobVersionsForKey,
rollbackBlobVersion,
};
+142
View File
@@ -0,0 +1,142 @@
/**
* Планировщик проверок Uptime Monitor.
* Периодически вызывает POST /api/uptime/check для каждого jumphost/home и обновляет кеш в S3.
* После перезапуска контейнера кеш обновляется по расписанию без необходимости открывать страницу.
*/
const { readS3TextObject } = require('./s3Service');
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
const DEFAULT_INTERVAL_MINUTES = 2;
const PARALLEL_CHECKS = 2;
let schedulerTimer = null;
let lastScheduledRunAt = null;
function getSettingsFromUiSettings(uiSettings) {
return {
enabled: uiSettings.uptimeMonitorSchedulerEnabled !== false,
intervalMinutes: Math.max(1, Math.min(1440, parseInt(uiSettings.uptimeMonitorSchedulerIntervalMinutes, 10) || DEFAULT_INTERVAL_MINUTES)),
};
}
function getNextRunAt(settings) {
if (!settings || !settings.enabled) return null;
const intervalMs = (settings.intervalMinutes || DEFAULT_INTERVAL_MINUTES) * 60 * 1000;
const from = lastScheduledRunAt || Date.now();
return from + intervalMs;
}
/**
* Запустить планировщик Uptime Monitor.
* @param {object} logger - pino logger
* @param {number} port - порт приложения для вызова API
*/
function initUptimeMonitorScheduler(logger, port) {
const log = logger || console;
const baseUrl = `http://127.0.0.1:${port}`;
async function runChecks(serverIds) {
const runOne = async (serverId) => {
try {
const res = await fetch(`${baseUrl}/api/uptime/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverId }),
});
if (!res.ok) {
log.warn({ component: 'uptime-monitor-scheduler', serverId, status: res.status }, 'Uptime check failed');
}
} catch (err) {
log.warn({ component: 'uptime-monitor-scheduler', serverId, err: err?.message }, 'Uptime check error');
}
};
for (let i = 0; i < serverIds.length; i += PARALLEL_CHECKS) {
const chunk = serverIds.slice(i, i + PARALLEL_CHECKS);
await Promise.all(chunk.map(runOne));
}
}
async function tick() {
lastScheduledRunAt = Date.now();
let uiSettings;
try {
const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
uiSettings = JSON.parse(raw?.body || '{}') || {};
} catch (_) {
return;
}
const settings = getSettingsFromUiSettings(uiSettings);
if (!settings.enabled) return;
let servers = [];
try {
const res = await fetch(`${baseUrl}/api/servers`);
const data = res.ok ? await res.json() : [];
servers = Array.isArray(data) ? data : [];
} catch (err) {
log.error({ component: 'uptime-monitor-scheduler', err: err?.message }, 'Failed to fetch servers');
return;
}
const jumphosts = servers.filter(
(s) => s && ['jumphost', 'home'].includes(String(s.type || '').toLowerCase())
);
const serverIds = jumphosts
.map((s) => s.id || s.dns || s.ip)
.filter(Boolean);
if (serverIds.length === 0) {
log.info({ component: 'uptime-monitor-scheduler' }, 'No jumphost/home servers to check');
return;
}
log.info(
{ component: 'uptime-monitor-scheduler', count: serverIds.length },
'Running uptime monitor checks for %d server(s)',
serverIds.length
);
await runChecks(serverIds);
}
async function schedule() {
let uiSettings;
try {
const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
uiSettings = JSON.parse(raw?.body || '{}') || {};
} catch (_) {
return;
}
const settings = getSettingsFromUiSettings(uiSettings);
if (!settings.enabled) {
log.info({ component: 'uptime-monitor-scheduler' }, 'Uptime monitor scheduler disabled in settings');
return;
}
const intervalMs = settings.intervalMinutes * 60 * 1000;
log.info(
{ component: 'uptime-monitor-scheduler', intervalMinutes: settings.intervalMinutes },
'Uptime monitor scheduler started (first run in 20 s, then every %d min)',
settings.intervalMinutes
);
schedulerTimer = setInterval(tick, intervalMs);
setTimeout(() => tick(), 20 * 1000);
}
schedule();
}
function stopUptimeMonitorScheduler() {
if (schedulerTimer) {
clearInterval(schedulerTimer);
schedulerTimer = null;
}
}
module.exports = {
initUptimeMonitorScheduler,
stopUptimeMonitorScheduler,
getSettingsFromUiSettings,
getNextRunAt,
};
+132
View File
@@ -0,0 +1,132 @@
/**
* Утилиты для шифрования/дешифрования данных
* Использует AES-256-GCM для шифрования паролей
*/
const crypto = require('crypto');
// Ключ шифрования из переменной окружения (обязателен для работы с IPSec паролями)
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY;
if (!ENCRYPTION_KEY) {
console.warn('WARNING: ENCRYPTION_KEY is not set. IPSec password encryption/decryption will fail.');
console.warn('Please set ENCRYPTION_KEY environment variable (64 hex characters for AES-256).');
console.warn('You can generate a key with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"');
}
const ALGORITHM = 'aes-256-gcm';
/**
* Получить ключ шифрования (32 байта)
*/
function getEncryptionKey() {
if (!ENCRYPTION_KEY) {
throw new Error('ENCRYPTION_KEY environment variable is not set. Cannot encrypt/decrypt IPSec passwords.');
}
// Если ENCRYPTION_KEY - hex строка (64 символа для 32 байт), конвертируем в Buffer
if (ENCRYPTION_KEY.length === 64) {
try {
return Buffer.from(ENCRYPTION_KEY, 'hex');
} catch (error) {
throw new Error(`Invalid ENCRYPTION_KEY format: not a valid hex string. ${error.message}`);
}
}
// Иначе используем как UTF-8 строку и дополняем/обрезаем до 32 байт
const key = Buffer.from(ENCRYPTION_KEY, 'utf8');
if (key.length === 32) return key;
// Дополняем или обрезаем до 32 байт
const result = Buffer.alloc(32);
key.copy(result, 0, 0, Math.min(key.length, 32));
return result;
}
/**
* Зашифровать текст
* @param {string} text - Текст для шифрования
* @returns {string} - Зашифрованная строка в формате iv:authTag:encryptedData (все в base64)
*/
function encrypt(text) {
if (!text) return '';
const key = getEncryptionKey();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(String(text), 'utf8', 'base64');
encrypted += cipher.final('base64');
const authTag = cipher.getAuthTag();
// Формат: iv:authTag:encryptedData (все в base64)
return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`;
}
/**
* Расшифровать текст
* @param {string} encryptedText - Зашифрованная строка в формате iv:authTag:encryptedData
* @returns {string} - Расшифрованный текст
*/
function decrypt(encryptedText) {
if (!encryptedText) {
throw new Error('Encrypted text is empty');
}
if (typeof encryptedText !== 'string') {
throw new Error('Encrypted text must be a string');
}
try {
const parts = encryptedText.split(':');
if (parts.length !== 3) {
throw new Error(`Invalid encrypted format: expected 3 parts separated by ':', got ${parts.length}`);
}
const [ivBase64, authTagBase64, encryptedBase64] = parts;
if (!ivBase64 || !authTagBase64 || !encryptedBase64) {
throw new Error('Invalid encrypted format: one or more parts are empty');
}
let iv, authTag;
try {
iv = Buffer.from(ivBase64, 'base64');
authTag = Buffer.from(authTagBase64, 'base64');
} catch (bufferError) {
throw new Error(`Failed to decode base64: ${bufferError.message}`);
}
if (iv.length !== 16) {
throw new Error(`Invalid IV length: expected 16 bytes, got ${iv.length}`);
}
if (authTag.length !== 16) {
throw new Error(`Invalid auth tag length: expected 16 bytes, got ${authTag.length}`);
}
const key = getEncryptionKey();
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedBase64, 'base64', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
if (error.message.includes('Unsupported state') || error.message.includes('bad decrypt')) {
throw new Error(`Decryption failed: possibly wrong encryption key or corrupted data. Original error: ${error.message}`);
}
console.error('Decryption error details:', {
error: error.message,
encryptedTextLength: encryptedText.length
});
throw error;
}
}
module.exports = {
encrypt,
decrypt,
};
+6 -5
View File
@@ -39,14 +39,15 @@ function mapAjvErrors(errors) {
}
/**
* Маппинг UI ресурсов на S3 ключи (для истории версий)
* Маппинг UI ресурсов на ключи в SQLite blobs (только локально версионируемые объекты).
* Справочники EvoBGP (domains-new / asns / ip-ranges) в истории не участвуют.
*/
function resourceToKey(resource) {
switch (resource) {
case 'domains-new': return 'bgp_data/domains_community.txt';
case 'ip-ranges': return 'bgp_data/ips.txt';
case 'asns': return 'bgp_data/asns.txt';
default: return null;
case 'servers':
return 'servers.json';
default:
return null;
}
}
+15 -2
View File
@@ -1,6 +1,7 @@
/**
* Утилиты для генерации конфигурации MikroTik
* Устраняют дублирование кода генерации конфигурации
* Поддерживает форматы: 'text' (как раньше) и 'json' (структурированные данные для API)
*/
const { buildNestedGatewayBlocks } = require('./helpers');
@@ -8,10 +9,14 @@ const { buildNestedGatewayBlocks } = require('./helpers');
/**
* Генерация конфигурации MikroTik из массива фильтров
* @param {Array} filters - Массив фильтров с полями {community, gateway}
* @returns {string} Сгенерированная конфигурация
* @param {object} options - { format: 'text'|'json' }
* @returns {string|object} Сгенерированная конфигурация (строка или объект)
*/
function generateMikrotikConfig(filters) {
function generateMikrotikConfig(filters, options = {}) {
const { format = 'text' } = options;
if (!Array.isArray(filters) || filters.length === 0) {
if (format === 'json') return { gatewayGroups: {}, config: null };
return '// No filters to generate configuration';
}
@@ -24,6 +29,14 @@ function generateMikrotikConfig(filters) {
gatewayGroups[filter.gateway].push(filter.community);
});
if (format === 'json') {
return {
gatewayGroups,
filterName: 'bgp-in-tmp',
generatedAt: new Date().toISOString(),
};
}
let config = '// Frouting filter configuration for MikroTik 7.14+\n';
config += '// Generated automatically\n';
config += `// Date: ${new Date().toISOString()}\n\n`;
+404
View File
@@ -0,0 +1,404 @@
/**
* Генератор конфигурации MikroTik для интерфейсов и маршрутов
* Поддерживает два формата вывода: 'text' (как раньше) и 'json' (структурированные операции для API)
*/
/**
* @param {string} serverId
* @param {Array} servers
* @returns {object|null}
*/
function getServerInfo(serverId, servers) {
if (!serverId || !Array.isArray(servers)) return null;
return servers.find(s => s.id === serverId || s.ip === serverId || s.dns === serverId) || null;
}
/**
* @param {string} parentId
* @param {object} config
* @param {Array} templateGateways
* @returns {object|null}
*/
function getParentGateway(parentId, config, templateGateways = null) {
if (!parentId || !config) return null;
if (Array.isArray(templateGateways)) {
const g = templateGateways.find(x => x.id === parentId);
if (g) return { parentType: 'gateway', ...g, ip: g.ip };
}
const gateway = config.gateways?.find(g => g.id === parentId);
if (gateway) return { parentType: 'gateway', ...gateway, ip: gateway.ip };
const iface = config.tunnelInterfaces?.find(i => i.id === parentId);
if (iface) {
return {
parentType: 'interface',
...iface,
remoteIp: iface.remoteIp,
localIp: iface.localIp,
name: iface.name,
interfaceType: iface.type,
};
}
return null;
}
/**
* Сериализация одной операции в строку RouterOS
* @param {object} op - { path, action, params }
* @returns {string}
*/
function operationToRouterOSLine(op) {
const { path, action, params } = op;
if (!path || !action) return '';
const pathStr = path.replace(/^\//, '').replace(/\//g, ' ');
const fullPath = pathStr ? `/${pathStr}` : '';
const parts = (params && typeof params === 'object')
? Object.entries(params)
.filter(([, v]) => v != null && v !== '')
.map(([k, v]) => {
const val = String(v);
if (val.includes(' ') || val.includes('"')) return `${k}="${val}"`;
return `${k}=${val}`;
})
: [];
return `${fullPath} ${action} ${parts.join(' ')}`.trim();
}
/**
* Сериализация массива операций в текст
* @param {Array} operations
* @returns {string}
*/
function serializeOperationsToText(operations) {
if (!Array.isArray(operations)) return '';
return operations.map(op => operationToRouterOSLine(op)).filter(Boolean).join('\n');
}
/**
* Построение блоков MikroTik для интерфейсов
* @param {object} config - { tunnelInterfaces, gateways }
* @param {Array} servers
* @param {object} passwordMap - { [ipsecPasswordId]: decryptedPassword }
* @param {object} options - { format: 'text'|'json', serverId?: string }
* @returns {Promise<Array>}
*/
async function buildMikrotikInterfaceBlocks(config, servers, passwordMap, options = {}) {
const { format = 'text', serverId: filterServerId } = options;
const interfaces = (config.tunnelInterfaces || []).filter(
i => i.serverId && i.serverId2 && i.localIp && i.remoteIp
);
if (interfaces.length === 0) return [];
const interfacesByServer = {};
for (const iface of interfaces) {
const server1 = getServerInfo(iface.serverId, servers);
const server2 = getServerInfo(iface.serverId2, servers);
if (!server1 || !server2) continue;
const server1Name = server1.dns || server1.ip || iface.serverId;
const server2Name = server2.dns || server2.ip || iface.serverId2;
const interfaceName1 = iface.name || `${iface.type}-tunnel`;
const interfaceName2 = iface.name2 || iface.name || `${iface.type}-tunnel`;
const ipsecPassword = (iface.ipsecPasswordId && passwordMap[iface.ipsecPasswordId]) || null;
if (!interfacesByServer[server1Name]) {
interfacesByServer[server1Name] = { serverInfo: server1, interfaces: [] };
}
interfacesByServer[server1Name].interfaces.push({
interfaceName: interfaceName1,
localIp: iface.localIp,
remoteIp: iface.remoteIp,
type: iface.type,
server2Name,
server2Info: server2,
ipsecPassword,
mtu: iface.mtu || null,
});
if (!interfacesByServer[server2Name]) {
interfacesByServer[server2Name] = { serverInfo: server2, interfaces: [] };
}
interfacesByServer[server2Name].interfaces.push({
interfaceName: interfaceName2,
localIp: iface.remoteIp,
remoteIp: iface.localIp,
type: iface.type,
server2Name: server1Name,
server2Info: server1,
ipsecPassword,
mtu: iface.mtu || null,
});
}
const sortedServers = Object.entries(interfacesByServer).sort(([a], [b]) => a.localeCompare(b));
const blocks = [];
for (const [serverName, serverData] of sortedServers) {
if (filterServerId) {
const s = getServerInfo(filterServerId, servers);
const matchName = s ? (s.dns || s.ip || filterServerId) : filterServerId;
if (serverName !== matchName && serverData.serverInfo?.id !== filterServerId) continue;
}
const server = serverData.serverInfo;
const isHomeRouter = server.type === 'home';
const hasGreInterfaces = serverData.interfaces.some(i => i.type === 'GRE');
const operations = [];
let code = '';
if (format === 'text') {
code += `# Настройка IP адресов интерфейсов для сервера: ${serverName}\n`;
if (server.provider) code += `# Провайдер: ${server.provider}\n`;
if (server.country) code += `# Страна: ${server.country}\n`;
if (server.ip && server.ip !== serverName) code += `# IP: ${server.ip}\n`;
code += '\n';
}
if (isHomeRouter && hasGreInterfaces) {
if (format === 'text') {
code += `# Создание interface list для GRE туннелей (если не существует)\n`;
code += `:if ([/interface list find name=GREs] = "") do={ /interface list add name=GREs comment="GRE tunnels list" }\n\n`;
} else {
operations.push({
path: '/interface/list',
action: 'add',
params: { name: 'GREs', comment: 'GRE tunnels list' },
meta: { ensureExists: true },
});
}
}
for (let i = 0; i < serverData.interfaces.length; i++) {
const iface = serverData.interfaces[i];
const remoteAddress = iface.server2Info?.dns || iface.server2Info?.ip || iface.server2Name;
const hasIpsecPassword = iface.ipsecPassword && String(iface.ipsecPassword).trim() !== '';
const localAddressParam = isHomeRouter && server.ip ? server.ip : null;
if (format === 'text') {
code += `# Интерфейс ${i + 1}: ${iface.interfaceName} (${iface.type})\n`;
code += `# Связь с сервером: ${iface.server2Name}\n`;
code += `# Local IP: ${iface.localIp}\n`;
code += `# Remote IP: ${iface.remoteIp}\n`;
}
if (iface.type === 'GRE') {
const greParams = {
name: iface.interfaceName,
'remote-address': remoteAddress,
'keepalive': '10s',
'allow-fast-path': 'no',
};
if (localAddressParam) greParams['local-address'] = localAddressParam;
if (iface.mtu) greParams.mtu = String(iface.mtu);
if (hasIpsecPassword) greParams['ipsec-secret'] = iface.ipsecPassword;
const op = { path: '/interface/gre', action: 'add', params: greParams };
operations.push(op);
if (format === 'text') {
if (hasIpsecPassword) code += `# Создание GRE туннеля с IPSec\n`;
else code += `# Создание GRE туннеля (без IPSec)\n`;
code += operationToRouterOSLine(op) + '\n';
}
if (isHomeRouter) {
const listOp = {
path: '/interface/list/member',
action: 'add',
params: { list: 'GREs', interface: iface.interfaceName, comment: `GRE tunnel to ${iface.server2Name}` },
};
operations.push(listOp);
if (format === 'text') code += operationToRouterOSLine(listOp) + '\n';
}
}
const addrOp = {
path: '/ip/address',
action: 'add',
params: {
address: `${iface.localIp}/30`,
interface: iface.interfaceName,
comment: `Interface: ${iface.interfaceName} (${iface.type}) to ${iface.server2Name}`,
},
};
operations.push(addrOp);
if (format === 'text') code += operationToRouterOSLine(addrOp) + '\n';
}
if (format === 'text') {
code += '# Проверка настроенных адресов:\n';
code += '# /ip address print\n';
}
blocks.push({
type: 'interface-addresses',
serverName,
server,
...(format === 'text' ? { code } : { operations }),
});
}
return blocks;
}
/**
* Построение блоков для рекурсивных маршрутов
* @param {object} config
* @param {Array} servers
* @param {object} options - { format: 'text'|'json', serverId?: string }
* @returns {Promise<Array>}
*/
async function buildMikrotikRecursiveRoutes(config, servers, options = {}) {
const { format = 'text', serverId: filterServerId } = options;
const recursiveGateways = (config.gateways || []).filter(g => g.type === 'recursive');
const blocks = [];
if (recursiveGateways.length === 0) return [];
const gatewaysByServer = {};
for (const gw of recursiveGateways) {
const sid = gw.serverId || '__unassigned__';
if (!gatewaysByServer[sid]) gatewaysByServer[sid] = [];
gatewaysByServer[sid].push(gw);
}
for (const [sid, gateways] of Object.entries(gatewaysByServer)) {
const server = getServerInfo(sid, servers);
const serverName = server?.dns || server?.ip || sid;
if (filterServerId && sid !== filterServerId) continue;
const operations = [];
let code = '';
if (format === 'text') {
code += `# Рекурсивные маршруты для сервера: ${serverName}\n`;
if (server?.provider) code += `# Провайдер: ${server.provider}\n`;
if (server?.country) code += `# Страна: ${server.country}\n`;
code += '\n';
code += '# Удаление старых рекурсивных маршрутов (comment начинается с "Recursive: ")\n';
code += '/ip route remove [find comment~"^Recursive: "] \n\n';
} else {
operations.push({
path: '/ip/route',
action: 'remove',
params: {},
meta: { findComment: '^Recursive: ' },
});
}
let validCount = 0;
for (const gw of gateways) {
const parentGatewaysList = (gw.parentGateways && gw.parentGateways.length > 0)
? gw.parentGateways
: (gw.parentGatewayId ? [{ id: gw.parentGatewayId, distance: undefined }] : []);
if (parentGatewaysList.length === 0 || !gw.ip) continue;
for (const parentRef of parentGatewaysList) {
const parent = getParentGateway(parentRef.id, config);
if (!parent) continue;
const parentIp = parent.parentType === 'interface' ? parent.remoteIp : parent.ip;
if (!parentIp) continue;
validCount++;
const gatewayValue = parent.parentType === 'interface'
? `${parentIp}%${parent.name || parent.interfaceType || 'interface'}`
: parentIp;
const distance = parentRef.distance;
const comment = gw.description
? `Recursive: ${gw.description} -> ${parentIp}`
: `Recursive: ${gw.ip} -> ${parentIp}`;
const routeParams = {
'dst-address': `${gw.ip}/32`,
gateway: gatewayValue,
comment,
};
if (distance != null) routeParams.distance = String(distance);
const op = { path: '/ip/route', action: 'add', params: routeParams };
operations.push(op);
if (format === 'text') {
code += `# Рекурсивный gateway: ${gw.ip}\n`;
code += operationToRouterOSLine(op) + '\n';
}
if (gw.description && (gw.description.toLowerCase().includes('default') || gw.description.toLowerCase().includes('0.0.0.0'))) {
const defaultParams = {
'dst-address': '0.0.0.0/0',
gateway: gatewayValue,
comment: `Recursive: ${gw.description || gw.ip} (default)`,
};
if (distance != null) defaultParams.distance = String(distance);
const defaultOp = { path: '/ip/route', action: 'add', params: defaultParams };
operations.push(defaultOp);
if (format === 'text') code += operationToRouterOSLine(defaultOp) + '\n';
}
}
}
if (validCount > 0) {
if (format === 'text') {
code += '# Проверка: /ip route print where comment~"Recursive"\n';
}
blocks.push({
type: 'recursive-routes',
serverName,
server,
...(format === 'text' ? { code } : { operations }),
});
}
}
return blocks;
}
/**
* Комбинированная генерация: интерфейсы + рекурсивные маршруты
* @param {object} config
* @param {Array} servers
* @param {object} passwordMap
* @param {object} options - { format: 'text'|'json', serverId?: string, includeInterfaces?: boolean, includeRecursive?: boolean }
*/
async function buildMikrotikConfig(config, servers, passwordMap, options = {}) {
const {
format = 'text',
serverId,
includeInterfaces = true,
includeRecursive = true,
} = options;
const opts = { format, serverId };
const blocks = [];
if (includeInterfaces) {
const ifBlocks = await buildMikrotikInterfaceBlocks(config, servers, passwordMap, opts);
blocks.push(...ifBlocks);
}
if (includeRecursive) {
const recBlocks = await buildMikrotikRecursiveRoutes(config, servers, opts);
blocks.push(...recBlocks);
}
return blocks;
}
module.exports = {
getServerInfo,
getParentGateway,
operationToRouterOSLine,
serializeOperationsToText,
buildMikrotikInterfaceBlocks,
buildMikrotikRecursiveRoutes,
buildMikrotikConfig,
};
+45 -59
View File
@@ -1,42 +1,37 @@
/**
* Универсальные хелперы для работы с S3
* Устраняют дублирование кода в routes
* Универсальные хелперы для JSON/текста из локального storage (SQLite)
*/
const { s3, BUCKET_NAME } = require('../services/s3Service');
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
const { streamToString } = require('../services/s3Service');
const { headMeta, readS3TextObject } = require('../services/s3Service');
const { sendError, checkIfNoneMatch } = require('../middleware/errorHandler');
/**
* Универсальный GET для JSON объектов из S3 с заголовками
* @param {string} s3Key - Ключ S3
* @param {object} req - Express request
* @param {object} res - Express response
* @param {object} options - Опции
* @param {function} options.transform - Функция трансформации данных
* @param {*} options.defaultValue - Значение по умолчанию при отсутствии файла
* Универсальный GET для JSON объектов с заголовками
*/
async function getS3JsonWithHeaders(s3Key, req, res, options = {}) {
const { transform = (data) => data, defaultValue = [] } = options;
try {
// Получаем HEAD для установки заголовков
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null);
const etag = head?.ETag || null;
// Устанавливаем заголовки
const head = await headMeta(s3Key);
const etag = head?.etag || null;
if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
// Проверяем If-None-Match
if (head?.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString());
if (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
if (checkIfNoneMatch(req, res, etag)) return;
// Получаем объект
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key }));
const fileContent = await streamToString(data.Body);
let fileContent;
try {
const data = await readS3TextObject(s3Key);
fileContent = data.body;
} catch (e) {
if (e?.code === 'NoSuchKey') {
return res.json(transform(defaultValue));
}
throw e;
}
let parsed;
try {
parsed = JSON.parse(fileContent);
@@ -47,55 +42,47 @@ async function getS3JsonWithHeaders(s3Key, req, res, options = {}) {
console.error(`Error parsing ${s3Key}:`, parseError);
parsed = defaultValue;
}
// Применяем трансформацию
const result = transform(parsed);
res.json(result);
} catch (error) {
if (error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
res.json(defaultValue);
} else {
console.error(error);
return sendError(res, 500, 'Error reading from S3', 'E_S3');
}
console.error(error);
return sendError(res, 500, 'Error reading storage', 'E_STORAGE');
}
}
/**
* Универсальный GET для текстовых объектов из S3 с заголовками
* @param {string} s3Key - Ключ S3
* @param {object} req - Express request
* @param {object} res - Express response
* @param {object} options - Опции
* @param {function} options.transform - Функция трансформации текста
* @param {string} options.defaultValue - Значение по умолчанию при отсутствии файла
* Универсальный GET для текстовых объектов с заголовками
*/
async function getS3TextWithHeaders(s3Key, req, res, options = {}) {
const { transform = (text) => ({ config: text }), defaultValue = '' } = options;
try {
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null);
const etag = head?.ETag || null;
const head = await headMeta(s3Key);
const etag = head?.etag || null;
if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
if (head?.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString());
if (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
if (checkIfNoneMatch(req, res, etag)) return;
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key }));
const text = await streamToString(data.Body);
let text;
try {
const data = await readS3TextObject(s3Key);
text = data.body;
} catch (e) {
if (e?.code === 'NoSuchKey') {
return res.json(transform(defaultValue));
}
throw e;
}
const result = transform(text);
res.json(result);
} catch (error) {
if (error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
res.json(transform(defaultValue));
} else {
console.error(error);
return sendError(res, 500, 'Error reading from S3', 'E_S3');
}
console.error(error);
return sendError(res, 500, 'Error reading storage', 'E_STORAGE');
}
}
@@ -103,4 +90,3 @@ module.exports = {
getS3JsonWithHeaders,
getS3TextWithHeaders,
};
+10 -5
View File
@@ -4,34 +4,39 @@
"dns": "srv1.shx.su",
"country": "RU",
"provider": "Yandex",
"tunnel": "GRE"
"tunnel": "GRE",
"type": "jumphost"
},
{
"ip": "10.0.0.5",
"dns": "srv2.shx.su",
"country": "US",
"provider": "AWS",
"tunnel": "IPSec"
"tunnel": "IPSec",
"type": "exit"
},
{
"ip": "172.16.0.10",
"dns": "srv3.shx.su",
"country": "DE",
"provider": "Hetzner",
"tunnel": "GRE"
"tunnel": "GRE",
"type": "jumphost"
},
{
"ip": "8.8.8.8",
"dns": "dns.google",
"country": "US",
"provider": "Google",
"tunnel": "IPSec"
"tunnel": "IPSec",
"type": "exit"
},
{
"ip": "1.1.1.1",
"dns": "cloudflare-dns.com",
"country": "US",
"provider": "Cloudflare",
"tunnel": "GRE"
"tunnel": "GRE",
"type": "jumphost"
}
]
+20 -8
View File
@@ -15,25 +15,37 @@ s3/
│ ├── simple-filters.json # Фильтры упрощённого режима
│ ├── server-filters-*.json # Фильтры для каждого сервера
│ └── config-*.txt # Сгенерированные конфигурации MikroTik
└── server-configs.json # Список серверов (в корне S3)
└── servers.json # Список серверов (в корне S3)
```
## Описание файлов
### `server-configs.json`
Список всех серверов в расширенном режиме.
### `servers.json`
Список всех серверов. Содержит как jumphost (для EasySwitch), так и exit серверы.
**ВАЖНО**: API `/api/server-configs` возвращает только серверы с `type: "jumphost"`.
```json
[
{
"id": "server-001",
"name": "SWE-HIPHOST",
"description": "Шведский сервер для стриминга и игр",
"enabled": true,
"createdAt": "2024-01-15T10:30:00.000Z"
"ip": "185.123.45.67",
"dns": "SWE-HIPHOST",
"country": "SE",
"provider": "HipHost",
"tunnel": "wg0",
"type": "jumphost",
"gateways": [
{ "name": "SWE-EXIT-1", "ip": "185.123.45.68", "comment": "Основной выход", "primary": true },
{ "name": "SWE-EXIT-2", "ip": "185.123.45.69", "comment": "Резервный выход", "primary": false }
]
}
]
```
Типы серверов:
- `jumphost` — основные серверы для маршрутизации (используются в EasySwitch)
- `exit` — выходные серверы (не попадают в EasySwitch)
### `filter-manager/simple-filters.json`
Фильтры для упрощённого режима (применяются ко всем серверам).
```json
+112
View File
@@ -0,0 +1,112 @@
{
"gateways": [
{
"id": "gw-cloudflare-swe",
"ip": "94.142.140.1",
"description": "Cloudflare Sweden - основной выход",
"serverId": "SWE-HIPHOST",
"country": "SWE",
"type": "direct"
},
{
"id": "gw-bunny-swe",
"ip": "94.142.140.2",
"description": "Bunny CDN Sweden",
"serverId": "SWE-HIPHOST",
"country": "SWE",
"type": "direct"
},
{
"id": "gw-telegram-nl",
"ip": "149.154.167.50",
"description": "Telegram DC2",
"serverId": "NL-AMSTERDAM",
"country": "NL",
"type": "direct"
},
{
"id": "gw-hetzner-de",
"ip": "195.100.30.21",
"description": "Hetzner Frankfurt",
"serverId": "DE-FRANKFURT",
"country": "DE",
"type": "direct"
},
{
"id": "gw-fastly-us",
"ip": "151.101.1.1",
"description": "Fastly US East",
"serverId": "US-NEWYORK",
"country": "US",
"type": "direct"
},
{
"id": "gw-recursive-swe",
"ip": "10.0.0.1",
"description": "Рекурсивный gateway через 94.142.140.1",
"serverId": "SWE-HIPHOST",
"country": "SWE",
"type": "recursive",
"parentGatewayId": "gw-cloudflare-swe"
}
],
"tunnelInterfaces": [
{
"id": "if-gre-swe-de",
"name": "gre-swe-de",
"name2": "gre-de-swe",
"type": "GRE",
"localIp": "10.10.0.1",
"remoteIp": "10.10.0.2",
"serverId": "SWE-HIPHOST",
"serverId2": "DE-FRANKFURT"
},
{
"id": "if-wg-swe-nl",
"name": "wg0",
"name2": "wg1",
"type": "WireGuard",
"localIp": "10.20.0.1",
"remoteIp": "10.20.0.2",
"serverId": "SWE-HIPHOST",
"serverId2": "NL-AMSTERDAM"
},
{
"id": "if-gre-de-us",
"name": "gre-de-us",
"type": "GRE",
"localIp": "10.10.1.1",
"remoteIp": "10.10.1.2",
"serverId": "DE-FRANKFURT",
"serverId2": "US-NEWYORK"
},
{
"id": "if-ipsec-nl-jp",
"name": "ipsec-nl-jp",
"type": "IPSec",
"localIp": "10.30.0.1",
"remoteIp": "10.30.0.2",
"serverId": "NL-AMSTERDAM"
}
],
"ipPools": [
{
"id": "pool-gre",
"name": "GRE Tunnels",
"cidr": "10.10.0.0/16",
"description": "Пул для GRE туннелей между серверами"
},
{
"id": "pool-wg",
"name": "WireGuard",
"cidr": "10.20.0.0/16",
"description": "Пул для WireGuard интерфейсов"
},
{
"id": "pool-ipsec",
"name": "IPSec VPN",
"cidr": "10.30.0.0/16",
"description": "Пул для IPSec туннелей"
}
]
}
-37
View File
@@ -1,37 +0,0 @@
[
{
"id": "server-001",
"name": "SWE-HIPHOST",
"description": "Шведский сервер для стриминга и игр",
"enabled": true,
"createdAt": "2024-01-15T10:30:00.000Z"
},
{
"id": "server-002",
"name": "NL-AMSTERDAM",
"description": "Голландский сервер с низкой задержкой",
"enabled": true,
"createdAt": "2024-01-16T14:20:00.000Z"
},
{
"id": "server-003",
"name": "DE-FRANKFURT",
"description": "Немецкий сервер для торрентов",
"enabled": false,
"createdAt": "2024-01-17T09:15:00.000Z"
},
{
"id": "server-004",
"name": "US-NEWYORK",
"description": "Американский сервер для Netflix",
"enabled": true,
"createdAt": "2024-01-18T16:45:00.000Z"
},
{
"id": "server-005",
"name": "JP-TOKYO",
"description": "Японский сервер для аниме и игр",
"enabled": true,
"createdAt": "2024-01-19T11:30:00.000Z"
}
]
+59
View File
@@ -0,0 +1,59 @@
[
{
"ip": "185.123.45.67",
"dns": "SWE-HIPHOST",
"country": "SE",
"provider": "HipHost",
"tunnel": "wg0",
"type": "jumphost",
"gateways": [
{ "name": "SWE-EXIT-1", "ip": "185.123.45.68", "comment": "Основной выход", "primary": true },
{ "name": "SWE-EXIT-2", "ip": "185.123.45.69", "comment": "Резервный выход", "primary": false }
]
},
{
"ip": "185.200.50.10",
"dns": "NL-AMSTERDAM",
"country": "NL",
"provider": "DataCenter NL",
"tunnel": "wg1",
"type": "jumphost",
"gateways": [
{ "name": "NL-EXIT-1", "ip": "185.200.50.11", "comment": "Амстердам выход", "primary": true }
]
},
{
"ip": "195.100.30.20",
"dns": "DE-FRANKFURT",
"country": "DE",
"provider": "Hetzner",
"tunnel": "wg2",
"type": "exit",
"gateways": [
{ "name": "DE-EXIT-MAIN", "ip": "195.100.30.21", "comment": "Франкфурт", "primary": true }
]
},
{
"ip": "104.200.100.50",
"dns": "US-NEWYORK",
"country": "US",
"provider": "DigitalOcean",
"tunnel": "wg3",
"type": "jumphost",
"gateways": [
{ "name": "US-EXIT-NYC", "ip": "104.200.100.51", "comment": "New York выход", "primary": true },
{ "name": "US-EXIT-LA", "ip": "104.200.100.52", "comment": "Los Angeles выход", "primary": false }
]
},
{
"ip": "203.150.80.100",
"dns": "JP-TOKYO",
"country": "JP",
"provider": "Sakura Internet",
"tunnel": "wg4",
"type": "jumphost",
"gateways": [
{ "name": "JP-EXIT-1", "ip": "203.150.80.101", "comment": "Токио выход", "primary": true }
]
}
]
+2 -2
View File
@@ -1,4 +1,4 @@
# S3 Lists Manager - Frontend
# RouterOS Manager - Frontend
Современный веб-интерфейс для управления списками доменов и AS с использованием Tabler UI.
@@ -172,4 +172,4 @@ import { IconPlus, IconEdit, IconTrash } from '@tabler/icons-react';
---
**S3 Lists Manager** - Современный интерфейс для управления списками с использованием Tabler UI.
**RouterOS Manager** - Современный интерфейс для управления списками с использованием Tabler UI.
+1 -1
View File
@@ -1,6 +1,6 @@
# 🎨 UX/UI Улучшения - Документация
Этот документ описывает все внедренные UX/UI улучшения в проект S3 Lists Manager.
Этот документ описывает все внедренные UX/UI улучшения в проект RouterOS Manager.
## 📋 Содержание
+43
View File
@@ -0,0 +1,43 @@
# Таблицы: UX/UI по Tabler и проекту
Рекомендации по формированию таблиц в приложении — [Tabler Tables](https://docs.tabler.io/ui/components/tables/), [Preview](https://preview.tabler.io/tables.html).
## Классы (Tabler)
- **Базовые:** `table card-table table-vcenter table-nowrap mb-0`
- **Чередование строк:** `table-striped` — улучшает читаемость
- **Компактный режим:** `table-sm` — меньше отступы в ячейках
- **Обёртка:** `table-responsive` вокруг `<table>`
- **Обрезка длинного текста:** для ячейки использовать `td-truncate` (Tabler) или `text-truncate` + `max-width`
## Компонент DataTable
Используйте `DataTable` там, где подходит декларативное описание колонок и общие действия (сортировка, выбор, пагинация):
- `columns`: `{ key, title, sortable?, icon?, render?, tooltip? }` — для подсказки по заголовку задайте `tooltip`
- `striped={true}` по умолчанию
- `compact={true}` — компактные ячейки
- Колонка «Действия» получает класс `td-actions` (фиксированная минимальная ширина в проекте)
## Информативность и лаконичность
1. **Заголовки колонок** — короткие, при необходимости уточнение через `tooltip` (например, значение иконки замка).
2. **Действия в строке** — иконки с `Tooltip` и `aria-label`; колонка «Действия» справа, без лишнего текста.
3. **Копирование** — кнопка/иконка копирования рядом с ячейкой (IP, домен и т.д.) вместо дублирования в «Действия», если это основной сценарий.
4. **Пустое состояние** — через `emptyState` в DataTable или свой блок с призывом к действию (импорт, добавление).
## Доступность
- У заголовков: `scope="col"`.
- У таблицы: `role="grid"` при семантической сетке данных.
- Кнопки действий: `aria-label` и/или Tooltip.
## Где таблица своя, а не DataTable
В IPRangesManager, DomainsNewManager, ServerManager и др. таблицы рендерятся вручную. Для единообразия:
- Те же классы: `table card-table table-vcenter table-nowrap mb-0`, при желании `table-striped`.
- Заголовки: `th` с `scope="col"`, короткий текст, при необходимости иконка и тултип.
- Колонка действий: класс `td-actions` у `th` и `td`, кнопки с тултипами.
При рефакторинге страниц можно постепенно переводить такие таблицы на DataTable с описанием `columns` и общих обработчиков.
+3 -2
View File
@@ -15,7 +15,7 @@ export default defineConfig([
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
globals: { ...globals.browser, process: 'readonly' },
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
@@ -23,7 +23,8 @@ export default defineConfig([
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]', argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }],
'no-empty': ['error', { allowEmptyCatch: true }],
},
},
])
+2 -2
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="S3 Lists Manager - Управление списками доменов и AS" />
<meta name="description" content="RouterOS Manager - Управление списками доменов и AS" />
<meta name="theme-color" content="#206bc4" />
<!-- Inter Font -->
@@ -12,7 +12,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<title>S3 Lists Manager</title>
<title>RouterOS Manager</title>
</head>
<body>
<div id="root"></div>
+871 -15
View File
File diff suppressed because it is too large Load Diff
+13 -3
View File
@@ -6,18 +6,27 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"build:check": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tabler/core": "^1.3.2",
"@tabler/icons-react": "^3.34.0",
"axios": "^1.10.0",
"@tanstack/react-query": "^5.56.2",
"@tanstack/react-virtual": "^3.10.8",
"@xyflow/react": "^12.3.4",
"axios": "^1.10.0",
"diff": "^8.0.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"react-router-dom": "^6.30.1",
"@xyflow/react": "^12.3.4"
"recharts": "^3.7.0"
},
"devDependencies": {
"@eslint/js": "^9.29.0",
@@ -28,6 +37,7 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.2.0",
"typescript": "^5.7.2",
"vite": "^7.0.0"
},
"optionalDependencies": {
File diff suppressed because it is too large Load Diff
+468 -5
View File
@@ -1,5 +1,166 @@
/* Custom styles for S3 Lists Manager */
/* Most styles are now provided by @tabler/core */
/* Custom styles for RouterOS Manager */
/* Layout: Tabler Fluid Vertical (https://preview.tabler.io/layout-fluid-vertical.html) */
/* --- Fluid Vertical: page structure --- */
.page {
min-height: 100vh;
}
/* Сайдбар: всегда тёмный (как на референсе Tabler) */
.navbar-vertical {
--tblr-body-color: rgba(255, 255, 255, 0.9);
--tblr-body-color-rgb: 255, 255, 255;
--tblr-border-color: rgba(255, 255, 255, 0.1);
--tblr-bg-surface: #1e293b;
--tblr-bg-surface-hover: rgba(255, 255, 255, 0.05);
--tblr-navbar-active-bg: rgba(255, 255, 255, 0.08);
--tblr-navbar-active-color: #fff;
--tblr-primary: #6ea8fe;
--tblr-muted: rgba(255, 255, 255, 0.6);
background-color: #1e293b !important;
color: rgba(255, 255, 255, 0.9);
}
.navbar-vertical .navbar-brand,
.navbar-vertical .nav-link,
.navbar-vertical .dropdown-item {
color: inherit;
}
.navbar-vertical .navbar-brand:hover,
.navbar-vertical .nav-link:hover,
.navbar-vertical .dropdown-item:hover {
color: #fff;
}
.navbar-vertical .nav-link.active,
.navbar-vertical .dropdown-item.active {
background-color: var(--tblr-navbar-active-bg);
color: var(--tblr-navbar-active-color);
}
.navbar-vertical .dropdown-toggle::after {
border-top-color: rgba(255, 255, 255, 0.6);
}
.navbar-vertical .navbar-toggler {
border-color: rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.9);
}
/* Сайдбар: у бренда убираем лишний отступ и стрелку (Tabler не должен добавлять caret к brand) */
.navbar-vertical .navbar-brand {
padding-left: 0;
padding-right: 0;
}
.navbar-vertical .navbar-brand::after {
display: none !important;
}
.navbar-vertical > .container-fluid {
padding-left: 1rem;
padding-right: 1rem;
display: flex;
flex-direction: column;
min-height: 100%;
}
.navbar-vertical .collapse.navbar-collapse {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.navbar-vertical .collapse .navbar-nav.pt-lg-3 {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
.navbar-vertical .navbar-sidebar-footer {
flex-shrink: 0;
border-top-color: rgba(255, 255, 255, 0.1) !important;
overflow: visible;
}
.navbar-vertical .navbar-sidebar-footer .nav-link.btn-icon {
padding: 0.35rem;
color: rgba(255, 255, 255, 0.75);
border: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.navbar-vertical .navbar-sidebar-footer .nav-link.btn-icon:hover {
color: #fff;
background: rgba(255, 255, 255, 0.08);
}
.navbar-vertical .navbar-sidebar-footer .nav-link.btn-icon .badge {
font-size: 0.7rem;
padding: 0.2rem 0.35rem;
}
.navbar-vertical .navbar-sidebar-footer .dropdown-toggle::after {
display: none;
}
/* Все выпадающие меню в футере сайдбара — поверх контента и без обрезки */
.navbar-vertical .navbar-sidebar-footer .dropdown-menu {
z-index: 1050;
}
.navbar-vertical .navbar-sidebar-footer .dropdown-menu-card {
overflow-x: hidden;
box-sizing: border-box;
}
.navbar-vertical .navbar-sidebar-footer .dropdown-menu-card .card-footer {
flex-wrap: nowrap;
min-width: 0;
}
.navbar-vertical .navbar-sidebar-footer .dropdown-menu-card .card-footer .btn {
flex: 1 1 auto;
min-width: 0;
justify-content: center;
}
/* Стрелка только у пунктов с подменю (dropdown-toggle), не у обычных ссылок и не у бренда */
.navbar-vertical .nav-link:not(.dropdown-toggle)::after {
display: none !important;
}
/* Sidebar: nav-link with icon (Tabler vertical) */
.navbar-vertical .nav-link {
display: flex;
align-items: center;
gap: 0.5rem;
}
.navbar-vertical .nav-link-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
}
.navbar-vertical .nav-link-icon .icon {
width: 1.25rem;
height: 1.25rem;
}
/* Sidebar dropdown: inline submenu (no popup) — только для основного списка меню,
не затрагиваем иконки в нижнем футере сайдбара (язык, тема, колокол и т.д.) */
.navbar-vertical .navbar-nav > .nav-item.dropdown .dropdown-menu {
position: static;
float: none;
border: none;
box-shadow: none;
background: transparent;
padding: 0 0 0.5rem 0;
margin: 0;
}
.navbar-vertical .navbar-nav > .nav-item.dropdown .dropdown-item {
padding-left: 1rem;
padding-top: 0.35rem;
padding-bottom: 0.35rem;
}
.navbar-vertical .navbar-nav > .nav-item.dropdown .dropdown-item .nav-link-icon {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.navbar-vertical .navbar-nav > .nav-item.dropdown .dropdown-item .nav-link-icon .icon {
width: 1rem;
height: 1rem;
}
/* Custom avatar background */
.avatar {
@@ -151,6 +312,150 @@
/* Убрали глобальную перекраску .badge, чтобы работали текстовые цвета Tabler
(например, badge bg-blue-lt text-blue) */
/* --- Server sidebar (фильтры на /servers) — Tabler card + list-group --- */
.server-sidebar {
width: 280px;
min-width: 280px;
max-height: calc(100vh - 180px);
display: flex;
flex-direction: column;
}
.server-sidebar .card-header {
flex-shrink: 0;
}
.server-sidebar .card-body.border-bottom,
.server-sidebar .card-footer {
flex-shrink: 0;
}
.server-sidebar-body {
flex: 1;
min-height: 0;
max-height: 50vh;
}
.server-sidebar .filter-section .list-group-header {
cursor: pointer;
font-size: 0.8125rem;
font-weight: 600;
text-transform: none;
color: var(--tblr-body-color);
}
.server-sidebar .filter-section .list-group-header:hover {
background-color: var(--tblr-bg-surface-tertiary);
}
.server-sidebar .list-group-item {
font-size: 0.8125rem;
padding-top: 0.375rem;
padding-bottom: 0.375rem;
}
/* Полные названия в фильтрах (без обрезания), перенос по словам */
.server-sidebar .list-group-item .text-break,
#server-filters-offcanvas .list-group-item span:first-of-type span {
word-break: break-word;
white-space: normal;
min-width: 0;
}
/* Tabler-style subheader (как на preview.tabler.io/settings.html) */
.subheader {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--tblr-muted-color, #6c757d);
margin-bottom: 0.25rem;
padding-left: 0;
}
/* ===== Мобильный сайдбар: верхняя полоса + оверлей меню (Tabler-style) ===== */
.sidebar-backdrop {
position: fixed;
inset: 0;
z-index: 1035;
background: rgba(0, 0, 0, 0.4);
transition: opacity 0.2s ease;
}
body.sidebar-overlay-open {
overflow: hidden;
touch-action: none;
}
@media (max-width: 767.98px) {
/* На мобильных: колоночный layout — сайдбар сверху как полоса, контент на всю ширину снизу */
.page {
display: flex;
flex-direction: column;
}
.page .navbar-vertical {
position: relative;
width: 100% !important;
max-width: 100% !important;
min-height: auto !important;
flex-shrink: 0;
flex-direction: row;
align-items: center;
z-index: 1041; /* выше бэкдропа (1035), чтобы меню и верхняя полоса были кликабельны */
}
.page .navbar-vertical > .container-fluid {
flex-direction: row !important;
flex-wrap: nowrap;
width: 100%;
min-height: 3.5rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.page .navbar-vertical .navbar-brand {
flex-shrink: 0;
margin-right: 0.5rem;
}
.page .navbar-vertical .navbar-brand .fw-bold {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 12rem;
}
.page .navbar-vertical .navbar-nav.flex-row {
margin-left: auto;
}
.page .navbar-vertical .navbar-toggler {
padding: 0.5rem;
}
/* Меню скрыто по умолчанию, при открытии — оверлей слева (z-index внутри контекста навбара) */
.page .navbar-vertical .navbar-collapse {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(100vw, 280px);
max-width: 280px;
z-index: 1042;
background: #1e293b;
margin: 0;
padding: 1rem 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15);
flex-direction: column;
display: none;
}
.page .navbar-vertical .navbar-collapse.show {
display: flex !important;
}
.page-wrapper {
margin-left: 0 !important;
flex: 1;
min-width: 0;
width: 100%;
}
.navbar-vertical .nav-link,
.navbar-vertical .dropdown-item {
min-height: 44px;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
display: flex;
align-items: center;
}
}
/* Responsive navbar */
@media (max-width: 991.98px) {
.navbar-collapse {
@@ -251,10 +556,12 @@
height: 100%;
}
.header-actions .btn svg { width: 18px; height: 18px; }
.header-actions .header-actions-right { flex-shrink: 0; }
/* Ensure dropdowns in header-actions overlay tables/cards correctly but stay below modals */
/* Ensure dropdowns in main content (and header) are above the sidebar, below modals */
.header-actions .dropdown-menu,
.page-header .dropdown-menu {
.page-header .dropdown-menu,
.page-wrapper .dropdown-menu {
z-index: 1035; /* above navbar(1030), below modal-backdrop(1040) and modal(1050) */
}
@@ -263,7 +570,7 @@
/* Responsive sizing for small screens */
@media (max-width: 992px) {
.header-actions { gap: 0.4rem; }
.header-actions { gap: 0.4rem; flex-wrap: wrap; }
.header-actions .btn {
padding: 0.35rem 0.6rem;
font-size: 0.875rem;
@@ -276,6 +583,30 @@
.header-actions { width: 100%; }
}
/* Мобильные отступы контента */
@media (max-width: 767.98px) {
.page-body .container-fluid,
.page-body .container-xl {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.card .card-body {
padding: 0.75rem 1rem;
}
.offcanvas-start {
width: min(320px, 100vw) !important;
max-width: 100vw !important;
}
.modal-dialog {
margin: 0.5rem;
max-width: calc(100% - 1rem);
}
.modal-fullscreen-sm-down .modal-dialog {
max-width: 100%;
margin: 0;
}
}
/* Custom responsive adjustments */
@media (max-width: 992px) {
.page-wrapper {
@@ -366,6 +697,82 @@
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* Easy Switch: bring visuals in line with Tabler pages */
.easy-switch-page .card-tabs .nav-link {
font-weight: 500;
}
.easy-switch-server-header {
background-color: rgba(32, 107, 196, 0.05);
cursor: pointer;
user-select: none;
}
.easy-switch-flag {
font-size: 1.5rem;
line-height: 1;
}
.easy-switch-gateway-card {
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
border: 1px solid var(--tblr-border-color);
position: relative;
}
.easy-switch-gateway-card:hover {
transform: translateY(-1px);
box-shadow: 0 0.125rem 0.375rem rgba(32, 41, 57, 0.1);
}
.easy-switch-gateway-card-active {
border: 1px solid var(--tblr-primary) !important;
box-shadow: 0 0.25rem 0.75rem rgba(32, 107, 196, 0.25);
}
.easy-switch-active-check {
width: 20px;
height: 20px;
position: absolute;
top: 8px;
right: 8px;
}
.easy-switch-fast-slot {
min-height: 18px;
}
.easy-switch-fast-badge {
font-size: 0.65rem;
padding: 2px 6px;
}
.easy-switch-gateway-flag {
width: 28px;
height: 28px;
font-size: 1.25rem;
line-height: 1;
background-color: rgba(0, 0, 0, 0.06);
}
.easy-switch-gateway-flag-active {
background-color: rgba(255, 255, 255, 0.25);
}
.easy-switch-gateway-title {
font-size: 0.85rem;
line-height: 1.2;
word-break: break-word;
}
.easy-switch-gateway-ip {
font-size: 0.75rem;
}
.easy-switch-gateway-ping {
font-size: 1.1rem;
}
/* Ripple effect для кнопок */
@keyframes ripple {
to {
@@ -520,4 +927,60 @@ button:focus-visible,
100% {
transform: scale(1);
}
}
/* ===== GraphView / ReactFlow custom styling ===== */
/* Плавные, аккуратные линии графа */
.react-flow__edge-path {
stroke-linecap: round;
transition: stroke 0.15s ease, stroke-width 0.15s ease, filter 0.15s ease;
}
/* Лёгкий ховер по связи, без агрессивной анимации */
.react-flow__edge:hover .react-flow__edge-path {
stroke-width: 3.2px;
filter: drop-shadow(0 0 4px rgba(15, 23, 42, 0.25));
}
/* Базовая прозрачность для всех связей, чтобы граф не выглядел «забитым» */
.react-flow__edge .react-flow__edge-path {
stroke-opacity: 0.9;
}
/* Подписи к рёбрам: не обрезаем, делаем «таблетку» поверх линий */
.react-flow__edge-textwrapper {
overflow: visible;
}
.react-flow__edge-textbg {
fill: #ffffff;
stroke: #e5e7eb;
stroke-width: 1;
rx: 999px;
ry: 999px;
}
.react-flow__edge-text {
font-size: 11px;
font-weight: 500;
fill: #0f172a;
white-space: nowrap;
}
/* Небольшие отличия по типу туннеля (на будущее, сейчас основное зашито в JS) */
.react-flow__edge.edge-tunnel-gre .react-flow__edge-path {
/* GRE — базовый синий, уже задан в JS, здесь только подчёркиваем плавность */
}
.react-flow__edge.edge-tunnel-ipsec .react-flow__edge-path {
stroke-dasharray: 6 4;
}
.react-flow__edge.edge-tunnel-wireguard .react-flow__edge-path {
stroke-width: 3px;
}
.react-flow__edge.edge-tunnel-openvpn .react-flow__edge-path {
stroke-dasharray: 3 3;
}
+454 -189
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, createContext, useContext } from 'react';
import { useState, useEffect, createContext, useContext, useRef } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
BrowserRouter as Router,
@@ -19,24 +19,50 @@ import {
IconFilter,
IconDownload,
IconCreditCard,
IconMenu2
IconMenu2,
IconArrowsExchange,
IconLayoutSidebarLeftExpand,
IconLayoutNavbar,
IconChartPie,
IconChartBar,
IconClock,
IconClockPlay,
IconShield,
IconRoute2
} from '@tabler/icons-react';
import ServerManager from './ServerManager';
import FilterManager from './FilterManager';
import EasySwitchManager from './EasySwitchManager';
import DomainsNewManager from './DomainsNewManager';
import IPRangesManager from './IPRangesManager';
import ASNsNewManager from './ASNsNewManager';
import AutoUrlManager from './AutoUrlManager';
import BillingManager from './BillingManager';
import CommunitiesManager from './CommunitiesManager';
import NetworkConfigManager from './NetworkConfigManager';
import MikrotikTools from './MikrotikTools.jsx';
import InterfaceSpeedTest from './InterfaceSpeedTest.jsx';
import Dashboard from './Dashboard';
import TrafficDashboard from './TrafficDashboard.jsx';
import NetworkMapDashboard from './NetworkMapDashboard.jsx';
import NetworkMapSchedulerPage from './NetworkMapSchedulerPage.jsx';
import UptimeMonitorPage from './UptimeMonitorPage.jsx';
import MikrotikBackupsManager from './MikrotikBackupsManager.jsx';
import PingServicesManager from './PingServicesManager.jsx';
import FirewallPage from './FirewallPage.jsx';
import SettingsPage from './SettingsPage.jsx';
import ResourceStatsPage from './ResourceStatsPage.jsx';
import RouteOptimizerPage from './RouteOptimizerPage.jsx';
import OspfToolsPage from './OspfToolsPage.jsx';
import './App.css';
import { NotifyProvider } from './components/NotifyProvider.jsx';
import SettingsModal from './components/SettingsModal.jsx';
import ToastContainer from './components/ToastContainer.jsx';
import CommandPalette, { KeyboardShortcutsButton } from './components/CommandPalette.jsx';
import ErrorBoundary from './components/ErrorBoundary.jsx';
import NetworkErrorHandler from './components/NetworkErrorHandler.jsx';
import { PingProvider } from './contexts/PingContext.jsx';
import { AlertsProvider } from './contexts/AlertsContext.jsx';
import AlertsBell from './components/AlertsBell.jsx';
// --- Simple i18n (RU/EN) ---
const LanguageContext = createContext({ lang: 'ru', setLang: () => {}, t: (k) => k });
@@ -47,15 +73,21 @@ function LanguageProvider({ children }) {
const dict = {
ru: {
home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты',
dashboard: 'Панель', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
dashboard: 'Панель', trafficTraffic: 'Расход трафика', resourceStats: 'Статистика ресурсов', networkMap: 'Карта сети', uptimeMonitor: 'Uptime Monitor', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
routeOptimizer: 'Оптимизация маршрутов ИИ',
communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL',
light: 'Светлая', dark: 'Тёмная'
easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы', pingServices: 'Пинг сервисов', firewall: 'Firewall', ospfTools: 'Инструменты OSPF',
light: 'Светлая', dark: 'Тёмная',
layoutSidebar: 'Сайдбар', layoutHorizontal: 'Верхнее меню'
},
en: {
home: 'Home', data: 'Data', management: 'Management', tools: 'Tools',
dashboard: 'Dashboard', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs',
dashboard: 'Dashboard', trafficTraffic: 'Traffic Usage', resourceStats: 'Resource Stats', networkMap: 'Network Map', uptimeMonitor: 'Uptime Monitor', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs',
routeOptimizer: 'AI Route Optimizer',
communities: 'Communities', servers: 'Servers', filters: 'Filters', billing: 'Billing', autoUrls: 'Auto URLs',
light: 'Light', dark: 'Dark'
easySwitch: 'Easy Switch', networkConfig: 'Network Config', mikrotikBackups: 'MikroTik Backups', pingServices: 'Ping Services', firewall: 'Firewall', ospfTools: 'OSPF Tools',
light: 'Light', dark: 'Dark',
layoutSidebar: 'Sidebar', layoutHorizontal: 'Top menu'
}
};
const t = (key) => (dict[lang] && key in dict[lang] ? dict[lang][key] : key);
@@ -98,12 +130,16 @@ function App() {
<QueryClientProvider client={queryClient}>
<LanguageProvider>
<ThemeProvider>
<ToastContainer>
<NotifyProvider>
<NetworkErrorHandler />
<MainLayout />
</NotifyProvider>
</ToastContainer>
<PingProvider>
<AlertsProvider>
<ToastContainer>
<NotifyProvider>
<NetworkErrorHandler />
<MainLayout />
</NotifyProvider>
</ToastContainer>
</AlertsProvider>
</PingProvider>
</ThemeProvider>
</LanguageProvider>
</QueryClientProvider>
@@ -112,12 +148,60 @@ function App() {
);
}
const LAYOUT_FLUID = 'fluid';
const LAYOUT_HORIZONTAL = 'horizontal';
const SIDEBAR_COLLAPSE_ID = 'sidebar-menu';
const MOBILE_BREAKPOINT = 768;
function MainLayout() {
const location = useLocation();
const { lang, setLang, t } = useContext(LanguageContext);
const { theme, setTheme } = useContext(ThemeContext);
// Навбар стал лаконичным: без неиспользуемых уведомлений/иконок
const [settingsOpen, setSettingsOpen] = useState(false);
const [layout, setLayout] = useState(() => localStorage.getItem('layout') || LAYOUT_FLUID);
const [sidebarBackdrop, setSidebarBackdrop] = useState(false);
const sidebarTogglerRef = useRef(null);
useEffect(() => {
localStorage.setItem('layout', layout);
}, [layout]);
// Мобильный сайдбар: бэкдроп при открытом меню и закрытие при навигации
useEffect(() => {
if (layout !== LAYOUT_FLUID) return;
const collapseEl = document.getElementById(SIDEBAR_COLLAPSE_ID);
const onShow = () => {
if (window.innerWidth < MOBILE_BREAKPOINT) {
setSidebarBackdrop(true);
document.body.classList.add('sidebar-overlay-open');
}
};
const onHide = () => {
setSidebarBackdrop(false);
document.body.classList.remove('sidebar-overlay-open');
};
if (collapseEl) {
collapseEl.addEventListener('show.bs.collapse', onShow);
collapseEl.addEventListener('hide.bs.collapse', onHide);
return () => {
collapseEl.removeEventListener('show.bs.collapse', onShow);
collapseEl.removeEventListener('hide.bs.collapse', onHide);
};
}
}, [layout]);
useEffect(() => {
if (layout !== LAYOUT_FLUID || window.innerWidth >= MOBILE_BREAKPOINT) return;
setSidebarBackdrop(false);
document.body.classList.remove('sidebar-overlay-open');
const collapseEl = document.getElementById(SIDEBAR_COLLAPSE_ID);
if (collapseEl?.classList.contains('show')) {
sidebarTogglerRef.current?.click?.();
}
}, [location.pathname, layout]);
useEffect(() => {
}, [location.pathname, layout]);
// Состояние для управления выпадающими меню
const [dropdownStates, setDropdownStates] = useState({
@@ -126,10 +210,12 @@ function MainLayout() {
tools: false
});
// Закрытие всех выпадающих меню при клике вне их области
// Закрытие выпадающих меню только при клике в сайдбаре вне открытого меню (клики по странице не закрывают)
useEffect(() => {
const handleClickOutside = (event) => {
if (!event.target.closest('.nav-item.dropdown')) {
const inSidebar = event.target.closest('.navbar-vertical');
const inDropdown = event.target.closest('.nav-item.dropdown');
if (inSidebar && !inDropdown) {
setDropdownStates({
data: false,
management: false,
@@ -165,8 +251,14 @@ function MainLayout() {
id: 'home',
title: t('home'),
icon: IconHome,
path: '/dashboard',
single: true
items: [
{ id: 'dashboard', title: t('dashboard'), path: '/dashboard', icon: IconHome },
{ id: 'traffic', title: t('trafficTraffic'), path: '/traffic', icon: IconChartPie },
{ id: 'resource-stats', title: t('resourceStats'), path: '/resource-stats', icon: IconChartBar },
{ id: 'network-map', title: t('networkMap'), path: '/network-map', icon: IconNetwork },
{ id: 'route-optimizer', title: t('routeOptimizer'), path: '/route-optimizer', icon: IconRoute2 },
{ id: 'uptime-monitor', title: t('uptimeMonitor'), path: '/uptime-monitor', icon: IconClock }
]
},
{
id: 'data',
@@ -186,6 +278,8 @@ function MainLayout() {
items: [
{ id: 'servers', title: t('servers'), path: '/servers', icon: IconServer },
{ id: 'filters', title: t('filters'), path: '/filters', icon: IconFilter },
{ id: 'network-config', title: t('networkConfig'), path: '/network-config', icon: IconNetwork },
{ id: 'easy-switch', title: t('easySwitch'), path: '/easy-switch', icon: IconArrowsExchange },
{ id: 'billing', title: t('billing'), path: '/billing', icon: IconCreditCard }
]
},
@@ -194,10 +288,24 @@ function MainLayout() {
title: t('tools'),
icon: IconSettings,
items: [
{ id: 'auto-urls', title: t('autoUrls'), path: '/auto-urls', icon: IconDownload }
{ id: 'auto-urls', title: t('autoUrls'), path: '/auto-urls', icon: IconDownload },
{ id: 'mikrotik-backups', title: t('mikrotikBackups'), path: '/mikrotik-backups', icon: IconDatabase },
{ id: 'ospf-tools', title: t('ospfTools'), path: '/ospf-tools', icon: IconRoute2 },
{ id: 'mikrotik-tools', title: 'MikroTik Инструменты', path: '/mikrotik-tools', icon: IconNetwork },
{ id: 'firewall', title: t('firewall'), path: '/firewall', icon: IconShield },
{ id: 'interface-speed', title: 'Скорость интерфейсов', path: '/interface-speed', icon: IconNetwork },
{ id: 'ping-services', title: t('pingServices'), path: '/ping-services', icon: IconNetwork },
{ id: 'uptime-monitor', title: t('uptimeMonitor'), path: '/uptime-monitor', icon: IconClock },
{ id: 'scheduler', title: 'Планировщик карты сети', path: '/scheduler', icon: IconClockPlay }
]
},
// Убрали неиспользуемые/неработающие разделы
{
id: 'settings',
title: 'Настройки',
icon: IconSettings,
single: true,
path: '/settings'
}
];
// Определяем активную вкладку по адресу
@@ -219,188 +327,345 @@ function MainLayout() {
const activeTab = getActiveTab();
// Старый вид: один верхний навбар с горизонтальным меню
if (layout === LAYOUT_HORIZONTAL) {
return (
<div className="page d-flex flex-column min-vh-100">
<a href="#main-content" className="skip-link">Перейти к основному содержанию</a>
<header className="navbar navbar-expand-md navbar-light d-print-none" style={{ boxShadow: '0 1px 0 0 #e9ecef' }}>
<div className="container-xl">
<div className="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pe-0 pe-md-3">
<Link to="/" className="navbar-brand d-flex align-items-center">
<IconBrandTabler className="navbar-brand-icon me-2" />
<span className="fw-bold">RouterOS Manager</span>
</Link>
</div>
<button
className="navbar-toggler d-md-none"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbar-menu-horizontal"
aria-controls="navbar-menu-horizontal"
aria-expanded="false"
aria-label="Toggle navigation"
>
<IconMenu2 />
</button>
<div className="collapse navbar-collapse" id="navbar-menu-horizontal">
<div className="d-flex flex-column flex-md-row flex-fill align-items-stretch align-items-md-center">
<ul className="navbar-nav">
{navCategories.map(category => (
<li key={category.id} className="nav-item dropdown">
{category.single ? (
<Link
to={category.path}
className={`nav-link${activeTab === category.id ? ' active' : ''}`}
>
<category.icon className="icon icon-tabler" />
<span className="nav-link-title">{category.title}</span>
</Link>
) : (
<>
<a
className={`nav-link dropdown-toggle${activeTab.startsWith(category.id) ? ' active' : ''}`}
href="#"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleDropdown(category.id);
}}
>
<category.icon className="icon icon-tabler" />
<span className="nav-link-title">{category.title}</span>
</a>
{dropdownStates[category.id] && (
<div className="dropdown-menu show" onClick={(e) => e.stopPropagation()}>
<div className="dropdown-menu-columns">
<div className="dropdown-menu-column">
{category.items.map(item => (
<Link
key={item.id}
to={item.path}
className={`dropdown-item${activeTab === item.id ? ' active' : ''}`}
>
<item.icon className="icon icon-tabler" />
{item.title}
</Link>
))}
</div>
</div>
</div>
)}
</>
)}
</li>
))}
</ul>
</div>
</div>
<div className="navbar-nav flex-row order-md-last">
<div className="nav-item dropdown me-1">
<a href="#" className="nav-link px-2 dropdown-toggle" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()}>
{layout === LAYOUT_FLUID ? <IconLayoutSidebarLeftExpand size={20} /> : <IconLayoutNavbar size={20} />}
</a>
<div className="dropdown-menu dropdown-menu-end">
<a href="#" className={`dropdown-item d-flex align-items-center ${layout === LAYOUT_FLUID ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLayout(LAYOUT_FLUID); }}>
<IconLayoutSidebarLeftExpand className="me-2" size={18} /> {t('layoutSidebar')}
</a>
<a href="#" className={`dropdown-item d-flex align-items-center ${layout === LAYOUT_HORIZONTAL ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLayout(LAYOUT_HORIZONTAL); }}>
<IconLayoutNavbar className="me-2" size={18} /> {t('layoutHorizontal')}
</a>
</div>
</div>
<div className="nav-item dropdown me-1">
<a href="#" className="nav-link px-2 dropdown-toggle d-flex align-items-center" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()} title="Язык">
<span className="fs-5" aria-hidden>{lang === 'ru' ? '🇷🇺' : '🇺🇸'}</span>
</a>
<div className="dropdown-menu dropdown-menu-end">
<a href="#" className={`dropdown-item d-flex align-items-center ${lang === 'ru' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('ru'); }}><span className="me-2" aria-hidden>🇷🇺</span>Русский</a>
<a href="#" className={`dropdown-item d-flex align-items-center ${lang === 'en' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('en'); }}><span className="me-2" aria-hidden>🇺🇸</span>English</a>
</div>
</div>
<div className="nav-item dropdown me-1">
<a href="#" className="nav-link px-2 dropdown-toggle" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()}>
{theme === 'dark' ? '🌙' : '☀️'}
</a>
<div className="dropdown-menu dropdown-menu-end">
<a href="#" className={`dropdown-item ${theme === 'light' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setTheme('light'); }}>☀️ {t('light')}</a>
<a href="#" className={`dropdown-item ${theme === 'dark' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setTheme('dark'); }}>🌙 {t('dark')}</a>
</div>
</div>
<div className="nav-item me-1">
<AlertsBell />
</div>
<div className="nav-item me-1">
<KeyboardShortcutsButton className="nav-link px-2" />
</div>
<div className="nav-item">
<Link to="/settings" className="nav-link px-2" title="Настройки">
<IconSettings size={20} />
</Link>
</div>
</div>
</div>
</header>
<main id="main-content" className="container-xl mt-4 flex-grow-1" tabIndex="-1">
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/traffic" element={<TrafficDashboard />} />
<Route path="/resource-stats" element={<ResourceStatsPage />} />
<Route path="/network-map" element={<NetworkMapDashboard />} />
<Route path="/route-optimizer" element={<RouteOptimizerPage />} />
<Route path="/uptime-monitor" element={<UptimeMonitorPage />} />
<Route path="/domains" element={<DomainsNewManager />} />
<Route path="/ip-ranges" element={<IPRangesManager />} />
<Route path="/asns" element={<ASNsNewManager />} />
<Route path="/communities" element={<CommunitiesManager />} />
<Route path="/auto-urls" element={<AutoUrlManager />} />
<Route path="/servers" element={<ServerManager />} />
<Route path="/billing" element={<BillingManager />} />
<Route path="/filters" element={<FilterManager />} />
<Route path="/network-config" element={<NetworkConfigManager />} />
<Route path="/easy-switch" element={<EasySwitchManager />} />
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
<Route path="/ospf-tools" element={<OspfToolsPage />} />
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
<Route path="/firewall" element={<FirewallPage />} />
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
<Route path="/ping-services" element={<PingServicesManager />} />
<Route path="/scheduler" element={<NetworkMapSchedulerPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</main>
<footer className="footer mt-auto py-2">
<div className="container-xl text-muted small">Версия UI: {import.meta?.env?.VITE_APP_VERSION || 'dev'}</div>
</footer>
<CommandPalette />
</div>
);
}
// Fluid Vertical: вертикальный сайдбар + верхний навбар + page-wrapper
return (
<div className="page d-flex flex-column min-vh-100">
<div className="page">
{/* Skip link для доступности */}
<a href="#main-content" className="skip-link">
Перейти к основному содержанию
</a>
{/* Верхнее меню Tabler Admin */}
<header className="navbar navbar-expand-md navbar-light d-print-none" style={{boxShadow: '0 1px 0 0 #e9ecef'}}>
<div className="container-xl">
{/* Логотип */}
<div className="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pe-0 pe-md-3">
<Link to="/" className="navbar-brand d-flex align-items-center">
<IconBrandTabler className="navbar-brand-icon me-2" />
<span className="fw-bold">S3 Lists Manager</span>
</Link>
</div>
{/* Кнопка бургера для мобильных */}
<button
className="navbar-toggler d-md-none"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbar-menu"
aria-controls="navbar-menu"
aria-expanded="false"
aria-label="Toggle navigation"
>
<IconMenu2 />
</button>
{/* Основное меню */}
<div className="collapse navbar-collapse" id="navbar-menu">
<div className="d-flex flex-column flex-md-row flex-fill align-items-stretch align-items-md-center">
<ul className="navbar-nav">
{navCategories.map(category => (
<li key={category.id} className="nav-item dropdown">
{category.single ? (
<Link
to={category.path}
className={`nav-link${activeTab === category.id ? ' active' : ''}`}
{/* Левая вертикальная навигация — всегда тёмная (Tabler navbar-vertical) */}
<header className="navbar navbar-vertical navbar-expand-md d-print-none" data-bs-theme="dark">
<div className="container-fluid">
<Link to="/" className="navbar-brand navbar-brand-autodark">
<IconBrandTabler className="navbar-brand-icon" />
<span className="fw-bold">RouterOS Manager</span>
</Link>
<div className="navbar-nav flex-row d-md-none">
<button
ref={sidebarTogglerRef}
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target={`#${SIDEBAR_COLLAPSE_ID}`}
aria-controls={SIDEBAR_COLLAPSE_ID}
aria-expanded="false"
aria-label="Открыть меню"
>
<IconMenu2 />
</button>
</div>
<div className="collapse navbar-collapse" id={SIDEBAR_COLLAPSE_ID}>
<ul className="navbar-nav pt-lg-3">
{navCategories.map(category => (
<li key={category.id} className={`nav-item${category.items ? ' dropdown' : ''}${(category.single && activeTab === category.id) || (category.items && category.items.some(i => activeTab === i.id)) ? ' active' : ''}`}>
{category.single ? (
<Link
to={category.path}
className="nav-link"
>
<span className="nav-link-icon">
<category.icon className="icon" />
</span>
<span className="nav-link-title">{category.title}</span>
</Link>
) : (
<>
<a
className="nav-link dropdown-toggle"
href="#"
onClick={(e) => {
e.preventDefault();
toggleDropdown(category.id);
}}
aria-expanded={dropdownStates[category.id]}
>
<category.icon className="icon icon-tabler" />
<span className="nav-link-icon">
<category.icon className="icon" />
</span>
<span className="nav-link-title">{category.title}</span>
</Link>
) : (
<>
<a
className={`nav-link dropdown-toggle${activeTab.startsWith(category.id) ? ' active' : ''}`}
href="#"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleDropdown(category.id);
}}
>
<category.icon className="icon icon-tabler" />
<span className="nav-link-title">{category.title}</span>
</a>
{dropdownStates[category.id] && (
<div className="dropdown-menu show" onClick={(e) => e.stopPropagation()}>
<div className="dropdown-menu-columns">
<div className="dropdown-menu-column">
{category.items.map(item => (
<Link
key={item.id}
to={item.path}
className={`dropdown-item${activeTab === item.id ? ' active' : ''}`}
>
<item.icon className="icon icon-tabler" />
{item.title}
</Link>
))}
</div>
</div>
</div>
)}
</>
)}
</li>
))}
</ul>
</div>
</div>
{/* Правая часть навбара: язык, тема и действия */}
<div className="navbar-nav flex-row order-md-last">
{/* Язык */}
<div className="nav-item dropdown me-1">
<a
href="#"
className="nav-link px-2 dropdown-toggle"
data-bs-toggle="dropdown"
onClick={(e) => e.preventDefault()}
>
<span className="badge">{lang.toUpperCase()}</span>
</a>
<div className="dropdown-menu dropdown-menu-end">
<a
href="#"
className={`dropdown-item ${lang === 'ru' ? 'active' : ''}`}
onClick={(e) => { e.preventDefault(); setLang('ru'); }}
>
Русский
</a>
<a
href="#"
className={`dropdown-item ${lang === 'en' ? 'active' : ''}`}
onClick={(e) => { e.preventDefault(); setLang('en'); }}
>
English
</a>
</a>
<div className={`dropdown-menu${dropdownStates[category.id] ? ' show' : ''}`}>
{category.items.map(item => (
<Link
key={item.id}
to={item.path}
className={`dropdown-item d-flex align-items-center${activeTab === item.id ? ' active' : ''}`}
>
<span className="nav-link-icon me-2">
<item.icon className="icon" />
</span>
{item.title}
</Link>
))}
</div>
</>
)}
</li>
))}
</ul>
{/* Горизонтальный ряд значков в самом низу сайдбара */}
<div className="navbar-sidebar-footer border-top mt-auto">
<div className="d-flex align-items-center justify-content-center gap-1 py-2 px-2">
<div className="nav-item dropdown dropup">
<a href="#" className="nav-link btn-icon rounded d-inline-flex align-items-center justify-content-center" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()} title="Язык">
<span className="fs-5" aria-hidden>{lang === 'ru' ? '🇷🇺' : '🇺🇸'}</span>
</a>
<div className="dropdown-menu dropdown-menu-start">
<a href="#" className={`dropdown-item d-flex align-items-center ${lang === 'ru' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('ru'); }}><span className="me-2" aria-hidden>🇷🇺</span>Русский</a>
<a href="#" className={`dropdown-item d-flex align-items-center ${lang === 'en' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('en'); }}><span className="me-2" aria-hidden>🇺🇸</span>English</a>
</div>
</div>
<div className="nav-item dropdown dropup">
<a href="#" className="nav-link btn-icon rounded" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()} title={t('layoutSidebar')}>
{layout === LAYOUT_FLUID ? <IconLayoutSidebarLeftExpand size={20} /> : <IconLayoutNavbar size={20} />}
</a>
<div className="dropdown-menu dropdown-menu-start">
<a href="#" className={`dropdown-item d-flex align-items-center ${layout === LAYOUT_FLUID ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLayout(LAYOUT_FLUID); }}>
<IconLayoutSidebarLeftExpand className="me-2" size={18} /> {t('layoutSidebar')}
</a>
<a href="#" className={`dropdown-item d-flex align-items-center ${layout === LAYOUT_HORIZONTAL ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLayout(LAYOUT_HORIZONTAL); }}>
<IconLayoutNavbar className="me-2" size={18} /> {t('layoutHorizontal')}
</a>
</div>
</div>
<div className="nav-item dropdown dropup">
<a href="#" className="nav-link btn-icon rounded" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()} title={t('light') + ' / ' + t('dark')}>
{theme === 'dark' ? '🌙' : '☀️'}
</a>
<div className="dropdown-menu dropdown-menu-start">
<a href="#" className={`dropdown-item ${theme === 'light' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setTheme('light'); }}>☀️ {t('light')}</a>
<a href="#" className={`dropdown-item ${theme === 'dark' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setTheme('dark'); }}>🌙 {t('dark')}</a>
</div>
</div>
<div className="nav-item">
<AlertsBell dropup />
</div>
<div className="nav-item">
<KeyboardShortcutsButton className="nav-link btn-icon rounded" title="Горячие клавиши" />
</div>
<div className="nav-item">
<Link to="/settings" className="nav-link btn-icon rounded" title="Настройки">
<IconSettings size={20} />
</Link>
</div>
</div>
</div>
{/* Тема */}
<div className="nav-item dropdown me-1">
<a
href="#"
className="nav-link px-2 dropdown-toggle"
data-bs-toggle="dropdown"
onClick={(e) => e.preventDefault()}
>
{theme === 'dark' ? '🌙' : '☀️'}
</a>
<div className="dropdown-menu dropdown-menu-end">
<a
href="#"
className={`dropdown-item ${theme === 'light' ? 'active' : ''}`}
onClick={(e) => { e.preventDefault(); setTheme('light'); }}
>
☀️ {t('light')}
</a>
<a
href="#"
className={`dropdown-item ${theme === 'dark' ? 'active' : ''}`}
onClick={(e) => { e.preventDefault(); setTheme('dark'); }}
>
🌙 {t('dark')}
</a>
</div>
</div>
{/* Горячие клавиши */}
<div className="nav-item me-1">
<KeyboardShortcutsButton className="nav-link px-2" />
</div>
{/* Настройки */}
<div className="nav-item">
<a
href="#"
className="nav-link px-2"
onClick={(e) => { e.preventDefault(); setSettingsOpen(true); }}
title="Настройки"
>
<IconSettings size={20} />
</a>
</div>
</div>
</div>
</header>
<main id="main-content" className="container-xl mt-4 flex-grow-1" tabIndex="-1">
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/domains" element={<DomainsNewManager />} />
<Route path="/ip-ranges" element={<IPRangesManager />} />
<Route path="/asns" element={<ASNsNewManager />} />
<Route path="/communities" element={<CommunitiesManager />} />
<Route path="/auto-urls" element={<AutoUrlManager />} />
<Route path="/servers" element={<ServerManager />} />
<Route path="/billing" element={<BillingManager />} />
<Route path="/filters" element={<FilterManager />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</main>
<footer className="footer mt-auto py-2">
<div className="container-xl text-muted small">
Версия UI: {import.meta?.env?.VITE_APP_VERSION || 'dev'}
{/* Бэкдроп мобильного меню: закрытие по клику */}
{sidebarBackdrop && (
<div
className="sidebar-backdrop d-md-none"
aria-hidden="true"
onClick={() => sidebarTogglerRef.current?.click?.()}
/>
)}
<div className="page-wrapper">
<div className="page-body">
<div className="container-fluid">
<main id="main-content" tabIndex="-1">
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/traffic" element={<TrafficDashboard />} />
<Route path="/resource-stats" element={<ResourceStatsPage />} />
<Route path="/network-map" element={<NetworkMapDashboard />} />
<Route path="/route-optimizer" element={<RouteOptimizerPage />} />
<Route path="/uptime-monitor" element={<UptimeMonitorPage />} />
<Route path="/domains" element={<DomainsNewManager />} />
<Route path="/ip-ranges" element={<IPRangesManager />} />
<Route path="/asns" element={<ASNsNewManager />} />
<Route path="/communities" element={<CommunitiesManager />} />
<Route path="/auto-urls" element={<AutoUrlManager />} />
<Route path="/servers" element={<ServerManager />} />
<Route path="/billing" element={<BillingManager />} />
<Route path="/filters" element={<FilterManager />} />
<Route path="/network-config" element={<NetworkConfigManager />} />
<Route path="/easy-switch" element={<EasySwitchManager />} />
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
<Route path="/ospf-tools" element={<OspfToolsPage />} />
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
<Route path="/firewall" element={<FirewallPage />} />
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
<Route path="/ping-services" element={<PingServicesManager />} />
<Route path="/scheduler" element={<NetworkMapSchedulerPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</main>
</div>
</div>
</footer>
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
<footer className="footer footer-transparent d-print-none">
<div className="container-fluid">
<ul className="list-inline list-inline-dots mb-0 text-muted small">
<li className="list-inline-item">Версия UI: {import.meta?.env?.VITE_APP_VERSION || 'dev'}</li>
</ul>
</div>
</footer>
</div>
<CommandPalette />
</div>
);
+53 -71
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import api from './lib/api.js';
import {
IconPlus,
@@ -101,25 +101,21 @@ function AutoUrlManager() {
const hasAtLeastOneValidRow = urls.some(u => isValidHttpUrl(u.url) && isValidCommunity(u.community));
const saveUrls = async () => {
const saveUrls = useCallback(async () => {
try {
setSaving(true);
setError('');
setSuccess('');
// Validate URLs
// Validate URLs (пустой список тоже валиден - для очистки)
const validUrls = urls
.filter(u => u != null && isValidHttpUrl(u.url) && isValidCommunity(u.community))
.map(u => ({ url: u.url.trim(), community: String(u.community).trim() }));
if (validUrls.length === 0) {
setError('Добавьте хотя бы одну корректную запись (валидный URL и числовой community)');
return;
}
await api.post('/auto-urls', { urls: validUrls });
setUrls(validUrls);
setLastSaved(new Date().toISOString());
setSuccess('URL-адреса сохранены успешно');
setSuccess(validUrls.length === 0 ? 'Список URL очищен' : 'URL-адреса сохранены успешно');
setSelectedRows(new Set());
setTimeout(() => setSuccess(''), 3000);
} catch (err) {
@@ -128,7 +124,7 @@ function AutoUrlManager() {
} finally {
setSaving(false);
}
};
}, [urls]);
const processUrls = async () => {
try {
@@ -162,7 +158,7 @@ function AutoUrlManager() {
}
setSuccess('Пример формата скопирован');
setTimeout(() => setSuccess(''), 2000);
} catch (e) {
} catch (_e) {
setError('Не удалось скопировать пример');
setTimeout(() => setError(''), 2000);
}
@@ -257,7 +253,9 @@ function AutoUrlManager() {
const handleKeyDown = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
if (hasAtLeastOneValidRow && !saving && !processing) {
// Разрешить сохранение: либо пустой список, либо есть валидные строки
const canSave = urls.length === 0 || hasAtLeastOneValidRow;
if (canSave && !saving && !processing) {
saveUrls();
}
}
@@ -272,13 +270,13 @@ function AutoUrlManager() {
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [hasAtLeastOneValidRow, saving, processing, saveUrls]);
}, [urls.length, hasAtLeastOneValidRow, saving, processing, saveUrls]);
return (
<div>
<PageHeader
title="Автоматические URL"
pretitle="Управление автоматическими URL-адресами для загрузки списков IP и доменов"
icon={<IconLink size={24} />}
actions={(
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div className="btn-list">
@@ -326,7 +324,7 @@ function AutoUrlManager() {
className="btn btn-outline-primary"
type="button"
onClick={saveUrls}
disabled={saving || processing || !hasAtLeastOneValidRow}
disabled={saving || processing || (urls.length > 0 && !hasAtLeastOneValidRow)}
title="Сохранить список URL (Ctrl+S)"
>
{saving ? (
@@ -385,69 +383,53 @@ function AutoUrlManager() {
{!loading && urls.length > 0 && (
<div className="row g-3 mb-4">
<div className="col-md-3">
<div className="card card-sm">
<div className="card-body">
<div className="row align-items-center">
<div className="col-auto">
<span className="avatar avatar-lg bg-blue-lt text-blue">
<IconWorld size={24} />
</span>
</div>
<div className="col">
<div className="font-weight-medium">{stats.total}</div>
<div className="text-muted small">Всего URL</div>
</div>
<div className="card h-100">
<div className="card-body d-flex align-items-center">
<span className="avatar avatar-lg me-3 bg-blue-lt text-blue border-0">
<IconWorld size={32} />
</span>
<div>
<div className="h3 mb-0 fw-bold">{stats.total}</div>
<div className="text-muted small">Всего URL</div>
</div>
</div>
</div>
</div>
<div className="col-md-3">
<div className="card card-sm">
<div className="card-body">
<div className="row align-items-center">
<div className="col-auto">
<span className="avatar avatar-lg bg-green-lt text-green">
<IconCheck size={24} />
</span>
</div>
<div className="col">
<div className="font-weight-medium">{stats.valid}</div>
<div className="text-muted small">Валидных</div>
</div>
<div className="card h-100">
<div className="card-body d-flex align-items-center">
<span className="avatar avatar-lg me-3 bg-green-lt text-green border-0">
<IconCheck size={32} />
</span>
<div>
<div className="h3 mb-0 fw-bold">{stats.valid}</div>
<div className="text-muted small">Валидных</div>
</div>
</div>
</div>
</div>
<div className="col-md-3">
<div className="card card-sm">
<div className="card-body">
<div className="row align-items-center">
<div className="col-auto">
<span className="avatar avatar-lg bg-red-lt text-red">
<IconAlertCircle size={24} />
</span>
</div>
<div className="col">
<div className="font-weight-medium">{stats.invalid}</div>
<div className="text-muted small">Невалидных</div>
</div>
<div className="card h-100">
<div className="card-body d-flex align-items-center">
<span className="avatar avatar-lg me-3 bg-red-lt text-red border-0">
<IconAlertCircle size={32} />
</span>
<div>
<div className="h3 mb-0 fw-bold">{stats.invalid}</div>
<div className="text-muted small">Невалидных</div>
</div>
</div>
</div>
</div>
<div className="col-md-3">
<div className="card card-sm">
<div className="card-body">
<div className="row align-items-center">
<div className="col-auto">
<span className="avatar avatar-lg bg-orange-lt text-orange">
<IconLink size={24} />
</span>
</div>
<div className="col">
<div className="font-weight-medium">{filteredAndSortedUrls.length}</div>
<div className="text-muted small">Отображается</div>
</div>
<div className="card h-100">
<div className="card-body d-flex align-items-center">
<span className="avatar avatar-lg me-3 bg-azure-lt text-azure border-0">
<IconLink size={32} />
</span>
<div>
<div className="h3 mb-0 fw-bold">{filteredAndSortedUrls.length}</div>
<div className="text-muted small">Отображается</div>
</div>
</div>
</div>
@@ -465,8 +447,8 @@ function AutoUrlManager() {
</h3>
</div>
<div className="col-auto">
<div className="input-group" style={{ maxWidth: 300 }}>
<span className="input-group-text">
<div className="input-icon" style={{ maxWidth: 320 }}>
<span className="input-icon-addon">
<IconSearch size={16} />
</span>
<input
@@ -479,7 +461,7 @@ function AutoUrlManager() {
/>
{searchTerm && (
<button
className="btn btn-outline-secondary"
className="btn btn-icon btn-sm btn-outline-secondary position-absolute top-50 end-0 translate-middle-y me-1"
type="button"
onClick={() => setSearchTerm('')}
title="Очистить поиск"
@@ -490,16 +472,16 @@ function AutoUrlManager() {
</div>
</div>
<div className="col-auto">
<div className="btn-group">
<div className="btn-group btn-group-sm">
<button
className={`btn btn-sm ${filterValid === 'all' ? 'btn-primary' : 'btn-outline-secondary'}`}
className={`btn ${filterValid === 'all' ? 'btn-primary' : 'btn-outline-secondary'}`}
onClick={() => setFilterValid('all')}
title="Все записи"
>
Все
</button>
<button
className={`btn btn-sm ${filterValid === 'valid' ? 'btn-success' : 'btn-outline-success'}`}
className={`btn ${filterValid === 'valid' ? 'btn-success' : 'btn-outline-success'}`}
onClick={() => setFilterValid('valid')}
title="Только валидные"
>
@@ -507,7 +489,7 @@ function AutoUrlManager() {
Валидные
</button>
<button
className={`btn btn-sm ${filterValid === 'invalid' ? 'btn-danger' : 'btn-outline-danger'}`}
className={`btn ${filterValid === 'invalid' ? 'btn-danger' : 'btn-outline-danger'}`}
onClick={() => setFilterValid('invalid')}
title="Только невалидные"
>
@@ -637,7 +619,7 @@ function AutoUrlManager() {
</tr>
</thead>
<tbody>
{filteredAndSortedUrls.map((url, displayIndex) => {
{filteredAndSortedUrls.map((url, _displayIndex) => {
const originalIndex = urls.findIndex(u => u === url);
const v = getRowValidity(url);
const isValid = v.url && v.community;
@@ -647,7 +629,7 @@ function AutoUrlManager() {
return (
<tr
key={originalIndex}
className={`${isSelected ? 'table-active' : ''} ${isInvalid ? 'table-danger' : isValid ? 'table-success' : ''}`}
className={isSelected ? 'table-active' : ''}
>
<td>
<input
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+283 -32
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import api from './lib/api.js';
import { formatDateTime } from './lib/datetime.js';
import {
IconWorld,
IconNetwork,
@@ -14,21 +15,30 @@ import {
IconFilter,
IconDownload,
IconCreditCard,
IconSearch
IconSearch,
IconChevronDown,
IconChevronUp,
IconBell,
IconCpu,
IconDeviceDesktop,
IconDatabase,
IconServerOff,
} from '@tabler/icons-react';
import { getIconById } from './lib/brandIcons.js';
import PageHeader from './components/PageHeader.jsx';
import Breadcrumbs from './components/Breadcrumbs.jsx';
import TopNStats from './components/TopNStats.jsx';
import TrendIndicator from './components/TrendIndicator.jsx';
import LastSaved from './components/LastSaved.jsx';
import Tooltip from './components/Tooltip.jsx';
import Sparkline from './components/Sparkline.jsx';
import { useAlerts } from './contexts/AlertsContext.jsx';
function StatCard({ icon: Icon, color, value, title, subtitle, to, trend, previousValue }) {
function StatCard({ icon: _Icon, color, value, title, subtitle, to, trend, previousValue }) {
return (
<div className="card h-100 position-relative card-hover">
<div className="card-body d-flex align-items-center">
<span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0`}>
<Icon size={32} />
<_Icon size={32} />
</span>
<div className="flex-grow-1">
<div className="d-flex align-items-baseline gap-2 mb-1">
@@ -54,12 +64,12 @@ function StatCard({ icon: Icon, color, value, title, subtitle, to, trend, previo
);
}
function MetricCard({ title, value, icon: Icon, color, description }) {
function MetricCard({ title, value, icon: _Icon, color, description }) {
return (
<div className="card h-100 position-relative">
<div className="card-body d-flex align-items-center">
<span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0`}>
<Icon size={32} />
<_Icon size={32} />
</span>
<div className="flex-grow-1">
<div className="h3 mb-0 fw-bold">
@@ -74,7 +84,115 @@ function MetricCard({ title, value, icon: Icon, color, description }) {
);
}
/** Обёртка для Sparkline по ширине контейнера */
function PingSparklineWrap({ history, color }) {
const wrapRef = useRef(null);
const [width, setWidth] = useState(280);
useEffect(() => {
const el = wrapRef.current;
if (!el) return;
const ro = new ResizeObserver(() => setWidth(el.offsetWidth || 280));
ro.observe(el);
setWidth(el.offsetWidth || 280);
return () => ro.disconnect();
}, []);
return (
<div ref={wrapRef} className="mt-2" style={{ height: 56, width: '100%', minWidth: 0 }}>
<Sparkline
data={history}
width={width}
height={56}
strokeColor={color ? `var(--tblr-${color})` : 'var(--tblr-primary)'}
fillColor={color ? `var(--tblr-${color})` : 'var(--tblr-primary)'}
strokeWidth={2}
/>
</div>
);
}
/** Разворачиваемая карточка сервиса: свёрнутый вид — иконка и пинг; развёрнутый — тренд и мини-график (как Revenue в Tabler) */
function PingServiceCard({ config, ms, previousMs, history = [], loading, isExpanded, onToggle }) {
const { name, host, color } = config;
const { Icon } = getIconById(config.icon);
const value = loading ? '...' : (ms != null ? `${ms} мс` : '—');
const currentNum = typeof ms === 'number' ? ms : null;
return (
<div
className={`card h-100 position-relative ${isExpanded ? '' : 'cursor-pointer'}`}
style={isExpanded ? { minHeight: '200px' } : undefined}
role="button"
tabIndex={0}
onClick={onToggle}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onToggle()}
aria-expanded={isExpanded}
aria-label={isExpanded ? `Свернуть ${name}` : `Развернуть ${name}, пинг ${value}`}
>
{!isExpanded ? (
<div className="card-body d-flex align-items-center">
<span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0 rounded`}>
<Icon size={28} stroke={1.5} />
</span>
<div className="flex-grow-1 min-w-0">
<div className="h4 mb-0 fw-bold">{value}</div>
<div className="text-muted small text-truncate" title={`${name} (${host})`}>
{name} <span className="opacity-75">({host})</span>
</div>
</div>
<IconChevronDown size={18} className="text-muted ms-1 flex-shrink-0" />
</div>
) : (
<div className="card-body">
<div className="d-flex align-items-center justify-content-between mb-2">
<span className="text-muted text-uppercase small fw-semibold">{name}</span>
<span className="text-muted small d-flex align-items-center">
Последние замеры
<IconChevronUp size={14} className="ms-1" title="Свернуть" />
</span>
</div>
<div className="d-flex align-items-baseline flex-wrap gap-2 mb-3">
<div className="h3 mb-0 fw-bold">
{loading ? '...' : currentNum != null ? `${currentNum} мс` : '—'}
</div>
{!loading && currentNum != null && previousMs != null && (
<TrendIndicator
value={currentNum}
previousValue={previousMs}
format="percent"
inverse
/>
)}
</div>
{history.length > 0 && (() => {
const sum = history.reduce((a, b) => a + b, 0);
const avg = Math.round(sum / history.length);
return (
<div className="text-muted small mb-2">
Среднее за период: <span className="fw-semibold text-body">{avg} мс</span>
</div>
);
})()}
{history.length > 0 ? (
<PingSparklineWrap history={history} color={color} />
) : (
<div className="text-muted small mt-2">Нет истории замеров. Данные накапливаются при обновлении.</div>
)}
</div>
)}
</div>
);
}
const ALERT_ICONS = {
server_offline: IconServerOff,
mikrotik_unreachable: IconServerOff,
high_cpu: IconCpu,
high_ram: IconDeviceDesktop,
high_hdd: IconDatabase,
};
function Dashboard() {
const { alerts, loading: alertsLoading, refresh: refreshAlerts } = useAlerts();
// Глобальный поиск удалён по требованию UX
const [stats, setStats] = useState({
domainsCount: null,
@@ -92,20 +210,28 @@ function Dashboard() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [lastFetchTime, setLastFetchTime] = useState(null);
const [pingServices, setPingServices] = useState(null);
const [pingServicesConfig, setPingServicesConfig] = useState([]);
const [pingLoading, setPingLoading] = useState(true);
const [pingHistory, setPingHistory] = useState(() => ({}));
const [expandedPingId, setExpandedPingId] = useState(null);
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
async function fetchStats() {
setLoading(true);
setError(null);
try {
// Загружаем данные с обработкой ошибок для каждого endpoint
const opts = { signal };
const results = await Promise.allSettled([
api.get('/domains-new', { params: { countOnly: true } }),
api.get('/ip-ranges', { params: { countOnly: true } }),
api.get('/asns', { params: { countOnly: true } }),
api.get('/servers'),
api.get('/servers/availability', { params: { ttlSeconds: 60 } }),
api.get('/s3/last-modified')
api.get('/domains-new', { params: { countOnly: true }, ...opts }),
api.get('/ip-ranges', { params: { countOnly: true }, ...opts }),
api.get('/asns', { params: { countOnly: true }, ...opts }),
api.get('/servers', opts),
api.get('/servers/availability', { params: { ttlSeconds: 60 }, ...opts }),
api.get('/s3/last-modified', opts)
]);
// Обрабатываем результаты
@@ -130,7 +256,7 @@ function Dashboard() {
// Получаем дату последнего обновления
// Новый формат: объект с ключами { domainsNew, asns, servers, filters, ipRanges }
const lmRaw = s3Res.status === 'fulfilled' ? s3Res.value.data?.domainsNew?.lastModified : null;
const lastModified = lmRaw ? new Date(lmRaw).toLocaleString() : new Date().toLocaleString();
const lastModified = lmRaw ? formatDateTime(lmRaw) : formatDateTime(new Date());
const domainsCount = domainsRes.status === 'fulfilled' && typeof domainsRes.value.data?.total === 'number'
? domainsRes.value.data.total
@@ -170,6 +296,7 @@ function Dashboard() {
console.warn('Некоторые API недоступны:', errors.map(e => e.reason?.message));
}
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
console.error('Критическая ошибка загрузки статистики:', e);
setError('Не удалось загрузить статистику');
setStats({
@@ -188,6 +315,44 @@ function Dashboard() {
}
}
fetchStats();
return () => controller.abort();
}, []);
useEffect(() => {
const controller = new AbortController();
api.get('/ping-services-list', { signal: controller.signal })
.then(({ data }) => {
if (Array.isArray(data?.list)) setPingServicesConfig(data.list);
})
.catch((e) => {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
setPingServicesConfig([]);
});
return () => controller.abort();
}, []);
function fetchPingServices(forceRefresh = false, abortSignal) {
setPingLoading(true);
const opts = abortSignal ? { signal: abortSignal } : {};
api.get('/ping-services', { params: forceRefresh ? { refresh: 1 } : {}, ...opts })
.then(({ data }) => {
if (!data || typeof data !== 'object') return;
const byId = data.byId ?? data;
const history = data.history ?? {};
setPingServices(byId);
setPingHistory(typeof history === 'object' ? history : {});
})
.catch((e) => {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
setPingServices(null);
})
.finally(() => setPingLoading(false));
}
useEffect(() => {
const controller = new AbortController();
fetchPingServices(false, controller.signal);
return () => controller.abort();
}, []);
if (error) {
@@ -205,18 +370,14 @@ function Dashboard() {
{/* Заголовок страницы */}
<PageHeader
title="Панель"
pretitle={(
<Breadcrumbs items={[
{ label: 'Главная' },
]} />
)}
icon={<IconCloud size={24} />}
actions={(
<div className="d-flex align-items-center gap-3">
<div className="header-actions d-flex align-items-center flex-wrap gap-2 gap-sm-3">
{lastFetchTime && (
<LastSaved timestamp={lastFetchTime} variant="compact" />
)}
<Tooltip content="Обновить данные" shortcut="⌘R">
<button className="btn btn-outline-primary" onClick={() => window.location.reload()}>
<button className="btn btn-outline-primary btn-sm" onClick={() => window.location.reload()}>
<IconRefresh className="me-2" /> Обновить
</button>
</Tooltip>
@@ -224,9 +385,99 @@ function Dashboard() {
)}
/>
{/* Основные метрики */}
{/* Пинг до сервисов (список из настроек / Пинг сервисов) */}
<div className="d-flex flex-column flex-sm-row align-items-start align-items-sm-center justify-content-between gap-2 mb-2">
<h3 className="mb-0">Пинг сервисов</h3>
<Tooltip content="Принудительно обновить пинг (без учёта кэша), история и тренд накапливаются">
<button
type="button"
className="btn btn-outline-primary btn-sm w-100 w-sm-auto"
onClick={() => fetchPingServices(true)}
disabled={pingLoading}
>
<IconRefresh className={pingLoading ? 'spin me-1' : 'me-1'} size={16} />
Обновить пинг
</button>
</Tooltip>
</div>
<div className="row g-3 mb-4">
<div className="col-md-3">
{pingServicesConfig.map((config) => (
<div
key={config.id}
className={expandedPingId === config.id ? 'col-12' : 'col-12 col-sm-6 col-md-3'}
>
<PingServiceCard
config={config}
ms={pingServices?.[config.id]?.ms ?? null}
previousMs={(() => {
const hist = pingHistory[config.id] || [];
return hist.length >= 2 ? hist[hist.length - 2] : null;
})()}
history={pingHistory[config.id] || []}
loading={pingLoading}
isExpanded={expandedPingId === config.id}
onToggle={() => setExpandedPingId((id) => (id === config.id ? null : config.id))}
/>
</div>
))}
</div>
{/* Активные оповещения */}
<div className="mb-4">
<div className="d-flex flex-column flex-sm-row align-items-start align-items-sm-center justify-content-between gap-2 mb-2">
<h3 className="mb-0 d-flex align-items-center gap-2">
<IconBell size={24} />
Активные оповещения
{alerts.length > 0 && (
<span className="badge bg-danger rounded-pill">{alerts.length}</span>
)}
</h3>
<button
type="button"
className="btn btn-outline-primary btn-sm"
onClick={() => refreshAlerts()}
disabled={alertsLoading}
>
<IconRefresh className={alertsLoading ? 'spin me-1' : 'me-1'} size={16} />
Обновить
</button>
</div>
{alerts.length === 0 && !alertsLoading && (
<div className="card">
<div className="card-body text-center text-muted py-4">
Нет активных оповещений
</div>
</div>
)}
{alerts.length > 0 && (
<div className="card">
<div className="list-group list-group-flush">
{alerts.map((alert) => {
const Icon = ALERT_ICONS[alert.type] || IconAlertTriangle;
return (
<Link
key={alert.id}
to={alert.link || '/resource-stats'}
className="list-group-item list-group-item-action d-flex align-items-center gap-3"
>
<span className={`avatar avatar-sm flex-shrink-0 bg-${alert.severity === 'critical' ? 'danger' : 'warning'}-lt text-${alert.severity === 'critical' ? 'danger' : 'warning'}`}>
<Icon size={20} />
</span>
<div className="min-w-0 flex-grow-1">
<div className="fw-semibold">{alert.title}</div>
<div className="text-muted small">{alert.description}</div>
</div>
</Link>
);
})}
</div>
</div>
)}
</div>
{/* Основные метрики */}
<div className="row g-2 g-md-3 mb-4">
<div className="col-6 col-lg-3">
<div className="animate-in">
<StatCard
icon={IconWorld}
@@ -240,7 +491,7 @@ function Dashboard() {
/>
</div>
</div>
<div className="col-md-3">
<div className="col-6 col-lg-3">
<div className="animate-in" style={{animationDelay: '0.1s'}}>
<StatCard
icon={IconNetwork}
@@ -254,7 +505,7 @@ function Dashboard() {
/>
</div>
</div>
<div className="col-md-3">
<div className="col-6 col-lg-3">
<div className="animate-in" style={{animationDelay: '0.2s'}}>
<StatCard
icon={IconNetwork}
@@ -268,7 +519,7 @@ function Dashboard() {
/>
</div>
</div>
<div className="col-md-3">
<div className="col-6 col-lg-3">
<div className="animate-in" style={{animationDelay: '0.3s'}}>
<StatCard
icon={IconServer}
@@ -285,8 +536,8 @@ function Dashboard() {
</div>
{/* Дополнительные метрики */}
<div className="row g-3 mb-4 justify-content-center">
<div className="col-sm-6 col-md-3">
<div className="row g-2 g-md-3 mb-4 justify-content-center">
<div className="col-6 col-md-6 col-lg-3">
<MetricCard
title="Стран"
value={loading ? '...' : stats.countriesCount ?? '—'}
@@ -295,7 +546,7 @@ function Dashboard() {
description="Географическое покрытие"
/>
</div>
<div className="col-sm-6 col-md-3">
<div className="col-6 col-md-6 col-lg-3">
<MetricCard
title="Провайдеров"
value={loading ? '...' : stats.providersCount ?? '—'}
@@ -304,7 +555,7 @@ function Dashboard() {
description="Облачные провайдеры"
/>
</div>
<div className="col-sm-6 col-md-3">
<div className="col-6 col-md-6 col-lg-3">
<MetricCard
title="Онлайн серверов"
value={loading ? '...' : `${stats.onlineServers ?? '—'}/${stats.totalServers ?? '—'}`}
@@ -313,7 +564,7 @@ function Dashboard() {
description="Активные серверы"
/>
</div>
<div className="col-sm-6 col-md-3">
<div className="col-6 col-md-6 col-lg-3">
<MetricCard
title="Последнее обновление"
value={loading ? '...' : (stats.lastModified ? stats.lastModified : '—')}
+12 -12
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import api from './lib/api.js';
import {
IconPlus,
@@ -39,11 +39,7 @@ function DataManager({ entityName, entityKey, placeholder }) {
const pageSize = 10;
const [confirmState, setConfirmState] = useState({ open: false, text: '', onConfirm: null });
useEffect(() => {
fetchItems();
}, [entityKey]);
const fetchItems = async () => {
const fetchItems = useCallback(async () => {
setLoading(true);
try {
const response = await api.get(`/${entityKey}`);
@@ -55,7 +51,11 @@ function DataManager({ entityName, entityKey, placeholder }) {
} finally {
setLoading(false);
}
};
}, [entityKey, entityName]);
useEffect(() => {
fetchItems();
}, [fetchItems]);
const handleAddItem = () => {
if (newItem.domain.trim() === '') {
@@ -212,8 +212,8 @@ function DataManager({ entityName, entityKey, placeholder }) {
</div>
)}
<div className="row">
<div className="col-lg-4">
<div className="row g-2 g-lg-3">
<div className="col-12 col-lg-4">
{/* Add New Item Card */}
<div className="card card-md">
<div className="card-header">
@@ -333,11 +333,11 @@ function DataManager({ entityName, entityKey, placeholder }) {
</div>
</div>
<div className="col-lg-8">
<div className="col-12 col-lg-8">
<div className="card">
<div className="card-header d-flex justify-content-between align-items-center">
<div className="card-header d-flex flex-column flex-md-row justify-content-between align-items-stretch align-items-md-center gap-2">
<h3 className="card-title mb-0">Список {entityName === 'домен' ? 'доменов' : entityName}</h3>
<div className="d-flex gap-2 w-50">
<div className="d-flex flex-wrap gap-2 flex-grow-1 flex-md-grow-0">
{/* Фильтр по шлюзу */}
<select className="form-select w-auto" value={filterType} onChange={e => { setFilterType(e.target.value); setCurrentPage(1); }}>
<option value="">Все шлюзы</option>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+623
View File
@@ -0,0 +1,623 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import api from './lib/api.js';
import PageHeader from './components/PageHeader.jsx';
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
import EmptyState from './components/EmptyState.jsx';
import ErrorAlert from './components/ErrorAlert.jsx';
import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx';
import Pagination from './components/Pagination.jsx';
import Tooltip from './components/Tooltip.jsx';
import Modal from './components/Modal.jsx';
import ConfirmDialog from './components/ConfirmDialog.jsx';
import {
IconShield,
IconRefresh,
IconSearch,
IconCopy,
IconAlertCircle,
IconList,
IconChartBar,
} from '@tabler/icons-react';
const PAGE_SIZE = 15;
const DEFAULT_MIN_COUNT_N = 5;
/** Одиночный IP: без маски или /32 */
function isSingleIp(addr) {
const s = String(addr || '').trim();
if (!s) return false;
if (!s.includes('/')) return true;
const mask = s.split('/')[1];
return mask === '32' || mask === '128';
}
/** Базовая подсеть /24 для группировки */
function getBase24(addr) {
const s = String(addr || '').trim();
const ipPart = s.includes('/') ? s.split('/')[0] : s;
const parts = ipPart.split('.');
if (parts.length !== 4) return null;
const [a, b, c] = parts.map((x) => parseInt(x, 10));
if (Number.isNaN(a) || Number.isNaN(b) || Number.isNaN(c)) return null;
return `${a}.${b}.${c}.0/24`;
}
/**
* Анализ списка ban: группировка одиночных IP по /24, предложения суммаризации если в подсети > N.
*/
function analyzeBanList(banEntries, minCountN) {
const groupBy = new Map();
for (const entry of banEntries || []) {
if (!entry?.address || !isSingleIp(entry.address)) continue;
const base = getBase24(entry.address);
if (!base) continue;
if (!groupBy.has(base)) groupBy.set(base, { subnet: base, ids: [], entries: [] });
const g = groupBy.get(base);
g.ids.push(entry.id);
g.entries.push(entry);
}
const suggestions = [];
for (const [, g] of groupBy) {
if (g.entries.length > minCountN) {
suggestions.push({
subnet: g.subnet,
count: g.entries.length,
ids: g.ids,
addEntry: { address: g.subnet, list: 'ban', comment: `суммаризация ${g.entries.length} IP` },
});
}
}
return { suggestions };
}
/** Нормализация записи address-list из RouterOS (поля могут приходить в разном регистре) */
function normalizeEntry(entry) {
if (!entry || typeof entry !== 'object') return null;
const addr = entry.address ?? entry.Address ?? '';
const list = entry.list ?? entry.List ?? '';
const timeout = entry.timeout ?? entry.Timeout ?? '';
const comment = entry.comment ?? entry.Comment ?? '';
const id = entry['.id'] ?? entry.id ?? '';
const disabled = entry.disabled ?? entry.Disabled ?? '';
const dynamic = entry.dynamic ?? entry.Dynamic ?? '';
return {
id: id || addr || Math.random().toString(36).slice(2),
address: String(addr).trim(),
list: String(list).trim(),
timeout: String(timeout).trim(),
comment: String(comment).trim(),
disabled: String(disabled).trim(),
dynamic: String(dynamic).trim(),
};
}
function AddressListTable({ title, listKey, items, loading, emptyMessage }) {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const filtered = useMemo(() => {
const term = String(search || '').toLowerCase().trim();
if (!term) return items;
return items.filter((e) => {
const a = (e.address || '').toLowerCase();
const c = (e.comment || '').toLowerCase();
return a.includes(term) || c.includes(term);
});
}, [items, search]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const paginated = useMemo(
() => filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[filtered, page]
);
useEffect(() => {
setPage(1);
}, [search]);
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text).then(() => {
window.notify?.success?.('Скопировано в буфер обмена');
}).catch(() => {
window.notify?.error?.('Не удалось скопировать');
});
};
return (
<div className="card card-flat mb-4">
<div className="card-header">
<div className="row align-items-center w-100 g-2">
<div className="col">
<div className="d-flex align-items-center gap-2">
<IconList className="icon text-muted" size={18} />
<span className="text-muted text-uppercase small">{title}</span>
<span className="badge bg-blue-lt text-blue">{filtered.length}</span>
</div>
</div>
<div className="col-12 mt-2" />
<div className="col">
<div className="input-icon flex-grow-1" style={{ maxWidth: '320px' }}>
<span className="input-icon-addon">
<IconSearch size={18} />
</span>
<input
type="text"
className="form-control"
placeholder="Поиск по IP или комментарию..."
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label="Поиск"
/>
</div>
</div>
</div>
</div>
<div className="card-body p-0">
{loading ? (
<TableSkeleton rows={8} cols={4} flat />
) : paginated.length === 0 ? (
<TableEmpty cols={4} flat>
<EmptyState
title={items.length === 0 ? emptyMessage : 'Нет совпадений'}
description={items.length === 0 ? `Список ${listKey} пуст на роутере.` : 'Измените поисковый запрос.'}
/>
</TableEmpty>
) : (
<div className="table-responsive">
<table className="table table-flat table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{ width: '40px' }}>#</th>
<th>IP-адрес</th>
<th>Таймаут</th>
<th>Комментарий</th>
<th className="text-end" style={{ width: '50px' }}></th>
</tr>
</thead>
<tbody>
{paginated.map((entry, idx) => (
<tr key={entry.id || entry.address || idx}>
<td className="text-muted small">{(page - 1) * PAGE_SIZE + idx + 1}</td>
<td className="font-monospace">
<div className="d-flex align-items-center">
<span>{entry.address || '—'}</span>
<Tooltip content="Копировать IP" position="top">
<button
type="button"
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
onClick={() => copyToClipboard(entry.address)}
aria-label="Копировать"
>
<IconCopy size={14} />
</button>
</Tooltip>
</div>
</td>
<td className="text-muted">{entry.timeout || '—'}</td>
<td className="text-muted small">{entry.comment || '—'}</td>
<td className="text-end"></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{!loading && filtered.length > PAGE_SIZE && (
<Pagination
currentPage={page}
totalPages={totalPages}
totalItems={filtered.length}
pageSize={PAGE_SIZE}
onPageChange={setPage}
/>
)}
</div>
);
}
export default function FirewallPage() {
const [servers, setServers] = useState([]);
const [serverId, setServerId] = useState('');
const [serverMeta, setServerMeta] = useState(null);
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [loadingServers, setLoadingServers] = useState(true);
const [error, setError] = useState('');
const [analysisOpen, setAnalysisOpen] = useState(false);
const [minCountN, setMinCountN] = useState(DEFAULT_MIN_COUNT_N);
const [analysisResult, setAnalysisResult] = useState(null);
const [applyConfirmOpen, setApplyConfirmOpen] = useState(false);
const [applying, setApplying] = useState(false);
const [ipInfoMap, setIpInfoMap] = useState({});
const [ipInfoLoading, setIpInfoLoading] = useState(false);
const [applyConfirmRow, setApplyConfirmRow] = useState(null);
const [applyingRow, setApplyingRow] = useState(null);
const routerServers = useMemo(
() =>
(servers || []).filter(
(s) => String(s.type || '').toLowerCase() === 'jumphost' || String(s.type || '').toLowerCase() === 'home'
),
[servers]
);
useEffect(() => {
let cancelled = false;
(async () => {
setLoadingServers(true);
try {
const res = await api.get('/servers');
const list = Array.isArray(res.data) ? res.data : [];
if (!cancelled) setServers(list);
} catch (_e) {
if (!cancelled) setError('Не удалось загрузить список серверов.');
} finally {
if (!cancelled) setLoadingServers(false);
}
})();
return () => { cancelled = true; };
}, []);
const fetchAddressLists = useCallback(async () => {
if (!serverId) {
setData(null);
return;
}
setLoading(true);
setError('');
try {
const res = await api.get('/mikrotik/address-lists', { params: { serverId } });
const payload = res?.data ?? {};
setData({
serverLabel: payload.serverLabel || serverId,
ban: (payload.ban || []).map(normalizeEntry).filter(Boolean),
ban_counter: (payload.ban_counter || []).map(normalizeEntry).filter(Boolean),
});
} catch (e) {
console.error('Firewall fetch:', e);
setData(null);
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить address-list с роутера.');
} finally {
setLoading(false);
}
}, [serverId]);
useEffect(() => {
if (serverId) fetchAddressLists();
else setData(null);
}, [serverId, fetchAddressLists]);
const runAnalysis = () => {
if (!data?.ban?.length) return;
const n = Math.max(2, parseInt(minCountN, 10) || DEFAULT_MIN_COUNT_N);
setMinCountN(n);
const result = analyzeBanList(data.ban, n);
setAnalysisResult(result);
setIpInfoMap({});
};
// Подгрузка AS/орг с ipinfo.io для подсетей из анализа (лимит 30, с задержкой между запросами)
useEffect(() => {
const suggestions = analysisResult?.suggestions;
if (!suggestions?.length) return;
const maxFetch = 30;
const slice = suggestions.slice(0, maxFetch);
let cancelled = false;
setIpInfoLoading(true);
(async () => {
for (let i = 0; i < slice.length && !cancelled; i++) {
const s = slice[i];
const firstIp = s.subnet.split('/')[0];
try {
const res = await fetch(`https://ipinfo.io/${firstIp}/json`, { method: 'GET' });
if (!res.ok) continue;
const info = await res.json();
if (!cancelled) {
setIpInfoMap((prev) => ({
...prev,
[s.subnet]: { org: info.org, country: info.country, city: info.city },
}));
}
} catch {
if (!cancelled) setIpInfoMap((prev) => ({ ...prev, [s.subnet]: null }));
}
if (i < slice.length - 1) await new Promise((r) => setTimeout(r, 180));
}
if (!cancelled) setIpInfoLoading(false);
})();
return () => { cancelled = true; };
}, [analysisResult?.suggestions]);
const applyPayload = applyConfirmRow
? { removeIds: applyConfirmRow.ids, addEntries: [applyConfirmRow.addEntry] }
: analysisResult?.suggestions?.length
? {
removeIds: analysisResult.suggestions.flatMap((s) => s.ids),
addEntries: analysisResult.suggestions.map((s) => s.addEntry),
}
: null;
const handleApplySummary = async () => {
if (!serverId || !applyPayload) return;
const isRow = !!applyConfirmRow;
if (isRow) setApplyingRow(applyConfirmRow.subnet);
else setApplying(true);
try {
await api.post('/mikrotik/address-lists/apply-summary', {
serverId,
removeIds: applyPayload.removeIds,
addEntries: applyPayload.addEntries,
});
const removed = applyPayload.removeIds.length;
const added = applyPayload.addEntries.length;
window.notify?.success?.(`Удалено ${removed} записей, добавлено ${added} подсетей.`);
setApplyConfirmOpen(false);
setApplyConfirmRow(null);
if (isRow && analysisResult?.suggestions) {
setAnalysisResult((prev) => ({
...prev,
suggestions: prev.suggestions.filter((s) => s.subnet !== applyConfirmRow.subnet),
}));
} else {
setAnalysisOpen(false);
setAnalysisResult(null);
}
await fetchAddressLists();
} catch (e) {
window.notify?.error?.(e?.response?.data?.message || e?.message || 'Ошибка применения');
} finally {
setApplying(false);
setApplyingRow(null);
}
};
const currentServerLabel = serverMeta?.dns || serverMeta?.ip || serverId || 'Не выбран';
return (
<div>
{error && (
<ErrorAlert message={error} onClose={() => setError('')} />
)}
<PageHeader
title="Firewall"
icon={<IconShield size={24} />}
meta={`Списки блокировки (address-list) с роутера: ${currentServerLabel}`}
actions={
<div className="btn-list">
<button
type="button"
className="btn btn-outline-primary"
onClick={() => {
setAnalysisOpen(true);
setAnalysisResult(null);
}}
disabled={!serverId || !data?.ban?.length || loading}
title="Анализ списка ban и суммаризация по /24"
>
<IconChartBar className="me-1" size={18} />
Анализ
</button>
<button
type="button"
className="btn btn-outline-primary"
onClick={fetchAddressLists}
disabled={!serverId || loading}
>
<IconRefresh className={loading ? 'icon spin me-1' : 'me-1'} size={18} />
Обновить
</button>
</div>
}
/>
<div className="card card-flat mb-4">
<div className="card-header py-3">
<h3 className="card-title mb-0">Сервер (MikroTik)</h3>
<p className="text-muted small mb-0 mt-1">
Выберите jumphost или home-роутер для просмотра списков <strong>ban</strong> и <strong>ban_counter</strong>.
</p>
</div>
<div className="card-body">
{loadingServers ? (
<div className="text-muted">Загрузка списка серверов...</div>
) : (
<div style={{ maxWidth: '420px' }}>
<ServerAutocompleteInput
value={serverId}
onChange={setServerId}
onSelectMeta={setServerMeta}
servers={routerServers}
placeholder="Выберите сервер..."
/>
</div>
)}
</div>
</div>
{!serverId && (
<div className="card card-flat">
<div className="card-body text-center py-5">
<IconAlertCircle className="icon text-muted mb-2" size={48} />
<p className="text-muted mb-0">Выберите сервер выше, чтобы загрузить address-list (ban и ban_counter).</p>
</div>
</div>
)}
{serverId && data && (
<>
<AddressListTable
title="Список ban"
listKey="ban"
items={data.ban}
loading={loading}
emptyMessage="Список ban пуст"
/>
<AddressListTable
title="Список ban_counter"
listKey="ban_counter"
items={data.ban_counter}
loading={loading}
emptyMessage="Список ban_counter пуст"
/>
</>
)}
{serverId && loading && !data && (
<div className="card card-flat">
<div className="card-body py-5 text-center text-muted">
<IconRefresh className="icon spin me-2" size={20} />
Загрузка списков с роутера...
</div>
</div>
)}
<Modal
show={analysisOpen}
onClose={() => {
setAnalysisOpen(false);
setAnalysisResult(null);
setApplyConfirmOpen(false);
setApplyConfirmRow(null);
setIpInfoMap({});
}}
title="Анализ списка ban"
size="xl"
>
<div className="mb-3">
<label className="form-label">Суммаризировать подсети /24, в которых одиночных IP больше чем</label>
<div className="d-flex align-items-center gap-2">
<input
type="number"
className="form-control"
style={{ width: '80px' }}
min={2}
max={100}
value={minCountN}
onChange={(e) => setMinCountN(e.target.value)}
/>
<span className="text-muted small">(по умолчанию {DEFAULT_MIN_COUNT_N})</span>
</div>
</div>
<div className="mb-3">
<button type="button" className="btn btn-primary" onClick={runAnalysis}>
<IconChartBar className="me-1" size={18} />
Анализировать
</button>
</div>
{analysisResult && (
<>
{analysisResult.suggestions.length === 0 ? (
<p className="text-muted mb-0">Нет подсетей для суммаризации (в каждой /24 не больше {minCountN} одиночных IP).</p>
) : (
<>
<p className="mb-2">
Найдено <strong>{analysisResult.suggestions.length}</strong> подсетей /24 для замены: удалить одиночные IP и добавить одну запись на подсеть.
{ipInfoLoading && (
<span className="ms-2 text-muted small">(загрузка AS/хостер с ipinfo.io…)</span>
)}
</p>
<div className="table-responsive">
<table className="table table-sm table-vcenter mb-0">
<thead>
<tr>
<th>Подсеть</th>
<th className="text-end">Кол-во IP</th>
<th>AS / Хостер</th>
<th>Ссылка</th>
<th>Действие</th>
<th className="text-end" style={{ width: '120px' }}>Применить</th>
</tr>
</thead>
<tbody>
{analysisResult.suggestions.map((s) => {
const firstIp = s.subnet.split('/')[0];
const info = ipInfoMap[s.subnet];
const rowApplying = applyingRow === s.subnet;
return (
<tr key={s.subnet}>
<td className="font-monospace">{s.subnet}</td>
<td className="text-end">{s.count}</td>
<td className="small">
{info === undefined
? (ipInfoLoading ? '…' : '—')
: info
? (info.org || info.country ? [info.org, info.country].filter(Boolean).join(' · ') : '—')
: '—'}
</td>
<td>
<a
href={`https://ipinfo.io/${firstIp}`}
target="_blank"
rel="noopener noreferrer"
className="text-primary"
>
ipinfo.io
</a>
</td>
<td className="text-muted small">Удалить {s.count} записей → добавить 1</td>
<td className="text-end">
<button
type="button"
className="btn btn-sm btn-warning"
disabled={rowApplying || applying}
onClick={() => {
setApplyConfirmRow(s);
setApplyConfirmOpen(true);
}}
>
{rowApplying ? (
<span className="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true" />
) : null}
Применить
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="mt-3">
<button
type="button"
className="btn btn-warning"
onClick={() => {
setApplyConfirmRow(null);
setApplyConfirmOpen(true);
}}
>
Применить всё на роутере
</button>
</div>
</>
)}
</>
)}
</Modal>
<ConfirmDialog
open={applyConfirmOpen}
title={applyConfirmRow ? `Применить для ${applyConfirmRow.subnet}?` : 'Применить суммаризацию на роутере?'}
message={
applyConfirmRow
? `Удалить ${applyConfirmRow.count} записей и добавить 1 запись (подсеть ${applyConfirmRow.subnet}). Продолжить?`
: applyPayload
? `Будет удалено ${applyPayload.removeIds.length} записей и добавлено ${applyPayload.addEntries.length} записей (подсети /24). Продолжить?`
: ''
}
confirmText="Применить"
cancelText="Отмена"
destructive
size="md"
loading={applying || !!applyingRow}
onConfirm={handleApplySummary}
onCancel={() => {
setApplyConfirmOpen(false);
setApplyConfirmRow(null);
}}
/>
</div>
);
}
+369 -68
View File
@@ -5,19 +5,27 @@ import {
MiniMap,
useEdgesState,
useNodesState,
addEdge,
MarkerType,
Handle,
Position,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { IconZoomIn, IconZoomOut, IconMaximize, IconMinimize } from '@tabler/icons-react';
import { IconZoomIn, IconZoomOut, IconMaximize, IconMinimize, IconSearch, IconX, IconRefresh } from '@tabler/icons-react';
import Tooltip from './components/Tooltip.jsx';
function GraphView({ servers, connections, onCreateConnection }) {
/**
* @param {Object} [pingMap] - опционально: ключ "fromIp:toIp" -> число (мс). Пинг между серверами для подписи на рёбрах.
*/
function GraphView({ servers, connections, onCreateConnection, pingMap }) {
const flowRef = useRef(null);
const instanceRef = useRef(null);
const containerRef = useRef(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [highlightedNodeId, setHighlightedNodeId] = useState(null);
const [selectedNodeId, setSelectedNodeId] = useState(null);
const [searchTerm, setSearchTerm] = useState('');
const [showSearch, setShowSearch] = useState(false);
const STORAGE_KEY = 'graph-layout-servers-v1';
const getTunnelStyle = useCallback((tunnelType) => {
const map = {
@@ -51,34 +59,141 @@ function GraphView({ servers, connections, onCreateConnection }) {
}, []);
const initialNodesData = useMemo(() => {
return (servers || []).map((s, i) => ({
// Базовая сетка
const baseNodes = (servers || []).map((s, i) => ({
id: String(s.ip),
type: 'server',
position: computeGridPosition(i, servers.length),
data: { server: s },
}));
// Переопределяем позиции из localStorage, если есть
if (typeof window !== 'undefined') {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw) {
const saved = JSON.parse(raw) || {};
return baseNodes.map((n) => {
const savedPos = saved[n.id];
if (savedPos && typeof savedPos.x === 'number' && typeof savedPos.y === 'number') {
return { ...n, position: { x: savedPos.x, y: savedPos.y } };
}
return n;
});
}
} catch {
// игнорируем проблемы с localStorage
}
}
return baseNodes;
}, [servers, computeGridPosition]);
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodesData);
const [nodes, setNodes, onNodesChangeInternal] = useNodesState(initialNodesData);
// Обёртка над onNodesChange: обновляем состояние и сохраняем позиции в localStorage
const onNodesChange = useCallback(
(changes) => {
onNodesChangeInternal(changes);
// Сохраняем только позиции
setNodes((current) => {
if (typeof window !== 'undefined') {
try {
const layout = {};
current.forEach((n) => {
if (n.position) {
layout[n.id] = { x: n.position.x, y: n.position.y };
}
});
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(layout));
} catch {
// ignore
}
}
return current;
});
},
[onNodesChangeInternal, setNodes]
);
useEffect(() => {
setNodes(initialNodesData);
}, [initialNodesData, setNodes]);
// Подсветка связей: вычисляем какие связи связаны с выделенным/выбранным узлом
// ВАЖНО: должно быть ПЕРЕД initialEdgesData, чтобы использоваться там
const highlightedEdges = useMemo(() => {
const nodeId = selectedNodeId || highlightedNodeId;
if (!nodeId) return new Set();
const related = new Set();
(connections || []).forEach((c, idx) => {
if (String(c.from) === nodeId || String(c.to) === nodeId) {
related.add(`${c.from}-${c.to}-${idx}`);
}
});
return related;
}, [selectedNodeId, highlightedNodeId, connections]);
// Счётчик связей для каждого сервера
// ВАЖНО: должно быть ПЕРЕД nodeTypes, чтобы использоваться там
const connectionCounts = useMemo(() => {
const counts = {};
(servers || []).forEach((s) => {
const ip = String(s.ip);
counts[ip] = (connections || []).filter((c) => String(c.from) === ip || String(c.to) === ip).length;
});
return counts;
}, [servers, connections]);
const initialEdgesData = useMemo(() => {
const getPingLabel = (fromIp, toIp) => {
if (!pingMap || typeof pingMap !== 'object') return '';
const fwd = pingMap[`${fromIp}:${toIp}`];
const rev = pingMap[`${toIp}:${fromIp}`];
const parts = [];
if (typeof fwd === 'number') parts.push(`→ ${fwd} ms`);
if (typeof rev === 'number') parts.push(`← ${rev} ms`);
if (parts.length === 0) return '';
return parts.join(' · ');
};
return (connections || []).map((c, idx) => {
const style = getTunnelStyle(c.tunnelType);
const baseLabel = c.tunnelType || 'TUNNEL';
const ipLabel = c.ipA && c.ipB ? `${c.ipA} ⇄ ${c.ipB}` : '';
const pingLabel = getPingLabel(String(c.from), String(c.to));
const labelParts = [baseLabel];
if (ipLabel) labelParts.push(ipLabel);
if (pingLabel) labelParts.push(pingLabel);
const fullLabel = labelParts.join(' · ');
const edgeId = `${c.from}-${c.to}-${idx}`;
const isHighlighted = highlightedEdges.has(edgeId);
return {
id: `${c.from}-${c.to}-${idx}`,
id: edgeId,
source: String(c.from),
target: String(c.to),
label: c.ipA && c.ipB ? `${c.tunnelType} ${c.ipA} ⇄ ${c.ipB}` : c.tunnelType,
style: { stroke: style.color, strokeWidth: style.width, strokeDasharray: style.dash },
markerEnd: { type: MarkerType.ArrowClosed, color: style.color, width: 20, height: 20 },
label: fullLabel,
type: 'smoothstep',
style: {
stroke: style.color,
strokeWidth: isHighlighted ? style.width * 1.5 : style.width,
strokeDasharray: style.dash,
opacity: highlightedNodeId || selectedNodeId ? (isHighlighted ? 1 : 0.25) : 0.9,
filter: isHighlighted ? 'drop-shadow(0 0 4px ' + style.color + ')' : 'none',
},
labelBgPadding: [6, 4],
labelBgBorderRadius: 999,
labelStyle: {
fontSize: 11,
fontWeight: 500,
fill: '#0f172a',
},
className: c.tunnelType ? `edge-tunnel-${String(c.tunnelType).toLowerCase()}` : 'edge-tunnel-default',
markerStart: { type: MarkerType.ArrowClosed, color: style.color, width: 16, height: 16 },
markerEnd: { type: MarkerType.ArrowClosed, color: style.color, width: 16, height: 16 },
animated: false,
};
});
}, [connections, getTunnelStyle]);
}, [connections, getTunnelStyle, highlightedEdges, highlightedNodeId, selectedNodeId, pingMap]);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdgesData);
@@ -86,6 +201,53 @@ function GraphView({ servers, connections, onCreateConnection }) {
setEdges(initialEdgesData);
}, [initialEdgesData, setEdges]);
// Поиск сервера: фильтруем по IP, DNS, провайдеру
const searchResults = useMemo(() => {
if (!searchTerm.trim()) return [];
const term = searchTerm.toLowerCase();
return (servers || []).filter((s) =>
String(s.ip).toLowerCase().includes(term) ||
String(s.dns || '').toLowerCase().includes(term) ||
String(s.provider || '').toLowerCase().includes(term)
);
}, [searchTerm, servers]);
// Фокус на найденный сервер
const focusNode = useCallback((nodeId) => {
if (!instanceRef.current || !nodeId) return;
try {
instanceRef.current.fitView({
nodes: [{ id: nodeId }],
padding: 0.3,
duration: 400
});
setSelectedNodeId(nodeId);
setTimeout(() => setSelectedNodeId(null), 2000);
} catch { /* no-op */ }
}, []);
// Сброс раскладки: очищаем localStorage и пересоздаём ноды
const resetLayout = useCallback(() => {
if (typeof window !== 'undefined') {
try {
window.localStorage.removeItem(STORAGE_KEY);
} catch { /* no-op */ }
}
setNodes((current) => {
return current.map((n, i) => ({
...n,
position: computeGridPosition(i, current.length),
}));
});
setTimeout(() => {
if (instanceRef.current) {
try {
instanceRef.current.fitView({ padding: 0.2 });
} catch { /* no-op */ }
}
}, 100);
}, [setNodes, computeGridPosition]);
const onConnect = useCallback(
(params) => {
// Делегируем создание связи в родителя, чтобы сохранить единую логику хранения
@@ -99,7 +261,7 @@ function GraphView({ servers, connections, onCreateConnection }) {
const nodeTypes = useMemo(
() => ({
server: ({ data }) => {
server: ({ id, data }) => {
const s = data.server || {};
const countryColor = getCountryColor(s.country);
const getFlagEmoji = (code) => {
@@ -111,17 +273,51 @@ function GraphView({ servers, connections, onCreateConnection }) {
return cc.replace(/./g, (ch) => String.fromCodePoint(127397 + ch.charCodeAt()));
};
const flag = getFlagEmoji(s.country);
const isHighlighted = highlightedNodeId === id;
const isSelected = selectedNodeId === id;
const connCount = connectionCounts[id] || 0;
const tooltipContent = (
<div style={{ fontSize: '12px', lineHeight: '1.5' }}>
<div><strong>IP:</strong> {s.ip}</div>
<div><strong>DNS:</strong> {s.dns}</div>
<div><strong>Провайдер:</strong> {s.provider}</div>
<div><strong>Страна:</strong> {s.country}</div>
<div><strong>Туннель:</strong> {s.tunnel}</div>
{s.gateway && <div><strong>Шлюз:</strong> {s.gateway}</div>}
<div style={{ marginTop: '4px', paddingTop: '4px', borderTop: '1px solid rgba(255,255,255,0.2)' }}>
<strong>Связей:</strong> {connCount}
</div>
</div>
);
return (
<div
className="card shadow-sm"
style={{
width: 200,
borderRadius: 12,
border: `2px solid ${countryColor}`,
overflow: 'hidden',
background: '#fff',
}}
>
<Tooltip content={tooltipContent} position="top" delay={300}>
<div
className={`card shadow-sm${isHighlighted || isSelected ? ' border-primary' : ''}`}
style={{
width: 200,
borderRadius: 12,
border: `2px solid ${isSelected ? '#206bc4' : isHighlighted ? '#206bc4' : countryColor}`,
overflow: 'hidden',
background: '#fff',
boxShadow: isSelected
? '0 0 0 2px rgba(32,107,196,0.2), 0 12px 28px rgba(15,23,42,0.3)'
: isHighlighted
? '0 0 0 1px rgba(32,107,196,0.15), 0 10px 24px rgba(15,23,42,0.25)'
: '0 4px 12px rgba(15,23,42,0.12)',
transform: isSelected ? 'translateY(-3px) scale(1.02)' : isHighlighted ? 'translateY(-2px)' : 'translateY(0)',
transition: 'box-shadow 120ms ease-out, transform 120ms ease-out, border-color 120ms ease-out',
cursor: 'pointer',
}}
onMouseEnter={() => setHighlightedNodeId(id)}
onMouseLeave={() => setHighlightedNodeId((prev) => (prev === id ? null : prev))}
onClick={() => {
if (selectedNodeId === id) {
setSelectedNodeId(null);
} else {
setSelectedNodeId(id);
}
}}
>
{/* Точка входа соединений */}
<Handle type="target" position={Position.Left} style={{ background: countryColor }} />
<div className="px-2 py-1 d-flex align-items-center" style={{ background: '#f8fafc', borderBottom: '1px solid #eef2f7' }}>
@@ -141,6 +337,25 @@ function GraphView({ servers, connections, onCreateConnection }) {
</span>
</div>
<div className="ms-2 text-truncate" style={{ fontWeight: 700, color: '#1f2937', fontSize: 13 }}>{s.dns}</div>
{connCount > 0 && (
<span
className="badge ms-auto"
style={{
background: isSelected ? '#206bc4' : '#e0e7ff',
color: isSelected ? '#fff' : '#206bc4',
fontSize: 10,
fontWeight: 600,
minWidth: '20px',
height: '18px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
title={`${connCount} ${connCount === 1 ? 'связь' : connCount < 5 ? 'связи' : 'связей'}`}
>
{connCount}
</span>
)}
</div>
<div className="px-2 py-2" style={{ lineHeight: 1.2 }}>
<div style={{ fontSize: 12, color: '#334155' }}>{s.ip}</div>
@@ -149,10 +364,11 @@ function GraphView({ servers, connections, onCreateConnection }) {
{/* Точка исхода соединений */}
<Handle type="source" position={Position.Right} style={{ background: countryColor }} />
</div>
</Tooltip>
);
},
}),
[getCountryColor]
[getCountryColor, highlightedNodeId, selectedNodeId, connectionCounts]
);
const onInit = useCallback((inst) => {
@@ -161,7 +377,7 @@ function GraphView({ servers, connections, onCreateConnection }) {
requestAnimationFrame(() => {
try {
inst.fitView({ padding: 0.2, includeHiddenNodes: true, minZoom: 0.2, maxZoom: 2 });
} catch {}
} catch { /* no-op */ }
});
}, []);
@@ -172,7 +388,7 @@ function GraphView({ servers, connections, onCreateConnection }) {
const t = setTimeout(() => {
try {
i.fitView({ padding: 0.2, includeHiddenNodes: true, minZoom: 0.2, maxZoom: 2 });
} catch {}
} catch { /* no-op */ }
}, 50);
return () => clearTimeout(t);
}, [servers, connections]);
@@ -186,7 +402,7 @@ function GraphView({ servers, connections, onCreateConnection }) {
setTimeout(() => {
try {
instanceRef.current.fitView({ padding: 0.2 });
} catch {}
} catch { /* no-op */ }
}, 60);
}
};
@@ -228,8 +444,7 @@ function GraphView({ servers, connections, onCreateConnection }) {
nodeTypes={nodeTypes}
fitView
defaultEdgeOptions={{
type: 'default',
markerEnd: { type: MarkerType.ArrowClosed },
type: 'smoothstep',
}}
onInit={onInit}
style={{ width: '100%', height: '100%', background: '#f8fafc' }}
@@ -241,49 +456,135 @@ function GraphView({ servers, connections, onCreateConnection }) {
maskColor="rgba(0,0,0,0.05)"
/>
</ReactFlow>
{/* Своя панель управления: -, +, Fit, Fullscreen */}
{/* Поиск сервера */}
{showSearch && (
<div className="position-absolute" style={{ left: 10, top: 10, zIndex: 10, pointerEvents: 'auto', minWidth: 280 }}>
<div className="card shadow-lg" style={{ borderRadius: 8 }}>
<div className="card-body p-2">
<div className="input-icon">
<span className="input-icon-addon">
<IconSearch size={16} />
</span>
<input
type="text"
className="form-control form-control-sm"
placeholder="Поиск по IP, DNS, провайдеру..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
autoFocus
/>
<button
type="button"
className="btn btn-ghost-secondary btn-icon btn-sm"
style={{ position: 'absolute', right: '4px', top: '50%', transform: 'translateY(-50%)' }}
onClick={() => {
setShowSearch(false);
setSearchTerm('');
}}
aria-label="Закрыть поиск"
>
<IconX size={14} />
</button>
</div>
{searchTerm.trim() && searchResults.length > 0 && (
<div className="mt-2" style={{ maxHeight: '200px', overflowY: 'auto' }}>
{searchResults.map((s) => (
<button
key={s.ip}
type="button"
className="btn btn-ghost-secondary btn-sm w-100 text-start mb-1"
onClick={() => {
focusNode(String(s.ip));
setShowSearch(false);
setSearchTerm('');
}}
style={{ fontSize: '12px' }}
>
<div className="fw-bold">{s.dns}</div>
<div className="text-muted small">{s.ip} · {s.provider}</div>
</button>
))}
</div>
)}
{searchTerm.trim() && searchResults.length === 0 && (
<div className="mt-2 text-muted small text-center">Ничего не найдено</div>
)}
</div>
</div>
</div>
)}
{/* Своя панель управления: Поиск, -, +, Fit, Reset, Fullscreen */}
<div className="position-absolute" style={{ right: 10, top: 10, zIndex: 10, pointerEvents: 'auto' }}>
<div className="btn-group btn-group-sm">
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => instanceRef.current?.zoomOut?.()}
title="Уменьшить"
>
<IconZoomOut size={16} />
</button>
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => instanceRef.current?.zoomIn?.()}
title="Увеличить"
>
<IconZoomIn size={16} />
</button>
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => instanceRef.current?.fitView?.({ padding: 0.2 })}
title="Подогнать к окну"
>
Fit
</button>
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => {
const el = containerRef.current;
if (!el) return;
if (!document.fullscreenElement) {
el.requestFullscreen?.();
} else {
document.exitFullscreen?.();
}
}}
title="Во весь экран"
>
{isFullscreen ? <IconMinimize size={16} /> : <IconMaximize size={16} />}
</button>
<Tooltip content="Поиск сервера" position="left">
<button
type="button"
className="btn btn-outline-secondary btn-icon"
onClick={() => setShowSearch(!showSearch)}
aria-label="Поиск сервера"
>
<IconSearch size={16} />
</button>
</Tooltip>
<Tooltip content="Сбросить раскладку" position="left">
<button
type="button"
className="btn btn-outline-secondary btn-icon"
onClick={resetLayout}
aria-label="Сбросить раскладку"
>
<IconRefresh size={16} />
</button>
</Tooltip>
<Tooltip content="Уменьшить масштаб" position="left">
<button
type="button"
className="btn btn-outline-secondary btn-icon"
onClick={() => instanceRef.current?.zoomOut?.()}
aria-label="Уменьшить масштаб"
>
<IconZoomOut size={16} />
</button>
</Tooltip>
<Tooltip content="Увеличить масштаб" position="left">
<button
type="button"
className="btn btn-outline-secondary btn-icon"
onClick={() => instanceRef.current?.zoomIn?.()}
aria-label="Увеличить масштаб"
>
<IconZoomIn size={16} />
</button>
</Tooltip>
<Tooltip content="Подогнать граф к окну" position="left">
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => instanceRef.current?.fitView?.({ padding: 0.2 })}
aria-label="Подогнать граф к окну"
>
Fit
</button>
</Tooltip>
<Tooltip content={isFullscreen ? 'Выйти из полноэкранного режима' : 'Во весь экран'} position="left">
<button
type="button"
className="btn btn-outline-secondary btn-icon"
onClick={() => {
const el = containerRef.current;
if (!el) return;
if (!document.fullscreenElement) {
el.requestFullscreen?.();
} else {
document.exitFullscreen?.();
}
}}
aria-label={isFullscreen ? 'Выйти из полноэкранного режима' : 'Во весь экран'}
>
{isFullscreen ? <IconMinimize size={16} /> : <IconMaximize size={16} />}
</button>
</Tooltip>
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
+506
View File
@@ -0,0 +1,506 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import api from './lib/api.js';
import { useNotify } from './components/NotifyProvider.jsx';
import PageHeader from './components/PageHeader.jsx';
import TableSkeleton from './components/TableSkeleton.jsx';
import { countryToFlag } from './utils/serverUtils.js';
import {
IconTopologyRing,
IconNetwork,
IconSearch,
IconRefresh,
} from '@tabler/icons-react';
export default function InterfaceSpeedTest() {
const notify = useNotify();
const [servers, setServers] = useState([]);
const [networkConfig, setNetworkConfig] = useState(null);
const [loading, setLoading] = useState(true);
const [serverSearch, setServerSearch] = useState('');
const [serverId, setServerId] = useState('');
const [selectedInterfaceName, setSelectedInterfaceName] = useState('');
const [running, setRunning] = useState(false);
const [speedResult, setSpeedResult] = useState(null);
const [speedSettings, setSpeedSettings] = useState({
protocol: 'tcp',
durationSeconds: 10,
cacheMinutes: 0,
});
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
const load = async () => {
setLoading(true);
try {
const [serversRes, netRes, uiRes] = await Promise.all([
api.get('/servers', { signal }),
api.get('/network-config', { signal }).catch(() => ({ data: null })),
api.get('/ui-settings', { signal }).catch(() => ({ data: {} })),
]);
setServers(Array.isArray(serversRes?.data) ? serversRes.data : []);
setNetworkConfig(
netRes?.data && typeof netRes.data === 'object'
? netRes.data
: { gateways: [], tunnelInterfaces: [] }
);
const ui = uiRes?.data || {};
const proto =
String(ui.interfaceSpeedTestProtocol || 'tcp').toLowerCase() ===
'udp'
? 'udp'
: 'tcp';
const duration = Math.max(
1,
Math.min(
600,
parseInt(ui.interfaceSpeedTestDurationSeconds, 10) || 5
)
);
const cacheMinutes = Math.max(
0,
parseInt(ui.interfaceSpeedTestCacheMinutes, 10) || 0
);
setSpeedSettings({
protocol: proto,
durationSeconds: duration,
cacheMinutes,
});
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
console.error('[InterfaceSpeedTest] load failed', e);
notify.error('Не удалось загрузить данные для замера скорости');
} finally {
setLoading(false);
}
};
load();
return () => controller.abort();
}, [notify]);
const _gateways = useMemo(
() =>
networkConfig?.gateways && Array.isArray(networkConfig.gateways)
? networkConfig.gateways
: [],
[networkConfig]
);
const tunnelInterfaces = useMemo(
() =>
networkConfig?.tunnelInterfaces &&
Array.isArray(networkConfig.tunnelInterfaces)
? networkConfig.tunnelInterfaces
: [],
[networkConfig]
);
const jumphostServers = useMemo(
() =>
(servers || []).filter((s) =>
['jumphost', 'home'].includes(
String(s.type || '').toLowerCase()
)
),
[servers]
);
const makeServerId = (s) => (s.id || s.dns || s.ip || '').toString();
const jumphostInputServers = useMemo(
() => jumphostServers.map((s) => ({ ...s, id: makeServerId(s) })),
[jumphostServers]
);
const visibleJumphostServers = useMemo(() => {
if (!serverSearch.trim()) return jumphostInputServers;
const q = serverSearch.trim().toLowerCase();
return jumphostInputServers.filter((s) => {
const fields = [
s.id,
s.ip,
s.dns,
s.extIp,
s.internalIp,
s.country,
s.provider,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return fields.includes(q);
});
}, [jumphostInputServers, serverSearch]);
const currentServer = useMemo(
() => jumphostServers.find((s) => makeServerId(s) === serverId) || null,
[jumphostServers, serverId]
);
const interfacesForServer = useMemo(() => {
if (!serverId || !tunnelInterfaces.length) return [];
const ids = new Set(
[serverId, currentServer?.id, currentServer?.ip, currentServer?.dns]
.filter(Boolean)
.map(String)
);
return tunnelInterfaces.filter(
(i) =>
i &&
i.name &&
(ids.has(String(i.serverId)) || ids.has(String(i.serverId2)))
);
}, [tunnelInterfaces, serverId, currentServer]);
const currentServerLabel =
currentServer?.dns ||
currentServer?.ip ||
makeServerId(currentServer || {}) ||
'Не выбран';
const isInitialLoading = loading && servers.length === 0;
const formatMbps = (bps) => {
if (bps == null || Number.isNaN(bps)) return '—';
const mbps = bps / 1_000_000;
if (!Number.isFinite(mbps)) return '—';
return `${mbps.toFixed(2)} Мбит/с`;
};
/** Запросить только кеш (без замера). При выборе сервера/интерфейса показываем кеш, если есть. */
const fetchCachedResult = useCallback(async () => {
if (!serverId || !selectedInterfaceName) {
setSpeedResult(null);
return;
}
try {
const res = await api.post('/mikrotik/speed-test', {
serverId,
interfaceName: selectedInterfaceName,
cacheOnly: true,
});
const data = res?.data || {};
if (data.ok !== false && !data.notInCache && (data.tcpDownloadBps != null || data.tcpUploadBps != null)) {
setSpeedResult(data);
} else {
setSpeedResult(null);
}
} catch {
setSpeedResult(null);
}
}, [serverId, selectedInterfaceName]);
useEffect(() => {
fetchCachedResult();
}, [fetchCachedResult]);
const handleRunTest = async () => {
if (!serverId) {
notify.error('Выберите сервер (jumphost/home) для замера скорости');
return;
}
if (!selectedInterfaceName) {
notify.error('Выберите туннельный интерфейс для замера скорости');
return;
}
setRunning(true);
setSpeedResult(null);
try {
const body = {
serverId,
interfaceName: selectedInterfaceName,
durationSeconds: speedSettings.durationSeconds,
forceRefresh: true,
};
const res = await api.post('/mikrotik/speed-test', body, {
timeout: 120000,
});
const data = res?.data || {};
if (data.ok === false) {
notify.error(
data.error || 'Замер скорости по интерфейсу завершился с ошибкой'
);
return;
}
setSpeedResult(data);
} catch (e) {
console.error('[InterfaceSpeedTest] test failed', e);
notify.error(
e?.response?.data?.message || e?.message || 'Ошибка замера скорости'
);
} finally {
setRunning(false);
}
};
const handleReset = () => {
setServerId('');
setSelectedInterfaceName('');
setSpeedResult(null);
};
return (
<div className="page">
<PageHeader
title="Скорость интерфейсов MikroTik"
icon={<IconTopologyRing size={24} />}
meta={`Текущий сервер: ${currentServerLabel}`}
/>
<div className="row g-2">
<div className="col-md-5">
<div className="card mb-2">
<div className="card-header py-2 d-flex align-items-center justify-content-between">
<div>
<h3 className="card-title mb-0">Серверы (jumphost / home)</h3>
<p className="text-muted small mb-0 mt-0">
Выберите роутер с настроенным MikroTik API
</p>
</div>
<div className="input-icon input-icon-sm" style={{ minWidth: 200 }}>
<input
type="text"
className="form-control form-control-sm"
placeholder="Поиск серверов..."
value={serverSearch}
onChange={(e) => setServerSearch(e.target.value)}
/>
<span className="input-icon-addon">
<IconSearch size={14} className="text-muted" />
</span>
</div>
</div>
<div className="card-body py-2">
{isInitialLoading ? (
<TableSkeleton rows={2} columns={2} />
) : jumphostServers.length === 0 ? (
<div className="text-muted small">
Нет серверов типа <code>jumphost</code> или <code>home</code>.
Добавьте их в разделе «Серверы», чтобы использовать замер
скорости.
</div>
) : visibleJumphostServers.length === 0 ? (
<div className="text-muted small">
По вашему запросу серверы не найдены. Попробуйте изменить фильтр
поиска.
</div>
) : (
<div className="row row-cards g-1">
{visibleJumphostServers.map((s) => {
const id = s.id;
const isActive = id === serverId;
const flag = s.country ? countryToFlag(s.country) : '';
const line = [s.dns, s.ip].filter(Boolean).join(' · ');
return (
<div
className="col-12 col-sm-6 col-md-6"
key={id}
>
<div
className={`card card-sm h-100 cursor-pointer ${
isActive ? 'border-primary card-hover' : 'card-hover'
}`}
onClick={() => setServerId(id)}
>
<div className="card-body py-2 px-2 d-flex align-items-center gap-2">
<span
className="avatar avatar-sm bg-primary-lt"
title={s.country || ''}
>
{flag || s.ip?.slice(0, 2) || '?'}
</span>
<div className="flex-grow-1 min-w-0">
<div
className="fw-semibold text-truncate small"
title={s.dns || s.ip || id}
>
{s.dns || s.ip || id}
</div>
<div
className="text-muted small text-truncate"
title={line}
>
{line ||
[s.country, s.provider]
.filter(Boolean)
.join(' · ') ||
'—'}
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
<div className="d-flex flex-wrap align-items-center gap-2 mt-2">
<span className="badge bg-primary-lt text-primary">
Сервер: {currentServerLabel}
</span>
</div>
</div>
</div>
<div className="card">
<div className="card-header py-2">
<h3 className="card-title mb-0">Интерфейсы туннелей</h3>
</div>
<div className="card-body py-2">
{loading && !networkConfig ? (
<div className="text-muted small">Загрузка сетевых настроек…</div>
) : !serverId ? (
<div className="text-muted small">
Выберите сервер слева, затем интерфейс для замера скорости.
</div>
) : interfacesForServer.length === 0 ? (
<div className="text-muted small">
Для выбранного сервера нет туннельных интерфейсов в
<code> /network-config</code>. Добавьте интерфейсы в разделе
«Сетевые настройки».
</div>
) : (
<>
<div className="mb-2">
<select
className="form-select form-select-sm"
value={selectedInterfaceName}
onChange={(e) => setSelectedInterfaceName(e.target.value)}
>
<option value="">Не выбран</option>
{interfacesForServer.map((iface) => (
<option key={iface.name} value={iface.name}>
{iface.name} ({iface.localIp} ⇄ {iface.remoteIp})
</option>
))}
</select>
</div>
<div className="text-muted small">
Для сопоставления используется имя интерфейса из
<code> /network-config.tunnelInterfaces</code>. В RouterOS
имя должно совпадать.
</div>
</>
)}
</div>
</div>
</div>
<div className="col-md-7">
<div className="card mb-2">
<div className="card-header py-2 d-flex align-items-center justify-content-between">
<h3 className="card-title mb-0">Параметры замера</h3>
<div className="btn-list">
<button
type="button"
className="btn btn-outline-secondary btn-sm"
onClick={handleReset}
disabled={running}
>
<IconRefresh size={16} className="me-1" />
Сбросить
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleRunTest}
disabled={running || !serverId || !selectedInterfaceName}
>
{running ? 'Замер…' : 'Запустить замер'}
</button>
</div>
</div>
<div className="card-body py-2">
<div className="row g-2">
<div className="col-md-4">
<label className="form-label small mb-0">Протокол</label>
<div className="d-flex align-items-center gap-1">
<IconNetwork size={16} className="text-muted" />
<span className="small text-uppercase">
{speedSettings.protocol === 'udp' ? 'UDP' : 'TCP'}
</span>
</div>
<div className="text-muted small">
Значение берётся из настроек интерфейса.
</div>
</div>
<div className="col-md-4">
<label className="form-label small mb-0">
Время замера (сек)
</label>
<div className="fw-semibold">
{speedSettings.durationSeconds}
</div>
</div>
<div className="col-md-4">
<label className="form-label small mb-0">
Кеш результата (мин)
</label>
<div className="fw-semibold">
{speedSettings.cacheMinutes}
</div>
</div>
</div>
</div>
</div>
<div className="card">
<div className="card-header py-2">
<h3 className="card-title mb-0">
Скорость по интерфейсу MikroTik
</h3>
</div>
<div className="card-body">
{!speedResult ? (
<div className="text-muted small">
Выберите сервер и интерфейс, затем запустите замер. Средние
скорости приёма и передачи появятся здесь.
</div>
) : (
<div className="row g-3">
<div className="col-12 col-md-6">
<div className="card bg-blue-lt">
<div className="card-body py-2">
<div className="text-muted small mb-1">Интерфейс</div>
<div className="fw-semibold">
{speedResult.interfaceName || selectedInterfaceName}
</div>
<div className="text-muted small mt-1">
Замер за {speedResult.durationSeconds} сек
{speedResult.cached ? ' (из кеша)' : ''}
</div>
</div>
</div>
</div>
<div className="col-12 col-md-6">
<div className="card bg-azure-lt">
<div className="card-body py-2">
<div className="text-muted small mb-1">
TCP скорость
</div>
<div className="fw-bold">
Download: {formatMbps(speedResult.tcpDownloadBps)}
</div>
<div className="fw-bold mt-1">
Upload: {formatMbps(speedResult.tcpUploadBps)}
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}
+999
View File
@@ -0,0 +1,999 @@
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import * as Diff from 'diff';
import api from './lib/api.js';
import { useNotify } from './components/NotifyProvider.jsx';
import PageHeader from './components/PageHeader.jsx';
import TableSkeleton from './components/TableSkeleton.jsx';
import EmptyState from './components/EmptyState.jsx';
import Tooltip from './components/Tooltip.jsx';
import DataTable from './components/DataTable.jsx';
import { LargeModal, FullscreenModal } from './components/Modal.jsx';
import { countryToFlag } from './utils/serverUtils.js';
import {
IconRefresh,
IconAlertTriangle,
IconEye,
IconArrowsLeftRight,
IconDownload,
IconCopy,
IconSearch,
} from '@tabler/icons-react';
/**
* Строит две колонки строк для side-by-side diff (как git diff).
* leftLines/rightLines — массивы { text, type: 'unchanged'|'removed'|'added'|'empty' }.
*/
function getDiffLines(oldStr, newStr) {
const left = [];
const right = [];
const changes = Diff.diffLines(oldStr || '', newStr || '');
for (const part of changes) {
const lines = (part.value || '').split('\n');
if (part.value && part.value.endsWith('\n') && lines.length > 0) lines.pop();
for (const line of lines) {
if (part.added) {
left.push({ text: '', type: 'empty' });
right.push({ text: line, type: 'added' });
} else if (part.removed) {
left.push({ text: line, type: 'removed' });
right.push({ text: '', type: 'empty' });
} else {
left.push({ text: line, type: 'unchanged' });
right.push({ text: line, type: 'unchanged' });
}
}
}
return { left, right };
}
const diffPanelStyle = {
maxHeight: 360,
overflow: 'auto',
fontSize: 12,
fontFamily: 'ui-monospace, monospace',
margin: 0,
padding: '0.5rem 0.75rem',
borderRadius: 4,
backgroundColor: 'var(--tblr-code-bg, #0f172a)', // как тёмный code-блок Tabler
border: '1px solid rgba(15, 23, 42, 0.9)',
};
const diffLineStyle = {
display: 'block',
minHeight: '1.25em',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
padding: '0 4px',
margin: 0,
color: 'var(--tblr-code-color, #e5e7eb)',
};
const diffRemovedBg = {
background: 'rgba(248, 81, 73, 0.2)',
borderLeft: '3px solid rgba(248, 81, 73, 0.9)',
};
const diffAddedBg = {
background: 'rgba(63, 185, 80, 0.2)',
borderLeft: '3px solid rgba(63, 185, 80, 0.9)',
};
function DiffPanel({ lines, side, innerRef, onScroll }) {
return (
<pre style={diffPanelStyle} ref={innerRef} onScroll={onScroll}>
{lines.length === 0 ? (
<span style={diffLineStyle}># пусто</span>
) : (
lines.map((item, i) => (
<span
key={i}
style={{
...diffLineStyle,
...(item.type === 'removed' && side === 'left' ? diffRemovedBg : null),
...(item.type === 'added' && side === 'right' ? diffAddedBg : null),
}}
>
{item.type === 'empty' ? '\u00A0' : item.text || ''}
</span>
))
)}
</pre>
);
}
function formatDateTime(value) {
if (!value) return '';
try {
const d = new Date(value);
return d.toLocaleString();
} catch {
return String(value);
}
}
function bytesToHuman(size) {
if (size == null) return '';
const n = Number(size);
if (!Number.isFinite(n) || n < 0) return '';
if (n < 1024) return `${n} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let v = n;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i += 1;
}
return `${v.toFixed(1)} ${units[i]}`;
}
function MikrotikBackupsManager() {
const notify = useNotify();
const [servers, setServers] = useState([]);
const [selectedServerId, setSelectedServerId] = useState('');
const [uiSettings, setUiSettings] = useState({});
const [uiSettingsEtag, setUiSettingsEtag] = useState('');
const [backupServerIds, setBackupServerIds] = useState(new Set());
const [backups, setBackups] = useState([]);
const [loadingServers, setLoadingServers] = useState(false);
const [loadingBackups, setLoadingBackups] = useState(false);
const [selectedKeys, setSelectedKeys] = useState([]);
const [viewConfigKey, setViewConfigKey] = useState(null);
const [viewConfigText, setViewConfigText] = useState('');
const [viewLoading, setViewLoading] = useState(false);
const [viewModalOpen, setViewModalOpen] = useState(false);
const [diffResult, setDiffResult] = useState(null);
const [diffLoading, setDiffLoading] = useState(false);
const [diffModalOpen, setDiffModalOpen] = useState(false);
const leftDiffRef = useRef(null);
const rightDiffRef = useRef(null);
const isSyncingScrollRef = useRef(false);
// UI-состояние выбора сервера для просмотра бэкапов (в стиле /filters)
const [serverSearch, setServerSearch] = useState('');
// UI-состояние для таблицы выбора серверов автобэкапов
const [backupSearch, setBackupSearch] = useState('');
const [backupSortField, setBackupSortField] = useState('ip');
const [backupSortOrder, setBackupSortOrder] = useState('asc');
const [backupPage, setBackupPage] = useState(1);
const backupPageSize = 10;
const makeServerId = (s) => (s.id || s.dns || s.ip || '').toString();
const fetchServers = useCallback(async (abortSignal) => {
const opts = abortSignal ? { signal: abortSignal } : {};
try {
setLoadingServers(true);
const res = await api.get('/servers', opts);
const list = Array.isArray(res.data) ? res.data : [];
setServers(list);
const firstJumphost = list.find((s) => String(s.type || '').toLowerCase() === 'jumphost');
if (firstJumphost && !selectedServerId) {
setSelectedServerId(makeServerId(firstJumphost));
}
} catch (err) {
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
console.error('Error fetching servers for backups:', err);
notify.error('Не удалось загрузить список серверов для бэкапов');
} finally {
setLoadingServers(false);
}
}, [selectedServerId, notify]);
const fetchUiSettings = useCallback(async (abortSignal) => {
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const res = await api.get('/ui-settings', opts);
const data = res?.data || {};
setUiSettings(data);
const list = Array.isArray(data.mikrotikBackupServers) ? data.mikrotikBackupServers : [];
setBackupServerIds(new Set(list.map((v) => String(v))));
const e = res?.headers?.etag || res?.headers?.ETag || '';
setUiSettingsEtag(e ? String(e) : '');
} catch (err) {
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
console.error('Error fetching UI settings for backups:', err);
}
}, []);
const fetchBackups = useCallback(async (serverId, abortSignal) => {
if (!serverId) return;
const opts = abortSignal ? { signal: abortSignal } : {};
try {
setLoadingBackups(true);
setSelectedKeys([]);
setDiffResult(null);
const res = await api.get('/mikrotik/backups', { params: { serverId }, ...opts });
const items = res.data?.items || [];
setBackups(items);
} catch (err) {
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
console.error('Error fetching backups:', err);
notify.error('Не удалось загрузить список бэкапов MikroTik');
} finally {
setLoadingBackups(false);
}
}, [notify]);
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
fetchServers(signal);
fetchUiSettings(signal);
return () => controller.abort();
}, [fetchServers, fetchUiSettings]);
useEffect(() => {
if (selectedServerId) {
const controller = new AbortController();
fetchBackups(selectedServerId, controller.signal);
return () => controller.abort();
} else {
setBackups([]);
setSelectedKeys([]);
}
}, [selectedServerId, fetchBackups]);
const jumphostServers = useMemo(
() => (servers || []).filter((s) => String(s.type || '').toLowerCase() === 'jumphost'),
[servers],
);
const jumphostInputServers = useMemo(
() => jumphostServers.map((s) => ({ ...s, id: makeServerId(s) })),
[jumphostServers],
);
// Список серверов для выбора "текущего сервера" бэкапов с поиском (как в /filters)
const visibleJumphostServers = useMemo(() => {
if (!serverSearch.trim()) return jumphostInputServers;
const q = serverSearch.trim().toLowerCase();
return jumphostInputServers.filter((s) => {
const fields = [
s.id,
s.ip,
s.dns,
s.extIp,
s.internalIp,
s.country,
s.provider,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return fields.includes(q);
});
}, [jumphostInputServers, serverSearch]);
const currentServer = useMemo(
() => jumphostServers.find((s) => makeServerId(s) === selectedServerId) || null,
[jumphostServers, selectedServerId],
);
const backupServerIdsList = useMemo(() => Array.from(backupServerIds), [backupServerIds]);
// Отфильтрованный и отсортированный список Jumphost-серверов для блока автобэкапов
const filteredBackupServers = useMemo(() => {
let list = [...jumphostInputServers];
if (backupSearch.trim()) {
const q = backupSearch.trim().toLowerCase();
list = list.filter((s) => {
const fields = [
s.id,
s.ip,
s.dns,
s.extIp,
s.internalIp,
s.country,
s.provider,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return fields.includes(q);
});
}
list.sort((a, b) => {
const field = backupSortField || 'ip';
let av = a[field] ?? '';
let bv = b[field] ?? '';
if (typeof av === 'string') av = av.toLowerCase();
if (typeof bv === 'string') bv = bv.toLowerCase();
if (av < bv) return backupSortOrder === 'asc' ? -1 : 1;
if (av > bv) return backupSortOrder === 'asc' ? 1 : -1;
return 0;
});
return list;
}, [jumphostInputServers, backupSearch, backupSortField, backupSortOrder]);
const backupTotalPages = useMemo(
() => Math.max(1, Math.ceil(filteredBackupServers.length / backupPageSize)),
[filteredBackupServers.length],
);
const paginatedBackupServers = useMemo(() => {
const page = Math.min(Math.max(1, backupPage), backupTotalPages);
const start = (page - 1) * backupPageSize;
return filteredBackupServers.slice(start, start + backupPageSize);
}, [filteredBackupServers, backupPage, backupTotalPages]);
const diffComputed = useMemo(() => {
if (diffResult?.configA == null || diffResult?.configB == null) {
return {
left: [],
right: [],
stats: { removed: 0, added: 0, total: 0 },
};
}
const { left, right } = getDiffLines(diffResult.configA, diffResult.configB);
const removed = left.filter((l) => l.type === 'removed').length;
const added = right.filter((l) => l.type === 'added').length;
return {
left,
right,
stats: { removed, added, total: removed + added },
};
}, [diffResult?.configA, diffResult?.configB]);
const syncScroll = (source) => {
const leftEl = leftDiffRef.current;
const rightEl = rightDiffRef.current;
if (!leftEl || !rightEl) return;
const current = source === 'left' ? leftEl : rightEl;
const other = source === 'left' ? rightEl : leftEl;
if (isSyncingScrollRef.current) return;
isSyncingScrollRef.current = true;
const maxCurrent = current.scrollHeight - current.clientHeight;
const ratio = maxCurrent > 0 ? current.scrollTop / maxCurrent : 0;
const maxOther = other.scrollHeight - other.clientHeight;
other.scrollTop = ratio * (maxOther > 0 ? maxOther : 0);
isSyncingScrollRef.current = false;
};
const handleBackupSort = (field) => {
setBackupSortField((prevField) => {
if (prevField === field) {
setBackupSortOrder((prevOrder) => (prevOrder === 'asc' ? 'desc' : 'asc'));
return prevField;
}
setBackupSortOrder('asc');
return field;
});
};
const handleBackupSelectAll = () => {
const allIds = filteredBackupServers.map((s) => String(s.id));
setBackupServerIds(new Set(allIds));
};
const handleBackupDeselectAll = () => {
setBackupServerIds(new Set());
};
const handleSelectKey = (key) => {
setSelectedKeys((prev) => {
if (prev.includes(key)) {
return prev.filter((k) => k !== key);
}
if (prev.length >= 2) {
return [prev[1], key];
}
return [...prev, key];
});
setDiffResult(null);
};
const handleViewConfig = async (key) => {
if (!key) return;
try {
setViewLoading(true);
setViewConfigKey(key);
setViewConfigText('');
setViewModalOpen(true);
const res = await api.get('/mikrotik/backups/item', { params: { key } });
setViewConfigText(res.data?.config || '');
} catch (err) {
console.error('Error reading backup:', err);
notify.error('Не удалось загрузить содержимое бэкапа');
} finally {
setViewLoading(false);
}
};
const handleDiff = async () => {
if (selectedKeys.length !== 2) {
notify.warning('Выберите два бэкапа для сравнения');
return;
}
try {
setDiffModalOpen(true);
setDiffLoading(true);
setDiffResult(null);
const res = await api.post('/mikrotik/backups/diff', {
keyA: selectedKeys[0],
keyB: selectedKeys[1],
});
setDiffResult(res.data || null);
} catch (err) {
console.error('Error diffing backups:', err);
notify.error('Не удалось сравнить бэкапы');
} finally {
setDiffLoading(false);
}
};
const handleDownload = async (key) => {
try {
const res = await api.get('/mikrotik/backups/item', { params: { key } });
const text = res.data?.config || '';
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const shortKey = key.split('/').slice(-1)[0] || 'backup';
a.download = shortKey.endsWith('.rsc') ? shortKey : `${shortKey}.rsc`;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
console.error('Error downloading backup:', err);
notify.error('Не удалось скачать бэкап');
}
};
const handleCopyToClipboard = async (text) => {
try {
if (!text) return;
if (navigator.clipboard) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
notify.success('Текст бэкапа скопирован в буфер обмена');
} catch (err) {
console.error('Error copying backup:', err);
notify.error('Не удалось скопировать текст бэкапа');
}
};
const toggleBackupServer = (serverId) => {
setBackupServerIds((prev) => {
const next = new Set(prev);
if (next.has(serverId)) next.delete(serverId);
else next.add(serverId);
return next;
});
};
const saveBackupServers = async () => {
try {
const settingsPayload = {
...uiSettings,
mikrotikBackupServers: backupServerIdsList,
};
const payload = {
settings: settingsPayload,
etag: uiSettingsEtag,
};
const res = await api.post('/ui-settings', payload);
const meta = res?.data || {};
setUiSettings(settingsPayload);
setUiSettingsEtag(String(meta?.etag || ''));
notify.success('Список серверов для автобэкапа сохранён');
} catch (err) {
console.error('Error saving backup servers to ui-settings:', err);
notify.error(err?.response?.data?.message || 'Не удалось сохранить список серверов для автобэкапа');
}
};
const actions = (
<div className="d-flex flex-wrap gap-2 align-items-center">
<button
type="button"
className="btn btn-outline-primary btn-sm d-inline-flex align-items-center"
onClick={() => selectedServerId && fetchBackups(selectedServerId)}
disabled={!selectedServerId || loadingBackups}
>
<IconRefresh size={16} className="me-1" />
Обновить список
</button>
<button
type="button"
className="btn btn-primary btn-sm d-inline-flex align-items-center"
onClick={async () => {
if (!selectedServerId) return;
try {
const serverId = selectedServerId;
notify.info('Запуск ручного бэкапа MikroTik...');
const res = await api.post('/mikrotik/backups/run', { serverId });
if (res?.data?.ok) {
notify.success('Ручной бэкап успешно создан');
await fetchBackups(serverId);
} else {
notify.error(res?.data?.message || 'Не удалось выполнить ручной бэкап');
}
} catch (err) {
console.error('Error running manual backup:', err);
const msg =
err?.response?.data?.message ||
err?.response?.data ||
'Ошибка при выполнении ручного бэкапа';
notify.error(msg);
}
}}
disabled={!selectedServerId || loadingBackups}
>
Создать бэкап сейчас
</button>
<Tooltip content="Выберите два бэкапа в таблице" position="top">
<button
type="button"
className="btn btn-outline-secondary btn-sm d-inline-flex align-items-center"
onClick={handleDiff}
disabled={selectedKeys.length !== 2 || diffLoading}
>
<IconArrowsLeftRight size={16} className="me-1" />
Сравнить выбранные
</button>
</Tooltip>
<div className="text-muted small ms-2">
Автобэкапы включаются для выбранных ниже Jumphost-серверов. Интервал и включение/выключение
планировщика настраиваются через переменные окружения
<code className="ms-1">MIKROTIK_BACKUP_ENABLED</code> и
<code className="ms-1">MIKROTIK_BACKUP_INTERVAL_MINUTES</code>.
</div>
</div>
);
const currentServerLabel =
currentServer?.dns || currentServer?.ip || makeServerId(currentServer || {}) || 'Не выбран';
return (
<div className="page">
<PageHeader
title="MikroTik Backups"
icon={<IconDownload size={24} />}
meta={`Текущий сервер: ${currentServerLabel}`}
actions={actions}
/>
{/* Выбор сервера для просмотра бэкапов (как в /filters) */}
<div className="card mb-3">
<div className="card-header">
<div className="row align-items-center g-2">
<div className="col">
<h3 className="card-title mb-1">Сервер для просмотра бэкапов</h3>
<div className="text-muted small">
Доступно Jumphost-серверов: {jumphostServers.length}.{' '}
{selectedServerId
? 'Кликните по другой карточке, чтобы сменить сервер.'
: 'Выберите сервер, чтобы увидеть его бэкапы.'}
</div>
</div>
<div className="col-auto">
<div className="input-icon" style={{ minWidth: 240 }}>
<input
type="text"
className="form-control form-control-sm"
placeholder="Поиск серверов по IP, DNS, провайдеру..."
value={serverSearch}
onChange={(e) => setServerSearch(e.target.value)}
/>
<span className="input-icon-addon">
<IconSearch size={16} />
</span>
</div>
</div>
</div>
</div>
<div className="card-body">
{jumphostServers.length === 0 ? (
<div className="text-muted small">
Нет серверов типа <code>jumphost</code>. Добавьте их в разделе «Серверы», чтобы смотреть
бэкапы.
</div>
) : visibleJumphostServers.length === 0 ? (
<div className="text-muted small">
По вашему запросу серверы не найдены. Попробуйте изменить фильтр поиска.
</div>
) : (
<div className="row row-cards g-2">
{visibleJumphostServers.map((s) => {
const id = s.id;
const isActive = id === selectedServerId;
const flag = s.country ? countryToFlag(s.country) : '';
const line = [s.dns, s.ip].filter(Boolean).join(' · ');
return (
<div className="col-12 col-sm-6 col-md-4 col-lg-3" key={id}>
<div
className={`card card-sm h-100 cursor-pointer ${
isActive ? 'border-primary card-hover' : 'card-hover'
}`}
onClick={() => setSelectedServerId(id)}
>
<div className="card-body d-flex align-items-start gap-2">
<span className="avatar bg-primary-lt" title={s.country || ''}>
{flag || s.ip?.slice(0, 2) || '?'}
</span>
<div className="flex-grow-1 min-w-0">
<div className="d-flex align-items-center gap-2">
<div className="fw-bold text-truncate" title={s.dns || s.ip || id}>
{s.dns || s.ip || id}
</div>
</div>
{line && (
<div className="text-muted small text-truncate" title={line}>
{line}
</div>
)}
<div className="text-muted small mt-1">
{[s.country, s.provider].filter(Boolean).join(' · ') || '—'}
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
<div className="text-muted small mt-2">
Текущий сервер:&nbsp;
<span className="fw-semibold">{currentServerLabel}</span>
</div>
</div>
</div>
{/* Таблица бэкапов для выбранного сервера */}
{loadingServers ? (
<TableSkeleton rows={5} columns={4} />
) : !selectedServerId ? (
<EmptyState
title="Нет доступных Jumphost-серверов"
description='Создайте хотя бы один сервер типа "jumphost" в разделе Серверы, чтобы включить бэкапы.'
/>
) : loadingBackups ? (
<TableSkeleton rows={5} columns={4} />
) : backups.length === 0 ? (
<EmptyState
title="Для этого сервера бэкапов ещё нет"
description="Автоматические бэкапы создаются планировщиком на backend. Можно также отправить бэкап вручную через API."
/>
) : (
<div className="card">
<div className="card-header d-flex align-items-center justify-content-between">
<div>
<div className="card-title mb-0">Бэкапы для {currentServerLabel}</div>
<div className="text-muted small">
Всего: {backups.length}. Выбрано для diff: {selectedKeys.length}/2.
</div>
</div>
<div className="text-muted small d-flex align-items-center">
<IconAlertTriangle size={16} className="me-1 text-warning" />
Восстановление выполняется вручную через Winbox/SSH, используя экспортированный .rsc
</div>
</div>
<div className="table-responsive">
<table className="table table-vcenter table-striped">
<thead>
<tr>
<th style={{ width: 40 }}></th>
<th>Создан</th>
<th>Размер</th>
<th>Ключ S3</th>
<th style={{ width: 220 }}>Действия</th>
</tr>
</thead>
<tbody>
{backups.map((b) => {
const active = selectedKeys.includes(b.key);
const shortKey = b.key.split('/').slice(-1)[0] || b.key;
return (
<tr key={b.key} className={active ? 'table-active' : ''}>
<td>
<input
type="checkbox"
className="form-check-input"
checked={active}
onChange={() => handleSelectKey(b.key)}
/>
</td>
<td>{formatDateTime(b.createdAt)}</td>
<td>{bytesToHuman(b.size)}</td>
<td className="text-truncate" style={{ maxWidth: 260 }} title={b.key}>
{shortKey}
</td>
<td>
<div className="btn-list">
<button
type="button"
className="btn btn-outline-secondary btn-sm"
onClick={() => handleViewConfig(b.key)}
>
<IconEye size={16} className="me-1" />
Просмотр
</button>
<button
type="button"
className="btn btn-outline-secondary btn-sm"
onClick={() => handleDownload(b.key)}
>
<IconDownload size={16} className="me-1" />
Скачать
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* Блок выбора серверов для автобэкапа – ниже как "настройки" */}
<div className="card mb-4 mt-4">
<div className="card-header">
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<div className="card-title mb-0">Настройки автоматических бэкапов</div>
<div className="text-muted small">
Отмеченные здесь Jumphost-серверы будут периодически бэкапиться планировщиком на backend.
</div>
<div className="text-muted small mt-1">
Выбрано для автобэкапов:{' '}
<span className="fw-semibold">
{backupServerIds.size} из {jumphostServers.length}
</span>
</div>
</div>
<div className="d-flex flex-wrap align-items-center gap-2">
<div className="input-icon">
<span className="input-icon-addon">
<svg
xmlns="http://www.w3.org/2000/svg"
className="icon"
width="16"
height="16"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10 4a6 6 0 1 0 4 10" />
<path d="M21 21l-6 -6" />
</svg>
</span>
<input
type="text"
className="form-control form-control-sm"
placeholder="Поиск по IP, DNS, провайдеру..."
value={backupSearch}
onChange={(e) => {
setBackupSearch(e.target.value);
setBackupPage(1);
}}
/>
</div>
<button
type="button"
className="btn btn-outline-primary btn-sm d-inline-flex align-items-center"
onClick={saveBackupServers}
disabled={jumphostServers.length === 0}
>
Сохранить список
</button>
</div>
</div>
</div>
<div className="card-body">
{jumphostServers.length === 0 ? (
<div className="text-muted small">
Нет серверов типа <code>jumphost</code>. Добавьте их в разделе «Серверы».
</div>
) : (
<DataTable
columns={[
{
key: 'ip',
title: 'Сервер',
sortable: true,
render: (_value, item) => (
<div>
<div className="fw-medium">{item.dns || item.ip || item.id}</div>
<div className="small text-muted">
{item.ip}
{item.dns && item.ip ? ' · ' : ''}
{item.dns}
</div>
</div>
),
},
{
key: 'country',
title: 'Страна',
sortable: true,
render: (value) => <span className="small">{value || '-'}</span>,
},
{
key: 'provider',
title: 'Провайдер',
sortable: true,
render: (value) => <span className="small">{value || '-'}</span>,
},
]}
items={paginatedBackupServers}
itemKey="id"
loading={loadingServers}
sortField={backupSortField}
sortOrder={backupSortOrder}
onSort={handleBackupSort}
selectedItems={backupServerIds}
onSelectItem={(id) => id && toggleBackupServer(String(id))}
onSelectAll={handleBackupSelectAll}
onDeselectAll={handleBackupDeselectAll}
pagination={{
currentPage: backupPage,
totalPages: backupTotalPages,
totalItems: filteredBackupServers.length,
pageSize: backupPageSize,
onPageChange: setBackupPage,
}}
emptyState={{
title: 'Серверы не найдены',
description:
backupSearch.trim().length > 0
? 'По вашему запросу нет Jumphost-серверов. Измените фильтры поиска.'
: 'Нет доступных Jumphost-серверов для автобэкапа.',
}}
skeletonRows={5}
/>
)}
</div>
</div>
<LargeModal
show={viewModalOpen}
onClose={() => {
setViewModalOpen(false);
}}
title="Содержимое бэкапа MikroTik"
footer={(
<div className="d-flex justify-content-between w-100">
<div className="text-muted small text-truncate" title={viewConfigKey || ''}>
{viewConfigKey || 'Бэкап не выбран'}
</div>
<div className="btn-list mb-0">
<button
type="button"
className="btn btn-outline-secondary btn-sm"
onClick={() => handleCopyToClipboard(viewConfigText)}
disabled={!viewConfigText}
>
<IconCopy size={16} className="me-1" />
Копировать
</button>
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => setViewModalOpen(false)}
>
Закрыть
</button>
</div>
</div>
)}
>
{viewLoading ? (
<TableSkeleton rows={6} columns={1} />
) : (
<pre
className="bg-dark text-white p-3 rounded"
style={{ maxHeight: 500, overflow: 'auto', fontSize: 12 }}
>
<code>{viewConfigText || '# Нет данных для отображения'}</code>
</pre>
)}
</LargeModal>
<FullscreenModal
show={diffModalOpen}
onClose={() => {
setDiffModalOpen(false);
}}
title="Сравнение бэкапов MikroTik"
footer={(
<div className="d-flex justify-content-between w-100">
<div className="text-muted small">
{diffResult?.same
? 'Конфигурации идентичны'
: 'Конфигурации отличаются — проверьте различия перед откатом.'}
</div>
<button
type="button"
className="btn btn-secondary"
onClick={() => setDiffModalOpen(false)}
>
Закрыть
</button>
</div>
)}
>
{diffLoading ? (
<TableSkeleton rows={8} columns={2} />
) : !diffResult ? (
<div className="text-muted small">
Не удалось загрузить результат сравнения. Попробуйте ещё раз.
</div>
) : (
<>
<div className="alert alert-info mb-3 small d-flex justify-content-between align-items-center">
<span>
{!diffResult.same ? (
<>
<IconAlertTriangle size={16} className="me-1 text-warning" />
Конфигурации отличаются — слева A, справа B. Проверьте различия перед откатом.
</>
) : (
'Конфигурации идентичны — изменений нет.'
)}
</span>
<span>
Всего изменений:{' '}
<span className="fw-semibold">{diffComputed.stats.total}</span>
<span className="text-success ms-2">+{diffComputed.stats.added}</span>
<span className="text-danger ms-2">−{diffComputed.stats.removed}</span>
</span>
</div>
<div className="row">
<div className="col-md-6 mb-3 mb-md-0">
<div className="mb-2 small text-muted text-truncate" title={diffResult.a?.key}>
A: {diffResult.a?.key}
</div>
<DiffPanel
lines={diffComputed.left}
side="left"
innerRef={leftDiffRef}
onScroll={() => syncScroll('left')}
/>
</div>
<div className="col-md-6">
<div className="mb-2 small text-muted text-truncate" title={diffResult.b?.key}>
B: {diffResult.b?.key}
</div>
<DiffPanel
lines={diffComputed.right}
side="right"
innerRef={rightDiffRef}
onScroll={() => syncScroll('right')}
/>
</div>
</div>
{!diffResult.same && (
<div className="mt-3 text-muted small">
<span style={{ background: 'rgba(248, 81, 73, 0.35)', padding: '0 4px', borderRadius: 2 }}> удалено (A)</span>
{' · '}
<span style={{ background: 'rgba(63, 185, 80, 0.35)', padding: '0 4px', borderRadius: 2 }}> добавлено (B)</span>
</div>
)}
</>
)}
</FullscreenModal>
</div>
);
}
export default MikrotikBackupsManager;
+930
View File
@@ -0,0 +1,930 @@
import { useEffect, useMemo, useState } from 'react';
import api from './lib/api.js';
import { useNotify } from './components/NotifyProvider.jsx';
import PageHeader from './components/PageHeader.jsx';
import TableSkeleton from './components/TableSkeleton.jsx';
import EmptyState from './components/EmptyState.jsx';
import { countryToFlag } from './utils/serverUtils.js';
import {
IconRoute,
IconWorld,
IconAlertTriangle,
IconRefresh,
IconSearch,
IconBrandGoogle,
IconBrandYandex,
IconBrandCloudflare,
} from '@tabler/icons-react';
/** Популярные цели для ping/traceroute — карточки с логотипами */
const POPULAR_TARGETS = [
{ id: 'yandex', name: 'Yandex', target: 'yandex.ru', Icon: IconBrandYandex, color: 'red' },
{ id: 'google', name: 'Google', target: '8.8.8.8', Icon: IconBrandGoogle, color: 'blue' },
{ id: 'cloudflare', name: 'Cloudflare DNS', target: '1.1.1.1', Icon: IconBrandCloudflare, color: 'orange' },
];
/**
* Раздел "Инструменты → MikroTik": проверка маршрута (traceroute)
* с выбором сервера, gateway и домена/IP.
*/
function MikrotikTools() {
const notify = useNotify();
const [servers, setServers] = useState([]);
const [networkConfig, setNetworkConfig] = useState(null);
const [loading, setLoading] = useState(true);
const [serverId, setServerId] = useState('');
const [gatewayRef, setGatewayRef] = useState('');
const [gatewayMeta, setGatewayMeta] = useState(null);
const [target, setTarget] = useState('');
const [maxHops, setMaxHops] = useState(30);
const [mode, setMode] = useState('traceroute'); // 'traceroute' | 'ping'
const [running, setRunning] = useState(false);
const [hops, setHops] = useState([]);
const [analysis, setAnalysis] = useState([]);
const [tracerouteTab, setTracerouteTab] = useState('table'); // 'table' | 'analysis'
const [pingResult, setPingResult] = useState(null);
const [useDns, setUseDns] = useState(true);
const [serverSearch, setServerSearch] = useState('');
// Загрузка серверов и сетевого конфига
useEffect(() => {
const load = async () => {
setLoading(true);
try {
const [serversRes, netRes] = await Promise.all([
api.get('/servers'),
api.get('/network-config'),
]);
setServers(Array.isArray(serversRes.data) ? serversRes.data : []);
setNetworkConfig(netRes.data && typeof netRes.data === 'object' ? netRes.data : { gateways: [], tunnelInterfaces: [] });
} catch (error) {
console.error('[MikrotikTools] failed to load initial data', error);
notify.error('Не удалось загрузить данные для инструментов MikroTik');
} finally {
setLoading(false);
}
};
load();
}, [notify]);
const gateways = useMemo(
() => (networkConfig?.gateways && Array.isArray(networkConfig.gateways) ? networkConfig.gateways : []),
[networkConfig]
);
const interfaces = useMemo(
() => (networkConfig?.tunnelInterfaces && Array.isArray(networkConfig.tunnelInterfaces) ? networkConfig.tunnelInterfaces : []),
[networkConfig]
);
const _handleSelectGatewayMeta = (meta) => {
setGatewayMeta(meta);
// Если цель не задана — подставляем IP gateway как target
if (!target && meta && meta.ip) {
setTarget(meta.ip);
}
};
const handleRunCheck = async () => {
setRunning(true);
setHops([]);
setAnalysis([]);
setPingResult(null);
try {
if (mode === 'traceroute') {
if (!serverId) {
notify.error('Выберите сервер (jumphost)');
return;
}
const trimmedTarget = String(target || '').trim();
if (!trimmedTarget) {
notify.error('Укажите домен или IP назначения');
return;
}
const body = {
serverId,
target: trimmedTarget,
useDns,
};
if (gatewayMeta?.ip) {
body.gatewayIp = gatewayMeta.ip;
}
const hopsNum = Number(maxHops);
if (Number.isFinite(hopsNum) && hopsNum > 0) {
body.maxHops = hopsNum;
}
const res = await api.post('/mikrotik/traceroute', body);
const data = res?.data || {};
if (!data.ok) {
notify.error(data.error || 'Трассировка завершилась с ошибкой');
return;
}
const hopsList = Array.isArray(data.hops) ? data.hops : [];
setHops(hopsList);
setAnalysis(buildTracerouteAnalysis(hopsList, networkConfig, servers, body.target));
} else if (mode === 'ping') {
if (!serverId) {
notify.error('Выберите сервер (jumphost)');
return;
}
const trimmedTarget = String(target || '').trim();
if (!trimmedTarget) {
notify.error('Укажите домен или IP назначения');
return;
}
const body = {
serverId,
target: trimmedTarget,
count: 4,
};
if (gatewayMeta?.ip) {
body.gatewayIp = gatewayMeta.ip;
}
const res = await api.post('/mikrotik/ping', body);
const data = res?.data || {};
if (data.ok === false) {
notify.error(data.error || 'Ping завершился с ошибкой');
return;
}
setPingResult({
target: trimmedTarget,
...data,
});
}
} catch (error) {
console.error('[MikrotikTools] check failed', error);
notify.error(error?.message || 'Ошибка при выполнении проверки');
} finally {
setRunning(false);
}
};
const handleReset = () => {
setGatewayRef('');
setGatewayMeta(null);
setTarget('');
setHops([]);
setAnalysis([]);
setPingResult(null);
};
const jumphostServers = useMemo(
() => (servers || []).filter((s) => String(s.type || '').toLowerCase() === 'jumphost'),
[servers]
);
const makeServerId = (s) => (s.id || s.dns || s.ip || '').toString();
const jumphostInputServers = useMemo(
() => jumphostServers.map((s) => ({ ...s, id: makeServerId(s) })),
[jumphostServers]
);
const visibleJumphostServers = useMemo(() => {
if (!serverSearch.trim()) return jumphostInputServers;
const q = serverSearch.trim().toLowerCase();
return jumphostInputServers.filter((s) => {
const fields = [
s.id,
s.ip,
s.dns,
s.extIp,
s.internalIp,
s.country,
s.provider,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return fields.includes(q);
});
}, [jumphostInputServers, serverSearch]);
const currentServer = useMemo(
() => jumphostServers.find((s) => makeServerId(s) === serverId) || null,
[jumphostServers, serverId]
);
const _interfacesForServer = useMemo(() => {
if (!serverId || !interfaces.length) return [];
const ids = new Set(
[serverId, currentServer?.id, currentServer?.ip, currentServer?.dns]
.filter(Boolean)
.map(String)
);
return interfaces.filter(
(i) =>
i &&
i.name &&
(ids.has(String(i.serverId)) || ids.has(String(i.serverId2)))
);
}, [interfaces, serverId, currentServer]);
const gatewaysForServer = useMemo(() => {
if (!serverId || !gateways.length) return [];
const ids = currentServer
? new Set(
[serverId, currentServer.id, currentServer.ip, currentServer.dns].filter(Boolean).map(String)
)
: new Set([String(serverId)]);
return gateways.filter((g) => g && g.ip && ids.has(String(g.serverId)));
}, [serverId, currentServer, gateways]);
const currentServerLabel =
currentServer?.dns || currentServer?.ip || makeServerId(currentServer || {}) || 'Не выбран';
const isInitialLoading = loading && servers.length === 0;
return (
<div className="page">
<PageHeader
title="MikroTik Tools"
icon={<IconRoute size={24} />}
meta={`Текущий сервер: ${currentServerLabel}`}
/>
{/* Сервер, шлюз, быстрые цели — один компактный блок */}
<div className="card mb-2">
<div className="card-header py-2">
<div className="row align-items-center g-2">
<div className="col">
<h3 className="card-title mb-0">Сервер и шлюз</h3>
<p className="text-muted small mb-0 mt-0">
{jumphostServers.length} серверов · {serverId ? 'шлюз опционален' : 'выберите сервер'}
</p>
</div>
<div className="col-auto">
<div className="input-icon input-icon-sm">
<input
type="text"
className="form-control form-control-sm"
placeholder="Поиск серверов..."
value={serverSearch}
onChange={(e) => setServerSearch(e.target.value)}
/>
<span className="input-icon-addon">
<IconSearch size={14} className="text-muted" />
</span>
</div>
</div>
</div>
</div>
<div className="card-body py-2">
{isInitialLoading ? (
<TableSkeleton rows={2} columns={2} />
) : jumphostServers.length === 0 ? (
<div className="text-muted small">
Нет серверов типа <code>jumphost</code>. Добавьте их в разделе «Серверы», чтобы использовать инструменты MikroTik.
</div>
) : visibleJumphostServers.length === 0 ? (
<div className="text-muted small">
По вашему запросу серверы не найдены. Попробуйте изменить фильтр поиска.
</div>
) : (
<div className="row row-cards g-1">
{visibleJumphostServers.map((s) => {
const id = s.id;
const isActive = id === serverId;
const flag = s.country ? countryToFlag(s.country) : '';
const line = [s.dns, s.ip].filter(Boolean).join(' · ');
return (
<div className="col-12 col-sm-6 col-md-4 col-lg-3" key={id}>
<div
className={`card card-sm h-100 cursor-pointer ${
isActive ? 'border-primary card-hover' : 'card-hover'
}`}
onClick={() => setServerId(id)}
>
<div className="card-body py-2 px-2 d-flex align-items-center gap-2">
<span className="avatar avatar-sm bg-primary-lt" title={s.country || ''}>
{flag || s.ip?.slice(0, 2) || '?'}
</span>
<div className="flex-grow-1 min-w-0">
<div className="fw-semibold text-truncate small" title={s.dns || s.ip || id}>
{s.dns || s.ip || id}
</div>
<div className="text-muted small text-truncate" title={line}>
{line || [s.country, s.provider].filter(Boolean).join(' · ') || '—'}
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
<div className="d-flex flex-wrap align-items-center gap-2 mt-2">
<span className="badge bg-primary-lt text-primary">Сервер: {currentServerLabel}</span>
{serverId && (
<span className="badge bg-azure-lt text-azure">
Шлюз: {gatewayRef || gatewayMeta?.ip || '—'}
</span>
)}
</div>
{/* Шлюз — только при выбранном сервере */}
{serverId && (
<>
<hr className="my-2" />
<div className="d-flex align-items-center gap-2 mb-1">
<span className="fw-semibold small">Шлюз</span>
{gatewaysForServer.length === 0 && (
<span className="text-muted small">нет настроенных</span>
)}
</div>
{loading && !networkConfig ? (
<div className="text-muted small">Загрузка сетевых настроек…</div>
) : (
<>
{gatewaysForServer.length > 0 && (
<div className="row row-cards g-1 mb-2">
{gatewaysForServer.map((g) => {
const isSelected = gatewayRef === g.ip || gatewayRef === g.id;
const flag = g.country ? countryToFlag(g.country) : '';
return (
<div className="col-12 col-sm-6 col-md-4 col-lg-3" key={g.id || g.ip}>
<div
className={`card card-sm h-100 cursor-pointer ${
isSelected ? 'border-primary card-hover' : 'card-hover'
}`}
onClick={() => {
const next = isSelected ? '' : (g.ip || g.id);
setGatewayRef(next);
if (!next) setGatewayMeta(null);
else {
setGatewayMeta({ ip: g.ip, gateway: g });
if (!target && g.ip) setTarget(g.ip);
}
}}
>
<div className="card-body py-2 px-2 d-flex align-items-center gap-2">
<span className="avatar avatar-sm bg-primary-lt" title={g.country || ''}>
{flag || (g.ip ? g.ip.slice(0, 2) : '?')}
</span>
<div className="flex-grow-1 min-w-0">
<div className="fw-semibold text-truncate small" title={g.ip || ''}>
{g.ip || '—'}
</div>
<div className="text-muted small text-truncate">
{g.description || [g.country, g.provider].filter(Boolean).join(' · ') || '—'}
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</>
)}
{/* Цели — карточки в стиле сервера/gateway */}
<div className="row row-cards g-1 mt-2">
{POPULAR_TARGETS.map(({ id: tid, name, target: tgt, Icon: _Icon, color }) => {
const isSelected = target === tgt;
const bgClass = color === 'red' ? 'bg-red-lt' : color === 'blue' ? 'bg-blue-lt' : 'bg-orange-lt';
const textClass = color === 'red' ? 'text-red' : color === 'blue' ? 'text-blue' : 'text-orange';
return (
<div className="col-12 col-sm-6 col-md-4" key={tid}>
<div
className={`card card-sm h-100 cursor-pointer ${isSelected ? 'border-primary card-hover' : 'card-hover'}`}
onClick={() => setTarget(tgt)}
title={tgt}
>
<div className="card-body py-2 px-2 d-flex align-items-center gap-2">
<span className={`avatar avatar-sm ${bgClass} ${textClass}`}>
<_Icon size={20} stroke={1.5} />
</span>
<div className="flex-grow-1 min-w-0">
<div className="fw-semibold text-truncate small">{name}</div>
<div className="text-muted small text-truncate">{tgt}</div>
</div>
</div>
</div>
</div>
);
})}
</div>
{/* Режим и действия — под выбором цели, стандартный размер Tabler */}
<div className="d-flex flex-wrap align-items-center gap-2 mt-3 pt-2 border-top">
<ul className="nav nav-pills mb-0" role="tablist">
<li className="nav-item">
<button
type="button"
className={`nav-link ${
mode === 'traceroute' ? 'active' : ''
}`}
onClick={() => {
setMode('traceroute');
setHops([]);
setAnalysis([]);
setPingResult(null);
}}
role="tab"
>
Traceroute
</button>
</li>
<li className="nav-item">
<button
type="button"
className={`nav-link ${
mode === 'ping' ? 'active' : ''
}`}
onClick={() => {
setMode('ping');
setHops([]);
setAnalysis([]);
setPingResult(null);
}}
role="tab"
>
Ping
</button>
</li>
</ul>
<div className="btn-list">
<button type="button" className="btn btn-outline-secondary" onClick={handleReset} disabled={running}>
<IconRefresh size={16} className="me-1" />
Сбросить
</button>
<button type="button" className="btn btn-primary" onClick={handleRunCheck} disabled={running || loading}>
{running ? 'Выполнение…' : (mode === 'traceroute' ? 'Запустить трассировку' : 'Запустить ping')}
</button>
</div>
</div>
</>
)}
</div>
</div>
{/* Параметры + результаты */}
<div className="row g-2">
<div className="col-md-4">
<div className="card mb-2">
<div className="card-header py-2">
<h3 className="card-title mb-0">Параметры</h3>
</div>
<div className="card-body py-2">
{loading && !networkConfig ? (
<div className="text-muted small">Загрузка…</div>
) : !serverId ? (
<div className="text-muted small">Выберите сервер выше.</div>
) : (
<div className="row g-2">
<div className="col-12">
<label className="form-label small mb-0">Домен или IP</label>
<div className="input-group input-group-flat input-group-sm">
<span className="input-group-text"><IconWorld size={14} className="text-muted" /></span>
<input
type="text"
className="form-control"
placeholder="example.com или 8.8.8.8"
value={target}
onChange={(e) => setTarget(e.target.value)}
/>
{gatewayMeta?.ip && (
<button type="button" className="btn btn-outline-secondary btn-sm" onClick={() => setTarget(gatewayMeta.ip)}>
IP gateway
</button>
)}
</div>
</div>
{mode === 'traceroute' && (
<div className="col-6 col-md-5">
<label className="form-label small mb-0">Макс. хопов</label>
<input type="number" className="form-control form-control-sm" min={1} max={64} value={maxHops} onChange={(e) => setMaxHops(e.target.value)} />
</div>
)}
<div className="col-12">
<div className="form-check form-switch form-check-inline">
<input
className="form-check-input"
type="checkbox"
id="useDnsToggle"
checked={useDns}
onChange={(e) => setUseDns(e.target.checked)}
/>
<label
className="form-check-label small"
htmlFor="useDnsToggle"
>
{useDns ? 'DNS+IP' : 'Только IP'}
</label>
</div>
</div>
</div>
)}
</div>
</div>
</div>
<div className="col-md-8">
{mode === 'ping' && (
<div className="card mb-2">
<div className="card-header py-2">
<h3 className="card-title mb-0">Результаты ping</h3>
</div>
<div className="card-body p-0">
{!pingResult ? (
<div className="p-3 text-muted small">
Результаты ping появятся здесь после запуска проверки.
</div>
) : (
<div className="table-responsive">
<table className="table table-vcenter card-table mb-0">
<thead>
<tr>
<th>Цель</th>
<th style={{ width: 130 }}>Среднее, мс</th>
<th style={{ width: 140 }}>Мин / Макс, мс</th>
<th style={{ width: 110 }}>Потери</th>
<th style={{ width: 200 }}>Оценка</th>
</tr>
</thead>
<tbody>
{(() => {
const avg = typeof pingResult.avgMs === 'number' ? pingResult.avgMs : null;
const loss = typeof pingResult.loss === 'number' ? pingResult.loss : null;
let severity = 'ok';
if (loss != null && loss >= 50) severity = 'danger';
else if ((loss != null && loss > 0) || (avg != null && avg > 200)) severity = 'warn';
const rowClass =
severity === 'danger'
? 'table-danger'
: severity === 'warn'
? 'table-warning'
: '';
return (
<tr className={rowClass}>
<td>
<div className="d-flex flex-column">
<span className="fw-semibold">
{pingResult.target || target || '—'}
</span>
{gatewayMeta?.ip && (
<span className="text-muted small">
через gateway {gatewayMeta.ip}
</span>
)}
</div>
</td>
<td>
{avg != null ? (
<span
className={
avg > 200
? 'text-danger fw-semibold'
: avg > 120
? 'text-warning fw-semibold'
: ''
}
>
{avg.toFixed(1)}
</span>
) : (
'—'
)}
</td>
<td>
{pingResult.minMs != null ? pingResult.minMs.toFixed(1) : '—'} /{' '}
{pingResult.maxMs != null ? pingResult.maxMs.toFixed(1) : '—'}
</td>
<td>
{loss != null ? (
<span
className={
loss >= 50
? 'badge bg-red-lt text-red'
: loss > 0
? 'badge bg-yellow-lt text-yellow'
: 'badge bg-green-lt text-green'
}
>
{loss}%
</span>
) : (
'—'
)}
</td>
<td>
{severity === 'danger' && (
<span className="badge bg-red-lt text-red me-1">Плохое качество</span>
)}
{severity === 'warn' && (
<span className="badge bg-yellow-lt text-yellow me-1">
Возможные проблемы
</span>
)}
<span className="text-muted small">
{loss === 0
? 'Все пакеты доставлены'
: loss != null
? `Потеряно пакетов: ${loss}%`
: 'Нет данных о потере пакетов'}
</span>
</td>
</tr>
);
})()}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
{mode === 'traceroute' && (
<div className="card mb-2">
<div className="card-header py-2">
<ul className="nav nav-tabs card-header-tabs" role="tablist">
<li className="nav-item" role="presentation">
<button
type="button"
className={`nav-link ${tracerouteTab === 'table' ? 'active' : ''}`}
onClick={() => setTracerouteTab('table')}
role="tab"
>
Результаты
</button>
</li>
<li className="nav-item" role="presentation">
<button
type="button"
className={`nav-link ${tracerouteTab === 'analysis' ? 'active' : ''}`}
onClick={() => setTracerouteTab('analysis')}
role="tab"
>
Расшифровка
</button>
</li>
</ul>
</div>
{tracerouteTab === 'table' ? (
<div className="card-body p-0">
{hops.length === 0 ? (
<div className="p-3 text-muted small">
Результаты трассировки появятся здесь после запуска проверки.
</div>
) : (
<div className="table-responsive">
<table className="table table-vcenter card-table table-hover mb-0">
<thead>
<tr>
<th style={{ width: 70 }}>Хоп</th>
<th>Адрес / узел</th>
<th style={{ width: 130 }}>Среднее, мс</th>
<th style={{ width: 140 }}>Мин / Макс, мс</th>
<th style={{ width: 110 }}>Потери</th>
<th style={{ width: 200 }}>Статус</th>
</tr>
</thead>
<tbody>
{hops.map((h) => {
const ip = h.ip || '';
const dns = h.dns || '';
let mainLabel;
if (useDns) {
if (dns && ip) mainLabel = `${dns} (${ip})`;
else mainLabel = dns || ip || h.host || '—';
} else {
mainLabel = ip || dns || h.host || '—';
}
const avg = typeof h.avgMs === 'number' ? h.avgMs : null;
const loss = typeof h.loss === 'number' ? h.loss : null;
const statusRaw = (h.status || '').toString();
const statusLower = statusRaw.toLowerCase();
const hasTimeout =
statusLower.includes('timeout') ||
statusLower.includes('no reply') ||
statusLower.includes('unreachable');
let severity = 'ok';
if (hasTimeout || (loss != null && loss >= 50)) {
severity = 'danger';
} else if ((loss != null && loss > 0) || (avg != null && avg > 200)) {
severity = 'warn';
}
const rowClass =
severity === 'danger'
? 'table-danger'
: severity === 'warn'
? 'table-warning'
: '';
return (
<tr key={h.hop || `${ip || h.host}-${Math.random()}`} className={rowClass}>
<td>
<span className="badge bg-blue-lt text-blue">
{h.hop}
</span>
</td>
<td>
<div className="d-flex flex-column">
<span className="fw-semibold text-truncate" title={mainLabel}>
{mainLabel}
</span>
{ip && dns && (
<span className="text-muted small text-truncate" title={ip}>
IP: {ip}
</span>
)}
</div>
</td>
<td>
{avg != null ? (
<span
className={
avg > 200
? 'text-danger fw-semibold'
: avg > 120
? 'text-warning fw-semibold'
: ''
}
>
{avg.toFixed(1)}
</span>
) : (
'—'
)}
</td>
<td>
{h.bestMs != null ? h.bestMs.toFixed(1) : '—'} /{' '}
{h.worstMs != null ? h.worstMs.toFixed(1) : '—'}
</td>
<td>
{loss != null ? (
<span
className={
loss >= 50
? 'badge bg-red-lt text-red'
: loss > 0
? 'badge bg-yellow-lt text-yellow'
: 'badge bg-green-lt text-green'
}
>
{loss}%
</span>
) : (
'—'
)}
</td>
<td>
{severity === 'danger' && (
<span className="badge bg-red-lt text-red me-1">Проблемный хоп</span>
)}
{severity === 'warn' && (
<span className="badge bg-yellow-lt text-yellow me-1">Возможная проблема</span>
)}
<span className="text-muted small">
{statusRaw || (loss != null || avg != null ? 'OK' : '—')}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
) : (
<div className="card-body">
{analysis.length === 0 ? (
<div className="text-muted small">
После выполнения трассировки здесь появится человекочитаемое описание пути и возможных проблем.
</div>
) : (
<ul className="list-unstyled mb-0">
{analysis.map((line, idx) => (
<li key={idx} className="mb-1">
{line}
</li>
))}
</ul>
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
function buildTracerouteAnalysis(hops, networkConfig, servers, target) {
if (!Array.isArray(hops) || hops.length === 0) return [];
const lines = [];
const gateways = (networkConfig?.gateways && Array.isArray(networkConfig.gateways)) ? networkConfig.gateways : [];
const ifaces = (networkConfig?.tunnelInterfaces && Array.isArray(networkConfig.tunnelInterfaces)) ? networkConfig.tunnelInterfaces : [];
const findServerLabel = (serverId) => {
if (!serverId) return null;
const s = (servers || []).find((srv) => srv.id === serverId || srv.ip === serverId || srv.dns === serverId);
if (!s) return serverId;
return s.dns || s.ip || serverId;
};
const targetStr = String(target || '').trim();
lines.push(`Цель трассировки: ${targetStr || 'не указана'}. Хопов в маршруте: ${hops.length}.`);
let firstTimeoutHop = null;
let worstLatencyHop = null;
hops.forEach((hop, index) => {
const ip = hop.ip || hop.host || hop.address || '';
const dns = hop.dns || null;
const hostDisplay = dns ? `${dns}${ip ? ` (${ip})` : ''}` : (ip || hop.host || '');
const avg = hop.avgMs;
const loss = hop.loss;
let descr = `Хоп ${hop.hop || index + 1}: ${hostDisplay || 'неизвестный узел'}`;
const details = [];
if (avg != null) details.push(`среднее время ≈ ${avg.toFixed(1)} мс`);
if (hop.bestMs != null && hop.worstMs != null) {
details.push(`мин/макс ${hop.bestMs.toFixed(1)}/${hop.worstMs.toFixed(1)} мс`);
}
if (loss != null) details.push(`потеря пакетов ${loss}%`);
// Привязка к gateway (по IP)
const gw = gateways.find((g) => g.ip && g.ip === ip);
if (gw) {
const srvLabel = findServerLabel(gw.serverId);
details.push(`gateway "${gw.description || gw.ip}" на сервере ${srvLabel || gw.serverId}`);
}
// Привязка к туннельному интерфейсу (по IP)
const iface = ifaces.find((i) => i.localIp === ip || i.remoteIp === ip);
if (iface) {
const s1 = findServerLabel(iface.serverId);
const s2 = findServerLabel(iface.serverId2);
details.push(
`туннель ${iface.type || ''} ${iface.name || ''} (${iface.localIp} ⇄ ${iface.remoteIp})` +
(s1 || s2 ? ` между ${s1 || 'сервером 1'} и ${s2 || 'сервером 2'}` : '')
);
}
// Статус/таймауты
if (hop.status) {
const st = String(hop.status).toLowerCase();
if (st.includes('timeout') || st.includes('no reply') || st.includes('unreachable')) {
details.push('узел не отвечает на ICMP (возможная фильтрация или недоступность)');
if (!firstTimeoutHop) firstTimeoutHop = hop;
}
}
if (details.length > 0) {
descr += ` — ${details.join('; ')}`;
}
lines.push(descr);
if (avg != null) {
if (!worstLatencyHop || avg > (worstLatencyHop.avgMs || 0)) {
worstLatencyHop = hop;
}
}
});
if (worstLatencyHop && worstLatencyHop.avgMs != null && worstLatencyHop.avgMs > 150) {
lines.push(
`Максимальная средняя задержка на хопе ${worstLatencyHop.hop}: около ${worstLatencyHop.avgMs.toFixed(
1
)} мс — возможен "узкое место" или удалённый сегмент сети.`
);
}
if (firstTimeoutHop) {
lines.push(
`Начиная с хопа ${firstTimeoutHop.hop} (${firstTimeoutHop.dns ? `${firstTimeoutHop.dns}${firstTimeoutHop.ip ? ` (${firstTimeoutHop.ip})` : ''}` : (firstTimeoutHop.ip || firstTimeoutHop.host || 'неизвестный узел')}) наблюдаются таймауты — цель может быть недоступна или где-то по пути фильтруется ICMP.`
);
} else {
lines.push('Таймаутов или явной потери пакетов по пути не обнаружено (по данным MikroTik).');
}
return lines;
}
export default MikrotikTools;
File diff suppressed because it is too large Load Diff
+476
View File
@@ -0,0 +1,476 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import api from './lib/api.js';
import { IconRefresh, IconTopologyRing, IconClock } from '@tabler/icons-react';
import PageHeader from './components/PageHeader.jsx';
import NetworkMapUnifi from './NetworkMapUnifi.jsx';
/** Параллельно выполняем промисы с лимитом одновременных */
async function runWithLimit(tasks, limit = 4) {
const results = [];
let index = 0;
async function runNext() {
const i = index++;
if (i >= tasks.length) return;
const task = tasks[i];
try {
const value = await task();
results[i] = { value };
} catch (err) {
results[i] = { error: err };
}
await runNext();
}
const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => runNext());
await Promise.all(workers);
return results;
}
/**
* Выполняет задачи так, что на каждый ключ (ноду) в один момент времени
* выполняется не более одной задачи. Разные ноды — параллельно.
* getKey(i) возвращает ключ для задачи с индексом i.
*/
async function runWithOnePerKey(tasks, getKey) {
const n = tasks.length;
const keyToIndices = new Map();
for (let i = 0; i < n; i++) {
const k = String(getKey(i));
if (!keyToIndices.has(k)) keyToIndices.set(k, []);
keyToIndices.get(k).push(i);
}
const results = [];
for (let i = 0; i < n; i++) results[i] = null;
const keys = Array.from(keyToIndices.keys());
let round = 0;
while (true) {
const batch = [];
for (const k of keys) {
const indices = keyToIndices.get(k);
if (round < indices.length) batch.push(indices[round]);
}
if (batch.length === 0) break;
await Promise.all(
batch.map(async (i) => {
try {
const value = await tasks[i]();
results[i] = { value };
} catch (err) {
results[i] = { error: err };
}
})
);
round++;
}
return results;
}
export default function NetworkMapDashboard() {
const [servers, setServers] = useState([]);
const [connections, setConnections] = useState([]);
const [pingMap, setPingMap] = useState({});
const [speedMap, setSpeedMap] = useState({});
const [pingStaleMap, setPingStaleMap] = useState({});
const [speedStaleMap, setSpeedStaleMap] = useState({});
const pingTimestampsRef = useRef({});
const lastSuccessfulPingRef = useRef({});
const lastSuccessfulSpeedRef = useRef({});
const [pingCacheSeconds, setPingCacheSeconds] = useState(0);
const [alertSettings, setAlertSettings] = useState({});
const [loading, setLoading] = useState(true);
const [pingLoading, setPingLoading] = useState(false);
const [speedLoading, setSpeedLoading] = useState(false);
const [error, setError] = useState(null);
const pingAbortRef = useRef(false);
const speedAbortRef = useRef(false);
/** Время применения кеша от планировщика (чтобы не запускать пинг/скорость повторно при открытии) */
const cacheAppliedAtRef = useRef(null);
/** Связи строятся из интерфейсов раздела «Сетевые настройки» (/network-config), tunnelInterfaces */
const fetchData = useCallback(async (abortSignal) => {
setLoading(true);
setError(null);
cacheAppliedAtRef.current = null;
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const [serversRes, configRes, uiSettingsRes, cacheRes] = await Promise.all([
api.get('/servers', opts),
api.get('/network-config', opts).catch(() => ({ data: null })),
api.get('/ui-settings', opts).catch(() => ({ data: {} })),
api.get('/network-map-cache', opts).catch(() => ({ data: {} })),
]);
const serversList = Array.isArray(serversRes?.data) ? serversRes.data : [];
const config = configRes?.data || {};
const tunnelInterfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : [];
const uiSettings = uiSettingsRes?.data || {};
const uiAlertSettings =
uiSettings.alertSettings && typeof uiSettings.alertSettings === 'object'
? uiSettings.alertSettings
: {};
const cache = cacheRes?.data || {};
const ttlSeconds = Math.max(
0,
parseInt(uiSettings.networkMapPingCacheSeconds, 10) || 0
);
setPingCacheSeconds(ttlSeconds);
setAlertSettings(uiAlertSettings);
const getServer = (serverId) =>
serversList.find((s) => s.id === serverId || s.ip === serverId || s.dns === serverId);
const connList = [];
tunnelInterfaces.forEach((iface) => {
if (!iface.serverId || !iface.serverId2) return;
const s1 = getServer(iface.serverId);
const s2 = getServer(iface.serverId2);
if (!s1 || !s2 || s1.ip === s2.ip) return;
const s1Key = s1.id || s1.dns || s1.ip;
const s2Key = s2.id || s2.dns || s2.ip;
if (!s1Key || !s2Key) return;
const isJumphost1 = s1 && ['jumphost', 'home'].includes(String(s1.type || '').toLowerCase());
const isJumphost2 = s2 && ['jumphost', 'home'].includes(String(s2.type || '').toLowerCase());
connList.push({
from: s1.ip,
to: s2.ip,
tunnelType: iface.type || 'GRE',
interfaceName: iface.name || null,
fromKey: s1Key,
toKey: s2Key,
internalFromTo: iface.remoteIp || null,
internalToFrom: iface.localIp || null,
speedTestServerId: isJumphost1 ? s1Key : isJumphost2 ? s2Key : null,
});
});
setServers(serversList);
setConnections(connList);
const cacheMaxAgeMs = Math.max((ttlSeconds || 60) * 1000, 60 * 1000);
const cachedPingMap = cache.pingMap && typeof cache.pingMap === 'object' ? cache.pingMap : {};
const cachedSpeedMap = cache.speedMap && typeof cache.speedMap === 'object' ? cache.speedMap : {};
const hasCacheData = Object.keys(cachedPingMap).length > 0 || Object.keys(cachedSpeedMap).length > 0;
const cacheValid =
cache.updatedAt &&
Date.now() - cache.updatedAt < cacheMaxAgeMs &&
hasCacheData;
if (hasCacheData) {
// Всегда показываем кеш, если он есть — чтобы на карте отображались хотя бы прошлые пинг/скорость
setPingMap(cachedPingMap);
setSpeedMap(cachedSpeedMap);
Object.keys(cachedPingMap).forEach((k) => {
if (typeof cachedPingMap[k] === 'number') {
lastSuccessfulPingRef.current[k] = cachedPingMap[k];
}
pingTimestampsRef.current[k] = cache.updatedAt;
});
Object.keys(cachedSpeedMap).forEach((k) => {
if (cachedSpeedMap[k] && typeof cachedSpeedMap[k] === 'object') {
lastSuccessfulSpeedRef.current[k] = cachedSpeedMap[k];
}
});
cacheAppliedAtRef.current = cache.updatedAt;
// Устаревший кеш (старше TTL) помечаем как stale, но данные всё равно отображаем
if (cacheValid) {
setPingStaleMap({});
setSpeedStaleMap({});
} else {
const stalePing = {};
const staleSpeed = {};
Object.keys(cachedPingMap).forEach((k) => { stalePing[k] = true; });
Object.keys(cachedSpeedMap).forEach((k) => { staleSpeed[k] = true; });
setPingStaleMap(stalePing);
setSpeedStaleMap(staleSpeed);
}
} else {
setSpeedMap({});
setPingStaleMap({});
setSpeedStaleMap({});
}
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
console.error('NetworkMap fetch:', e);
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить данные');
setServers([]);
setConnections([]);
} finally {
setLoading(false);
}
}, []);
/** Один ключ пинга на ребро (маршруты статичны — одна сторона) */
const edgePingKey = (a, b) => [String(a), String(b)].sort().join(':');
/**
* Запросить пинг для всех соединений или для конкретного подмножества.
* Если передать force=true, игнорирует TTL кеша по пингу.
*/
const requestPings = useCallback(async (options = {}) => {
const { connections: targetConnections, force = false } = options;
const sourceConnections =
Array.isArray(targetConnections) && targetConnections.length > 0 ? targetConnections : connections;
if (sourceConnections.length === 0) return;
pingAbortRef.current = false;
setPingLoading(true);
const ttlMs = Math.max(0, (pingCacheSeconds || 0) * 1000);
const tasks = [];
sourceConnections.forEach((c) => {
const from = String(c.from);
const to = String(c.to);
if (!from || !to || from === to) return;
const srcFrom = servers.find((s) => s.ip === from);
const srcTo = servers.find((s) => s.ip === to);
const canPingFrom =
srcFrom && ['jumphost', 'home'].includes(String(srcFrom.type || '').toLowerCase()) && c.internalFromTo;
const canPingTo =
srcTo && ['jumphost', 'home'].includes(String(srcTo.type || '').toLowerCase()) && c.internalToFrom;
const ekey = edgePingKey(from, to);
if (!force && ttlMs > 0 && pingTimestampsRef.current[ekey] && Date.now() - pingTimestampsRef.current[ekey] < ttlMs) {
return;
}
// Один пинг на ребро — с той стороны, где есть jumphost
if (canPingFrom) {
tasks.push(async () => {
if (pingAbortRef.current) return;
try {
const { data } = await api.post('/mikrotik/ping', {
serverId: c.fromKey,
target: c.internalFromTo,
gatewayIp: c.internalFromTo,
count: 3,
});
const ms = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null;
if (ms != null) {
pingTimestampsRef.current[ekey] = Date.now();
lastSuccessfulPingRef.current[ekey] = ms;
setPingMap((prev) => ({ ...prev, [ekey]: ms }));
setPingStaleMap((prev) => ({ ...prev, [ekey]: false }));
}
} catch {
pingTimestampsRef.current[ekey] = Date.now();
// При ошибке сохраняем последнее успешное значение с пометкой устаревшего
const lastSuccess = lastSuccessfulPingRef.current[ekey];
if (lastSuccess != null) {
setPingMap((prev) => ({ ...prev, [ekey]: lastSuccess }));
setPingStaleMap((prev) => ({ ...prev, [ekey]: true }));
} else {
setPingMap((prev) => ({ ...prev, [ekey]: null }));
setPingStaleMap((prev) => ({ ...prev, [ekey]: false }));
}
}
});
} else if (canPingTo) {
tasks.push(async () => {
if (pingAbortRef.current) return;
try {
const { data } = await api.post('/mikrotik/ping', {
serverId: c.toKey,
target: c.internalToFrom,
gatewayIp: c.internalToFrom,
count: 3,
});
const ms = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null;
if (ms != null) {
pingTimestampsRef.current[ekey] = Date.now();
lastSuccessfulPingRef.current[ekey] = ms;
setPingMap((prev) => ({ ...prev, [ekey]: ms }));
setPingStaleMap((prev) => ({ ...prev, [ekey]: false }));
}
} catch {
pingTimestampsRef.current[ekey] = Date.now();
// При ошибке сохраняем последнее успешное значение с пометкой устаревшего
const lastSuccess = lastSuccessfulPingRef.current[ekey];
if (lastSuccess != null) {
setPingMap((prev) => ({ ...prev, [ekey]: lastSuccess }));
setPingStaleMap((prev) => ({ ...prev, [ekey]: true }));
} else {
setPingMap((prev) => ({ ...prev, [ekey]: null }));
setPingStaleMap((prev) => ({ ...prev, [ekey]: false }));
}
}
});
}
});
await runWithLimit(tasks, 4);
if (pingAbortRef.current) return;
setPingLoading(false);
}, [connections, servers, pingCacheSeconds]);
/** Ключ скорости по паре серверов (без учёта порядка) */
const speedKey = (key1, key2) =>
[String(key1), String(key2)].sort().join(':');
/**
* Запросить скорость для всех соединений или подмножества.
* Если передать force=true, принудительно выполняет новый speed-test (игнорируя кеш на бэкенде).
*/
const requestSpeeds = useCallback(async (options = {}) => {
const { connections: targetConnections, force: _force = false } = options;
const sourceConnections =
Array.isArray(targetConnections) && targetConnections.length > 0 ? targetConnections : connections;
const withSpeed = sourceConnections.filter(
(c) => c.speedTestServerId && c.interfaceName
);
if (withSpeed.length === 0) return;
speedAbortRef.current = false;
setSpeedLoading(true);
const tasks = withSpeed.map((c) => async () => {
if (speedAbortRef.current) return;
const key = speedKey(c.fromKey, c.toKey);
try {
const { data } = await api.post(
'/mikrotik/speed-test',
{
serverId: c.speedTestServerId,
interfaceName: c.interfaceName,
forceRefresh: true, // ручной запуск со страницы карты сети всегда должен делать новый замер
},
{ timeout: 120000 }
);
if (data?.ok && (data.tcpDownloadBps != null || data.tcpUploadBps != null)) {
const speedData = {
tcpDownloadBps: data.tcpDownloadBps,
tcpUploadBps: data.tcpUploadBps,
cached: data.cached === true,
durationSeconds: data.durationSeconds,
};
lastSuccessfulSpeedRef.current[key] = speedData;
setSpeedMap((prev) => ({ ...prev, [key]: speedData }));
setSpeedStaleMap((prev) => ({ ...prev, [key]: false }));
}
} catch {
// При ошибке сохраняем последнее успешное значение с пометкой устаревшего
const lastSuccess = lastSuccessfulSpeedRef.current[key];
if (lastSuccess != null) {
setSpeedMap((prev) => ({ ...prev, [key]: lastSuccess }));
setSpeedStaleMap((prev) => ({ ...prev, [key]: true }));
} else {
setSpeedMap((prev) => ({ ...prev, [key]: null }));
setSpeedStaleMap((prev) => ({ ...prev, [key]: false }));
}
}
});
// В один момент — только один тест на один сервер назначения (удалённый конец туннеля),
// иначе несколько замеров к одному хостингу делят канал и дают заниженные результаты
await runWithOnePerKey(tasks, (i) => {
const c = withSpeed[i];
return c.speedTestServerId === c.fromKey ? c.toKey : c.fromKey;
});
if (speedAbortRef.current) return;
setSpeedLoading(false);
}, [connections]);
const handleRefreshPingForConnection = useCallback(
(connection) => {
if (!connection) return;
requestPings({ connections: [connection], force: true });
},
[requestPings]
);
const handleRefreshSpeedForConnection = useCallback(
(connection) => {
if (!connection) return;
requestSpeeds({ connections: [connection], force: true });
},
[requestSpeeds]
);
useEffect(() => {
const controller = new AbortController();
fetchData(controller.signal);
return () => controller.abort();
}, [fetchData]);
// Пинг и скорость не запускаются автоматически при открытии карты (даже если кеш просрочен).
// Обновление только по кнопкам «Пинг» и «Скорость» или по планировщику.
const handleRefreshPings = () => {
requestPings();
};
const handleRefreshSpeeds = () => {
requestSpeeds({ force: true });
};
if (loading) {
return (
<div>
<PageHeader
title="Карта сети"
icon={<IconTopologyRing size={24} />}
meta="Серверы и пинг между ними"
/>
<div className="text-center py-5 text-muted">Загрузка серверов и связей…</div>
</div>
);
}
if (error) {
return (
<div>
<PageHeader
title="Карта сети"
icon={<IconTopologyRing size={24} />}
meta="Серверы и пинг между ними"
/>
<div className="alert alert-danger">{error}</div>
</div>
);
}
return (
<div>
<PageHeader
title="Карта сети"
icon={<IconTopologyRing size={24} />}
meta="Связи из раздела «Сетевые настройки» → Интерфейсы; на рёбрах — пинг и скорость"
actions={
<div className="btn-list">
<button
type="button"
className="btn btn-outline-primary"
onClick={handleRefreshPings}
disabled={pingLoading}
>
<IconRefresh className={pingLoading ? 'spin me-2' : 'me-2'} size={18} />
{pingLoading ? 'Пинг…' : 'Пинг'}
</button>
<button
type="button"
className="btn btn-outline-primary"
onClick={handleRefreshSpeeds}
disabled={speedLoading}
>
<IconRefresh className={speedLoading ? 'spin me-2' : 'me-2'} size={18} />
{speedLoading ? 'Скорость…' : 'Скорость'}
</button>
</div>
}
/>
{servers.length === 0 ? (
<div className="alert alert-info">
Нет серверов. Добавьте серверы в разделе «Серверы».
</div>
) : (
<NetworkMapUnifi
servers={servers}
connections={connections}
pingMap={pingMap}
speedMap={speedMap}
pingStaleMap={pingStaleMap}
speedStaleMap={speedStaleMap}
onRefreshPingForConnection={handleRefreshPingForConnection}
onRefreshSpeedForConnection={handleRefreshSpeedForConnection}
pingLoading={pingLoading}
speedLoading={speedLoading}
alertSettings={alertSettings}
/>
)}
</div>
);
}
+325
View File
@@ -0,0 +1,325 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import api from './lib/api.js';
import PageHeader from './components/PageHeader.jsx';
import {
IconClockPlay,
IconSettings,
IconTerminal2,
IconPlayerPlay,
IconRefresh,
} from '@tabler/icons-react';
const POLL_LOGS_MS = 2500;
const INTERVAL_MIN = 1;
const INTERVAL_MAX = 1440;
const LOG_LINES_MIN = 100;
const LOG_LINES_MAX = 2000;
export default function NetworkMapSchedulerPage() {
const [settings, setSettings] = useState({
enabled: false,
intervalMinutes: 5,
runPing: true,
runSpeedTest: true,
logMaxLines: 500,
});
const [nextRunAt, setNextRunAt] = useState(null);
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [running, setRunning] = useState(false);
const [error, setError] = useState(null);
const logEndRef = useRef(null);
const logContainerRef = useRef(null);
const [autoScroll, setAutoScroll] = useState(true);
const fetchSettings = useCallback(async (abortSignal) => {
try {
const opts = abortSignal ? { signal: abortSignal } : {};
const { data } = await api.get('/scheduler/network-map/settings', opts);
setSettings({
enabled: Boolean(data.enabled),
intervalMinutes: Math.max(INTERVAL_MIN, Math.min(INTERVAL_MAX, Number(data.intervalMinutes) || 5)),
runPing: data.runPing !== false,
runSpeedTest: data.runSpeedTest !== false,
logMaxLines: Math.max(LOG_LINES_MIN, Math.min(LOG_LINES_MAX, Number(data.logMaxLines) || 500)),
});
setNextRunAt(typeof data.nextRunAt === 'number' ? data.nextRunAt : null);
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить настройки');
}
}, []);
const fetchLogs = useCallback(async () => {
try {
const { data } = await api.get('/scheduler/network-map/logs');
setLogs(Array.isArray(data?.lines) ? data.lines : []);
} catch (_) {
// не показываем ошибку при опросе логов
}
}, []);
useEffect(() => {
const controller = new AbortController();
(async () => {
setLoading(true);
await fetchSettings(controller.signal);
setLoading(false);
})();
return () => controller.abort();
}, [fetchSettings]);
useEffect(() => {
fetchLogs();
const t = setInterval(fetchLogs, POLL_LOGS_MS);
return () => clearInterval(t);
}, [fetchLogs]);
useEffect(() => {
if (!settings.enabled || !nextRunAt) return;
const t = setInterval(fetchSettings, 60 * 1000);
return () => clearInterval(t);
}, [settings.enabled, nextRunAt, fetchSettings]);
useEffect(() => {
if (autoScroll && logEndRef.current) {
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [logs, autoScroll]);
const handleSave = async () => {
setSaving(true);
setError(null);
try {
await api.patch('/scheduler/network-map/settings', {
networkMapSchedulerEnabled: settings.enabled,
networkMapSchedulerIntervalMinutes: settings.intervalMinutes,
networkMapSchedulerRunPing: settings.runPing,
networkMapSchedulerRunSpeedTest: settings.runSpeedTest,
networkMapSchedulerLogMaxLines: settings.logMaxLines,
});
await fetchSettings();
} catch (e) {
setError(e?.response?.data?.message || e?.message || 'Не удалось сохранить');
} finally {
setSaving(false);
}
};
const handleRunNow = async () => {
setRunning(true);
setError(null);
try {
await api.post('/scheduler/network-map/run-now');
setTimeout(fetchLogs, 500);
} catch (e) {
setError(e?.response?.data?.message || e?.message || 'Не удалось запустить');
} finally {
setRunning(false);
}
};
if (loading) {
return (
<div>
<PageHeader
title="Планировщик карты сети"
icon={<IconClockPlay size={24} />}
meta="Пинг и замер скорости между серверами по расписанию"
/>
<div className="text-center py-5 text-muted">Загрузка настроек…</div>
</div>
);
}
return (
<div>
<PageHeader
title="Планировщик карты сети"
icon={<IconClockPlay size={24} />}
meta="Пинг и замер скорости между серверами по расписанию; кеш ускоряет загрузку карты сети."
actions={
<div className="btn-list">
<button
type="button"
className="btn btn-primary"
onClick={handleRunNow}
disabled={running}
>
<IconPlayerPlay className={running ? 'spin me-2' : 'me-2'} size={18} />
{running ? 'Запуск…' : 'Запустить сейчас'}
</button>
</div>
}
/>
{error && (
<div className="alert alert-danger alert-dismissible fade show mb-3" role="alert">
{error}
<button type="button" className="btn-close" aria-label="Закрыть" onClick={() => setError(null)} />
</div>
)}
<div className="row row-deck row-cards g-3">
<div className="col-12 col-lg-5">
<div className="card">
<div className="card-header">
<h3 className="card-title d-flex align-items-center">
<IconSettings className="me-2" size={20} />
Настройки
</h3>
<div className="card-actions">
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleSave}
disabled={saving}
>
{saving ? 'Сохранение…' : 'Сохранить'}
</button>
</div>
</div>
<div className="card-body">
<div className="mb-3">
<label className="form-check form-switch">
<input
className="form-check-input"
type="checkbox"
checked={settings.enabled}
onChange={(e) => setSettings((s) => ({ ...s, enabled: e.target.checked }))}
/>
<span className="form-check-label">Включить планировщик</span>
</label>
<div className="form-hint">Периодически обновлять пинг и скорость по связям из «Сетевые настройки»</div>
{settings.enabled && nextRunAt != null && (
<div className="mt-2 text-muted small">
Следующий запуск: {new Date(nextRunAt).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'medium' })}
</div>
)}
</div>
<div className="mb-3">
<label className="form-label">Интервал (минут)</label>
<input
type="number"
className="form-control"
min={INTERVAL_MIN}
max={INTERVAL_MAX}
value={settings.intervalMinutes}
onChange={(e) =>
setSettings((s) => ({
...s,
intervalMinutes: Math.max(INTERVAL_MIN, Math.min(INTERVAL_MAX, parseInt(e.target.value, 10) || INTERVAL_MIN)),
}))
}
/>
<div className="form-hint">От 1 до 1440 (сутки). По умолчанию 5 мин.</div>
</div>
<div className="mb-3">
<label className="form-label">Задачи</label>
<div>
<label className="form-check form-switch me-3">
<input
className="form-check-input"
type="checkbox"
checked={settings.runPing}
onChange={(e) => setSettings((s) => ({ ...s, runPing: e.target.checked }))}
/>
<span className="form-check-label">Пинг</span>
</label>
<label className="form-check form-switch">
<input
className="form-check-input"
type="checkbox"
checked={settings.runSpeedTest}
onChange={(e) => setSettings((s) => ({ ...s, runSpeedTest: e.target.checked }))}
/>
<span className="form-check-label">Замер скорости</span>
</label>
</div>
</div>
<div className="mb-0">
<label className="form-label">Строк в логе</label>
<input
type="number"
className="form-control"
min={LOG_LINES_MIN}
max={LOG_LINES_MAX}
value={settings.logMaxLines}
onChange={(e) =>
setSettings((s) => ({
...s,
logMaxLines: Math.max(LOG_LINES_MIN, Math.min(LOG_LINES_MAX, parseInt(e.target.value, 10) || 500)),
}))
}
/>
<div className="form-hint">Кольцевой буфер: от 100 до 2000 строк.</div>
</div>
</div>
</div>
</div>
<div className="col-12 col-lg-7">
<div className="card flex-fill">
<div className="card-header d-flex align-items-center">
<h3 className="card-title d-flex align-items-center mb-0">
<IconTerminal2 className="me-2" size={20} />
Логи
</h3>
<div className="card-actions ms-auto">
<label className="form-check form-switch form-check-inline mb-0 me-2">
<input
className="form-check-input"
type="checkbox"
checked={autoScroll}
onChange={(e) => setAutoScroll(e.target.checked)}
/>
<span className="form-check-label small">Автопрокрутка</span>
</label>
<button
type="button"
className="btn btn-outline-secondary btn-sm"
onClick={fetchLogs}
aria-label="Обновить логи"
>
<IconRefresh size={16} />
</button>
</div>
</div>
<div
ref={logContainerRef}
className="card-body p-0"
style={{ minHeight: 320 }}
>
<pre
className="mb-0 p-3 bg-dark text-light rounded-0 rounded-bottom"
style={{
fontSize: 12,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
maxHeight: 420,
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{logs.length === 0 ? (
<span className="text-muted">Логов пока нет. Включите планировщик и нажмите «Запустить сейчас» или дождитесь следующего запуска.</span>
) : (
logs.map((line, i) => (
<span key={i} style={{ display: 'block' }}>
{line}
</span>
))
)}
<span ref={logEndRef} />
</pre>
</div>
</div>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+500
View File
@@ -0,0 +1,500 @@
import { useEffect, useMemo, useState, useCallback } from 'react';
import { IconDatabase, IconRefresh, IconRoute2, IconWand } from '@tabler/icons-react';
import PageHeader from './components/PageHeader.jsx';
import api from './lib/api.js';
import { useNotify } from './components/NotifyProvider.jsx';
const OSPF_COST_STEP = 10;
function moveInArray(arr, fromIndex, toIndex) {
if (fromIndex < 0 || toIndex < 0 || fromIndex >= arr.length || toIndex >= arr.length) return arr;
const copy = [...arr];
const [moved] = copy.splice(fromIndex, 1);
copy.splice(toIndex, 0, moved);
return copy;
}
function isUnknownOspfInterfaceName(name) {
const value = String(name || '').trim();
if (!value) return true;
if (value.startsWith('*')) return true;
if (value.toLowerCase() === 'unknown') return true;
return false;
}
function getOspfRouterIdentity(server) {
if (!server || typeof server !== 'object') {
return { key: 'unknown', label: 'Unknown router' };
}
const host = String(server.mikrotikHost || server.ip || server.dns || server.id || '').trim();
const port = String(server.mikrotikPort || 80).trim();
const user = String(server.mikrotikUser || 'admin').trim();
const key = `${host}::${port}::${user}`;
const label = host || String(server.dns || server.ip || server.id || 'Unknown router');
return { key, label };
}
function buildOspfTemplatesFromRouterResults(routerResults = [], servers = []) {
const serverById = new Map((servers || []).map((s) => [String(s.id || s.ip || s.dns || ''), s]));
const byComposite = new Map();
(routerResults || []).forEach((result) => {
const serverId = String(result?.serverId || '').trim();
if (!serverId) return;
const server = serverById.get(serverId) || {
id: serverId,
ip: String(result?.serverLabel || serverId),
dns: String(result?.serverLabel || ''),
};
const identity = getOspfRouterIdentity(server);
const templates = Array.isArray(result?.templates) ? result.templates : [];
templates.forEach((tpl) => {
const interfaceName = String(tpl?.interfaceName || '').trim();
if (isUnknownOspfInterfaceName(interfaceName)) return;
const area = String(tpl?.area || '').trim() || 'backbone';
const numericCost = Number(tpl?.cost);
const cost = Number.isFinite(numericCost) && numericCost >= 0 ? numericCost : OSPF_COST_STEP;
const compositeKey = `${identity.key}::${area.toLowerCase()}::${interfaceName.toUpperCase()}`;
if (byComposite.has(compositeKey)) return;
byComposite.set(compositeKey, {
key: compositeKey,
rosId: tpl?.id || null,
serverId,
routerKey: identity.key,
routerLabel: identity.label,
interfaceName,
area,
cost,
active: !tpl?.disabled,
});
});
});
return Array.from(byComposite.values());
}
function buildOptimizerProbabilityMap(routeOptimizerData, servers = []) {
const homes = Array.isArray(routeOptimizerData?.homes) ? routeOptimizerData.homes : [];
const byRef = new Map();
(servers || []).forEach((server) => {
[server?.id, server?.ip, server?.dns]
.filter(Boolean)
.forEach((ref) => byRef.set(String(ref), server));
});
const probabilities = {};
homes.forEach((entry) => {
const homeRefCandidates = [
entry?.home?.id,
entry?.home?.ip,
entry?.home?.dns,
].filter(Boolean).map(String);
const homeServer = homeRefCandidates.map((ref) => byRef.get(ref)).find(Boolean);
if (!homeServer) return;
const identity = getOspfRouterIdentity(homeServer);
const candidates = Array.isArray(entry?.homeToJumphostCandidates)
? [...entry.homeToJumphostCandidates]
: [];
candidates.sort((a, b) =>
Number(b?.probabilityOptimal || 0) - Number(a?.probabilityOptimal || 0) ||
Number(b?.score || 0) - Number(a?.score || 0)
);
const uniqueIface = [];
const seen = new Set();
candidates.forEach((candidate) => {
const iface = String(candidate?.interfaceName || '').trim();
if (!iface) return;
const key = iface.toUpperCase();
if (seen.has(key)) return;
seen.add(key);
uniqueIface.push(candidate);
});
uniqueIface.forEach((candidate, _idx) => {
const iface = String(candidate?.interfaceName || '').trim();
const hintKey = `${identity.key}::${iface.toUpperCase()}`;
const probabilityOptimal = Number(candidate?.probabilityOptimal || 0);
const prev = probabilities[hintKey];
if (prev == null || probabilityOptimal > prev) {
probabilities[hintKey] = probabilityOptimal;
}
});
});
return probabilities;
}
export default function OspfToolsPage() {
const notify = useNotify();
const [servers, setServers] = useState([]);
const [templates, setTemplates] = useState([]);
const [loading, setLoading] = useState(false);
const [optimizerLoading, setOptimizerLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [dragging, setDragging] = useState(null);
const [optimizerProbabilities, setOptimizerProbabilities] = useState({});
const groupedByRouter = useMemo(() => {
const grouped = {};
(templates || []).forEach((item) => {
const sid = String(item?.serverId || '');
const server = servers.find((s) => String(s.id || s.ip || s.dns || '') === sid);
const identity = getOspfRouterIdentity(server || { id: sid, ip: sid });
if (!grouped[identity.key]) {
grouped[identity.key] = {
routerKey: identity.key,
routerLabel: identity.label,
representativeServerId: sid,
areas: {},
};
}
if (!grouped[identity.key].areas[item.area]) grouped[identity.key].areas[item.area] = [];
grouped[identity.key].areas[item.area].push(item);
});
return Object.values(grouped).map((group) => ({
...group,
areas: Object.entries(group.areas)
.map(([area, areaItems]) => ({
area,
items: [...areaItems].sort((a, b) => Number(a?.cost || 0) - Number(b?.cost || 0)),
}))
.sort((a, b) => a.area.localeCompare(b.area)),
}));
}, [templates, servers]);
const optimalHints = useMemo(() => {
const hints = {};
groupedByRouter.forEach((routerGroup) => {
(routerGroup.areas || []).forEach((areaGroup) => {
const ranked = [...(areaGroup.items || [])]
.map((item) => {
const ifaceKey = `${routerGroup.routerKey}::${String(item.interfaceName || '').toUpperCase()}`;
const probabilityOptimal = Number(optimizerProbabilities[ifaceKey] || 0);
return { item, probabilityOptimal };
})
.sort((a, b) =>
Number(b.probabilityOptimal || 0) - Number(a.probabilityOptimal || 0) ||
String(a.item.interfaceName || '').localeCompare(String(b.item.interfaceName || ''))
);
ranked.forEach(({ item, probabilityOptimal }, idx) => {
const hintKey = `${routerGroup.routerKey}::${areaGroup.area}::${String(item.interfaceName || '').toUpperCase()}`;
hints[hintKey] = {
optimalCost: (idx + 1) * OSPF_COST_STEP,
probabilityOptimal,
};
});
});
});
return hints;
}, [groupedByRouter, optimizerProbabilities]);
const routerColumnClass = groupedByRouter.length > 1 ? 'col-12 col-xxl-6' : 'col-12';
const loadServers = async () => {
try {
const response = await api.get('/servers');
setServers(Array.isArray(response.data) ? response.data : []);
} catch (error) {
console.error('Failed to load servers:', error);
setServers([]);
}
};
const loadOspf = useCallback(async ({ silent = false } = {}) => {
setLoading(true);
try {
const response = await api.get('/mikrotik/ospf-interface-templates');
const results = Array.isArray(response.data?.results) ? response.data.results : [];
const errors = Array.isArray(response.data?.errors) ? response.data.errors : [];
setTemplates(buildOspfTemplatesFromRouterResults(results, servers));
if (!silent) {
if (results.length > 0) notify.success(`OSPF загружен: ${results.length} роутер(ов)`);
else notify.warning('OSPF Interface Template не найдены');
if (errors.length > 0) notify.warning(`Ошибки чтения OSPF: ${errors.length}`);
}
} catch (error) {
console.error('Failed to load OSPF:', error);
if (!silent) notify.error('Не удалось загрузить OSPF с MikroTik');
} finally {
setLoading(false);
}
}, [servers, notify]);
const loadOptimizerHints = useCallback(async ({ silent = false } = {}) => {
setOptimizerLoading(true);
try {
const response = await api.get('/route-optimizer', { timeout: 30000 });
const probabilities = buildOptimizerProbabilityMap(response?.data, servers);
setOptimizerProbabilities(probabilities);
return probabilities;
} catch (error) {
console.error('Failed to load route optimizer hints:', error);
setOptimizerProbabilities({});
if (!silent) notify.warning('Не удалось загрузить рекомендации из оптимизатора маршрутов');
return {};
} finally {
setOptimizerLoading(false);
}
}, [servers, notify]);
useEffect(() => {
loadServers();
}, []);
useEffect(() => {
if (servers.length === 0) return;
loadOspf({ silent: true });
loadOptimizerHints({ silent: true });
}, [servers.length, loadOspf, loadOptimizerHints]);
const onDrop = (routerKey, area, targetKey) => {
if (!dragging || dragging.routerKey !== routerKey || dragging.area !== area) return;
if (dragging.key === targetKey) {
setDragging(null);
return;
}
setTemplates((prev) => {
const current = prev
.filter((item) => item.routerKey === routerKey && String(item.area) === String(area))
.sort((a, b) => Number(a?.cost || 0) - Number(b?.cost || 0));
const fromIndex = current.findIndex((item) => item.key === dragging.key);
const toIndex = current.findIndex((item) => item.key === targetKey);
if (fromIndex < 0 || toIndex < 0) return prev;
const reordered = moveInArray(current, fromIndex, toIndex).map((item, idx) => ({
...item,
cost: (idx + 1) * OSPF_COST_STEP,
}));
const other = prev.filter(
(item) => !(item.routerKey === routerKey && String(item.area) === String(area))
);
return [...other, ...reordered];
});
setDragging(null);
};
const optimizeCosts = async () => {
let hints = optimalHints;
if (!hints || Object.keys(hints).length === 0) {
const probabilities = await loadOptimizerHints({ silent: true });
const refreshedHints = {};
groupedByRouter.forEach((routerGroup) => {
(routerGroup.areas || []).forEach((areaGroup) => {
const ranked = [...(areaGroup.items || [])]
.map((item) => {
const ifaceKey = `${routerGroup.routerKey}::${String(item.interfaceName || '').toUpperCase()}`;
const probabilityOptimal = Number((probabilities || {})[ifaceKey] || 0);
return { item, probabilityOptimal };
})
.sort((a, b) =>
Number(b.probabilityOptimal || 0) - Number(a.probabilityOptimal || 0) ||
String(a.item.interfaceName || '').localeCompare(String(b.item.interfaceName || ''))
);
ranked.forEach(({ item, probabilityOptimal }, idx) => {
const hintKey = `${routerGroup.routerKey}::${areaGroup.area}::${String(item.interfaceName || '').toUpperCase()}`;
refreshedHints[hintKey] = {
optimalCost: (idx + 1) * OSPF_COST_STEP,
probabilityOptimal,
};
});
});
});
hints = refreshedHints;
}
if (!hints || Object.keys(hints).length === 0) {
notify.warning('Нет рекомендаций оптимизатора для расчета cost');
return;
}
let changedCount = 0;
let hintedCount = 0;
const nextTemplates = (templates || []).map((item) => {
const hintKey = `${item.routerKey}::${item.area}::${String(item.interfaceName || '').toUpperCase()}`;
const hint = hints[hintKey];
if (!hint) return item;
hintedCount += 1;
const nextCost = Number(hint.optimalCost);
if (!Number.isFinite(nextCost) || nextCost < 0 || Number(item.cost) === nextCost) return item;
changedCount += 1;
return { ...item, cost: nextCost };
});
setTemplates(nextTemplates);
if (hintedCount === 0) {
notify.warning('Для интерфейсов на странице нет рекомендаций оптимизатора');
return;
}
if (changedCount === 0) {
notify.success('Текущие cost уже соответствуют оптимальным рекомендациям');
return;
}
notify.success(`Оптимизация применена: обновлено cost у ${changedCount} интерфейсов`);
};
const saveOspf = async () => {
if (groupedByRouter.length === 0) {
notify.warning('Нет OSPF данных для сохранения');
return;
}
setSaving(true);
try {
const payload = {
servers: groupedByRouter.map((group) => ({
serverId: group.representativeServerId,
templates: group.areas.flatMap((areaGroup) =>
areaGroup.items.map((item) => ({
rosId: item.rosId || null,
interfaceName: item.interfaceName,
area: item.area,
cost: item.cost,
}))
),
})),
};
const response = await api.post('/mikrotik/ospf-interface-templates/apply', payload);
const summary = response.data?.summary || {};
notify.success(`OSPF сохранен: обновлено ${summary.updated || 0}, пропущено ${summary.skipped || 0}`);
} catch (error) {
console.error('Failed to save OSPF:', error);
notify.error('Не удалось сохранить OSPF в MikroTik');
} finally {
setSaving(false);
}
};
return (
<div className="ospf-tools-page">
<PageHeader
title="Инструменты OSPF"
icon={<IconRoute2 size={24} />}
actions={(
<div className="btn-list">
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => {
loadOspf();
loadOptimizerHints({ silent: true });
}}
disabled={loading || optimizerLoading}
>
<IconRefresh size={16} className={`me-1 ${(loading || optimizerLoading) ? 'spin' : ''}`} />
Загрузить из MikroTik
</button>
<button
type="button"
className="btn btn-outline-primary"
onClick={optimizeCosts}
disabled={loading || optimizerLoading || groupedByRouter.length === 0}
title="Проставить интерфейсам потенциально оптимальный cost"
>
<IconWand size={16} className="me-1" />
Оптимизировать
</button>
<button
type="button"
className="btn btn-primary"
onClick={saveOspf}
disabled={saving || groupedByRouter.length === 0}
>
<IconDatabase size={16} className={`me-1 ${saving ? 'spin' : ''}`} />
Сохранить OSPF
</button>
</div>
)}
/>
<div className="card">
<div className="card-header">
<div className="text-muted small">
Данные OSPF берутся напрямую с MikroTik. Группировка: роутер → Area → интерфейсы.
Чем меньше cost, тем выше приоритет. Рядом отображается потенциально оптимальный cost из оптимизатора маршрутов.
</div>
</div>
<div className="card-body">
{groupedByRouter.length === 0 ? (
<div className="text-muted">OSPF Interface Template не найдены.</div>
) : (
<div className="row g-3">
{groupedByRouter.map((routerGroup) => (
<div key={routerGroup.routerKey} className={routerColumnClass}>
<div className="card card-sm border">
<div className="card-header fw-semibold">{routerGroup.routerLabel}</div>
<div className="card-body p-0">
{routerGroup.areas.map((areaGroup) => (
<div key={`${routerGroup.routerKey}-${areaGroup.area}`} className="border-top">
<div className="px-3 py-2 bg-light text-muted small fw-semibold">
Area: {areaGroup.area}
</div>
<div className="list-group list-group-flush">
{areaGroup.items.map((item) => (
(() => {
const hint = optimalHints[`${routerGroup.routerKey}::${areaGroup.area}::${String(item.interfaceName || '').toUpperCase()}`];
const hintMatches = hint && Number(hint.optimalCost) === Number(item.cost);
return (
<div
key={item.key}
className="list-group-item"
draggable
onDragStart={() => setDragging({
routerKey: routerGroup.routerKey,
area: areaGroup.area,
key: item.key,
})}
onDragOver={(e) => e.preventDefault()}
onDrop={() => onDrop(routerGroup.routerKey, areaGroup.area, item.key)}
onDragEnd={() => setDragging(null)}
style={{ cursor: 'grab' }}
>
<div className="d-flex align-items-center justify-content-between gap-2 flex-wrap">
<div className="d-flex align-items-center gap-2 flex-wrap">
<span className="text-muted small">::</span>
<code>{item.interfaceName}</code>
<span className={`badge ${item.active ? 'bg-green-lt text-green' : 'bg-secondary-lt text-secondary'}`}>
{item.active ? 'Активен' : 'Неактивен'}
</span>
</div>
<div className="d-flex align-items-center gap-2 ms-auto">
{hint && (
<span
className={`badge ${hintMatches ? 'bg-green-lt text-green' : 'bg-yellow-lt text-yellow'}`}
title={`Вероятность оптимальности: ${hint.probabilityOptimal}%`}
>
opt cost {hint.optimalCost}
</span>
)}
<span className="badge bg-primary-lt text-primary">cost {item.cost}</span>
</div>
</div>
</div>
);
})()
))}
</div>
</div>
))}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
+415
View File
@@ -0,0 +1,415 @@
import { useState, useEffect } from 'react';
import api from './lib/api.js';
import {
IconPlus,
IconEdit,
IconTrash,
IconDeviceFloppy,
IconCloud,
IconSearch,
} from '@tabler/icons-react';
import PageHeader from './components/PageHeader.jsx';
import FormModal from './components/FormModal.jsx';
import FormField from './components/FormField.jsx';
import ConfirmModal from './components/ConfirmModal.jsx';
import ErrorAlert from './components/ErrorAlert.jsx';
import IconPicker from './components/IconPicker.jsx';
import { getIconById } from './lib/brandIcons.js';
const COLOR_OPTIONS = [
{ value: 'primary', label: 'Основной' },
{ value: 'blue', label: 'Синий' },
{ value: 'green', label: 'Зелёный' },
{ value: 'orange', label: 'Оранжевый' },
{ value: 'red', label: 'Красный' },
{ value: 'pink', label: 'Розовый' },
{ value: 'purple', label: 'Фиолетовый' },
{ value: 'cyan', label: 'Голубой' },
{ value: 'yellow', label: 'Жёлтый' },
];
function slugId(name) {
const s = String(name || '').trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
return s || `svc-${Date.now()}`;
}
export default function PingServicesManager() {
const [items, setItems] = useState([]);
const [rawSettings, setRawSettings] = useState({});
const [etag, setEtag] = useState('');
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [addModalOpen, setAddModalOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
const [newItem, setNewItem] = useState({
id: '',
name: '',
host: '',
port: '443',
icon: 'IconBrandGoogle',
color: 'blue',
});
const [editingItem, setEditingItem] = useState(null);
const [editDraft, setEditDraft] = useState({ id: '', name: '', host: '', port: '443', icon: 'IconBrandGoogle', color: 'blue' });
const loadSettings = async (abortSignal) => {
setLoading(true);
setError('');
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const res = await api.get('/ui-settings', opts);
const data = res?.data || {};
setRawSettings(data);
setEtag(String(res?.headers?.etag || res?.headers?.ETag || ''));
const list = Array.isArray(data.pingServicesList) ? data.pingServicesList : [];
setItems(list.map((s) => ({
id: String(s?.id ?? '').trim() || slugId(s?.name),
name: String(s?.name ?? '').trim(),
host: String(s?.host ?? '').trim(),
port: Math.max(1, parseInt(s?.port, 10) || 443),
icon: typeof s?.icon === 'string' ? s.icon : 'IconWorld',
color: typeof s?.color === 'string' ? s.color : 'primary',
})).filter((s) => s.host));
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
console.error(e);
setError('Не удалось загрузить настройки');
} finally {
setLoading(false);
}
};
useEffect(() => {
const controller = new AbortController();
loadSettings(controller.signal);
return () => controller.abort();
}, []);
const saveSettings = async () => {
setSaving(true);
setError('');
setSuccess('');
const payloadToSave = {
...rawSettings,
pingServicesList: items.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
port: s.port,
icon: s.icon,
color: s.color,
})),
};
try {
let currentEtag = etag;
let res = await api.post('/ui-settings', { settings: payloadToSave, etag: currentEtag });
setRawSettings(payloadToSave);
setEtag(String(res?.data?.etag || currentEtag));
setSuccess('Список сервисов сохранён');
setTimeout(() => setSuccess(''), 3000);
} catch (e) {
if (e?.response?.status === 412) {
try {
const fresh = await api.get('/ui-settings');
const freshData = fresh?.data || {};
const freshEtag = String(fresh?.headers?.etag || fresh?.headers?.ETag || '');
const retryPayload = { ...freshData, pingServicesList: payloadToSave.pingServicesList };
const retryRes = await api.post('/ui-settings', { settings: retryPayload, etag: freshEtag });
setRawSettings(retryPayload);
setEtag(String(retryRes?.data?.etag || freshEtag));
setSuccess('Список сервисов сохранён (настройки были обновлены)');
setTimeout(() => setSuccess(''), 3000);
} catch (retryErr) {
setError(retryErr?.response?.data?.message || 'Настройки изменились. Обновите страницу и сохраните снова.');
}
} else {
setError(e?.response?.data?.message || 'Не удалось сохранить');
}
} finally {
setSaving(false);
}
};
const openAdd = () => {
setNewItem({
id: '',
name: '',
host: '',
port: '443',
icon: 'IconBrandGoogle',
color: 'blue',
});
setAddModalOpen(true);
};
const submitAdd = () => {
const name = String(newItem.name || '').trim();
const host = String(newItem.host || '').trim();
if (!name) { setError('Укажите название'); return; }
if (!host) { setError('Укажите хост'); return; }
const id = newItem.id?.trim() || slugId(name);
if (items.some((i) => i.id === id)) { setError('Такой ID уже есть'); return; }
setError('');
setItems((prev) => [...prev, {
id,
name,
host,
port: Math.max(1, parseInt(newItem.port, 10) || 443),
icon: newItem.icon || 'IconWorld',
color: newItem.color || 'primary',
}]);
setAddModalOpen(false);
setSuccess('Сервис добавлен. Нажмите «Сохранить».');
setTimeout(() => setSuccess(''), 3000);
};
const openEdit = (item) => {
setEditingItem(item);
setEditDraft({
id: item.id,
name: item.name,
host: item.host,
port: String(item.port),
icon: item.icon || 'IconWorld',
color: item.color || 'primary',
});
setEditModalOpen(true);
};
const submitEdit = () => {
const name = String(editDraft.name || '').trim();
const host = String(editDraft.host || '').trim();
if (!name) { setError('Укажите название'); return; }
if (!host) { setError('Укажите хост'); return; }
const id = editDraft.id?.trim() || editingItem.id;
if (id !== editingItem.id && items.some((i) => i.id === id)) { setError('Такой ID уже есть'); return; }
setError('');
setItems((prev) => prev.map((i) => (i.id === editingItem.id ? {
id,
name,
host,
port: Math.max(1, parseInt(editDraft.port, 10) || 443),
icon: editDraft.icon || 'IconWorld',
color: editDraft.color || 'primary',
} : i)));
setEditModalOpen(false);
setEditingItem(null);
setSuccess('Сервис обновлён. Нажмите «Сохранить».');
setTimeout(() => setSuccess(''), 3000);
};
const openDelete = (item) => {
setItemToDelete(item);
setDeleteModalOpen(true);
};
const confirmDelete = () => {
if (itemToDelete) {
setItems((prev) => prev.filter((i) => i.id !== itemToDelete.id));
setDeleteModalOpen(false);
setItemToDelete(null);
setSuccess('Сервис удалён. Нажмите «Сохранить».');
setTimeout(() => setSuccess(''), 3000);
}
};
const [searchTerm, setSearchTerm] = useState('');
const filtered = items.filter((i) => {
const q = searchTerm.trim().toLowerCase();
if (!q) return true;
return (i.name || '').toLowerCase().includes(q) || (i.host || '').toLowerCase().includes(q) || (i.id || '').toLowerCase().includes(q);
});
return (
<div>
<PageHeader
title="Пинг сервисов"
icon={<IconCloud size={24} />}
actions={(
<div className="btn-list">
<button
type="button"
className="btn btn-primary"
onClick={openAdd}
disabled={loading}
>
<IconPlus size={18} className="me-1" />
Добавить
</button>
<button
type="button"
className="btn btn-outline-primary"
onClick={saveSettings}
disabled={saving || loading}
>
{saving && <span className="spinner-border spinner-border-sm me-2" />}
<IconDeviceFloppy size={18} className="me-1" />
Сохранить
</button>
</div>
)}
/>
{error && <ErrorAlert message={error} onClose={() => setError('')} onRetry={loadSettings} />}
{success && (
<div className="alert alert-success alert-dismissible">
{success}
<button type="button" className="btn-close" onClick={() => setSuccess('')} aria-label="Закрыть" />
</div>
)}
<div className="card">
<div className="card-header d-flex align-items-center gap-2 flex-wrap">
<div className="input-group input-group-flat" style={{ maxWidth: 280 }}>
<span className="input-group-text">
<IconSearch size={16} />
</span>
<input
type="text"
className="form-control"
placeholder="Поиск по названию, хосту, ID..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<span className="text-muted small ms-2">
{filtered.length} из {items.length}
</span>
</div>
<div className="table-responsive">
<table className="table table-vcenter card-table table-striped">
<thead>
<tr>
<th style={{ width: 56 }}>Иконка</th>
<th>ID</th>
<th>Название</th>
<th>Хост</th>
<th>Порт</th>
<th>Цвет</th>
<th className="w-1">Действия</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="text-center py-4 text-muted">Загрузка...</td>
</tr>
) : filtered.length === 0 ? (
<tr>
<td colSpan={7} className="text-center py-4 text-muted">
Нет сервисов. Нажмите «Добавить» или сохраните список — на главной будут использоваться сервисы по умолчанию.
</td>
</tr>
) : (
filtered.map((item) => {
const { Icon } = getIconById(item.icon);
return (
<tr key={item.id}>
<td>
<span className={`avatar avatar-sm bg-${item.color}-lt text-${item.color} rounded d-inline-flex align-items-center justify-content-center`}>
<Icon size={18} stroke={1.5} />
</span>
</td>
<td><code className="small">{item.id}</code></td>
<td>{item.name}</td>
<td><code className="small">{item.host}</code></td>
<td>{item.port}</td>
<td>
<span className={`badge bg-${item.color}-lt text-${item.color}`}>{item.color}</span>
</td>
<td>
<div className="btn-list">
<button
type="button"
className="btn btn-sm btn-outline-primary"
onClick={() => openEdit(item)}
>
<IconEdit size={14} />
</button>
<button
type="button"
className="btn btn-sm btn-outline-danger"
onClick={() => openDelete(item)}
>
<IconTrash size={14} />
</button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
<FormModal
show={addModalOpen}
onClose={() => setAddModalOpen(false)}
onSubmit={submitAdd}
title="Добавить сервис для пинга"
submitLabel="Добавить"
>
<FormField label="Название" name="name" value={newItem.name} onChange={(v) => setNewItem((p) => ({ ...p, name: v }))} placeholder="Google" />
<FormField label="Хост (IP или домен)" name="host" value={newItem.host} onChange={(v) => setNewItem((p) => ({ ...p, host: v }))} placeholder="8.8.8.8" />
<FormField label="Порт" name="port" type="number" value={newItem.port} onChange={(v) => setNewItem((p) => ({ ...p, port: v }))} min={1} max={65535} />
<IconPicker value={newItem.icon} onChange={(id) => setNewItem((p) => ({ ...p, icon: id }))} />
<div className="mb-2">
<label className="form-label small">Цвет</label>
<select
className="form-select form-select-sm"
value={newItem.color}
onChange={(e) => setNewItem((p) => ({ ...p, color: e.target.value }))}
>
{COLOR_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<FormField label="ID (необязательно)" name="id" value={newItem.id} onChange={(v) => setNewItem((p) => ({ ...p, id: v }))} placeholder="auto из названия" helpText="Уникальный ключ. Пусто — из названия." />
</FormModal>
<FormModal
show={editModalOpen}
onClose={() => { setEditModalOpen(false); setEditingItem(null); }}
onSubmit={submitEdit}
title="Редактировать сервис"
>
<FormField label="ID" name="id" value={editDraft.id} onChange={(v) => setEditDraft((p) => ({ ...p, id: v }))} />
<FormField label="Название" name="name" value={editDraft.name} onChange={(v) => setEditDraft((p) => ({ ...p, name: v }))} />
<FormField label="Хост" name="host" value={editDraft.host} onChange={(v) => setEditDraft((p) => ({ ...p, host: v }))} />
<FormField label="Порт" name="port" type="number" value={editDraft.port} onChange={(v) => setEditDraft((p) => ({ ...p, port: v }))} min={1} max={65535} />
<IconPicker value={editDraft.icon} onChange={(id) => setEditDraft((p) => ({ ...p, icon: id }))} />
<div className="mb-2">
<label className="form-label small">Цвет</label>
<select
className="form-select form-select-sm"
value={editDraft.color}
onChange={(e) => setEditDraft((p) => ({ ...p, color: e.target.value }))}
>
{COLOR_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
</FormModal>
<ConfirmModal
show={deleteModalOpen}
title="Удалить сервис?"
message={itemToDelete ? `Сервис «${itemToDelete.name}» (${itemToDelete.host}) будет удалён из списка.` : ''}
confirmLabel="Удалить"
variant="danger"
onConfirm={confirmDelete}
onClose={() => { setDeleteModalOpen(false); setItemToDelete(null); }}
/>
</div>
);
}
+243
View File
@@ -0,0 +1,243 @@
import { useState, useEffect, useCallback } from 'react';
import api from './lib/api.js';
import {
IconServer,
IconRefresh,
IconChartBar,
IconAlertCircle,
} from '@tabler/icons-react';
import PageHeader from './components/PageHeader.jsx';
const REQUEST_TIMEOUT_MS = 15000;
function formatBytes(bytes) {
if (bytes == null || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
/** Цвет прогресс-бара по проценту: зелёный / жёлтый / красный */
function progressVariant(percent) {
if (percent == null) return 'secondary';
if (percent >= 90) return 'danger';
if (percent >= 70) return 'warning';
return 'success';
}
/** Один KPI-блок: заголовок, значение, прогресс-бар (как на референсной карточке) */
function KpiBlock({ label, value, percent, variant }) {
const barVariant = variant || progressVariant(percent);
return (
<div className="mb-3">
<div className="text-uppercase small fw-semibold text-secondary mb-1">{label}</div>
<div className="d-flex align-items-baseline gap-2 flex-wrap">
<span className="h4 mb-0 fw-bold">{value}</span>
{percent != null && (
<span className={`small fw-medium text-${barVariant}`}>{percent}%</span>
)}
</div>
{percent != null && (
<div className="progress progress-sm mt-1" style={{ height: 6 }}>
<div
className={`progress-bar bg-${barVariant}`}
role="progressbar"
style={{ width: `${Math.min(100, percent)}%` }}
aria-valuenow={percent}
aria-valuemin={0}
aria-valuemax={100}
/>
</div>
)}
</div>
);
}
/** Карточка одного роутера: иконка перед названием, сводка, KPI (RAM, CPU, HDD) */
function RouterResourceCard({ router }) {
const { name, host, error, resource } = router;
const hasResource = resource && !error;
return (
<div className="card rounded-3 shadow-sm border-0 overflow-hidden h-100">
<div className="card-body p-4">
<div className="d-flex align-items-center mb-3">
<span className="avatar avatar-lg bg-primary-lt text-primary rounded-3 me-3 flex-shrink-0">
<IconServer size={28} />
</span>
<div className="min-w-0">
<h3 className="h4 fw-bold mb-0 text-body text-truncate">
{name}
</h3>
<p className="text-secondary small mb-0 mt-1">
{host}
{resource?.version && (
<span className="ms-1"> · RouterOS {resource.version}</span>
)}
</p>
</div>
</div>
<div>
{error && (
<div className="alert alert-danger py-2 mb-3 d-flex align-items-center">
<IconAlertCircle size={20} className="me-2 flex-shrink-0" />
<span className="small">{error}</span>
</div>
)}
{hasResource && (
<div className="row g-3">
{/* RAM */}
<div className="col-12 col-md-4">
<KpiBlock
label="RAM"
value={
resource.usedMemory != null && resource.totalMemory != null
? `${formatBytes(resource.usedMemory)} / ${formatBytes(resource.totalMemory)}`
: '—'
}
percent={resource.memoryUsagePercent}
/>
</div>
{/* CPU */}
<div className="col-12 col-md-4">
<KpiBlock
label="CPU"
value={
resource.cpuLoad != null
? `${resource.cpuLoad}%`
: '—'
}
percent={resource.cpuLoad}
/>
</div>
{/* HDD */}
<div className="col-12 col-md-4">
<KpiBlock
label="HDD"
value={
resource.usedHddSpace != null && resource.totalHddSpace != null
? `${formatBytes(resource.usedHddSpace)} / ${formatBytes(resource.totalHddSpace)}`
: resource.totalHddSpace != null
? formatBytes(resource.totalHddSpace)
: '—'
}
percent={resource.hddUsagePercent}
/>
</div>
</div>
)}
{hasResource && (resource.boardName || resource.uptime) && (
<div className="mt-2 pt-2 border-top small text-muted">
{resource.boardName && <span>{resource.boardName}</span>}
{resource.uptime && (
<span className="ms-2"> · Uptime: {resource.uptime}</span>
)}
</div>
)}
</div>
</div>
</div>
);
}
export default function ResourceStatsPage() {
const [routers, setRouters] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchStats = useCallback(async (abortSignal) => {
setLoading(true);
setError(null);
const opts = { timeout: REQUEST_TIMEOUT_MS, noRetry: true, ...(abortSignal ? { signal: abortSignal } : {}) };
try {
const res = await api.get('/resources/stats', opts);
const data = res?.data;
setRouters(Array.isArray(data?.routers) ? data.routers : []);
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить статистику');
// Не очищаем список — пользователь видит старые данные и сообщение об ошибке
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
const controller = new AbortController();
fetchStats(controller.signal);
return () => controller.abort();
}, [fetchStats]);
return (
<div className="page-body">
<PageHeader
title="Статистика ресурсов"
pretitle="Роутеры"
icon={<IconChartBar size={28} />}
meta="Потребление RAM, CPU и HDD по каждому роутеру (MikroTik)"
actions={
<button
type="button"
className="btn btn-outline-primary"
onClick={() => fetchStats()}
disabled={loading}
>
<IconRefresh size={18} className={loading ? 'spin' : ''} />
<span className="ms-1">Обновить</span>
</button>
}
/>
{error && (
<div className="alert alert-danger d-flex align-items-center">
<IconAlertCircle size={24} className="me-2" />
<span>{error}</span>
</div>
)}
{loading && routers.length === 0 ? (
<div className="row">
{[1, 2, 3].map((i) => (
<div key={i} className="col-12 col-lg-6 col-xl-4 mb-4">
<div className="card rounded-3 border-0 placeholder-glow">
<div className="card-body p-4">
<div className="placeholder col-6 mb-2" />
<div className="placeholder col-8 mb-3" />
<div className="placeholder col-12" style={{ height: 60 }} />
</div>
</div>
</div>
))}
</div>
) : routers.length === 0 ? (
<div className="card rounded-3 border-0">
<div className="card-body text-center py-5 text-muted">
<IconServer size={48} className="mb-2 opacity-50" />
<p className="mb-0">Нет роутеров с настроенным MikroTik API (jumphost/home).</p>
</div>
</div>
) : (
<div className="row">
{routers.map((router) => (
<div key={router.serverId || router.name} className="col-12 col-lg-6 col-xl-4 mb-4">
<RouterResourceCard router={router} />
</div>
))}
</div>
)}
<style>{`
.spin {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</div>
);
}
+531
View File
@@ -0,0 +1,531 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
IconRoute2,
IconRefresh,
IconBrain,
IconAlertCircle,
} from '@tabler/icons-react';
import api from './lib/api.js';
import PageHeader from './components/PageHeader.jsx';
import { countryToFlag } from './utils/serverUtils.js';
function MetricBadge({ label, value, tone = 'secondary' }) {
return (
<span className={`badge bg-${tone}-lt text-${tone}`}>
{label}: {value}
</span>
);
}
function fmtProb(v) {
return typeof v === 'number' ? `${v}%` : '—';
}
function fmtSpeed(dl, ul, fallback) {
if (typeof dl === 'number' || typeof ul === 'number') {
return `DL: ${dl ?? '—'} Mbps, UL: ${ul ?? '—'} Mbps`;
}
return `speed: ${fallback ?? '—'} Mbps`;
}
function routeNodeKey(node) {
return String(node?.id || node?.dns || node?.ip || node?.label || '').trim();
}
const OPT_TABLE_STYLE = { tableLayout: 'fixed', width: '100%' };
const OPT_COLS = ['24%', '20%', '22%', '18%', '16%'];
function OptimizerColgroup() {
return (
<colgroup>
{OPT_COLS.map((w) => (
<col key={w} style={{ width: w }} />
))}
</colgroup>
);
}
export default function RouteOptimizerPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [data, setData] = useState(null);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const res = await api.get('/route-optimizer', { timeout: 30000 });
setData(res?.data || null);
} catch (e) {
console.error('[RouteOptimizerPage] load failed:', e);
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить маршруты');
setData(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
const id = setInterval(load, 30000);
return () => clearInterval(id);
}, [load]);
const homes = Array.isArray(data?.homes) ? data.homes : [];
const communityOptimization = Array.isArray(data?.communityOptimizationByJumphost)
? data.communityOptimizationByJumphost
: [];
const communityFullRoutes = (() => {
const out = [];
const homesSafe = Array.isArray(homes) ? homes : [];
const byJh = new Map();
homesSafe.forEach((h) => {
const home = h?.home || null;
const routes = Array.isArray(h?.fullRouteCandidates) ? h.fullRouteCandidates : [];
routes.forEach((r) => {
const jhKey = routeNodeKey(r?.jumphost);
const exKey = routeNodeKey(r?.exit);
if (!jhKey || !exKey) return;
const mapKey = `${jhKey}:::${exKey}`;
const prev = byJh.get(mapKey);
if (!prev || Number(r?.score || 0) > Number(prev?.route?.score || 0)) {
byJh.set(mapKey, { home, route: r });
}
});
});
communityOptimization.forEach((item) => {
const jh = item?.jumphost || null;
const jhKey = routeNodeKey(jh);
const rows = Array.isArray(item?.recommendations) ? item.recommendations : [];
rows.forEach((r) => {
const rec = r?.recommended || null;
const exKey = routeNodeKey(rec?.exit);
if (!rec || !jhKey || !exKey) return;
const bestFull = byJh.get(`${jhKey}:::${exKey}`);
if (!bestFull?.route) return;
out.push({
community: r.community,
communityInfo: r.communityInfo,
home: bestFull.home || bestFull.route.home,
jumphost: bestFull.route.jumphost || jh,
exit: bestFull.route.exit || rec.exit,
fullRouteScore: bestFull.route.score,
fullRouteProbability: bestFull.route.probabilityOptimal,
fullRouteConfidence: bestFull.route.confidence,
homeToJumphost: bestFull.route.homeToJumphost || null,
jumphostToExit: bestFull.route.jumphostToExit || null,
recommendedGateway: rec.gateway || null,
tunnelGateway: rec.tunnelGateway || null,
pinnedBySettings: Boolean(r?.pinnedBySettings),
shouldSwitch: Boolean(r?.shouldSwitch),
});
});
});
return out.sort((a, b) => Number(b.fullRouteScore || 0) - Number(a.fullRouteScore || 0));
})();
return (
<div>
<PageHeader
title="Локальный ИИ: оптимизатор маршрутов"
icon={<IconBrain size={24} />}
meta="Rule-based выбор маршрутов: Home → Jumphost → Exit"
actions={(
<div className="btn-list">
<Link to="/settings#route-ai" className="btn btn-outline-secondary">
Настройки AI
</Link>
<button type="button" className="btn btn-outline-primary" onClick={load} disabled={loading}>
<IconRefresh className={loading ? 'spin me-2' : 'me-2'} size={18} />
{loading ? 'Обновление...' : 'Обновить'}
</button>
</div>
)}
/>
{error && (
<div className="alert alert-danger d-flex align-items-center">
<IconAlertCircle className="me-2" size={18} />
{error}
</div>
)}
{loading && !data && (
<div className="text-muted py-4">Расчёт маршрутов...</div>
)}
{!loading && !error && homes.length === 0 && (
<div className="alert alert-info">
Нет доступных маршрутов. Проверьте типы серверов (`home`, `jumphost`, `exit`) и `network-config.tunnelInterfaces`.
</div>
)}
<div className="row g-3">
{homes.map((entry) => {
const best = entry.bestFullRoute;
const bestHomeToJh = entry.bestHomeToJumphost || null;
const home = entry.home || {};
const hjCandidates = Array.isArray(entry.homeToJumphostCandidates)
? entry.homeToJumphostCandidates
: [];
const routes = Array.isArray(entry.fullRouteCandidates) ? entry.fullRouteCandidates : [];
return (
<div className="col-12" key={home.id || home.ip || home.dns || home.label || 'home'}>
<div className="card">
<div className="card-header d-flex flex-wrap gap-2 align-items-center justify-content-between">
<h3 className="card-title mb-0 d-flex align-items-center">
<IconRoute2 className="me-2" size={18} />
{home.label || home.dns || home.ip || 'Home'}
</h3>
{best ? (
<MetricBadge
label="Вероятность оптимальности лучшего маршрута"
value={`${best.probabilityOptimal}%`}
tone="green"
/>
) : (
<MetricBadge label="Статус" value="Нет полного маршрута до Exit" tone="orange" />
)}
</div>
<div className="card-body">
<div className="mb-4">
<div className="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
<div className="fw-semibold">Оптимальный маршрут Home → Jumphost</div>
{bestHomeToJh ? (
<MetricBadge
label="Вероятность оптимальности"
value={`${bestHomeToJh.probabilityOptimal}%`}
tone="green"
/>
) : (
<MetricBadge label="Статус" value="Нет кандидатов Home→Jumphost" tone="orange" />
)}
</div>
<div className="table-responsive">
<table className="table table-vcenter card-table align-middle" style={OPT_TABLE_STYLE}>
<OptimizerColgroup />
<thead>
<tr>
<th>Jumphost</th>
<th>Интерфейс</th>
<th>Ping / Speed</th>
<th>Score / Confidence</th>
<th>Вероятность</th>
</tr>
</thead>
<tbody>
{hjCandidates.length === 0 ? (
<tr>
<td colSpan={5} className="text-muted">
Кандидаты Home→Jumphost не найдены.
</td>
</tr>
) : hjCandidates.map((c) => (
<tr key={c.id}>
<td className="fw-semibold text-break">{c.jumphost?.label || c.jumphost?.dns || c.jumphost?.ip || 'jumphost'}</td>
<td className="small text-muted text-break">{c.interfaceName || '—'}</td>
<td className="small text-muted text-break">
ping: {c.pingMs ?? '—'} ms, {fmtSpeed(c.speedDownloadMbps, c.speedUploadMbps, c.speedMbps)}
</td>
<td className="small">
{c.score} / {c.confidence}
</td>
<td>
<span className={`badge ${bestHomeToJh?.id === c.id ? 'bg-green-lt text-green' : 'bg-secondary-lt text-secondary'}`}>
{fmtProb(c.probabilityOptimal)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{best ? (
<div className="mb-3">
<div className="fw-semibold mb-2">Оптимальный маршрут</div>
<div className="d-flex flex-wrap gap-2">
<span className="badge bg-blue-lt text-blue">{best.home?.label || 'home'}</span>
<span className="text-muted">→</span>
<span className="badge bg-indigo-lt text-indigo">{best.jumphost?.label || 'jumphost'}</span>
<span className="text-muted">→</span>
<span className="badge bg-orange-lt text-orange">{best.exit?.label || 'exit'}</span>
<MetricBadge label="Score" value={best.score} tone="primary" />
<MetricBadge label="Confidence" value={best.confidence} tone="secondary" />
</div>
</div>
) : null}
<div className="fw-semibold mb-2">Полные маршруты Home → Jumphost → Exit</div>
<div className="table-responsive">
<table className="table table-vcenter card-table align-middle" style={OPT_TABLE_STYLE}>
<OptimizerColgroup />
<thead>
<tr>
<th>Маршрут</th>
<th>Home→Jumphost</th>
<th>Jumphost→Exit</th>
<th>Score / Confidence</th>
<th>Вероятность</th>
</tr>
</thead>
<tbody>
{routes.length === 0 ? (
<tr>
<td colSpan={5} className="text-muted">
Полные маршруты до exit-ноды не найдены.
</td>
</tr>
) : routes.map((r) => (
<tr key={r.id}>
<td className="text-break">
<span className="fw-semibold">{r.home?.label}</span>
<span className="text-muted"> → </span>
<span className="fw-semibold">{r.jumphost?.label}</span>
<span className="text-muted"> → </span>
<span className="fw-semibold">{r.exit?.label}</span>
</td>
<td className="small text-muted text-break">
ping: {r.homeToJumphost?.pingMs ?? '—'} ms, {fmtSpeed(
r.homeToJumphost?.speedDownloadMbps,
r.homeToJumphost?.speedUploadMbps,
r.homeToJumphost?.speedMbps
)}
</td>
<td className="small text-muted text-break">
ping: {r.jumphostToExit?.pingMs ?? '—'} ms, {fmtSpeed(
r.jumphostToExit?.speedDownloadMbps,
r.jumphostToExit?.speedUploadMbps,
r.jumphostToExit?.speedMbps
)}
</td>
<td className="small">{r.score} / {r.confidence ?? '—'}</td>
<td>
<span className="badge bg-green-lt text-green">{r.probabilityOptimal}%</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
})}
</div>
<div className="mt-4">
<div className="card">
<div className="card-header">
<h3 className="card-title mb-0">Оптимизированные маршруты Home -&gt; JumpHost -&gt; Exit по BGP community</h3>
</div>
<div className="card-body">
<div className="text-muted small mb-3">
Таблица показывает лучший полный маршрут для каждой community с учетом рекомендованного
gateway на Jumphost.
</div>
<div className="table-responsive mb-0">
<table className="table table-vcenter card-table align-middle" style={OPT_TABLE_STYLE}>
<OptimizerColgroup />
<thead>
<tr>
<th>Community</th>
<th>Маршрут (Home -&gt; JumpHost -&gt; Exit)</th>
<th>Сегменты</th>
<th>Gateway / Действие</th>
<th>Вероятность</th>
</tr>
</thead>
<tbody>
{communityFullRoutes.length === 0 ? (
<tr>
<td colSpan={5} className="text-muted">Недостаточно данных для построения полных маршрутов по community.</td>
</tr>
) : communityFullRoutes.map((r, idx) => (
<tr key={`${r.community}-${routeNodeKey(r.home)}-${routeNodeKey(r.jumphost)}-${routeNodeKey(r.exit)}-${idx}`}>
<td className="text-break">
<div className="fw-semibold">{r.community}</div>
{(r.communityInfo?.name || r.communityInfo?.description) && (
<div className="small text-muted">{r.communityInfo?.name || r.communityInfo?.description}</div>
)}
</td>
<td className="small text-break">
<span className="fw-semibold">{r.home?.label || r.home?.dns || r.home?.ip || 'home'}</span>
<span className="text-muted"> → </span>
<span className="fw-semibold">{r.jumphost?.label || r.jumphost?.dns || r.jumphost?.ip || 'jumphost'}</span>
<span className="text-muted"> → </span>
<span className="fw-semibold">{r.exit?.label || r.exit?.dns || r.exit?.ip || 'exit'}</span>
</td>
<td className="small text-break">
<div>
H→J: ping {r.homeToJumphost?.pingMs ?? '—'} ms, {fmtSpeed(
r.homeToJumphost?.speedDownloadMbps,
r.homeToJumphost?.speedUploadMbps,
r.homeToJumphost?.speedMbps
)}
</div>
<div className="text-muted">
J→E: ping {r.jumphostToExit?.pingMs ?? '—'} ms, {fmtSpeed(
r.jumphostToExit?.speedDownloadMbps,
r.jumphostToExit?.speedUploadMbps,
r.jumphostToExit?.speedMbps
)}
</div>
</td>
<td className="small text-break">
<div>{r.recommendedGateway || '—'}</div>
{r.tunnelGateway && <div className="text-muted">{'->'} {r.tunnelGateway}</div>}
<div className="mt-1">
{r.pinnedBySettings ? (
<span className="badge bg-blue-lt text-blue">pin</span>
) : r.shouldSwitch ? (
<span className="badge bg-orange-lt text-orange">switch</span>
) : (
<span className="badge bg-green-lt text-green">ok</span>
)}
</div>
</td>
<td className="small">
<div>{fmtProb(r.fullRouteProbability)}</div>
<div className="text-muted">score {r.fullRouteScore} / conf {r.fullRouteConfidence ?? '—'}</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div className="mt-4">
<h3 className="mb-3">Оптимизация по community и filters</h3>
<div className="text-muted small mb-3">
Рекомендации строятся на основе ваших <code>server-filters</code> (связки
<code> community -&gt; gateway</code>) и текущих метрик канала
<code> jumphost -&gt; exit</code>.
</div>
{communityOptimization.length === 0 ? (
<div className="alert alert-info">Нет данных по <code>server-filters</code> для jumphost.</div>
) : (
<div className="row g-3">
{communityOptimization.map((item) => {
const jh = item.jumphost || {};
const rows = Array.isArray(item.recommendations) ? item.recommendations : [];
return (
<div className="col-12" key={jh.id || jh.ip || jh.dns || jh.label || 'jumphost'}>
<div className="card">
<div className="card-header d-flex flex-wrap align-items-center justify-content-between gap-2">
<h3 className="card-title mb-0">
Jumphost: {jh.label || jh.dns || jh.ip || 'unknown'}
</h3>
<MetricBadge label="Communities" value={rows.length} tone="blue" />
</div>
<div className="card-body">
<div className="table-responsive">
<table className="table table-vcenter card-table align-middle" style={OPT_TABLE_STYLE}>
<OptimizerColgroup />
<thead>
<tr>
<th>Community</th>
<th>Текущий (рекурсивный -&gt; туннель)</th>
<th>Рекомендуемый (рекурсивный -&gt; туннель)</th>
<th>Действие</th>
<th>Вероятность (тек/реком)</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={5} className="text-muted">
Для этого jumphost нет фильтров.
</td>
</tr>
) : rows.map((r) => (
<tr key={`${jh.label}-${r.community}`}>
<td className="text-break">
<div className="fw-semibold">{r.community}</div>
{(r.communityInfo?.name || r.communityInfo?.description) && (
<div className="small text-muted">
{r.communityInfo?.name || r.communityInfo?.description}
</div>
)}
</td>
<td className="small text-break">
<div>{r.current?.gateway || r.currentGateway || '—'}</div>
{r.current?.tunnelGateway && (
<div className="text-muted">
{'->'} {r.current.tunnelGateway}
{r.current.distance != null ? ` (dist ${r.current.distance})` : ''}
</div>
)}
{(r.current?.interfaceName || r.current?.exit?.country) && (
<div className="text-muted">
{r.current?.exit?.country ? `${countryToFlag(r.current.exit.country)} ` : ''}
{r.current?.interfaceName ? `iface: ${r.current.interfaceName}` : ''}
</div>
)}
</td>
<td className="small text-break">
<div>{r.recommended?.gateway || '—'}</div>
{r.pinnedBySettings && (
<div className="text-muted">
pin: {r.pinnedGateway || '—'}
</div>
)}
{r.recommended?.tunnelGateway && (
<div className="text-muted">
{'->'} {r.recommended.tunnelGateway}
{r.recommended.distance != null ? ` (dist ${r.recommended.distance})` : ''}
</div>
)}
{(r.recommended?.interfaceName || r.recommended?.exit?.country) && (
<div className="text-muted">
{r.recommended?.exit?.country ? `${countryToFlag(r.recommended.exit.country)} ` : ''}
{r.recommended?.interfaceName ? `iface: ${r.recommended.interfaceName}` : ''}
</div>
)}
</td>
<td className="text-break">
{r.current == null && r.recommended == null ? (
<span className="badge bg-secondary-lt text-secondary">Недостаточно данных</span>
) : r.pinnedBySettings && !r.pinnedGatewayResolved ? (
<span className="badge bg-yellow-lt text-yellow">Pin задан, но gateway не найден</span>
) : r.pinnedBySettings && r.shouldSwitch ? (
<span className="badge bg-orange-lt text-orange">Принудительный switch (pin)</span>
) : r.pinnedBySettings ? (
<span className="badge bg-blue-lt text-blue">Зафиксировано (pin)</span>
) : r.current == null ? (
<span className="badge bg-orange-lt text-orange">Текущий gateway не сопоставлен</span>
) : r.shouldSwitch ? (
<span className="badge bg-orange-lt text-orange">Рекомендовано переключить</span>
) : (
<span className="badge bg-green-lt text-green">Оставить текущий</span>
)}
</td>
<td className="small">
{fmtProb(r.current?.probabilityOptimal)} / {fmtProb(r.recommended?.probabilityOptimal)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+392
View File
@@ -0,0 +1,392 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import api from './lib/api.js';
import {
IconChartPie,
IconRefresh,
IconServer,
IconAlertCircle,
} from '@tabler/icons-react';
import {
PieChart,
Pie,
Cell,
ResponsiveContainer,
Tooltip,
} from 'recharts';
import PageHeader from './components/PageHeader.jsx';
/** Палитра в стиле UniFi: зелёные, жёлтые, оранжевые, красные, фиолетовые, серые */
const CHART_COLORS = [
'#2fb344', '#5eba00', '#94c748', '#fab005', '#fd7e14', '#f76707',
'#e64980', '#ae3ec9', '#7950f2', '#5c7cfa', '#748cab', '#868e96',
'#adb5bd', '#ced4da', '#206bc4', '#20c997',
];
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
const DONUT_SIZE = 200;
const DONUT_OUTER = 88;
const DONUT_INNER = 52;
const REQUEST_TIMEOUT_MS = 15000;
function JumphostCard({ jumphost }) {
const { name, host, error, interfaces } = jumphost;
const hasData = Array.isArray(interfaces) && interfaces.length > 0;
const totalBytes = hasData ? interfaces.reduce((acc, i) => acc + (i.totalBytes || 0), 0) : 0;
const chartData = hasData
? interfaces
.filter((i) => (i.totalBytes || 0) > 0)
.map((i, idx) => ({
name: i.name,
value: i.totalBytes,
rxBytes: i.rxBytes,
txBytes: i.txBytes,
fill: CHART_COLORS[idx % CHART_COLORS.length],
}))
: [];
return (
<div className="card h-100">
<div className="card-header d-flex align-items-center">
<span className="avatar avatar-sm me-2 bg-blue-lt text-blue">
<IconServer size={18} />
</span>
<div className="flex-grow-1 min-w-0">
<h3 className="card-title mb-0 text-truncate" title={name}>
{name || host || 'Jumphost'}
</h3>
{host && (
<div className="text-muted small text-truncate" title={host}>
{host}
</div>
)}
</div>
</div>
<div className="card-body">
{error && (
<div className="alert alert-warning py-2 mb-0 d-flex align-items-center">
<IconAlertCircle className="me-2 flex-shrink-0" size={18} />
<span className="small">{error}</span>
</div>
)}
{!error && !hasData && (
<div className="text-muted text-center py-4 small">
Нет данных по интерфейсам
</div>
)}
{!error && hasData && chartData.length === 0 && (
<div className="text-muted text-center py-4 small">
Все счётчики нулевые
</div>
)}
{!error && chartData.length > 0 && (
<div className="traffic-donut-wrapper d-flex flex-wrap align-items-start gap-3">
<div
className="position-relative flex-shrink-0"
style={{ width: DONUT_SIZE, height: DONUT_SIZE }}
>
<ResponsiveContainer width={DONUT_SIZE} height={DONUT_SIZE}>
<PieChart margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
<Pie
data={chartData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={DONUT_INNER}
outerRadius={DONUT_OUTER}
paddingAngle={0}
stroke="none"
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Pie>
<Tooltip
formatter={(value) => formatBytes(value)}
contentStyle={{ fontSize: '12px' }}
labelFormatter={(name, payload) => {
const p = payload?.[0];
if (!p) return name;
return `${name} — RX: ${formatBytes(p.rxBytes || 0)}, TX: ${formatBytes(p.txBytes || 0)}`;
}}
/>
</PieChart>
</ResponsiveContainer>
<div
className="position-absolute top-50 start-50 translate-middle text-center text-body"
style={{ pointerEvents: 'none', width: '60%' }}
>
<div className="fw-bold lh-1" style={{ fontSize: '1.1rem' }}>
{formatBytes(totalBytes)}
</div>
<div className="text-muted small mt-1" style={{ fontSize: '0.65rem', letterSpacing: '0.02em' }}>
TRAFFIC
</div>
</div>
</div>
<div className="traffic-legend flex-grow-1 min-w-0" style={{ maxWidth: 240 }}>
{chartData.map((item) => (
<div
key={item.name}
className="d-flex align-items-center mb-2 small"
title={`${item.name}: RX ${formatBytes(item.rxBytes)}, TX ${formatBytes(item.txBytes)}`}
>
<span
className="flex-shrink-0 rounded"
style={{
width: 10,
height: 10,
backgroundColor: item.fill,
}}
/>
<span className="ms-2 text-truncate">
{item.name} - {formatBytes(item.value)}
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
/** Ключ jumphost для сопоставления с настройками (как в SettingsPage) */
function getJumphostKey(jh) {
return String(jh?.serverId ?? jh?.host ?? jh?.name ?? '');
}
/** Множество выбранных пар (serverKey + interfaceName). Пустое = учитывать все интерфейсы. */
function getTrafficInterfaceFilter(settings) {
const raw = settings?.trafficInterfaces;
if (!raw || !Array.isArray(raw)) return null;
const pairs = raw
.filter((p) => p && (p.serverKey != null || p.serverId != null) && (p.interfaceName != null || p.name != null))
.map((p) => `${String(p.serverKey ?? p.serverId ?? '')}\n${String(p.interfaceName ?? p.name ?? '')}`);
return pairs.length > 0 ? new Set(pairs) : null;
}
export default function TrafficDashboard() {
const [jumphosts, setJumphosts] = useState([]);
const [uiSettings, setUiSettings] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const interfaceFilter = getTrafficInterfaceFilter(uiSettings);
const fetchStats = useCallback(async (abortSignal) => {
setLoading(true);
setError(null);
const opts = { timeout: REQUEST_TIMEOUT_MS, ...(abortSignal ? { signal: abortSignal } : {}) };
try {
const [statsRes, settingsRes] = await Promise.all([
api.get('/traffic/interface-stats', opts),
api.get('/ui-settings', opts).catch(() => ({ data: {} })),
]);
const data = statsRes?.data || {};
setJumphosts(Array.isArray(data?.jumphosts) ? data.jumphosts : []);
setUiSettings(settingsRes?.data || {});
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
console.error('Traffic stats:', e);
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить статистику');
setJumphosts([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
const controller = new AbortController();
fetchStats(controller.signal);
return () => controller.abort();
}, [fetchStats]);
const filteredJumphosts = useMemo(() => {
if (!interfaceFilter) return jumphosts;
return jumphosts.map((jh) => {
const sk = getJumphostKey(jh);
return {
...jh,
interfaces: Array.isArray(jh.interfaces)
? jh.interfaces.filter(
(i) =>
i.name != null &&
interfaceFilter.has(`${sk}\n${String(i.name).trim()}`)
)
: [],
};
});
}, [jumphosts, interfaceFilter]);
// Суммарная статистика по всем отображаемым системам (без ошибок, с данными)
const summary = (() => {
const withData = (filteredJumphosts || []).filter((jh) => !jh.error && Array.isArray(jh.interfaces) && jh.interfaces.length > 0);
let totalBytes = 0;
let totalRx = 0;
let totalTx = 0;
const byJumphost = [];
for (const jh of withData) {
let jhTotal = 0;
let jhRx = 0;
let jhTx = 0;
for (const i of jh.interfaces) {
jhTotal += i.totalBytes || 0;
jhRx += i.rxBytes || 0;
jhTx += i.txBytes || 0;
}
totalBytes += jhTotal;
totalRx += jhRx;
totalTx += jhTx;
if (jhTotal > 0) {
byJumphost.push({
name: jh.name || jh.host || 'Jumphost',
value: jhTotal,
fill: CHART_COLORS[byJumphost.length % CHART_COLORS.length],
});
}
}
return {
totalBytes,
totalRx,
totalTx,
systemsCount: withData.length,
byJumphost,
};
})();
const hasSummary = summary.systemsCount > 0 && summary.totalBytes > 0;
return (
<div>
<PageHeader
title="Расход трафика"
icon={<IconChartPie size={24} />}
meta="Статистика по интерфейсам MikroTik RouterOS (jumphost)"
actions={
<button
type="button"
className="btn btn-outline-primary"
onClick={() => fetchStats()}
disabled={loading}
>
<IconRefresh className={loading ? 'spin me-2' : 'me-2'} size={18} />
{loading ? 'Загрузка…' : 'Обновить'}
</button>
}
/>
{error && (
<div className="alert alert-danger d-flex align-items-center">
<IconAlertCircle className="me-2" size={20} />
{error}
</div>
)}
{loading && jumphosts.length === 0 && !error && (
<div className="text-center py-5 text-muted">
Загрузка статистики с роутеров…
</div>
)}
{!loading && !error && jumphosts.length === 0 && (
<div className="alert alert-info">
Нет jumphost-серверов с настроенным MikroTik API. Добавьте серверы типа «Jumphost» и укажите учётные данные RouterOS.
</div>
)}
{!loading && jumphosts.length > 0 && (
<>
{hasSummary && (
<div className="card mb-4">
<div className="card-header">
<h3 className="card-title mb-0">Суммарно по всем системам</h3>
</div>
<div className="card-body">
<div className="d-flex flex-wrap align-items-stretch gap-4">
<div className="position-relative flex-shrink-0" style={{ width: 220, height: 220 }}>
<ResponsiveContainer width={220} height={220}>
<PieChart margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
<Pie
data={summary.byJumphost}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={58}
outerRadius={98}
paddingAngle={0}
stroke="none"
>
{summary.byJumphost.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Pie>
<Tooltip formatter={(value) => formatBytes(value)} />
</PieChart>
</ResponsiveContainer>
<div
className="position-absolute top-50 start-50 translate-middle text-center text-body"
style={{ pointerEvents: 'none', width: '55%' }}
>
<div className="fw-bold lh-1" style={{ fontSize: '1.35rem' }}>
{formatBytes(summary.totalBytes)}
</div>
<div className="text-muted small mt-1" style={{ fontSize: '0.7rem', letterSpacing: '0.02em' }}>
TRAFFIC
</div>
</div>
</div>
<div className="d-flex flex-wrap gap-4 align-items-center flex-grow-1">
<div className="traffic-summary-legend">
{summary.byJumphost.map((item) => (
<div key={item.name} className="d-flex align-items-center mb-2 small">
<span
className="flex-shrink-0 rounded"
style={{ width: 10, height: 10, backgroundColor: item.fill }}
/>
<span className="ms-2">
{item.name} - {formatBytes(item.value)}
</span>
</div>
))}
</div>
<div className="d-flex flex-wrap gap-3 ms-md-auto">
<div className="px-3 py-2 rounded bg-blue-lt text-blue">
<span className="d-block small text-muted">Всего трафика</span>
<span className="fw-bold">{formatBytes(summary.totalBytes)}</span>
</div>
<div className="px-3 py-2 rounded bg-green-lt text-green">
<span className="d-block small text-muted">RX</span>
<span className="fw-bold">{formatBytes(summary.totalRx)}</span>
</div>
<div className="px-3 py-2 rounded bg-orange-lt text-orange">
<span className="d-block small text-muted">TX</span>
<span className="fw-bold">{formatBytes(summary.totalTx)}</span>
</div>
<div className="px-3 py-2 rounded bg-azure-lt text-azure">
<span className="d-block small text-muted">Систем</span>
<span className="fw-bold">{summary.systemsCount}</span>
</div>
</div>
</div>
</div>
</div>
</div>
)}
<div className="row row-cards g-3">
{filteredJumphosts.map((jh) => (
<div key={jh.serverId || jh.host || jh.name} className="col-12 col-md-6 col-xl-4">
<JumphostCard jumphost={jh} />
</div>
))}
</div>
</>
)}
</div>
);
}
+516
View File
@@ -0,0 +1,516 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import api from './lib/api.js';
import {
IconClock,
IconRefresh,
IconServer,
IconCircleCheck,
IconCircleX,
IconChartLine,
} from '@tabler/icons-react';
import {
AreaChart,
Area,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import PageHeader from './components/PageHeader.jsx';
import { formatRelative } from './lib/datetime.js';
const DEFAULT_CHECK_INTERVAL_SEC = 120;
const PARALLEL_CHECKS = 4; // количество одновременных проверок
const HISTORY_MAX = 500;
const STORAGE_KEY = 'uptime-monitor-history';
function loadHistoryFromStorage() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') return parsed;
} catch { /* no-op */ }
return {};
}
function saveHistoryToStorage(historyMap) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(historyMap));
} catch { /* no-op */ }
}
/** Группировка истории по дням: массив { day, dayLabel, incidents } */
function historyToIncidentsByDay(history) {
if (!Array.isArray(history) || history.length === 0) return [];
const byDay = new Map(); // dayKey -> count of down checks
history.forEach(({ ts, ok }) => {
if (ok) return;
const d = new Date(ts);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
byDay.set(key, (byDay.get(key) || 0) + 1);
});
return Array.from(byDay.entries())
.map(([day, incidents]) => ({
day,
dayLabel: new Date(day + 'T12:00:00').toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'short',
}),
incidents,
}))
.sort((a, b) => a.day.localeCompare(b.day));
}
/** История в точки для графика задержек: { time, ts, ms } */
function historyToResponseTimeData(history) {
if (!Array.isArray(history) || history.length === 0) return [];
return history.map(({ ts, ok, ms }) => ({
ts,
time: new Date(ts).toLocaleString('ru-RU', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}),
ms: ok && ms != null ? Math.round(ms) : null,
}));
}
export default function UptimeMonitorPage() {
const location = useLocation();
const [servers, setServers] = useState([]);
const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false);
const [error, setError] = useState(null);
const [statusMap, setStatusMap] = useState({});
/** По serverId: массив { ts, ok, ms } */
const [historyMap, setHistoryMap] = useState(loadHistoryFromStorage);
const [selectedServerId, setSelectedServerId] = useState(null);
const [checkIntervalSeconds, setCheckIntervalSeconds] = useState(DEFAULT_CHECK_INTERVAL_SEC);
/** serverId, по которым сейчас идёт проверка — показываем «Проверка…» в строке (до PARALLEL_CHECKS одновременно) */
const [currentCheckingServerIds, setCurrentCheckingServerIds] = useState([]);
const intervalRef = useRef(null);
const checkingRef = useRef(false);
const activeRunIdRef = useRef(0);
const checkControllersRef = useRef(new Set());
const jumphosts = useMemo(
() =>
servers.filter((s) =>
['jumphost', 'home'].includes(String(s.type || '').toLowerCase())
),
[servers]
);
const controllerRef = useRef(null);
const fetchServers = useCallback(async (abortSignal) => {
setLoading(true);
setError(null);
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const [serversRes, settingsRes] = await Promise.all([
api.get('/servers', opts),
api.get('/ui-settings', opts).catch(() => ({ data: {} })),
]);
const data = serversRes?.data;
setServers(Array.isArray(data) ? data : []);
const ui = settingsRes?.data || {};
const interval = Math.max(30, Math.min(86400, parseInt(ui.uptimeMonitorIntervalSeconds, 10) || DEFAULT_CHECK_INTERVAL_SEC));
setCheckIntervalSeconds(interval);
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить серверы');
setServers([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
const c = new AbortController();
controllerRef.current = c;
fetchServers(c.signal);
return () => {
c.abort();
controllerRef.current = null;
};
}, [fetchServers]);
// При заходе на страницу подставляем кеш — сразу видны последние проверки
useEffect(() => {
if (jumphosts.length === 0) return;
const signal = controllerRef.current?.signal;
if (!signal) return;
api.get('/uptime/cache', { signal })
.then(({ data }) => {
if (data?.results && typeof data.results === 'object' && Object.keys(data.results).length > 0) {
setStatusMap((prev) => ({ ...prev, ...data.results }));
}
})
.catch((e) => {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
});
}, [jumphosts]);
const checkIntervalMs = checkIntervalSeconds * 1000;
/** Параллельный запуск проверок (до PARALLEL_CHECKS одновременно), результат по мере готовности */
const runChecks = useCallback(async () => {
if (jumphosts.length === 0) return;
if (checkingRef.current) return;
const runId = ++activeRunIdRef.current;
checkingRef.current = true;
setChecking(true);
setCurrentCheckingServerIds([]);
let index = 0;
const getNext = () => {
const i = index++;
return i < jumphosts.length ? jumphosts[i] : null;
};
const checkOne = async () => {
let server;
while ((server = getNext()) != null) {
if (activeRunIdRef.current !== runId) break;
const serverId = server.id || server.dns || server.ip;
if (!serverId) continue;
setCurrentCheckingServerIds((prev) => [...prev, serverId]);
const t0 = Date.now();
let ok = false;
let ms = null;
const controller = new AbortController();
checkControllersRef.current.add(controller);
try {
const { data: checkData } = await api.post('/uptime/check', { serverId }, { signal: controller.signal });
ok = checkData?.ok === true;
ms = typeof checkData?.ms === 'number' ? checkData.ms : Date.now() - t0;
} catch (e) {
if (e?.code === 'ERR_CANCELED' || e?.name === 'CanceledError' || e?.name === 'AbortError') {
setCurrentCheckingServerIds((prev) => prev.filter((id) => id !== serverId));
checkControllersRef.current.delete(controller);
continue;
}
ms = Date.now() - t0;
} finally {
checkControllersRef.current.delete(controller);
}
const lastCheckTs = Date.now();
const entry = { ts: lastCheckTs, ok, ms };
setStatusMap((prev) => ({ ...prev, [serverId]: { ok, lastCheckTs } }));
setHistoryMap((prev) => {
const list = Array.isArray(prev[serverId]) ? prev[serverId] : [];
const merged = [...list, entry].slice(-HISTORY_MAX);
return { ...prev, [serverId]: merged };
});
setCurrentCheckingServerIds((prev) => prev.filter((id) => id !== serverId));
}
};
await Promise.all(Array.from({ length: PARALLEL_CHECKS }, checkOne));
if (activeRunIdRef.current !== runId) return;
setCurrentCheckingServerIds([]);
checkingRef.current = false;
setChecking(false);
}, [jumphosts]);
// Первый запуск проверки через несколько секунд после загрузки (после перезапуска контейнера кеш может быть пустым или устаревшим)
useEffect(() => {
if (jumphosts.length === 0) return;
const t = setTimeout(runChecks, 4000);
return () => clearTimeout(t);
}, [jumphosts.length, runChecks]);
// Автопроверка по интервалу в фоне
useEffect(() => {
if (jumphosts.length === 0) return;
intervalRef.current = setInterval(runChecks, checkIntervalMs);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [jumphosts, runChecks, checkIntervalMs]);
// Остановка фоновых uptime-check при уходе со страницы
useEffect(() => {
if (location.pathname === '/uptime-monitor') return;
activeRunIdRef.current += 1;
checkingRef.current = false;
if (intervalRef.current) clearInterval(intervalRef.current);
const controllers = checkControllersRef.current;
controllers.forEach((controller) => controller.abort());
controllers.clear();
setCurrentCheckingServerIds([]);
setChecking(false);
}, [location.pathname]);
useEffect(() => {
const controllers = checkControllersRef.current;
return () => {
activeRunIdRef.current += 1;
checkingRef.current = false;
if (intervalRef.current) clearInterval(intervalRef.current);
controllers.forEach((controller) => controller.abort());
controllers.clear();
};
}, []);
// Сохраняем историю в localStorage при изменении
useEffect(() => {
saveHistoryToStorage(historyMap);
}, [historyMap]);
const selectedServer = selectedServerId
? jumphosts.find((s) => (s.id || s.dns || s.ip) === selectedServerId)
: null;
const selectedHistory = useMemo(
() => (selectedServerId ? historyMap[selectedServerId] || [] : []),
[selectedServerId, historyMap]
);
const responseTimeData = useMemo(
() => historyToResponseTimeData(selectedHistory),
[selectedHistory]
);
const incidentsByDay = useMemo(
() => historyToIncidentsByDay(selectedHistory),
[selectedHistory]
);
return (
<div className="page-body">
<div className="container-fluid">
<PageHeader
title="Uptime Monitor"
icon={<IconClock size={28} />}
pretitle="Extra"
meta="Проверка доступности всех Jumphost и домашних роутеров (MikroTik API)"
actions={
<button
type="button"
className="btn btn-primary"
onClick={runChecks}
disabled={jumphosts.length === 0 || checking}
>
<IconRefresh className={checking ? 'spin' : ''} size={18} />
{checking ? ' Проверка…' : ' Проверить все'}
</button>
}
/>
{error && (
<div className="alert alert-danger" role="alert">
{error}
</div>
)}
{!loading && jumphosts.length === 0 && (
<div className="empty">
<div className="empty-icon">
<IconChartLine size={48} />
</div>
<p className="empty-title">Нет Jumphost</p>
<p className="empty-subtitle text-muted">
Добавьте серверы типа «Jumphost» или «Home» с настроенным MikroTik API — здесь будет отображаться их доступность.
</p>
</div>
)}
{!loading && jumphosts.length > 0 && (
<>
<div className="card">
<div className="card-header">
<h3 className="card-title">Доступность Jumphost и домашних роутеров</h3>
<div className="card-actions text-muted small">
Проверка каждые {checkIntervalSeconds} сек · Выберите сервер для графиков
</div>
</div>
<div className="table-responsive">
<table className="table table-vcenter card-table table-striped">
<thead>
<tr>
<th>Сервер</th>
<th>Статус</th>
<th>Последняя проверка</th>
</tr>
</thead>
<tbody>
{jumphosts.map((s) => {
const id = s.id || s.dns || s.ip;
const status = statusMap[id];
const label = s.dns || s.name || s.ip || id;
const isSelected = selectedServerId === id;
return (
<tr
key={id}
role="button"
tabIndex={0}
onClick={() => setSelectedServerId(id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setSelectedServerId(id);
}
}}
className={isSelected ? 'table-active' : ''}
style={{ cursor: 'pointer' }}
>
<td>
<div className="d-flex align-items-center">
<IconServer size={18} className="me-2 text-muted" />
{label}
</div>
</td>
<td>
{currentCheckingServerIds.includes(id) && (
<span className="badge bg-blue-lt text-blue">
<span className="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true" />
Проверка…
</span>
)}
{!currentCheckingServerIds.includes(id) && status == null && (
<span className="badge bg-secondary">—</span>
)}
{!currentCheckingServerIds.includes(id) && status?.ok === true && (
<span className="badge bg-success-lt text-success">
<IconCircleCheck size={14} /> Доступен
</span>
)}
{!currentCheckingServerIds.includes(id) && status?.ok === false && (
<span className="badge bg-danger-lt text-danger">
<IconCircleX size={14} /> Недоступен
</span>
)}
</td>
<td className="text-muted">
{currentCheckingServerIds.includes(id)
? '…'
: status?.lastCheckTs
? formatRelative(status.lastCheckTs)
: '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{selectedServer && (
<div className="row mt-4">
<div className="col-lg-6 mb-4">
<div className="card h-100">
<div className="card-header">
<h3 className="card-title">Время ответа за период</h3>
<div className="card-actions text-muted small">
{selectedServer.dns || selectedServer.name || selectedServer.ip}
</div>
</div>
<div className="card-body">
{responseTimeData.length === 0 ? (
<p className="text-muted mb-0">
Нет данных. Проверки накапливаются со временем (хранятся в браузере).
</p>
) : (
<ResponsiveContainer width="100%" height={260}>
<AreaChart
data={responseTimeData}
margin={{ top: 8, right: 16, left: 8, bottom: 8 }}
>
<defs>
<linearGradient id="colorMs" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#206bc4" stopOpacity={0.3} />
<stop offset="95%" stopColor="#206bc4" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e9ecef" />
<XAxis
dataKey="time"
tick={{ fontSize: 11 }}
interval="preserveStartEnd"
/>
<YAxis
unit=" мс"
tick={{ fontSize: 11 }}
domain={[0, (dataMax) => Math.max(dataMax || 0, 100)]}
/>
<Tooltip
formatter={(value) => [value != null ? `${value} мс` : '—', 'Задержка']}
labelFormatter={(_, payload) =>
payload[0]?.payload?.time ?? ''
}
/>
<Area
type="monotone"
dataKey="ms"
stroke="#206bc4"
fill="url(#colorMs)"
strokeWidth={2}
connectNulls={false}
dot={false}
activeDot={{ r: 4 }}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
</div>
</div>
<div className="col-lg-6 mb-4">
<div className="card h-100">
<div className="card-header">
<h3 className="card-title">Инциденты по дням</h3>
<div className="card-actions text-muted small">
Недоступность (кол-во проверок)
</div>
</div>
<div className="card-body">
{incidentsByDay.length === 0 ? (
<p className="text-muted mb-0">
Нет инцидентов за сохранённый период или нет данных.
</p>
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart
data={incidentsByDay}
margin={{ top: 8, right: 16, left: 8, bottom: 8 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e9ecef" />
<XAxis
dataKey="dayLabel"
tick={{ fontSize: 11 }}
interval="preserveStartEnd"
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 11 }}
domain={[0, (dataMax) => Math.max(dataMax || 0, 1)]}
/>
<Tooltip
formatter={(value) => [value, 'Инцидентов']}
labelFormatter={(_, payload) =>
payload[0]?.payload?.day ?? ''
}
/>
<Bar
dataKey="incidents"
fill="#d63939"
radius={[2, 2, 0, 0]}
name="Инциденты"
/>
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}
+262
View File
@@ -0,0 +1,262 @@
import { useState, useEffect } from 'react';
import FormModal from './FormModal.jsx';
import FormField from './FormField.jsx';
import CommunityAutocompleteInput from './CommunityAutocompleteInput.jsx';
import { IconPlus, IconWorld, IconNetwork, IconHash } from '@tabler/icons-react';
/**
* Универсальное модальное окно для добавления записи (домен/IP-диапазон/ASN)
*
* @param {Object} props
* @param {boolean} props.open - Открыто ли модальное окно
* @param {function} props.onClose - Закрытие модального окна
* @param {function} props.onSubmit - Обработчик добавления (item) => void
* @param {string} props.type - Тип записи: 'domain' | 'ipRange' | 'asn'
* @param {Array} props.communities - Список community для автодополнения
* @param {boolean} props.loading - Состояние загрузки
*/
export default function AddItemModal({
open,
onClose,
onSubmit,
type = 'domain',
communities = [],
loading = false
}) {
const [formData, setFormData] = useState({ value: '', community: '' });
const [errors, setErrors] = useState({ value: '', community: '' });
// Сброс формы при закрытии
useEffect(() => {
if (!open) {
setFormData({ value: '', community: '' });
setErrors({ value: '', community: '' });
}
}, [open]);
const validators = {
domain: (v) => {
const val = String(v).trim().toLowerCase();
return /^([a-z0-9-]+\.)+[a-z]{2,}$/i.test(val);
},
ipRange: (v) => {
const val = String(v).trim();
const parts = val.split('/');
if (parts.length !== 2) return false;
const [ip, mask] = parts;
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) return false;
const octets = ip.split('.');
if (!octets.every(o => /^\d{1,3}$/.test(o) && Number(o) >= 0 && Number(o) <= 255)) return false;
if (!/^\d{1,2}$/.test(mask)) return false;
const m = Number(mask);
return m >= 0 && m <= 32;
},
asn: (v) => {
const val = String(v).trim();
return /^[0-9]+$/.test(val);
}
};
const isValidCommunity = (v) => /^(\d+|\d+:\d+)$/.test(String(v).trim());
const validate = () => {
const valueValid = validators[type]?.(formData.value);
const communityValid = isValidCommunity(formData.community);
setErrors({
value: valueValid ? '' : getValueError(),
community: communityValid ? '' : 'Неверный формат community (N или N:N)'
});
return valueValid && communityValid;
};
const getValueError = () => {
switch (type) {
case 'domain':
return 'Неверный формат домена (sub.domain.tld)';
case 'ipRange':
return 'Неверный формат IP-диапазона (A.B.C.D/Mask)';
case 'asn':
return 'Неверный формат ASN (только цифры, например 12345)';
default:
return 'Неверный формат';
}
};
const getValueLabel = () => {
switch (type) {
case 'domain':
return 'Домен';
case 'ipRange':
return 'IP-диапазон';
case 'asn':
return 'ASN';
default:
return 'Значение';
}
};
const getValuePlaceholder = () => {
switch (type) {
case 'domain':
return 'example.com';
case 'ipRange':
return '192.168.1.0/24';
case 'asn':
return '12345';
default:
return '';
}
};
const getValueHelp = () => {
switch (type) {
case 'domain':
return 'Формат: sub.domain.tld (например foo.example.org)';
case 'ipRange':
return 'Формат: A.B.C.D/Mask, например 10.0.0.0/24';
case 'asn':
return 'Формат: только цифры, например 12345';
default:
return '';
}
};
const getValueIcon = () => {
switch (type) {
case 'domain':
return IconWorld;
case 'ipRange':
return IconNetwork;
case 'asn':
return IconHash;
default:
return IconHash;
}
};
const getTitle = () => {
switch (type) {
case 'domain':
return 'Добавить домен';
case 'ipRange':
return 'Добавить IP-диапазон';
case 'asn':
return 'Добавить ASN';
default:
return 'Добавить запись';
}
};
const handleSubmit = (e) => {
e.preventDefault();
if (!validate()) return;
const normalizedValue = type === 'domain'
? formData.value.trim().toLowerCase()
: formData.value.trim();
const item = {
[type === 'domain' ? 'domain' : type === 'ipRange' ? 'ipRange' : 'asn']: normalizedValue,
community: String(formData.community).trim()
};
onSubmit?.(item);
onClose?.();
};
const ValueIcon = getValueIcon();
return (
<FormModal
show={open}
onClose={onClose}
onSubmit={handleSubmit}
title={getTitle()}
submitLabel="Добавить"
submitIcon={IconPlus}
loading={loading}
size="md"
centered
>
<FormField
label={getValueLabel()}
name="value"
type="text"
value={formData.value}
onChange={(v) => {
setFormData({ ...formData, value: v });
if (errors.value) setErrors({ ...errors, value: '' });
}}
onValidate={(v) => ({
valid: validators[type]?.(v) ?? false,
message: validators[type]?.(v) ? 'Корректный формат' : getValueError()
})}
placeholder={getValuePlaceholder()}
helpText={getValueHelp()}
disabled={loading}
autoFocus
icon={ValueIcon}
/>
<div className="mb-3">
<label className="form-label">
Community
<span className="text-danger ms-1" aria-label="обязательное поле">*</span>
</label>
<div className="input-icon">
<span className="input-icon-addon">
<IconHash size={18} />
</span>
<CommunityAutocompleteInput
value={formData.community}
onChange={(v) => {
setFormData({ ...formData, community: v });
if (errors.community) setErrors({ ...errors, community: '' });
}}
communities={communities}
placeholder="# Начните вводить номер или имя"
className={`form-control${errors.community ? ' is-invalid' : isValidCommunity(formData.community) ? ' is-valid' : ''}`}
useShortValue={true}
disabled={loading}
/>
</div>
{errors.community && (
<div className="invalid-feedback d-block">{errors.community}</div>
)}
{isValidCommunity(formData.community) && !errors.community && (
<div className="valid-feedback d-block">Корректный формат</div>
)}
{!errors.community && !isValidCommunity(formData.community) && (
<div className="form-text text-muted small">
Формат: N или N:N (например 65000:100)
</div>
)}
</div>
{(() => {
if (!formData.community) return null;
const match = communities.find(c => c.value === String(formData.community).trim());
if (!match) return null;
return (
<div className="alert alert-info alert-dismissible mt-2 mb-0" role="alert">
<div className="d-flex">
<div className="flex-grow-1">
<strong>{match.name || match.value}</strong>
{match.description && <div className="text-muted small mt-1">{match.description}</div>}
{match.tags && match.tags.length > 0 && (
<div className="mt-2">
{match.tags.map((tag, idx) => (
<span key={idx} className="badge bg-blue-lt text-blue me-1">{tag}</span>
))}
</div>
)}
</div>
</div>
</div>
);
})()}
</FormModal>
);
}
+224
View File
@@ -0,0 +1,224 @@
import { useState, useRef, useEffect, useLayoutEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Link } from 'react-router-dom';
import { IconBell, IconAlertTriangle, IconServerOff, IconCpu, IconDeviceDesktop, IconDatabase } from '@tabler/icons-react';
import { useAlerts } from '../contexts/AlertsContext.jsx';
const SEVERITY_ICONS = {
critical: IconAlertTriangle,
warning: IconAlertTriangle,
};
const TYPE_ICONS = {
server_offline: IconServerOff,
mikrotik_unreachable: IconServerOff,
high_cpu: IconCpu,
high_ram: IconDeviceDesktop,
high_hdd: IconDatabase,
};
function AlertIcon({ type, severity }) {
const Icon = TYPE_ICONS[type] || SEVERITY_ICONS[severity] || IconAlertTriangle;
const colorClass = severity === 'critical' ? 'text-danger' : 'text-warning';
return <Icon size={18} className={colorClass} />;
}
const PANEL_WIDTH = 320;
const PANEL_MAX_HEIGHT = 320;
const Z_INDEX = 1050;
function AlertsPanelContent({ loading, refresh, alerts, onClose }) {
return (
<>
<div className="card-header d-flex align-items-center justify-content-between py-2 px-3">
<span className="fw-semibold">Оповещения</span>
<button
type="button"
className="btn btn-ghost-secondary btn-sm"
onClick={(e) => { e.preventDefault(); refresh(); }}
disabled={loading}
title="Обновить"
>
{loading ? '…' : '↻'}
</button>
</div>
<div className="list-group list-group-flush list-group-hoverable" style={{ maxHeight: `${PANEL_MAX_HEIGHT}px`, overflowY: 'auto' }}>
{alerts.length === 0 && !loading && (
<div className="list-group-item text-muted small text-center py-3">
Нет активных оповещений
</div>
)}
{alerts.slice(0, 20).map((alert) => (
<Link
key={alert.id}
to={alert.link || '/dashboard'}
className="list-group-item list-group-item-action py-2"
onClick={onClose}
>
<div className="d-flex align-items-start gap-2">
<span className="flex-shrink-0 mt-1">
<AlertIcon type={alert.type} severity={alert.severity} />
</span>
<div className="min-w-0 flex-grow-1">
<div className="fw-medium small">{alert.title}</div>
<div className="text-muted small text-truncate" title={alert.description}>
{alert.entity}
</div>
</div>
</div>
</Link>
))}
{alerts.length > 20 && (
<div className="list-group-item text-muted small text-center py-2">
и ещё {alerts.length - 20}…
</div>
)}
</div>
<div className="card-footer py-2 px-3 d-flex align-items-center gap-2 flex-nowrap">
<Link to="/settings#alerts" className="btn btn-sm btn-ghost-secondary text-nowrap" onClick={onClose}>
Настройки
</Link>
<Link to="/dashboard" className="btn btn-sm btn-outline-primary text-nowrap" onClick={onClose}>
Панель
</Link>
</div>
</>
);
}
export default function AlertsBell({ dropup = false }) {
const { alerts, total, loading, refresh } = useAlerts();
const [open, setOpen] = useState(false);
const triggerRef = useRef(null);
const panelRef = useRef(null);
const [position, setPosition] = useState({ top: 0, left: 0 });
const updatePosition = useCallback(() => {
const el = triggerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const padding = 4;
const maxLeft = window.innerWidth - PANEL_WIDTH - padding;
if (dropup) {
setPosition({
left: Math.max(padding, Math.min(rect.left, maxLeft)),
bottom: window.innerHeight - rect.top + padding,
});
} else {
setPosition({
top: rect.bottom + padding,
left: Math.max(padding, Math.min(rect.left, maxLeft)),
});
}
}, [dropup]);
useLayoutEffect(() => {
if (open) updatePosition();
}, [open, dropup, updatePosition]);
useEffect(() => {
if (!open) return;
const onScroll = () => updatePosition();
const onResize = () => updatePosition();
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
const onClickOutside = (e) => {
const panel = panelRef.current;
const trigger = triggerRef.current;
if (!panel || !trigger) return;
if (!panel.contains(e.target) && !trigger.contains(e.target)) setOpen(false);
};
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onResize);
document.addEventListener('keydown', onKey);
document.addEventListener('mousedown', onClickOutside);
return () => {
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onResize);
document.removeEventListener('keydown', onKey);
document.removeEventListener('mousedown', onClickOutside);
};
}, [open, updatePosition]);
const handleTriggerClick = (e) => {
e.preventDefault();
if (dropup) {
setOpen((v) => !v);
}
};
if (dropup) {
return (
<>
<div className="nav-item">
<a
ref={triggerRef}
href="#"
className="nav-link px-2 d-inline-flex align-items-center justify-content-center"
onClick={handleTriggerClick}
aria-expanded={open}
aria-haspopup="true"
aria-label={`Оповещения: ${total} активных`}
title="Оповещения о проблемах"
>
<span className="position-relative d-inline-flex">
<IconBell size={20} />
{total > 0 && (
<span className="badge badge-sm bg-danger position-absolute rounded-pill" style={{ top: '-2px', right: '-2px' }}>
{total > 99 ? '99+' : total}
</span>
)}
</span>
</a>
</div>
{open && typeof document !== 'undefined' && createPortal(
<div
ref={panelRef}
className="dropdown-menu dropdown-menu-card show overflow-hidden shadow-lg border"
data-bs-theme="light"
style={{
position: 'fixed',
left: position.left,
...(position.bottom !== undefined ? { bottom: position.bottom } : { top: position.top }),
width: `${PANEL_WIDTH}px`,
maxWidth: 'calc(100vw - 1rem)',
zIndex: Z_INDEX,
}}
>
<AlertsPanelContent
loading={loading}
refresh={refresh}
alerts={alerts}
onClose={() => setOpen(false)}
/>
</div>,
document.body
)}
</>
);
}
return (
<div className="nav-item dropdown">
<a
href="#"
className="nav-link px-2 dropdown-toggle position-relative d-inline-flex align-items-center justify-content-center"
data-bs-toggle="dropdown"
onClick={(e) => e.preventDefault()}
aria-label={`Оповещения: ${total} активных`}
title="Оповещения о проблемах"
>
<span className="position-relative d-inline-flex">
<IconBell size={20} />
{total > 0 && (
<span className="badge badge-sm bg-danger position-absolute top-0 end-0 translate-middle rounded-pill" style={{ top: '-2px', right: '-2px' }}>
{total > 99 ? '99+' : total}
</span>
)}
</span>
</a>
<div className="dropdown-menu dropdown-menu-end dropdown-menu-card overflow-hidden" style={{ width: `${PANEL_WIDTH}px`, maxWidth: '95vw' }}>
<AlertsPanelContent loading={loading} refresh={refresh} alerts={alerts} onClose={() => {}} />
</div>
</div>
);
}
+59 -106
View File
@@ -1,120 +1,73 @@
import { IconTrash, IconEdit, IconDownload, IconX, IconChecks } from '@tabler/icons-react'
import { IconTrash, IconEdit, IconDownload } from '@tabler/icons-react';
import FloatingBulkActionsBar from './FloatingBulkActionsBar.jsx';
/**
* Панель массовых действий для таблиц
* Показывается когда выбраны элементы
* Панель массовых действий для таблиц (всплывающая, как на скрине).
* Показывается когда выбраны элементы. Использует единый дизайн тёмной плавающей панели.
* «Выбрать все» остаётся в тулбаре/шапке таблицы (чекбокс).
*/
function BulkActionsBar({
function BulkActionsBar({
selectedCount = 0,
totalCount = 0,
onSelectAll = () => {},
totalCount: _totalCount = 0,
onSelectAll: _onSelectAll = () => {},
onDeselectAll = () => {},
onDelete = null,
onEdit = null,
onExport = null,
customActions = [], // [{icon: Icon, label, onClick, variant}]
className = ''
customActions = [],
className: _className = '',
}) {
if (selectedCount === 0) return null
const actions = [];
const allSelected = selectedCount === totalCount
if (onExport) {
actions.push({
type: 'button',
label: 'Экспорт',
icon: IconDownload,
onClick: onExport,
title: 'Экспортировать выбранное',
});
}
if (onEdit) {
actions.push({
type: 'button',
label: 'Изменить',
icon: IconEdit,
onClick: onEdit,
title: 'Редактировать выбранное',
});
}
customActions.forEach((action) => {
actions.push({
type: 'button',
label: action.label,
icon: action.icon,
onClick: action.onClick,
variant: action.variant === 'btn-outline-danger' || action.variant === 'danger' ? 'danger' : undefined,
title: action.title,
});
});
if (onDelete) {
actions.push({
type: 'button',
label: 'Удалить',
icon: IconTrash,
onClick: onDelete,
variant: 'danger',
title: 'Удалить выбранное',
});
}
return (
<div className={`card mb-3 ${className}`} style={{
position: 'sticky',
top: '10px',
zIndex: 10,
boxShadow: '0 0.5rem 1rem rgba(0,0,0,.15)'
}}>
<div className="card-body py-2 px-3">
<div className="d-flex align-items-center justify-content-between">
{/* Левая часть: информация о выборе */}
<div className="d-flex align-items-center gap-3">
<div className="d-flex align-items-center">
<IconChecks size={20} className="text-blue me-2" />
<span className="fw-bold">
Выбрано: <span className="text-blue">{selectedCount}</span>
</span>
</div>
{/* Кнопка выбрать все / снять выбор */}
{!allSelected && totalCount > selectedCount && (
<button
className="btn btn-sm btn-outline-primary"
onClick={onSelectAll}
>
Выбрать все ({totalCount})
</button>
)}
<button
className="btn btn-sm btn-ghost-secondary"
onClick={onDeselectAll}
>
<IconX size={16} className="me-1" />
Снять выбор
</button>
</div>
{/* Правая часть: действия */}
<div className="d-flex align-items-center gap-2">
{/* Экспорт */}
{onExport && (
<button
className="btn btn-sm btn-outline-primary"
onClick={onExport}
title="Экспортировать выбранное"
>
<IconDownload size={16} className="me-1" />
Экспорт
</button>
)}
{/* Редактирование */}
{onEdit && (
<button
className="btn btn-sm btn-outline-primary"
onClick={onEdit}
title="Редактировать выбранное"
>
<IconEdit size={16} className="me-1" />
Изменить
</button>
)}
{/* Пользовательские действия */}
{customActions.map((action, idx) => {
const Icon = action.icon
return (
<button
key={idx}
className={`btn btn-sm ${action.variant || 'btn-outline-primary'}`}
onClick={action.onClick}
title={action.title}
>
{Icon && <Icon size={16} className="me-1" />}
{action.label}
</button>
)
})}
{/* Удаление */}
{onDelete && (
<button
className="btn btn-sm btn-outline-danger"
onClick={onDelete}
title="Удалить выбранное"
>
<IconTrash size={16} className="me-1" />
Удалить ({selectedCount})
</button>
)}
</div>
</div>
</div>
</div>
)
<FloatingBulkActionsBar
selectedCount={selectedCount}
onClearSelection={onDeselectAll}
actions={actions}
/>
);
}
export default BulkActionsBar
export default BulkActionsBar;
+12 -1
View File
@@ -10,7 +10,12 @@ import {
IconDatabase,
IconCreditCard,
IconDownload,
IconKeyboard
IconKeyboard,
IconChartPie,
IconSettings,
IconClock,
IconClockPlay,
IconShield
} from '@tabler/icons-react'
/**
@@ -38,14 +43,20 @@ function CommandPalette() {
// Список команд
const commands = [
{ icon: IconHome, label: 'Главная', description: 'Панель управления', action: () => navigate('/dashboard'), keywords: ['главная', 'панель', 'dashboard'] },
{ icon: IconChartPie, label: 'Расход трафика', description: 'Статистика по интерфейсам MikroTik', action: () => navigate('/traffic'), keywords: ['трафик', 'traffic', 'mikrotik', 'интерфейсы'] },
{ icon: IconNetwork, label: 'Карта сети', description: 'Граф серверов и пинг между ними', action: () => navigate('/network-map'), keywords: ['карта', 'сеть', 'network', 'map', 'пинг', 'ping'] },
{ icon: IconClock, label: 'Uptime Monitor', description: 'Мониторинг доступности по ping с выбранного роутера', action: () => navigate('/uptime-monitor'), keywords: ['uptime', 'мониторинг', 'доступность', 'ping', 'роутер'] },
{ icon: IconClockPlay, label: 'Планировщик карты сети', description: 'Пинг и скорость по расписанию, логи', action: () => navigate('/scheduler'), keywords: ['планировщик', 'scheduler', 'карта', 'сеть', 'логи'] },
{ icon: IconWorld, label: 'Домены', description: 'Управление доменами', action: () => navigate('/domains'), keywords: ['домены', 'domains'] },
{ icon: IconNetwork, label: 'IP-диапазоны', description: 'Управление IP диапазонами', action: () => navigate('/ip-ranges'), keywords: ['ip', 'диапазоны', 'ranges'] },
{ icon: IconNetwork, label: 'ASN', description: 'Управление Autonomous Systems', action: () => navigate('/asns'), keywords: ['asn', 'as', 'autonomous'] },
{ icon: IconFilter, label: 'Community', description: 'Справочник BGP Community', action: () => navigate('/communities'), keywords: ['community', 'справочник'] },
{ icon: IconServer, label: 'Серверы', description: 'Управление серверами', action: () => navigate('/servers'), keywords: ['серверы', 'servers'] },
{ icon: IconFilter, label: 'Фильтры', description: 'Filter Manager', action: () => navigate('/filters'), keywords: ['фильтры', 'filters', 'mikrotik'] },
{ icon: IconShield, label: 'Firewall', description: 'Списки блокировки ban и ban_counter с роутера', action: () => navigate('/firewall'), keywords: ['firewall', 'ban', 'address-list', 'блокировка', 'роутер'] },
{ icon: IconCreditCard, label: 'Биллинг', description: 'Управление биллингом', action: () => navigate('/billing'), keywords: ['биллинг', 'billing', 'оплата'] },
{ icon: IconDownload, label: 'Авто-URL', description: 'Генератор ссылок', action: () => navigate('/auto-urls'), keywords: ['url', 'ссылки', 'генератор'] },
{ icon: IconSettings, label: 'Настройки интерфейса', description: 'WebSocket, DoH, пинг, PTR зона', action: () => navigate('/settings'), keywords: ['настройки', 'settings', 'интерфейс', 'doh', 'websocket'] },
]
// Фильтрация команд по поисковому запросу
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { IconHash } from '@tabler/icons-react';
import { getIconById } from '../lib/brandIcons.js';
function CommunityAutocompleteInput({
value,
@@ -9,6 +10,7 @@ function CommunityAutocompleteInput({
className = 'form-control',
onSelectMeta,
maxSuggestions = 8,
useShortValue = false, // если true, передавать только короткую часть community (120 вместо 65000:120)
}) {
const containerRef = useRef(null);
const inputRef = useRef(null);
@@ -38,7 +40,12 @@ function CommunityAutocompleteInput({
}, []);
const selectItem = (item) => {
onChange(item.value);
// Если useShortValue=true, извлекаем только часть после двоеточия (если есть)
let valueToSet = item.value;
if (useShortValue && typeof item.value === 'string' && item.value.includes(':')) {
valueToSet = item.value.split(':')[1] || item.value;
}
onChange(valueToSet);
if (onSelectMeta) onSelectMeta(item);
setOpen(false);
setActiveIndex(-1);
@@ -99,6 +106,7 @@ function CommunityAutocompleteInput({
const label = s.name ? `${s.value} - ${s.name}` : `${s.value}`;
const description = s.description || '';
const tags = Array.isArray(s.tags) ? s.tags : [];
const iconInfo = s.icon ? getIconById(s.icon) : null;
return (
<button
type="button"
@@ -108,9 +116,15 @@ function CommunityAutocompleteInput({
onMouseEnter={() => setActiveIndex(idx)}
>
<div className="d-flex align-items-start">
<span className="avatar me-2 bg-blue-lt text-blue border-0" style={{ width: 22, height: 22 }}>
<IconHash size={14} />
</span>
{iconInfo ? (
<span className="avatar me-2 bg-blue-lt text-blue border-0" style={{ width: 22, height: 22 }}>
<iconInfo.Icon size={14} stroke={1.5} />
</span>
) : (
<span className="avatar me-2 bg-blue-lt text-blue border-0" style={{ width: 22, height: 22 }}>
<IconHash size={14} />
</span>
)}
<div className="flex-fill text-start">
<div className="fw-medium">{label}</div>
{(description || tags.length > 0) && (
+7 -1
View File
@@ -1,3 +1,5 @@
import { getIconById } from '../lib/brandIcons.js';
function normalizeTablerColor(input) {
const allowed = new Set(['blue','azure','indigo','purple','pink','red','orange','yellow','lime','green','teal','cyan','grape','gray']);
const c = String(input || 'blue').toLowerCase().replace(/[^a-z-]/g, '');
@@ -11,8 +13,12 @@ function CommunityBadge({ value, communities = [] }) {
const color = normalizeTablerColor(meta?.color || 'blue');
const cls = `badge bg-${color}-lt text-${color}`;
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${Array.isArray(meta.tags) && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : '';
const iconInfo = meta?.icon ? getIconById(meta.icon) : null;
return (
<span className={cls} title={title}>{v}</span>
<span className={cls} title={title}>
{iconInfo ? <iconInfo.Icon size={12} stroke={1.8} className="me-1" /> : null}
{v}
</span>
);
}
+6 -6
View File
@@ -100,8 +100,8 @@ function CommunityStats({ communities = [] }) {
) : (
<>
{/* Общая статистика */}
<div className="row mb-4">
<div className="col-md-4">
<div className="row g-2 g-md-3 mb-4">
<div className="col-6 col-md-4">
<div className="card card-sm bg-blue-lt">
<div className="card-body">
<div className="h1 mb-0">{stats.length}</div>
@@ -109,7 +109,7 @@ function CommunityStats({ communities = [] }) {
</div>
</div>
</div>
<div className="col-md-4">
<div className="col-6 col-md-4">
<div className="card card-sm bg-green-lt">
<div className="card-body">
<div className="h1 mb-0">{totalUsage}</div>
@@ -117,7 +117,7 @@ function CommunityStats({ communities = [] }) {
</div>
</div>
</div>
<div className="col-md-4">
<div className="col-6 col-md-4">
<div className="card card-sm bg-purple-lt">
<div className="card-body">
<div className="h1 mb-0">{Object.keys(statsByCategory).length}</div>
@@ -198,12 +198,12 @@ function CommunityStats({ communities = [] }) {
{Object.keys(statsByCategory).length > 1 && (
<div>
<h4 className="mb-3">По категориям</h4>
<div className="row g-3">
<div className="row g-2 g-md-3">
{Object.entries(statsByCategory).map(([category, items]) => {
const categoryTotal = items.reduce((sum, item) => sum + item.count, 0);
const categoryPercentage = ((categoryTotal / totalUsage) * 100).toFixed(1);
return (
<div key={category} className="col-md-6">
<div key={category} className="col-12 col-md-6">
<div className="card card-sm">
<div className="card-body">
<div className="d-flex justify-content-between align-items-start mb-2">

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