98 Commits
v3 ... v5
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
115 changed files with 10586 additions and 4303 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 3060 сек
- Уменьшит количество обращений к 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`
-59
View File
@@ -1,59 +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."
- name: Trigger container update webhook
run: |
curl -X POST -sS --fail "${{ secrets.portainer_webhook }}"
+35 -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,8 +67,8 @@ 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 }}
@@ -47,5 +76,6 @@ jobs:
BUILDKIT_INLINE_CACHE=1
- name: Trigger container update webhook
continue-on-error: true
run: |
curl -X POST -sS --fail "${{ secrets.portainer_webhook }}"
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-валидация критических сценариев.
+145 -172
View File
@@ -1,240 +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=your-64-hex-character-key-here \
-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 \
-e ENCRYPTION_KEY=your-64-hex-character-key-here \
--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 \
-e ENCRYPTION_KEY=your-64-hex-character-key-here \
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 \
-e ENCRYPTION_KEY=your-64-hex-character-key-here \
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
- ENCRYPTION_KEY=${ENCRYPTION_KEY} # Обязательно! Используйте .env файл или secrets
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
- ENCRYPTION_KEY=${ENCRYPTION_KEY} # Обязательно! Используйте .env файл или secrets
volumes:
router-lists-data:
```
**Создайте файл `.env` в той же директории:**
```bash
ENCRYPTION_KEY=your-64-hex-character-key-here
Отдельный сервис для **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;
}
```
Запуск:
```bash
docker-compose up -d
```
**Типичная ошибка**: `proxy_pass http://127.0.0.1:3001/;` — nginx срезает `/api`. В коде есть запасной маршрут на `/evobgp`, но лучше исправить nginx.
## 🔧 Переменные окружения
**Проверка**: `GET http://<хост>:<порт>/api/evobgp` → JSON вида `{"ok":true,"proxy":true,"evobgpConfigured":true}`.
| Переменная | Описание | По умолчанию | Обязательная |
|------------|----------|--------------|--------------|
| `NODE_ENV` | Окружение Node.js | `production` | Нет |
| `PORT` | Порт для запуска сервера | `3001` | Нет |
| `ENCRYPTION_KEY` | Ключ шифрования для IPSec паролей (64 hex символа) | - | **Да** |
## Генерация `ENCRYPTION_KEY`
### ENCRYPTION_KEY
**Важно**: `ENCRYPTION_KEY` является обязательным параметром для работы с IPSec паролями. Этот ключ используется для шифрования/дешифрования паролей, хранящихся в S3.
**Генерация ключа:**
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
**Пример запуска с ENCRYPTION_KEY:**
## Мониторинг
```bash
docker run -d \
--name s3-lists-manager \
-p 3001:3001 \
-e ENCRYPTION_KEY=your-64-hex-character-key-here \
git.shts.su/[repository]:latest
docker logs -f router-lists-ui
docker stats router-lists-ui
```
**⚠️ Внимание**: Используйте один и тот же `ENCRYPTION_KEY` для всех запусков контейнера. При изменении ключа старые зашифрованные пароли не смогут быть расшифрованы.
## Локальная сборка (как в CI)
## 📊 Мониторинг
### Просмотр логов
```bash
# Основная версия
docker logs s3-lists-manager
# Tabler версия
docker logs s3-lists-manager-tabler
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 stats s3-lists-manager s3-lists-manager-tabler
docker network create router-lists-net
docker run -d --network router-lists-net ... git.shts.su/[repository]:latest
```
## 🔍 Отладка
Ограничение ресурсов:
### Вход в контейнер
```bash
# Основная версия
docker exec -it s3-lists-manager /bin/bash
# Tabler версия
docker exec -it s3-lists-manager-tabler /bin/bash
docker run -d --memory=512m --cpus=0.5 ... git.shts.su/[repository]:latest
```
### Проверка процессов
```bash
docker exec s3-lists-manager ps aux
```
## Устранение неполадок
## 🏗️ Сборка локально
### Основная версия
```bash
git checkout main
docker build -t s3-lists-manager:local .
```
### 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`).
+5
View File
@@ -48,8 +48,13 @@ 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 ...
+76 -294
View File
@@ -1,347 +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
- `ENCRYPTION_KEY` — ключ шифрования (64 hex символа) для IPSec и MikroTik паролей
- `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?format=text|json` — сгенерировать конфиг 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, format?: 'text'|'json' }`.
- POST `/api/mikrotik/generate` — сгенерировать конфиг интерфейсов и маршрутов. Body: `{ format?: 'text'|'json', type?: 'interfaces'|'recursive'|'all', serverId?: string, config?, servers? }`. Возвращает `{ blocks }` — массив блоков с полем `code` (text) или `operations` (json).
- GET `/api/mikrotik/generate-interfaces?format=text|json&serverId=` — только интерфейсы.
- GET `/api/mikrotik/generate-recursive-routes?format=text|json&serverId=` — только рекурсивные маршруты.
- POST `/api/mikrotik/test-connection` — проверить соединение с MikroTik. Body: `{ serverId }` или `{ host, port?, user?, password }`.
- POST `/api/mikrotik/apply` — применить конфигурацию на MikroTik по API. Body: `{ serverId, type?: 'interfaces'|'recursive'|'all', dryRun?: boolean }`. Только для jumphost.
Критично для 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` (опциональные soft-locks для редактирования; доступ к S3 и долгие операции вроде speed-test блокировок не используют).
- 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,
};
+409 -1666
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -16,8 +16,8 @@
"license": "ISC",
"description": "",
"dependencies": {
"@aws-sdk/client-s3": "^3.687.0",
"ajv": "^8.17.1",
"better-sqlite3": "^11.6.0",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^17.0.1",
+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,
};
+16 -25
View File
@@ -2,12 +2,10 @@
* Роуты для фильтров и генерации конфигураций 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?format=text|json
async function generateConfig(req, res) {
@@ -16,13 +14,16 @@ async function generateConfig(req, res) {
if (!['text', 'json'].includes(format)) {
return sendError(res, 400, 'format must be "text" or "json"', 'E_BAD_REQUEST');
}
await getS3JsonWithHeaders('filters.json', req, res, {
transform: (filters) => {
const result = generateMikrotikConfig(filters, { format });
return format === 'json' ? result : { config: result };
},
defaultValue: [],
});
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');
@@ -32,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: 'Нет фильтров для экспорта' });
@@ -50,10 +41,10 @@ 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');
}
}
+30 -46
View File
@@ -2,14 +2,12 @@
* Общие роуты для 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 {string} s3Key - Ключ объекта (логический, как раньше в S3)
* @param {Object} options - Опции
* @param {boolean} options.singleObject - Если true, возвращает объект вместо массива
* @param {any} options.defaultValue - Значение по умолчанию ([] для массива, {} для объекта)
@@ -17,66 +15,63 @@ const { streamToString } = require('../services/s3Service');
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 fileContent;
try {
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;
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 = fallback;
}
res.json(items);
} catch (error) {
if (error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
res.json(fallback);
} 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');
}
};
}
/**
* Создать POST эндпоинт для JSON данных
* @param {string} s3Key - Ключ в S3
* @param {Function} validateItem - Функция валидации (для массива - валидирует каждый элемент, для объекта - весь объект)
* @param {Object} options - Опции
* @param {boolean} options.singleObject - Если true, ожидает объект вместо массива
*/
function createJsonDataPOST(s3Key, validateItem = null, options = {}) {
const { singleObject = false } = options;
return async (req, res) => {
const { domains: items } = req.body; // Используем 'domains' для обратной совместимости
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) {
@@ -84,12 +79,10 @@ function createJsonDataPOST(s3Key, validateItem = null, options = {}) {
}
}
} 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);
@@ -105,23 +98,15 @@ function createJsonDataPOST(s3Key, validateItem = null, options = {}) {
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)
* @param {string} s3Key - Ключ в S3
* @param {Function} validateItem - Функция валидации
* @param {Object} options - Опции
* @param {boolean} options.singleObject - Если true, работает с объектом вместо массива
* @param {any} options.defaultValue - Значение по умолчанию
*/
function createJsonDataRoutes(s3Key, validateItem = null, options = {}) {
return {
get: createJsonDataGET(s3Key, options),
post: createJsonDataPOST(s3Key, validateItem, options)
post: createJsonDataPOST(s3Key, validateItem, options),
};
}
@@ -130,4 +115,3 @@ module.exports = {
createJsonDataGET,
createJsonDataPOST,
};
+18 -19
View File
@@ -1,8 +1,8 @@
/**
* Роуты для бэкапов конфигурации MikroTik
* Хранят сырый текст конфигурации (например, вывод /export) в S3.
* Хранят сырый текст конфигурации (например, вывод /export) на локальной ФС.
*
* Ключи в S3: backups/mikrotik/{serverId}/{timestamp}-{rand}.rsc
* Логические ключи: backups/mikrotik/{serverId}/{timestamp}-{rand}.rsc
*
* Задачи:
* - создать бэкап (ручной или из планировщика)
@@ -13,7 +13,7 @@
const crypto = require('crypto');
const { sendError, sendOk } = require('../middleware/errorHandler');
const { writeS3TextObject, readS3TextObject, listS3Objects } = require('../services/s3Service');
const { writeBackupFile, readBackupFile, listBackupObjects } = require('../services/backupFsService');
const { readServersFromS3 } = require('./serversRoutes');
const { createRosClient, fetchExportViaFile } = require('../services/mikrotikApplyService');
const { encrypt, decrypt } = require('../utils/encryption');
@@ -61,7 +61,7 @@ async function saveBackupForServer(serverId, config, { source, comment } = {}) {
let content = `${headerLines.join('\n')}\n${String(config || '').replace(/^\uFEFF/, '')}`;
// Шифруем содержимое на S3, если задан ENCRYPTION_KEY
// Шифруем содержимое файла, если задан ENCRYPTION_KEY
if (process.env.ENCRYPTION_KEY) {
try {
content = ENCRYPTED_PREFIX + encrypt(content);
@@ -71,15 +71,15 @@ async function saveBackupForServer(serverId, config, { source, comment } = {}) {
}
}
const meta = await writeS3TextObject(key, content, 'text/plain');
return { key, createdAt: now.toISOString(), s3: meta };
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) в S3.
* Сохраняет текст конфигурации (обычно вывод /export) в каталог бэкапов на диске.
* Автоматический бэкап можно организовать внешним планировщиком, дергающим этот эндпоинт.
*/
async function createBackup(req, res) {
@@ -93,14 +93,14 @@ async function createBackup(req, res) {
return sendError(res, 400, 'config (MikroTik export text) is required', 'E_BAD_REQUEST');
}
const { key, createdAt, s3 } = await saveBackupForServer(serverId, config, { source, comment });
const { key, createdAt, storage } = await saveBackupForServer(serverId, config, { source, comment });
return res.json({
ok: true,
key,
serverId,
createdAt,
s3,
storage,
});
} catch (error) {
console.error('createBackup error:', error);
@@ -120,12 +120,11 @@ async function listBackups(req, res) {
return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
}
const prefix = `${BACKUP_PREFIX}/${String(serverId).replace(/[^a-zA-Z0-9._-]/g, '_')}/`;
const objects = await listS3Objects(prefix, { maxKeys: 200 });
const safeId = String(serverId).replace(/[^a-zA-Z0-9._-]/g, '_');
const objects = await listBackupObjects(safeId, 200);
const items = objects
.filter(o => o.key && o.key.startsWith(prefix))
.map(o => ({
.map((o) => ({
key: o.key,
serverId,
createdAt: o.lastModified,
@@ -156,7 +155,7 @@ async function getBackup(req, res) {
return sendError(res, 400, 'Invalid backup key', 'E_BAD_REQUEST');
}
const data = await readS3TextObject(key);
const data = await readBackupFile(key);
const config = decryptBackupContent(data.body || '');
return res.json({
@@ -191,8 +190,8 @@ async function diffBackups(req, res) {
}
const [a, b] = await Promise.all([
readS3TextObject(keyA),
readS3TextObject(keyB),
readBackupFile(keyA),
readBackupFile(keyB),
]);
const bodyA = decryptBackupContent(a.body || '');
@@ -265,7 +264,7 @@ async function getMikrotikClientForServerId(serverId) {
* POST /api/mikrotik/backups/run
* Body: { serverId: string }
*
* Немедленно делает /rest/export compact на указанном MikroTik и сохраняет результат в S3.
* Немедленно делает /rest/export compact на указанном MikroTik и сохраняет результат на диск.
*/
async function runBackupNow(req, res) {
try {
@@ -293,7 +292,7 @@ async function runBackupNow(req, res) {
return sendError(res, 502, 'Empty export result from MikroTik', 'E_EMPTY_EXPORT');
}
const { key, createdAt, s3 } = await saveBackupForServer(serverId, config, {
const { key, createdAt, storage } = await saveBackupForServer(serverId, config, {
source: 'manual',
comment: 'Manual backup from UI (/api/mikrotik/backups/run)',
});
@@ -303,7 +302,7 @@ async function runBackupNow(req, res) {
key,
serverId,
createdAt,
s3,
storage,
});
} catch (error) {
console.error('runBackupNow error:', error?.message, error?.stack, error?.response?.data);
+400 -46
View File
@@ -4,8 +4,7 @@
*/
const crypto = require('crypto');
const { GetObjectCommand } = require('@aws-sdk/client-s3');
const { s3, BUCKET_NAME, streamToString, readS3TextObject, writeS3JsonObject } = require('../services/s3Service');
const { readS3TextObject, writeS3JsonObject } = require('../services/s3Service');
const { sendError, sendOk } = require('../middleware/errorHandler');
const { decrypt } = require('../utils/encryption');
const { readServersFromS3 } = require('./serversRoutes');
@@ -15,7 +14,7 @@ const {
buildMikrotikRecursiveRoutes,
getParentGateway,
} = require('../utils/mikrotikInterfaceGenerator');
const { createRosClient, applyBlock, rosPrint, rosAdd, rosRemove } = require('../services/mikrotikApplyService');
const { createRosClient, applyBlock, rosPrint, rosAdd, rosSet, rosRemove } = require('../services/mikrotikApplyService');
const IPSEC_PASSWORDS_KEY = 'network-config/ipsec-passwords.json';
const NETWORK_CONFIG_KEY = 'network-config.json';
@@ -54,12 +53,11 @@ function speedTestCacheKey(serverId, interfaceName) {
/** Загрузить network-config из S3 */
async function loadNetworkConfig() {
try {
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: NETWORK_CONFIG_KEY }));
const body = await streamToString(data.Body);
const parsed = JSON.parse(body || '{}');
const data = await readS3TextObject(NETWORK_CONFIG_KEY);
const parsed = JSON.parse(data.body || '{}');
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (e) {
if (e?.name === 'NoSuchKey' || e?.$metadata?.httpStatusCode === 404) return {};
if (e?.code === 'NoSuchKey') return {};
throw e;
}
}
@@ -264,32 +262,44 @@ function updateUptimeCache(serverId, entry) {
}
/**
* GET /api/uptime/cache
* Возвращает закешированные результаты проверок, если кеш младше TTL из настроек.
* Получить данные кеша Uptime Monitor (для оповещений и др.).
* Учитывает TTL из ui-settings.uptimeMonitorCacheSeconds.
* @returns {Promise<{ results: Record<string, { ok, lastCheckTs, ms }>, updatedAt: number|null }>}
*/
async function getUptimeCache(req, res) {
async function getUptimeCacheData() {
try {
const uiSettings = await loadUiSettings();
const ttlSec = Math.max(0, parseInt(uiSettings.uptimeMonitorCacheSeconds, 10) || 120);
if (ttlSec === 0) {
return res.json({ results: {}, updatedAt: null });
}
if (ttlSec === 0) return { results: {}, updatedAt: null };
const data = await readS3TextObject(UPTIME_CACHE_KEY).catch(() => null);
if (!data?.body) {
return res.json({ results: {}, updatedAt: null });
}
if (!data?.body) return { results: {}, updatedAt: null };
let cache = { results: {}, updatedAt: null };
try {
const parsed = JSON.parse(data.body);
if (parsed && typeof parsed === 'object') cache = parsed;
} catch (_) {}
const now = Date.now();
if (!cache.updatedAt || now - cache.updatedAt > ttlSec * 1000) {
return res.json({ results: {}, updatedAt: null });
}
return res.json({
if (!cache.updatedAt || now - cache.updatedAt > ttlSec * 1000) return { results: {}, updatedAt: null };
return {
results: cache.results && typeof cache.results === 'object' ? cache.results : {},
updatedAt: cache.updatedAt,
};
} catch (e) {
console.warn('[uptime] getUptimeCacheData', e?.message);
return { results: {}, updatedAt: null };
}
}
/**
* GET /api/uptime/cache
* Возвращает закешированные результаты проверок, если кеш младше TTL из настроек.
*/
async function getUptimeCache(req, res) {
try {
const { results, updatedAt } = await getUptimeCacheData();
return res.json({
results: Object.keys(results).length ? results : {},
updatedAt,
});
} catch (e) {
console.error('[uptime] getUptimeCache', e);
@@ -1462,7 +1472,368 @@ async function getAddressLists(req, res) {
}
}
function normalizeOspfInterfaceName(entry) {
if (!entry || typeof entry !== 'object') return '';
return String(entry.interfaces ?? entry.interface ?? '').trim();
}
function parseOspfCost(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) return null;
return parsed;
}
function sameOspfInterface(a, b) {
return String(a || '').trim().toUpperCase() === String(b || '').trim().toUpperCase();
}
function sameOspfArea(a, b) {
const left = String(a || '').trim().toUpperCase() || 'BACKBONE';
const right = String(b || '').trim().toUpperCase() || 'BACKBONE';
return left === right;
}
/**
* GET /api/mikrotik/ospf-interface-templates?serverId=
* Возвращает текущие OSPF interface-template с HOME роутеров.
*/
async function getOspfInterfaceTemplates(req, res) {
try {
const requestedServerId = String(req.query?.serverId || '').trim();
const servers = await readServersFromS3();
const homeServers = servers.filter((s) => String(s?.type || '').toLowerCase() === 'home');
const targets = requestedServerId
? homeServers.filter((s) => [s.id, s.ip, s.dns].map((x) => String(x || '')).includes(requestedServerId))
: homeServers;
if (requestedServerId && targets.length === 0) {
return sendError(res, 404, 'HOME server not found', 'E_NOT_FOUND');
}
const results = [];
const errors = [];
for (const server of targets) {
const serverId = server.id || server.ip || server.dns;
const creds = getMikrotikCredentials(server);
if (!creds) {
errors.push({
serverId,
serverLabel: server.dns || server.ip || serverId,
error: 'MikroTik credentials not configured for this server',
});
continue;
}
try {
const client = createRosClient(creds);
const raw = await rosPrint(client, 'routing/ospf/interface-template');
const templates = (Array.isArray(raw) ? raw : [])
.map((item) => {
const interfaceName = normalizeOspfInterfaceName(item);
if (!interfaceName) return null;
return {
id: item['.id'] || null,
interfaceName,
cost: parseOspfCost(item.cost),
area: item.area || '',
networks: item.networks || '',
networkType: item['type'] || item['network-type'] || '',
disabled: String(item.disabled || '').toLowerCase() === 'true',
};
})
.filter(Boolean);
results.push({
serverId,
serverLabel: server.dns || server.ip || serverId,
templates,
});
} catch (error) {
errors.push({
serverId,
serverLabel: server.dns || server.ip || serverId,
error: error?.response?.data?.detail || error?.response?.data?.message || error.message || 'Failed to read OSPF templates',
});
}
}
return res.json({
ok: errors.length === 0,
results,
errors,
});
} catch (error) {
return sendError(res, 500, error.message || 'Failed to load OSPF templates', 'E_OSPF_LOAD');
}
}
/**
* POST /api/mikrotik/ospf-interface-templates/apply
* Body: { servers: [{ serverId, templates: [{ rosId?, interfaceName, area?, cost }] }] }
*/
async function applyOspfInterfaceTemplates(req, res) {
try {
const payloadServers = Array.isArray(req.body?.servers) ? req.body.servers : [];
if (payloadServers.length === 0) {
return sendError(res, 400, 'servers array is required', 'E_BAD_REQUEST');
}
const servers = await readServersFromS3();
const results = [];
const errors = [];
const summary = { updated: 0, skipped: 0, missing: 0, invalid: 0 };
for (const payload of payloadServers) {
const serverRef = String(payload?.serverId || '').trim();
const desiredTemplates = Array.isArray(payload?.templates) ? payload.templates : [];
const server = servers.find((s) =>
[s?.id, s?.ip, s?.dns].map((x) => String(x || '')).includes(serverRef)
);
if (!server || String(server.type || '').toLowerCase() !== 'home') {
errors.push({ serverId: serverRef, error: 'HOME server not found' });
continue;
}
const serverId = server.id || server.ip || server.dns;
const creds = getMikrotikCredentials(server);
if (!creds) {
errors.push({ serverId, error: 'MikroTik credentials not configured for this server' });
continue;
}
try {
const client = createRosClient(creds);
const raw = await rosPrint(client, 'routing/ospf/interface-template');
const existing = (Array.isArray(raw) ? raw : []).filter((item) => normalizeOspfInterfaceName(item));
let updated = 0;
let skipped = 0;
let missing = 0;
let invalid = 0;
for (const desired of desiredTemplates) {
const desiredRosId = String(desired?.rosId || '').trim();
const interfaceName = String(desired?.interfaceName || '').trim();
const desiredArea = String(desired?.area || '').trim();
const desiredCost = parseOspfCost(desired?.cost);
if ((!interfaceName && !desiredRosId) || desiredCost == null) {
invalid += 1;
continue;
}
let match = null;
if (desiredRosId) {
match = existing.find((item) => String(item?.['.id'] || '') === desiredRosId);
}
if (!match && interfaceName) {
match = existing.find((item) => {
if (!sameOspfInterface(normalizeOspfInterfaceName(item), interfaceName)) return false;
if (!desiredArea) return true;
return sameOspfArea(item?.area, desiredArea);
});
}
if (!match || !match['.id']) {
missing += 1;
continue;
}
const currentCost = parseOspfCost(match.cost);
if (currentCost === desiredCost) {
skipped += 1;
continue;
}
await rosSet(client, 'routing/ospf/interface-template', match['.id'], { cost: String(desiredCost) });
updated += 1;
}
summary.updated += updated;
summary.skipped += skipped;
summary.missing += missing;
summary.invalid += invalid;
results.push({
serverId,
serverLabel: server.dns || server.ip || serverId,
updated,
skipped,
missing,
invalid,
});
} catch (error) {
errors.push({
serverId,
serverLabel: server.dns || server.ip || serverId,
error: error?.response?.data?.detail || error?.response?.data?.message || error.message || 'Failed to apply OSPF templates',
});
}
}
return res.json({
ok: errors.length === 0,
summary,
results,
errors,
});
} catch (error) {
return sendError(res, 500, error.message || 'Failed to apply OSPF templates', 'E_OSPF_APPLY');
}
}
const ADDRESS_LIST_PATH = 'ip/firewall/address-list';
const ADDRESS_LIST_REMOVE_CHUNK_SIZE = 100;
const ADDRESS_LIST_ADD_CONCURRENCY = 8;
const ADDRESS_LIST_ADD_EXECUTE_CHUNK_SIZE = 80;
function chunkArray(items, size) {
if (!Array.isArray(items) || items.length === 0) return [];
const chunkSize = Math.max(1, Number(size) || 1);
const chunks = [];
for (let i = 0; i < items.length; i += chunkSize) {
chunks.push(items.slice(i, i + chunkSize));
}
return chunks;
}
/**
* Удаление address-list по .id:
* 1) пытаемся батчем через POST .../remove { numbers: "*1,*2" }
* 2) при ошибке откатываемся к поштучному DELETE, чтобы не ломать совместимость.
*/
async function removeAddressListIds(client, ids) {
const normalizedIds = [...new Set(
(Array.isArray(ids) ? ids : [])
.map((id) => String(id || '').trim())
.filter(Boolean)
)];
if (normalizedIds.length === 0) return 0;
let removed = 0;
const chunks = chunkArray(normalizedIds, ADDRESS_LIST_REMOVE_CHUNK_SIZE);
for (const chunk of chunks) {
try {
await client.command(`${ADDRESS_LIST_PATH}/remove`, { numbers: chunk.join(',') });
removed += chunk.length;
continue;
} catch (err) {
console.warn(
`[address-lists] batch remove fallback (${chunk.length} ids):`,
err?.message || String(err)
);
}
for (const id of chunk) {
try {
await rosRemove(client, ADDRESS_LIST_PATH, id);
removed++;
} catch (err) {
console.warn(`[address-lists] remove ${id}:`, err?.message);
}
}
}
return removed;
}
function normalizeAddressListEntries(entries) {
return [...new Map(
(Array.isArray(entries) ? entries : [])
.map((entry) => {
const address = String(entry?.address || '').trim();
const list = String(entry?.list || 'ban').trim();
const comment = entry?.comment != null ? String(entry.comment).trim() : '';
if (!address || !list) return null;
const params = { address, list };
if (comment) params.comment = comment;
const key = `${list}|${address}|${comment}`;
return [key, params];
})
.filter(Boolean)
).values()];
}
function escapeRouterOsString(value) {
return `"${String(value || '')
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')}"`;
}
function buildAddressListAddScript(entriesChunk) {
return entriesChunk
.map((params) => {
const parts = [
'/ip/firewall/address-list/add',
`address=${escapeRouterOsString(params.address)}`,
`list=${escapeRouterOsString(params.list)}`,
];
if (params.comment != null && String(params.comment).trim()) {
parts.push(`comment=${escapeRouterOsString(params.comment)}`);
}
return parts.join(' ');
})
.join(';\n');
}
async function addAddressListEntriesParallel(client, normalized) {
if (!Array.isArray(normalized) || normalized.length === 0) return { added: 0, failed: 0 };
let index = 0;
let added = 0;
let failed = 0;
const workersCount = Math.min(ADDRESS_LIST_ADD_CONCURRENCY, normalized.length);
const workers = Array.from({ length: workersCount }, async () => {
while (true) {
const current = index;
index += 1;
if (current >= normalized.length) break;
const params = normalized[current];
try {
await rosAdd(client, ADDRESS_LIST_PATH, params);
added++;
} catch (err) {
failed++;
console.warn('[address-lists] add:', err?.message);
}
}
});
await Promise.all(workers);
return { added, failed };
}
async function addAddressListEntries(client, entries) {
const normalized = normalizeAddressListEntries(entries);
if (normalized.length === 0) return { added: 0, failed: 0 };
let added = 0;
let failed = 0;
const chunks = chunkArray(normalized, ADDRESS_LIST_ADD_EXECUTE_CHUNK_SIZE);
for (const chunk of chunks) {
const script = buildAddressListAddScript(chunk);
try {
// Один execute на пачку резко снижает HTTP overhead.
await client.command('execute', { script });
added += chunk.length;
continue;
} catch (err) {
console.warn(
`[address-lists] execute add fallback (${chunk.length} entries):`,
err?.message || String(err)
);
}
const fallbackResult = await addAddressListEntriesParallel(client, chunk);
added += fallbackResult.added;
failed += fallbackResult.failed;
}
return { added, failed };
}
/**
* POST /api/mikrotik/address-lists/apply-summary
@@ -1475,7 +1846,9 @@ async function applyAddressListSummary(req, res) {
if (!serverId) {
return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
}
const ids = Array.isArray(removeIds) ? removeIds.filter((id) => id != null && String(id).trim()) : [];
const ids = Array.isArray(removeIds)
? [...new Set(removeIds.map((id) => String(id || '').trim()).filter(Boolean))]
: [];
const entries = Array.isArray(addEntries)
? addEntries.filter((e) => e && (e.address || e.list))
: [];
@@ -1495,32 +1868,10 @@ async function applyAddressListSummary(req, res) {
let removed = 0;
let added = 0;
for (const id of ids) {
try {
const pathId = String(id).trim();
await rosRemove(client, ADDRESS_LIST_PATH, pathId);
removed++;
} catch (err) {
console.warn(`[address-lists] remove ${id}:`, err?.message);
}
}
removed = await removeAddressListIds(client, ids);
for (const entry of entries) {
try {
const params = {
address: String(entry.address || '').trim(),
list: String(entry.list || 'ban').trim(),
};
if (entry.comment != null && String(entry.comment).trim()) {
params.comment = String(entry.comment).trim();
}
if (!params.address || !params.list) continue;
await rosAdd(client, ADDRESS_LIST_PATH, params);
added++;
} catch (err) {
console.warn('[address-lists] add:', err?.message);
}
}
const addResult = await addAddressListEntries(client, entries);
added = addResult.added;
return res.json({ ok: true, removed, added });
} catch (error) {
@@ -1544,7 +1895,10 @@ module.exports = {
speedTestViaTunnel,
loadNetworkConfig,
getAddressLists,
getOspfInterfaceTemplates,
applyOspfInterfaceTemplates,
applyAddressListSummary,
uptimeCheck,
getUptimeCache,
getUptimeCacheData,
};
+138 -163
View File
@@ -2,10 +2,18 @@
* Разные утилитарные роуты (auto-urls, history, s3 meta, availability, etc.)
*/
const { s3, BUCKET_NAME, writeS3TextObject, writeS3JsonObject, readS3TextObject, 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');
}
}
@@ -357,9 +333,8 @@ function getPingServicesListFromSettings(uiSettings) {
/** Загрузить UI-настройки из S3 (для pingServicesSource и др.) */
async function loadUiSettingsSync() {
try {
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/rt_ui_settings.json' }));
const jsonText = await streamToString(data.Body);
const parsed = JSON.parse(jsonText || '{}');
const data = await readS3TextObject('bgp_data/rt_ui_settings.json');
const parsed = JSON.parse(data.body || '{}');
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
@@ -598,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 = [];
@@ -683,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 });
@@ -700,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);
@@ -728,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');
}
}
@@ -756,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) });
}
}
@@ -776,5 +749,7 @@ module.exports = {
getPingServicesList,
getPingServices,
refreshPingServicesCache,
checkOneServerFast,
loadUiSettingsSync,
};
+16 -7
View File
@@ -74,13 +74,11 @@ function parseResource(raw) {
}
/**
* GET /api/resources/stats
* По каждому jumphost/home с MikroTik — запрос system/resource, ответ с группировкой по роутерам.
* Внутренняя функция: возвращает данные по ресурсам роутеров (без HTTP).
*/
async function getResourceStats(req, res) {
try {
const servers = await readServersFromS3();
const routers = (Array.isArray(servers) ? servers : []).filter(
async function getResourceStatsData() {
const servers = await readServersFromS3();
const routers = (Array.isArray(servers) ? servers : []).filter(
(s) =>
s &&
(String(s.type || '').toLowerCase() === 'jumphost' ||
@@ -132,7 +130,17 @@ async function getResourceStats(req, res) {
})
);
return res.json({ routers: results });
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(
@@ -146,4 +154,5 @@ async function getResourceStats(req, res) {
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,
};
+14 -13
View File
@@ -5,10 +5,8 @@
* Это нужно для EasySwitch, который работает только с jumphost серверами
*/
const { s3, BUCKET_NAME, writeS3TextObject, deleteS3Object, headMeta, writeS3JsonObject } = require('../services/s3Service');
const { writeS3TextObject, deleteS3Object, headMeta, writeS3JsonObject, readS3TextObject } = require('../services/s3Service');
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
const { streamToString } = require('../services/s3Service');
const { getS3TextWithHeaders, getS3JsonWithHeaders } = require('../utils/s3Helpers');
const { isValidServerId } = require('../lib/validators');
@@ -41,15 +39,20 @@ function validateJumphostServer(server, i) {
// GET /api/server-configs (список только jumphost серверов)
async function getServerConfigsList(req, res) {
try {
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null);
const etag = head?.ETag || null;
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 (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: S3_KEY }));
const fileContent = await streamToString(data.Body);
let fileContent = '[]';
try {
const data = await readS3TextObject(S3_KEY);
fileContent = data.body;
} catch (e) {
if (e?.code !== 'NoSuchKey') throw e;
}
let allServers = [];
try {
@@ -113,12 +116,10 @@ async function postServerConfigsList(req, res) {
// Читаем текущие данные
let allServers = [];
try {
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY }));
const fileContent = await streamToString(data.Body);
allServers = JSON.parse(fileContent);
const data = await readS3TextObject(S3_KEY);
allServers = JSON.parse(data.body);
if (!Array.isArray(allServers)) allServers = [];
} catch (e) {
// Файл не существует или пустой - это нормально
allServers = [];
}
+22 -29
View File
@@ -3,8 +3,7 @@
* Для jumphost и home (входной роутер): mikrotikHost, mikrotikPort, mikrotikUser, encryptedMikrotikPassword
*/
const { GetObjectCommand, PutObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
const { s3, BUCKET_NAME, streamToString } = require('../services/s3Service');
const { readS3TextObject, writeS3JsonObject, headMeta } = require('../services/s3Service');
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
const { encrypt, decrypt } = require('../utils/encryption');
@@ -28,7 +27,7 @@ function validateServer(server, i) {
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);
const primaries = server.gateways.filter((g) => g && g.primary);
if (primaries.length !== 1) {
return `Server at index ${i} must have exactly one primary gateway`;
}
@@ -45,24 +44,17 @@ function validateServer(server, i) {
async function readServersFromS3() {
try {
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY }));
const body = await streamToString(data.Body);
const parsed = JSON.parse(body || '[]');
const data = await readS3TextObject(S3_KEY);
const parsed = JSON.parse(data.body || '[]');
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
if (e?.name === 'NoSuchKey' || e?.$metadata?.httpStatusCode === 404) return [];
if (e?.code === 'NoSuchKey') return [];
throw e;
}
}
async function writeServersToS3(servers) {
const body = JSON.stringify(servers, null, 2);
await s3.send(new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: S3_KEY,
Body: body,
ContentType: 'application/json',
}));
await writeS3JsonObject(S3_KEY, servers);
}
/**
@@ -70,15 +62,15 @@ async function writeServersToS3(servers) {
*/
async function getServers(req, res) {
try {
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null);
const etag = head?.ETag || null;
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 (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 sanitized = servers.map((s) => {
const out = { ...s };
const hasMikrotikApi = s.type === 'jumphost' || s.type === 'home';
if (hasMikrotikApi && out.encryptedMikrotikPassword) {
@@ -91,11 +83,11 @@ async function getServers(req, res) {
});
res.json(sanitized);
} catch (error) {
if (error?.name === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
if (error?.code === 'NoSuchKey') {
return res.json([]);
}
console.error(error);
return sendError(res, 500, 'Error reading servers', 'E_S3');
return sendError(res, 500, 'Error reading servers', 'E_STORAGE');
}
}
@@ -115,9 +107,10 @@ async function postServers(req, res) {
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 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);
@@ -140,15 +133,15 @@ async function postServers(req, res) {
});
await writeServersToS3(toSave);
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null);
const meta = await headMeta(S3_KEY);
return sendOk(res, {
etag: head?.ETag || null,
lastModified: head?.LastModified ? head.LastModified.toISOString() : null,
contentLength: typeof head?.ContentLength === 'number' ? head.ContentLength : null,
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_S3');
return sendError(res, 500, error.message || 'Error saving servers', 'E_STORAGE');
}
}
+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 -95
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');
@@ -31,11 +38,14 @@ 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;
@@ -154,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) => {
@@ -211,99 +238,100 @@ 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);
@@ -340,24 +368,21 @@ 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);
@@ -471,6 +496,8 @@ app.post('/api/mikrotik/speed-test', writeLimiter, mikrotikConfigRoutes.speedTes
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 из настроек) ===
@@ -483,7 +510,10 @@ app.get('/api/traffic/interface-stats', trafficRoutes.getInterfaceStats);
// === RESOURCE STATS (RAM/CPU/HDD по роутерам) ===
app.get('/api/resources/stats', resourceStatsRoutes.getResourceStats);
// === MIKROTIK BACKUPS (S3) ===
// === 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);
@@ -493,6 +523,9 @@ app.post('/api/mikrotik/backups/run', writeLimiter, mikrotikBackupRoutes.runBack
// === 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);
+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,
};
+2 -2
View File
@@ -4,7 +4,7 @@
* Работает внутри Node-процесса backend:
* - периодически обходит jumphost-сервера из servers.json
* - вызывает RouterOS REST API /rest/export (compact)
* - сохраняет конфиг в S3 через saveBackupForServer
* - сохраняет конфиг на диск через saveBackupForServer
*
* Управляется через переменные окружения:
* - MIKROTIK_BACKUP_ENABLED=true|false (по умолчанию true)
@@ -131,7 +131,7 @@ async function runBackupOnce(logger) {
log.info(
{ component: 'mikrotik-backup', serverId: id, key: result.key },
'Automatic backup saved to S3',
'Automatic backup saved to disk',
);
} catch (err) {
log.error(
+80 -10
View File
@@ -11,9 +11,11 @@ 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 {
@@ -329,16 +331,34 @@ async function writeCache(pingMap, speedMap) {
}
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,
};
}
@@ -351,8 +371,14 @@ function initNetworkMapScheduler(logger, port) {
const log = logger || console;
const baseUrl = `http://127.0.0.1:${port}`;
async function tick() {
lastScheduledRunAt = Date.now();
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: '{}' }));
@@ -369,6 +395,7 @@ function initNetworkMapScheduler(logger, port) {
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);
@@ -389,29 +416,62 @@ function initNetworkMapScheduler(logger, port) {
}
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 },
'Network map scheduler started (first run in 10 s, then every %d min)',
{
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
);
schedulerTimer = setInterval(tick, intervalMs);
// Первый запуск через 10 сек, чтобы кеш появился вскоре после старта сервера
setTimeout(() => tick(), 10 * 1000);
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() {
@@ -439,9 +499,19 @@ function pushLog(msg) {
*/
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;
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 = {
+120 -243
View File
@@ -1,58 +1,35 @@
/**
* Сервис для работы с S3 (Yandex Object Storage)
* Централизованные операции чтения/записи/кэширования
* Локальное хранилище объектов (SQLite). Сохранены имена экспортов readS3TextObject / writeS3JsonObject и т.д. для совместимости с роутами.
* Справочники EvoBGP не хранятся здесь см. evobgpClient.js
*/
const { S3Client, GetObjectCommand, HeadObjectCommand, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command } = 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,213 +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;
}
}
/**
* Записать текстовый объект в S3
*/
async function writeS3TextObject(key, content, contentType = 'text/plain') {
await s3.send(new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: key,
Body: content,
ContentType: contentType,
}));
invalidateCacheForKey(key);
return await headMeta(key);
}
/**
* Записать JSON объект в S3
*/
async function writeS3JsonObject(key, data) {
return await writeS3TextObject(key, JSON.stringify(data, null, 2), 'application/json');
}
/**
* Удалить объект из S3
*/
async function deleteS3Object(key) {
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
invalidateCacheForKey(key);
}
/**
* Список объектов в S3 по префиксу
* Используется для истории/бэкапов (количество ограничено для безопасности)
*/
async function listS3Objects(prefix, { maxKeys = 100 } = {}) {
const out = [];
let continuationToken = undefined;
while (out.length < maxKeys) {
const resp = await s3.send(new ListObjectsV2Command({
Bucket: BUCKET_NAME,
Prefix: prefix,
ContinuationToken: continuationToken,
MaxKeys: Math.min(1000, maxKeys - out.length),
}));
const contents = resp.Contents || [];
for (const obj of contents) {
out.push({
key: obj.Key,
size: typeof obj.Size === 'number' ? obj.Size : null,
lastModified: obj.LastModified ? new Date(obj.LastModified).toISOString() : null,
etag: obj.ETag || null,
});
if (out.length >= maxKeys) break;
}
if (!resp.IsTruncated || !resp.NextContinuationToken || out.length >= maxKeys) {
break;
}
continuationToken = resp.NextContinuationToken;
}
const out = {
body: row.body,
etag: row.etag,
lastModified: row.lastModified,
contentLength: row.contentLength,
};
setCache(cache.text, key, out);
return out;
}
/**
* Потоковое чтение с пагинацией больших текстовых файлов
*/
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 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);
}
async function writeS3TextObject(key, content, contentType = 'text/plain') {
const db = openDatabase();
blobStorage.writeBlobTx(db, key, content, contentType);
invalidateCacheForKey(key);
return headMeta(key);
}
async function writeS3JsonObject(key, data) {
return writeS3TextObject(key, JSON.stringify(data, null, 2), 'application/json');
}
async function deleteS3Object(key) {
blobStorage.deleteBlob(key);
invalidateCacheForKey(key);
}
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,
@@ -294,5 +170,6 @@ module.exports = {
streamPaginatedText,
invalidateCacheForKey,
listS3Objects,
listBlobVersionsForKey,
rollbackBlobVersion,
};
+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;
}
}
+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,
};
+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.
## 📋 Содержание
+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>
+10
View File
@@ -20,6 +20,7 @@
"diff": "^8.0.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"react-router-dom": "^6.30.1",
"recharts": "^3.7.0"
},
@@ -2798,6 +2799,15 @@
"react": "^19.1.0"
}
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
+1
View File
@@ -24,6 +24,7 @@
"diff": "^8.0.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"react-router-dom": "^6.30.1",
"recharts": "^3.7.0"
},
+50 -73
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 CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx';
import CommunityBadge from './components/CommunityBadge.jsx';
@@ -43,21 +43,24 @@ import AddItemModal from './components/AddItemModal.jsx';
import FormModal from './components/FormModal.jsx';
import { getAsnName, getAsnNameSync } from './lib/asn.js';
import LastSaved from './components/LastSaved.jsx';
const API_URL = '/api';
import {
listCommunities,
fetchAsnsData,
saveAsnsData,
} from './lib/evobgpData.js';
function ASNsNewManager() {
const [items, setItems] = useState([]);
const [originalItems, setOriginalItems] = useState([]);
const [etag, setEtag] = useState('');
const [, setEtag] = useState('');
const [lastModified, setLastModified] = useState('');
const [contentLength, setContentLength] = useState(null);
const [, setContentLength] = useState(null);
const [newItem, setNewItem] = useState({ asn: '', community: '' });
const [newInvalid, setNewInvalid] = useState({ asn: false, community: false });
const [, setNewInvalid] = useState({ asn: false, community: false });
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const searchTimer = useRef(null);
const _searchTimer = useRef(null);
const didInit = useRef(false);
const [editingValue, setEditingValue] = useState('');
const [editModalOpen, setEditModalOpen] = useState(false);
@@ -80,9 +83,9 @@ function ASNsNewManager() {
useEffect(() => {
(async () => {
try {
const res = await api.get(`/communities`);
setCommunities(Array.isArray(res.data) ? res.data : []);
} catch (e) {
const list = await listCommunities();
setCommunities(list);
} catch (_e) {
// тихо игнорируем
}
})();
@@ -112,21 +115,17 @@ function ASNsNewManager() {
return () => controller.abort();
}, []);
const fetchItems = async (abortSignal) => {
const fetchItems = useCallback(async (_abortSignal) => {
setLoading(true);
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const response = await api.get(`/asns`, { params: { offset: 0, limit: 0, format: 'std' }, ...opts });
const payload = Array.isArray(response.data?.items) ? response.data.items : [];
const mapped = payload.map(item => ({ asn: String(item.domain), community: String(item.type) }));
const mapped = await fetchAsnsData();
const total = mapped.length;
setItems(mapped);
setOriginalItems(mapped);
setTotalItems(Number.isFinite(total) ? total : 0);
setEtag(response.headers?.etag || '');
setLastModified(response.headers?.['last-modified'] || '');
const lengthHeader = response.headers?.['content-length-source'];
setContentLength(typeof lengthHeader !== 'undefined' ? Number(lengthHeader) : null);
setEtag('');
setLastModified(new Date().toISOString());
setContentLength(null);
setError('');
} catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
@@ -135,7 +134,7 @@ function ASNsNewManager() {
} finally {
setLoading(false);
}
};
}, []);
useEffect(() => {
if (!didInit.current) {
@@ -144,7 +143,7 @@ function ASNsNewManager() {
fetchItems(controller.signal);
return () => controller.abort();
}
}, []);
}, [fetchItems]);
const handleAddItem = (item) => {
const asn = item?.asn || newItem.asn;
@@ -298,7 +297,7 @@ function ASNsNewManager() {
return { added, removed, changed };
};
const [showDiff, setShowDiff] = useState(false);
const [_showDiff, setShowDiff] = useState(false);
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
@@ -312,7 +311,29 @@ function ASNsNewManager() {
setShowDiff(true);
};
const handleSaveChanges = async () => {
const performSave = useCallback(async () => {
setConfirmSaveOpen(false);
setLoading(true);
try {
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
.map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
await saveAsnsData(unique, originalItems);
setEtag('');
await fetchItems();
setSuccess('Изменения успешно сохранены!');
setTimeout(() => setSuccess(''), 3000);
notifyMutationSuccess('Изменения сохранены');
} catch (error) {
console.error('Error saving changes:', error);
setError('Не удалось сохранить изменения.');
} finally {
setLoading(false);
}
}, [items, originalItems, fetchItems]);
const handleSaveChanges = useCallback(async () => {
// подготовим diff и спросим подтверждение только при массовых изменениях (>10)
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
.map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() }));
@@ -330,49 +351,7 @@ function ASNsNewManager() {
// Малое изменение - сохраняем сразу
performSave();
}
};
const performSave = async () => {
setConfirmSaveOpen(false);
setLoading(true);
try {
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
.map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
// Загрузим полный список, совместим и сохраним
const fullRes = await api.get(`/asns`, { params: { offset: 0, limit: 0, format: 'std' } });
const fullPayload = Array.isArray(fullRes.data?.items) ? fullRes.data.items : (Array.isArray(fullRes.data) ? fullRes.data : []);
const full = fullPayload.map(item => ({ asn: String(item.domain).trim(), community: String(item.type || '').trim() }));
const fullMap = new Map(full.map(i => [i.asn, { asn: i.asn, community: i.community }]));
const originalPageMap = new Map(originalItems.map(i => [String(i.asn).trim(), true]));
const uniqueMap = new Map(unique.map(i => [String(i.asn).trim(), { asn: i.asn, community: i.community }]));
for (const key of originalPageMap.keys()) {
if (!uniqueMap.has(key)) fullMap.delete(key);
}
for (const [key, val] of uniqueMap.entries()) fullMap.set(key, val);
const fullToSave = Array.from(fullMap.values()).map(i => ({ domain: i.asn, type: i.community }));
const et = fullRes?.headers?.etag || etag;
const payload = { domains: fullToSave, etag: et };
const response = await api.post(`/asns`, payload, { validateStatus: () => true });
if (response.status === 412) {
setError('Данные изменились в S3 (ETag mismatch). Обновите список и попробуйте снова.');
return;
}
if (response.status >= 400) throw new Error(`Save failed with status ${response.status}`);
setEtag(response.headers?.etag || et);
await fetchItems();
setSuccess('Изменения успешно сохранены!');
setTimeout(() => setSuccess(''), 3000);
notifyMutationSuccess('Изменения сохранены');
} catch (error) {
console.error('Error saving changes:', error);
setError('Не удалось сохранить изменения.');
} finally {
setLoading(false);
}
};
}, [items, originalItems, performSave]);
const handleImport = () => {
setImportOpen(true);
@@ -380,10 +359,7 @@ function ASNsNewManager() {
const handleExport = async () => {
try {
const effectiveQ = searchTerm ? searchTerm : (filterCommunity ? filterCommunity : '');
const response = await api.get(`/asns`, { params: { q: effectiveQ, offset: 0, limit: 0, format: 'std' } });
const payload = Array.isArray(response.data?.items) ? response.data.items : (Array.isArray(response.data) ? response.data : []);
const all = payload.map(item => ({ asn: item.domain, community: item.type }));
const all = filtered;
const header = ['asn', 'community'];
const csv = [header, ...all.map(i => [i.asn, i.community])]
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
@@ -401,7 +377,7 @@ function ASNsNewManager() {
};
// Drag&Drop импорт (быстрый путь)
const onDropImport = async (e) => {
const _onDropImport = async (e) => {
e.preventDefault();
const file = e.dataTransfer?.files?.[0];
if (!file) return;
@@ -469,6 +445,7 @@ function ASNsNewManager() {
});
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const paginatedItems = filtered.slice((currentPage - 1) * pageSize, (currentPage) * pageSize);
const paginatedAsns = paginatedItems.map(i => i?.asn).filter(Boolean).join(',');
// Подгружаем имена ASN из кэша/внешних API (после вычисления paginatedItems)
useEffect(() => {
@@ -482,7 +459,7 @@ function ASNsNewManager() {
const name = await getAsnName(asn);
if (name !== null) setAsnNameMap(prev => ({ ...prev, [asn]: name }));
});
}, [JSON.stringify(paginatedItems)]);
}, [paginatedAsns, paginatedItems]);
// Для фильтра - список всех уникальных community
const allCommunities = Array.from(new Set(items.filter(i => i != null).map(i => String(i.community || '')).filter(c => c !== '')));
@@ -507,7 +484,7 @@ function ASNsNewManager() {
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [loading, items, originalItems, etag, sortField, sortOrder, filterCommunity]);
}, [loading, handleSaveChanges]);
const handleEditKeyDown = (e) => {
if (e.key === 'Enter') {
+216 -7
View File
@@ -1,4 +1,4 @@
/* Custom styles for S3 Lists Manager */
/* Custom styles for RouterOS Manager */
/* Layout: Tabler Fluid Vertical (https://preview.tabler.io/layout-fluid-vertical.html) */
/* --- Fluid Vertical: page structure --- */
@@ -72,6 +72,7 @@
.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;
@@ -92,6 +93,23 @@
.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;
@@ -115,8 +133,9 @@
width: 1.25rem;
height: 1.25rem;
}
/* Sidebar dropdown: inline submenu (no popup) */
.navbar-vertical .nav-item.dropdown .dropdown-menu {
/* Sidebar dropdown: inline submenu (no popup) только для основного списка меню,
не затрагиваем иконки в нижнем футере сайдбара (язык, тема, колокол и т.д.) */
.navbar-vertical .navbar-nav > .nav-item.dropdown .dropdown-menu {
position: static;
float: none;
border: none;
@@ -125,12 +144,12 @@
padding: 0 0 0.5rem 0;
margin: 0;
}
.navbar-vertical .nav-item.dropdown .dropdown-item {
.navbar-vertical .navbar-nav > .nav-item.dropdown .dropdown-item {
padding-left: 1rem;
padding-top: 0.35rem;
padding-bottom: 0.35rem;
}
.navbar-vertical .nav-item.dropdown .dropdown-item .nav-link-icon {
.navbar-vertical .navbar-nav > .nav-item.dropdown .dropdown-item .nav-link-icon {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
@@ -138,7 +157,7 @@
align-items: center;
justify-content: center;
}
.navbar-vertical .nav-item.dropdown .dropdown-item .nav-link-icon .icon {
.navbar-vertical .navbar-nav > .nav-item.dropdown .dropdown-item .nav-link-icon .icon {
width: 1rem;
height: 1rem;
}
@@ -347,6 +366,96 @@
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 {
@@ -461,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;
@@ -474,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 {
@@ -564,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 {
+99 -26
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,
@@ -27,7 +27,8 @@ import {
IconChartBar,
IconClock,
IconClockPlay,
IconShield
IconShield,
IconRoute2
} from '@tabler/icons-react';
import ServerManager from './ServerManager';
import FilterManager from './FilterManager';
@@ -51,6 +52,8 @@ 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 ToastContainer from './components/ToastContainer.jsx';
@@ -58,6 +61,8 @@ import CommandPalette, { KeyboardShortcutsButton } from './components/CommandPal
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 });
@@ -69,16 +74,18 @@ function LanguageProvider({ children }) {
ru: {
home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты',
dashboard: 'Панель', trafficTraffic: 'Расход трафика', resourceStats: 'Статистика ресурсов', networkMap: 'Карта сети', uptimeMonitor: 'Uptime Monitor', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
routeOptimizer: 'Оптимизация маршрутов ИИ',
communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL',
easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы', pingServices: 'Пинг сервисов', firewall: 'Firewall',
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', 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',
easySwitch: 'Easy Switch', networkConfig: 'Network Config', mikrotikBackups: 'MikroTik Backups', pingServices: 'Ping Services', firewall: 'Firewall',
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'
}
@@ -124,12 +131,14 @@ function App() {
<LanguageProvider>
<ThemeProvider>
<PingProvider>
<ToastContainer>
<NotifyProvider>
<NetworkErrorHandler />
<MainLayout />
</NotifyProvider>
</ToastContainer>
<AlertsProvider>
<ToastContainer>
<NotifyProvider>
<NetworkErrorHandler />
<MainLayout />
</NotifyProvider>
</ToastContainer>
</AlertsProvider>
</PingProvider>
</ThemeProvider>
</LanguageProvider>
@@ -142,16 +151,58 @@ 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 [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({
data: false,
@@ -205,6 +256,7 @@ function MainLayout() {
{ 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 }
]
},
@@ -238,6 +290,7 @@ function MainLayout() {
items: [
{ 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 },
@@ -284,7 +337,7 @@ function MainLayout() {
<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>
<span className="fw-bold">RouterOS Manager</span>
</Link>
</div>
<button
@@ -365,12 +418,12 @@ function MainLayout() {
</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()}>
<span className="badge">{lang.toUpperCase()}</span>
<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 ${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 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">
@@ -382,6 +435,9 @@ function MainLayout() {
<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>
@@ -399,6 +455,7 @@ function MainLayout() {
<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 />} />
@@ -411,6 +468,7 @@ function MainLayout() {
<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 />} />
@@ -441,22 +499,23 @@ function MainLayout() {
<div className="container-fluid">
<Link to="/" className="navbar-brand navbar-brand-autodark">
<IconBrandTabler className="navbar-brand-icon" />
<span className="fw-bold">S3 Lists Manager</span>
<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-menu"
aria-controls="sidebar-menu"
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-menu">
<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' : ''}`}>
@@ -509,19 +568,19 @@ function MainLayout() {
<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" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()} title="Язык">
<span className="badge bg-secondary">{lang.toUpperCase()}</span>
<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-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>
<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-end">
<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>
@@ -534,11 +593,14 @@ function MainLayout() {
<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-end">
<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>
@@ -553,6 +615,15 @@ function MainLayout() {
</div>
</header>
{/* Бэкдроп мобильного меню: закрытие по клику */}
{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">
@@ -562,6 +633,7 @@ function MainLayout() {
<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 />} />
@@ -574,6 +646,7 @@ function MainLayout() {
<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 />} />
+5 -5
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,7 +101,7 @@ function AutoUrlManager() {
const hasAtLeastOneValidRow = urls.some(u => isValidHttpUrl(u.url) && isValidCommunity(u.community));
const saveUrls = async () => {
const saveUrls = useCallback(async () => {
try {
setSaving(true);
setError('');
@@ -124,7 +124,7 @@ function AutoUrlManager() {
} finally {
setSaving(false);
}
};
}, [urls]);
const processUrls = async () => {
try {
@@ -158,7 +158,7 @@ function AutoUrlManager() {
}
setSuccess('Пример формата скопирован');
setTimeout(() => setSuccess(''), 2000);
} catch (e) {
} catch (_e) {
setError('Не удалось скопировать пример');
setTimeout(() => setError(''), 2000);
}
@@ -619,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;
+128 -124
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import api from './lib/api.js';
import axios from 'axios';
import FormModal from './components/FormModal.jsx';
@@ -41,8 +41,43 @@ import {
import BulkActionsBar from './components/BulkActionsBar.jsx';
import { BillingCard } from './components/billing/index.js';
function normalizeBillingItems(rawItems, rates) {
const toRUB = (amt, ccy) => {
if (!amt) return 0;
const rub = Number(rates?.RUB) || 1;
if (!ccy || ccy === 'RUB') return amt;
if (ccy === 'USD') return amt * rub;
const c = Number(rates?.[ccy]);
return c ? (amt / c) * rub : amt * rub;
};
return (rawItems || []).map((item, idx) => {
const payments = Array.isArray(item.payments)
? item.payments
: (item.lastPaymentDate && item.lastPaymentAmount
? [{ date: item.lastPaymentDate, amount: item.lastPaymentAmount, currency: item.lastPaymentCurrency || 'USD', note: 'Импортировано' }]
: []);
const merged = { id: item.id ?? String(idx + 1), payments, ...item };
if (payments.length > 0) {
const last = payments.slice().sort((a, b) => new Date(b.date) - new Date(a.date))[0];
if (last?.date) {
const current = new Date(item.nextPaymentDate || 0);
const now = new Date();
if (current < now || !item.nextPaymentDate) {
const costRUB = toRUB(item.monthlyCost || 0, item.monthlyCostCurrency || 'USD');
const amtRUB = toRUB(last.amount, last.currency || 'USD');
const months = costRUB > 0 ? Math.max(1, Math.floor(amtRUB / costRUB)) : 1;
const d = new Date(last.date);
d.setMonth(d.getMonth() + months);
merged.nextPaymentDate = d.toISOString().slice(0, 10);
}
}
}
return merged;
});
}
function BillingManager() {
const [billingData, setBillingData] = useState([]);
const [rawBillingData, setRawBillingData] = useState([]);
const [servers, setServers] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
@@ -51,7 +86,7 @@ function BillingManager() {
const [currentPage, setCurrentPage] = useState(1);
const [sortField, setSortField] = useState('nextPaymentDate');
const [sortOrder, setSortOrder] = useState('asc');
const [filterStatus, setFilterStatus] = useState('');
const [filterStatus, _setFilterStatus] = useState('');
const [filterUrgency, setFilterUrgency] = useState('');
const [viewMode, setViewMode] = useState('cards'); // 'cards' | 'table'
const [activeTab, setActiveTab] = useState('subscriptions'); // 'subscriptions' | 'payments'
@@ -76,14 +111,14 @@ function BillingManager() {
const [editingPayment, setEditingPayment] = useState(null);
const [showDeletePaymentModal, setShowDeletePaymentModal] = useState(false);
const [paymentToDelete, setPaymentToDelete] = useState(null);
const [futureDateConfirm, setFutureDateConfirm] = useState(false);
const [_futureDateConfirm, _setFutureDateConfirm] = useState(false);
// История платежей
const [paymentSearchTerm, setPaymentSearchTerm] = useState('');
const [paymentSortField, setPaymentSortField] = useState('date');
const [paymentSortOrder, setPaymentSortOrder] = useState('desc');
const [selectedPayments, setSelectedPayments] = useState(new Set());
const [paymentPage, setPaymentPage] = useState(1);
const [paymentSearchTerm, _setPaymentSearchTerm] = useState('');
const [_paymentSortField, _setPaymentSortField] = useState('date');
const [_paymentSortOrder, _setPaymentSortOrder] = useState('desc');
const [_selectedPayments, _setSelectedPayments] = useState(new Set());
const [_paymentPage, _setPaymentPage] = useState(1);
const paymentPageSize = 10;
// Курсы валют
@@ -92,7 +127,7 @@ function BillingManager() {
EUR: 1,
RUB: 1
});
const [ratesLoading, setRatesLoading] = useState(false);
const [_ratesLoading, setRatesLoading] = useState(false);
// Новый элемент
const [newItem, setNewItem] = useState({
@@ -112,6 +147,73 @@ function BillingManager() {
serverId: '',
});
const fetchExchangeRates = useCallback(async (abortSignal) => {
setRatesLoading(true);
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const cbr = await axios.get('https://www.cbr-xml-daily.ru/latest.js', opts);
const rubPerUsd = cbr.data && cbr.data.rates?.USD ? 1 / Number(cbr.data.rates.USD) : null;
const rubPerEur = cbr.data && cbr.data.rates?.EUR ? 1 / Number(cbr.data.rates.EUR) : null;
if (rubPerUsd) {
const eurPerUsd = rubPerEur ? rubPerUsd / rubPerEur : 1;
setExchangeRates({
USD: 1,
EUR: eurPerUsd,
RUB: rubPerUsd
});
return;
}
} catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
console.warn('ЦБ недоступен, пробуем резервный источник:', error?.message);
}
try {
const response = await axios.get('https://open.er-api.com/v6/latest/USD', opts);
const rates = response.data?.rates || {};
setExchangeRates({
USD: 1,
EUR: rates.EUR || 1,
RUB: rates.RUB || 1
});
} catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
console.error('Ошибка при загрузке курсов валют:', error);
setExchangeRates({ USD: 1, EUR: 1.05, RUB: 95 });
} finally {
setRatesLoading(false);
}
}, []);
const fetchServers = useCallback(async (abortSignal) => {
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const res = await api.get(`/servers`, opts);
setServers(Array.isArray(res.data) ? res.data : []);
} catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
console.error('Ошибка при загрузке серверов:', error);
}
}, []);
const fetchBillingData = useCallback(async (abortSignal) => {
setLoading(true);
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const response = await api.get(`/billing`, opts);
setRawBillingData(Array.isArray(response.data) ? response.data : []);
setError('');
} catch (err) {
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
console.error('Ошибка при загрузке данных биллинга:', err);
setError('Ошибка при загрузке данных');
setRawBillingData([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
@@ -119,13 +221,19 @@ function BillingManager() {
fetchExchangeRates(signal);
fetchServers(signal);
return () => controller.abort();
}, []);
}, [fetchBillingData, fetchExchangeRates, fetchServers]);
// Нормализация с учётом курсов валют (без повторных запросов)
const billingData = useMemo(
() => normalizeBillingItems(rawBillingData, exchangeRates),
[rawBillingData, exchangeRates]
);
// Автоматическая синхронизация: добавляем серверы без записей в биллинге
useEffect(() => {
if (servers.length === 0 || loading) return;
setBillingData((prev) => {
setRawBillingData((prev) => {
const existingServerIds = new Set(
prev.map(b => b.serverId).filter(Boolean)
);
@@ -165,118 +273,14 @@ function BillingManager() {
}, [servers, loading]);
const fetchExchangeRates = async (abortSignal) => {
setRatesLoading(true);
const opts = abortSignal ? { signal: abortSignal } : {};
try {
// Основной источник: ЦБ РФ (base = RUB)
const cbr = await axios.get('https://www.cbr-xml-daily.ru/latest.js', opts);
const rubPerUsd = cbr.data && cbr.data.rates?.USD ? 1 / Number(cbr.data.rates.USD) : null;
const rubPerEur = cbr.data && cbr.data.rates?.EUR ? 1 / Number(cbr.data.rates.EUR) : null;
if (rubPerUsd) {
const eurPerUsd = rubPerEur ? rubPerUsd / rubPerEur : 1;
setExchangeRates({
USD: 1,
EUR: eurPerUsd, // сколько EUR за 1 USD
RUB: rubPerUsd // сколько RUB за 1 USD
});
return;
}
} catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
console.warn('ЦБ недоступен, пробуем резервный источник:', error?.message);
}
try {
// Резервный источник: open.er-api
const response = await axios.get('https://open.er-api.com/v6/latest/USD', opts);
const rates = response.data?.rates || {};
setExchangeRates({
USD: 1,
EUR: rates.EUR || 1,
RUB: rates.RUB || 1
});
} catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
console.error('Ошибка при загрузке курсов валют:', error);
// Фолбек на разумные значения
setExchangeRates({ USD: 1, EUR: 1.05, RUB: 95 });
} finally {
setRatesLoading(false);
}
};
const fetchServers = async (abortSignal) => {
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const res = await api.get(`/servers`, opts);
setServers(Array.isArray(res.data) ? res.data : []);
} catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
console.error('Ошибка при загрузке серверов:', error);
}
};
const fetchBillingData = async (abortSignal) => {
setLoading(true);
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const response = await api.get(`/billing`, opts);
const rates = exchangeRates; // из closure, на первой загрузке может быть {1,1,1} для USD достаточно
const toRUB = (amt, ccy) => {
if (!amt) return 0;
const rub = Number(rates.RUB) || 1;
if (!ccy || ccy === 'RUB') return amt;
if (ccy === 'USD') return amt * rub;
const c = Number(rates[ccy]);
return c ? (amt / c) * rub : amt * rub;
};
const normalized = (response.data || []).map((item, idx) => {
const payments = Array.isArray(item.payments)
? item.payments
: (item.lastPaymentDate && item.lastPaymentAmount
? [{ date: item.lastPaymentDate, amount: item.lastPaymentAmount, currency: item.lastPaymentCurrency || 'USD', note: 'Импортировано' }]
: []
);
const merged = { id: item.id ?? String(idx + 1), payments, ...item };
if (payments.length > 0) {
const last = payments.slice().sort((a, b) => new Date(b.date) - new Date(a.date))[0];
if (last?.date) {
const current = new Date(item.nextPaymentDate || 0);
const now = new Date();
if (current < now || !item.nextPaymentDate) {
const costRUB = toRUB(item.monthlyCost || 0, item.monthlyCostCurrency || 'USD');
const amtRUB = toRUB(last.amount, last.currency || 'USD');
const months = costRUB > 0 ? Math.max(1, Math.floor(amtRUB / costRUB)) : 1;
const d = new Date(last.date);
d.setMonth(d.getMonth() + months);
merged.nextPaymentDate = d.toISOString().slice(0, 10);
}
}
}
return merged;
});
setBillingData(normalized);
setError('');
} catch (err) {
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
console.error('Ошибка при загрузке данных биллинга:', err);
setError('Ошибка при загрузке данных');
setBillingData([]);
} finally {
setLoading(false);
}
};
const handleSaveChanges = async () => {
setLoading(true);
try {
// Убираем служебное поле _external перед сохранением
const payload = billingData.map(({ _external, ...rest }) => rest);
const payload = billingData.map(({ _external: _ext, ...rest }) => rest);
await api.post(`/billing`, { domains: payload });
// После сохранения снимаем флаг _external со всех записей
setBillingData(prev => prev.map(item => ({ ...item, _external: false })));
setRawBillingData(prev => prev.map(item => ({ ...item, _external: false })));
setSuccess('Данные успешно сохранены');
setTimeout(() => setSuccess(''), 3000);
} catch (err) {
@@ -466,7 +470,7 @@ function BillingManager() {
const confirmDelete = () => {
if (selectedItem) {
setBillingData(prev => prev.filter(item => item.id !== selectedItem.id));
setRawBillingData(prev => prev.filter(item => item.id !== selectedItem.id));
setShowDeleteModal(false);
setSelectedItem(null);
}
@@ -479,7 +483,7 @@ function BillingManager() {
}
const newId = Math.max(...billingData.map(item => parseInt(item.id) || 0), 0) + 1;
setBillingData(prev => [...prev, { ...newItem, id: newId.toString() }]);
setRawBillingData(prev => [...prev, { ...newItem, id: newId.toString() }]);
setShowAddModal(false);
setSuccess('Элемент добавлен');
setTimeout(() => setSuccess(''), 3000);
@@ -492,7 +496,7 @@ function BillingManager() {
}
// Снимаем флаг _external при редактировании (запись теперь заполнена)
setBillingData(prev => prev.map(item =>
setRawBillingData(prev => prev.map(item =>
item.id === editingItem.id ? { ...editingItem, _external: false } : item
));
setShowEditModal(false);
@@ -504,7 +508,7 @@ function BillingManager() {
const handleDeletePayment = () => {
if (!paymentToDelete) return;
const { serverId, index } = paymentToDelete;
setBillingData(prev => prev.map(s => {
setRawBillingData(prev => prev.map(s => {
if (s.id !== serverId) return s;
const payments = [...(s.payments || [])];
payments.splice(index, 1);
@@ -1151,7 +1155,7 @@ function BillingManager() {
}
if (editingPayment) {
setBillingData(prev => prev.map(s => {
setRawBillingData(prev => prev.map(s => {
if (s.id !== editingPayment.serverId) return s;
const payments = [...(s.payments || [])];
payments[editingPayment.index] = {
@@ -1166,7 +1170,7 @@ function BillingManager() {
}));
setEditingPayment(null);
} else {
setBillingData(prev => prev.map(s => {
setRawBillingData(prev => prev.map(s => {
if (s.id !== paymentDraft.serverId) return s;
const payments = [...(s.payments || []), {
date: paymentDraft.date,
+159 -97
View File
@@ -11,13 +11,13 @@ import {
IconFilter,
IconChartBar,
IconList,
IconLink,
IconRefresh,
IconSortAscending,
IconSortDescending,
IconUpload,
IconDownload,
IconDeviceFloppy
IconDeviceFloppy,
IconPlugConnected
} from '@tabler/icons-react';
import PageHeader from './components/PageHeader.jsx';
import PortalDropdown from './components/PortalDropdown.jsx';
@@ -29,8 +29,9 @@ import ConfirmModal from './components/ConfirmModal.jsx';
import FormField from './components/FormField.jsx';
import ErrorAlert from './components/ErrorAlert.jsx';
import Pagination from './components/Pagination.jsx';
const API_URL = '/api';
import IconPicker from './components/IconPicker.jsx';
import { getIconById } from './lib/brandIcons.js';
import { listCommunities, saveCommunitiesData, normalizeEvobgpApiBaseUrl } from './lib/evobgpData.js';
const normalizeCommunityValue = (value) => {
const trimmed = String(value || '').trim();
@@ -53,14 +54,25 @@ function CommunitiesManager() {
// Состояние для переключения между списком и статистикой
const [activeTab, setActiveTab] = useState('list'); // 'list' | 'stats'
const [newItem, setNewItem] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '', category: '', priority: 0, enabled: true });
const [newItem, setNewItem] = useState({ value: '', name: '', description: '', tags: '', color: '', icon: '', category: '', priority: 0, enabled: true });
const [editingValue, setEditingValue] = useState(null);
const [editingDraft, setEditingDraft] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '', category: '', priority: 0, enabled: true });
const [editingDraft, setEditingDraft] = useState({ value: '', name: '', description: '', tags: '', color: '', icon: '', category: '', priority: 0, enabled: true });
const [filterUsage, setFilterUsage] = useState({});
const [filterUsageLoading, setFilterUsageLoading] = useState(false);
const [filterUsageError, setFilterUsageError] = useState('');
const [showOnlyUsedInFilters, setShowOnlyUsedInFilters] = useState(false);
const [evobgpUrlDraft, setEvobgpUrlDraft] = useState('');
const [evobgpTokenDraft, setEvobgpTokenDraft] = useState('');
const [evobgpSettingsOpen, setEvobgpSettingsOpen] = useState(() => {
if (import.meta.env?.VITE_EVOBGP_API_URL) return false;
try {
return Boolean(window.localStorage?.getItem('evobgp_api_url'));
} catch {
return false;
}
});
// Модальные окна
const [addModalOpen, setAddModalOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
@@ -72,6 +84,15 @@ function CommunitiesManager() {
fetchFilterUsage();
}, []);
useEffect(() => {
try {
setEvobgpUrlDraft(String(window.localStorage?.getItem('evobgp_api_url') || ''));
setEvobgpTokenDraft(String(window.localStorage?.getItem('evobgp_api_token') || ''));
} catch {
/* no-op */
}
}, []);
useEffect(() => {
if (!filterUsage) return;
setItems((prev) => {
@@ -79,7 +100,7 @@ function CommunitiesManager() {
Object.keys(filterUsage).forEach((value) => {
if (!value) return;
if (!map.has(value)) {
map.set(value, { value, name: '', description: '', tags: [], gatewayDefault: '', color: '', _external: true });
map.set(value, { value, name: '', description: '', tags: [], color: '', icon: '', _external: true });
}
});
return Array.from(map.values());
@@ -89,57 +110,12 @@ function CommunitiesManager() {
const fetchItems = async () => {
setLoading(true);
try {
const [dictRes, asnsRes, ipRes, domRes] = await Promise.all([
api.get(`/communities`),
api.get(`/asns`, { params: { offset: 0, limit: 0 } }).catch(() => ({ data: [] })),
api.get(`/ip-ranges`, { params: { offset: 0, limit: 0 } }).catch(() => ({ data: [] })),
api.get(`/domains-new`, { params: { offset: 0, limit: 0 } }).catch(() => ({ data: [] })),
]);
const dict = Array.isArray(dictRes.data) ? dictRes.data : [];
const asnsPayload = Array.isArray(asnsRes.data?.items) ? asnsRes.data.items : (Array.isArray(asnsRes.data) ? asnsRes.data : []);
const ipPayload = Array.isArray(ipRes.data?.items) ? ipRes.data.items : (Array.isArray(ipRes.data) ? ipRes.data : []);
const domPayload = Array.isArray(domRes.data?.items) ? domRes.data.items : (Array.isArray(domRes.data) ? domRes.data : []);
const usedCommunities = new Set();
// ASNs: fields domain/type
for (const it of asnsPayload) {
const v = normalizeCommunityValue(it.type);
if (v) usedCommunities.add(v);
}
// IP ranges: fields ipRange/community
for (const it of ipPayload) {
const v = normalizeCommunityValue(it.community);
if (v) usedCommunities.add(v);
}
// Domains: fields domain/community
for (const it of domPayload) {
const v = normalizeCommunityValue(it.community);
if (v) usedCommunities.add(v);
}
const map = new Map();
// put dictionary items first
for (const d of dict) {
const raw = String(d.value || '').trim();
if (!raw) continue;
const value = normalizeCommunityValue(raw);
map.set(value, {
value,
name: String(d.name || ''),
description: String(d.description || ''),
tags: Array.isArray(d.tags) ? d.tags : [],
gatewayDefault: String(d.gatewayDefault || ''),
color: String(d.color || ''),
_external: false,
});
}
// add unknown ones from usage
for (const v of usedCommunities) {
if (!map.has(v)) {
map.set(v, { value: v, name: '', description: '', tags: [], gatewayDefault: '', color: '', _external: true });
}
}
setItems(Array.from(map.values()));
const rows = await listCommunities();
const normalized = rows.map((item) => ({
...item,
value: normalizeCommunityValue(item.value),
}));
setItems(normalized);
setError('');
} catch (e) {
console.error('Error fetching communities:', e);
@@ -188,9 +164,7 @@ function CommunitiesManager() {
const saveAll = async (data) => {
setLoading(true);
try {
// не отправляем служебное поле _external
const payload = data.map(({ _external, ...rest }) => rest);
await api.post(`/communities`, { communities: payload });
await saveCommunitiesData(data);
setSuccess('Справочник сохранён!');
setTimeout(() => setSuccess(''), 3000);
} catch (e) {
@@ -215,10 +189,10 @@ function CommunitiesManager() {
name: String(newItem.name || ''),
description: String(newItem.description || ''),
tags: toTagsArray(newItem.tags),
gatewayDefault: String(newItem.gatewayDefault || ''),
color: String(newItem.color || ''),
icon: String(newItem.icon || ''),
}]);
setNewItem({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '' });
setNewItem({ value: '', name: '', description: '', tags: '', color: '', icon: '' });
setAddModalOpen(false);
setSuccess('Community успешно добавлен!');
setTimeout(() => setSuccess(''), 3000);
@@ -231,8 +205,8 @@ function CommunitiesManager() {
name: item.name || '',
description: item.description || '',
tags: fromTagsArray(item.tags),
gatewayDefault: item.gatewayDefault || '',
color: item.color || '',
icon: item.icon || '',
});
setEditModalOpen(true);
};
@@ -247,8 +221,8 @@ function CommunitiesManager() {
name: String(editingDraft.name || ''),
description: String(editingDraft.description || ''),
tags: toTagsArray(editingDraft.tags),
gatewayDefault: String(editingDraft.gatewayDefault || ''),
color: String(editingDraft.color || ''),
icon: String(editingDraft.icon || ''),
} : i);
setItems(next);
setEditingValue(null);
@@ -278,7 +252,7 @@ function CommunitiesManager() {
};
const handleImport = () => {
const text = window.prompt('Вставьте JSON-массив community (value, name, description, tags, gatewayDefault, color)');
const text = window.prompt('Вставьте JSON-массив community (value, name, description, tags, color, icon)');
if (!text) return;
try {
const arr = JSON.parse(text);
@@ -295,13 +269,13 @@ function CommunitiesManager() {
name: String(x.name || ''),
description: String(x.description || ''),
tags: Array.isArray(x.tags) ? x.tags : [],
gatewayDefault: String(x.gatewayDefault || ''),
color: String(x.color || ''),
icon: String(x.icon || ''),
});
});
return Array.from(map.values());
});
} catch (e) {
} catch (_e) {
setError('Неверный JSON.');
}
};
@@ -318,6 +292,43 @@ function CommunitiesManager() {
const handleSave = () => saveAll(items);
const saveEvobgpConnection = () => {
const trimmedUrl = String(evobgpUrlDraft || '').trim();
if (!trimmedUrl) {
try {
window.localStorage.removeItem('evobgp_api_url');
window.localStorage.removeItem('evobgp_api_token');
} catch (_e) {
setError('Не удалось очистить настройки в localStorage.');
return;
}
setEvobgpUrlDraft('');
setEvobgpTokenDraft('');
setError('');
setSuccess('Включён серверный прокси /api/evobgp (EVOBGP_API_URL и EVOBGP_API_TOKEN в контейнере).');
fetchItems();
setTimeout(() => setSuccess(''), 5000);
return;
}
const norm = normalizeEvobgpApiBaseUrl(trimmedUrl);
if (!norm) {
setError('Укажите корректный URL EvoBGP (например http://192.168.0.1:8080). Порт — у API EvoBGP, не у этого интерфейса.');
return;
}
try {
window.localStorage.setItem('evobgp_api_url', norm);
window.localStorage.setItem('evobgp_api_token', String(evobgpTokenDraft || '').trim());
} catch (_e) {
setError('Не удалось сохранить настройки в localStorage.');
return;
}
setEvobgpUrlDraft(norm);
setError('');
setSuccess('Прямое подключение к EvoBGP из браузера сохранено. Загружаем данные…');
fetchItems();
setTimeout(() => setSuccess(''), 4000);
};
const sorted = useMemo(() => {
const arr = [...items];
arr.sort((a, b) => {
@@ -359,6 +370,61 @@ function CommunitiesManager() {
icon={<IconFilter size={24} />}
/>
<div className="card mb-3">
<div className="card-header py-2 d-flex align-items-center justify-content-between">
<div className="d-flex align-items-center gap-2">
<IconPlugConnected size={18} />
<span className="fw-semibold">Подключение к EvoBGP API</span>
</div>
<button
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={() => setEvobgpSettingsOpen((o) => !o)}
>
{evobgpSettingsOpen ? 'Скрыть' : 'Настроить'}
</button>
</div>
{evobgpSettingsOpen && (
<div className="card-body">
<p className="text-secondary small mb-3">
<strong>По умолчанию</strong> запросы идут на внутренний прокси <code>/api/evobgp</code>: URL и токен задаются в{' '}
<strong>переменных окружения контейнера</strong> <code>EVOBGP_API_URL</code> и <code>EVOBGP_API_TOKEN</code>{' '}
(без CORS). Ниже только <strong>опциональное</strong> прямое подключение из браузера к EvoBGP; оставьте URL пустым и
нажмите «Сохранить», чтобы снова использовать прокси.
</p>
<div className="row g-2 align-items-end">
<div className="col-12 col-md-6">
<label className="form-label">Прямой Base URL EvoBGP (необязательно)</label>
<input
type="url"
className="form-control"
placeholder="оставьте пустым для прокси"
autoComplete="off"
value={evobgpUrlDraft}
onChange={(e) => setEvobgpUrlDraft(e.target.value)}
/>
</div>
<div className="col-12 col-md-4">
<label className="form-label">Токен (только для прямого режима)</label>
<input
type="password"
className="form-control"
placeholder="Bearer token"
autoComplete="off"
value={evobgpTokenDraft}
onChange={(e) => setEvobgpTokenDraft(e.target.value)}
/>
</div>
<div className="col-12 col-md-2">
<button type="button" className="btn btn-primary w-100" onClick={saveEvobgpConnection}>
Сохранить
</button>
</div>
</div>
</div>
)}
</div>
{/* Уведомления */}
{error && (
<ErrorAlert message={error} onClose={() => setError('')} onRetry={fetchItems} />
@@ -553,6 +619,7 @@ function CommunitiesManager() {
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{ width: '8%' }}>Иконка</th>
<th className="cursor-pointer user-select-none" onClick={()=>changeSort('value')}>
<div className="d-flex align-items-center">
<IconHash size={16} className="me-1 text-muted" />
@@ -577,7 +644,6 @@ function CommunitiesManager() {
</th>
<th>Описание</th>
<th>Теги</th>
<th>Gateway</th>
<th style={{ width: '10%' }}>Исп. в фильтрах</th>
<th className="text-end">Действия</th>
</tr>
@@ -600,8 +666,18 @@ function CommunitiesManager() {
</td></tr>
) : paginated.map((item) => {
const usageCount = filterUsage[item.value] || 0;
const iconInfo = item.icon ? getIconById(item.icon) : null;
return (
<tr key={item.value}>
<td>
{iconInfo ? (
<span className="avatar avatar-sm bg-blue-lt text-blue border">
<iconInfo.Icon size={20} stroke={1.5} />
</span>
) : (
<span className="text-muted"></span>
)}
</td>
<td>
<code className="text-blue">{item.value}</code>
{item._external && (
@@ -617,9 +693,6 @@ function CommunitiesManager() {
<span key={t} className="badge bg-blue-lt text-blue me-1">{t}</span>
))}
</td>
<td>
<span className="text-muted">{item.gatewayDefault || '—'}</span>
</td>
<td>
{usageCount > 0 ? (
<span className="badge bg-blue-lt text-blue">{usageCount} в фильтрах</span>
@@ -629,13 +702,6 @@ function CommunitiesManager() {
</td>
<td className="text-end">
<div className="btn-list">
<a
className="btn btn-outline-primary btn-icon btn-sm"
href={`/filters?community=${encodeURIComponent(item.value)}`}
title="Открыть в /filters с поиском по community"
>
<IconLink size={16} />
</a>
<button
className="btn btn-outline-primary btn-icon btn-sm"
onClick={()=>startEdit(item)}
@@ -681,7 +747,7 @@ function CommunitiesManager() {
show={addModalOpen}
onClose={() => {
setAddModalOpen(false);
setNewItem({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '' });
setNewItem({ value: '', name: '', description: '', tags: '', color: '', icon: '' });
setError('');
}}
onSubmit={addItem}
@@ -742,14 +808,6 @@ function CommunitiesManager() {
helpText="Разделяйте теги запятыми"
/>
<FormField
label="Gateway по умолчанию"
name="gatewayDefault"
value={newItem.gatewayDefault}
onChange={(val) => setNewItem({...newItem, gatewayDefault: val})}
placeholder="SWE-HIPHOST"
/>
<FormField
label="Цвет бейджа (опционально)"
name="color"
@@ -758,6 +816,12 @@ function CommunitiesManager() {
placeholder="blue, green, red ..."
helpText="Цвет для отображения бейджа в интерфейсе"
/>
<IconPicker
value={newItem.icon}
onChange={(id) => setNewItem({...newItem, icon: id})}
label="Иконка бренда (опционально)"
allowNone
/>
</FormModal>
{/* Модальное окно редактирования */}
@@ -824,14 +888,6 @@ function CommunitiesManager() {
helpText="Разделяйте теги запятыми"
/>
<FormField
label="Gateway по умолчанию"
name="gatewayDefault"
value={editingDraft.gatewayDefault}
onChange={(val) => setEditingDraft({...editingDraft, gatewayDefault: val})}
placeholder="SWE-HIPHOST"
/>
<FormField
label="Цвет бейджа (опционально)"
name="color"
@@ -840,6 +896,12 @@ function CommunitiesManager() {
placeholder="blue, green, red ..."
helpText="Цвет для отображения бейджа в интерфейсе"
/>
<IconPicker
value={editingDraft.icon}
onChange={(id) => setEditingDraft({...editingDraft, icon: id})}
label="Иконка бренда (опционально)"
allowNone
/>
</FormModal>
{/* Модальное окно подтверждения удаления */}
+87 -19
View File
@@ -18,6 +18,11 @@ import {
IconSearch,
IconChevronDown,
IconChevronUp,
IconBell,
IconCpu,
IconDeviceDesktop,
IconDatabase,
IconServerOff,
} from '@tabler/icons-react';
import { getIconById } from './lib/brandIcons.js';
import PageHeader from './components/PageHeader.jsx';
@@ -26,13 +31,14 @@ 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">
@@ -58,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">
@@ -177,7 +183,16 @@ function PingServiceCard({ config, ms, previousMs, history = [], loading, isExpa
);
}
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,
@@ -357,12 +372,12 @@ function Dashboard() {
title="Панель"
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>
@@ -371,12 +386,12 @@ function Dashboard() {
/>
{/* Пинг до сервисов (список из настроек / Пинг сервисов) */}
<div className="d-flex align-items-center justify-content-between mb-2">
<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"
className="btn btn-outline-primary btn-sm w-100 w-sm-auto"
onClick={() => fetchPingServices(true)}
disabled={pingLoading}
>
@@ -389,7 +404,7 @@ function Dashboard() {
{pingServicesConfig.map((config) => (
<div
key={config.id}
className={expandedPingId === config.id ? 'col-12' : 'col-6 col-md-3'}
className={expandedPingId === config.id ? 'col-12' : 'col-12 col-sm-6 col-md-3'}
>
<PingServiceCard
config={config}
@@ -407,9 +422,62 @@ function Dashboard() {
))}
</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-3 mb-4">
<div className="col-md-3">
<div className="row g-2 g-md-3 mb-4">
<div className="col-6 col-lg-3">
<div className="animate-in">
<StatCard
icon={IconWorld}
@@ -423,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}
@@ -437,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}
@@ -451,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}
@@ -468,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 ?? '—'}
@@ -478,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 ?? '—'}
@@ -487,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 ?? '—'}`}
@@ -496,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>
+48 -78
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 CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx';
import CommunityBadge from './components/CommunityBadge.jsx';
@@ -49,22 +49,25 @@ import AddItemModal from './components/AddItemModal.jsx';
import LastSaved from './components/LastSaved.jsx';
import FormModal from './components/FormModal.jsx';
import { useToast } from './components/ToastContainer.jsx';
const API_URL = '/api';
import {
listCommunities,
fetchDomainsData,
saveDomainsData,
} from './lib/evobgpData.js';
function DomainsNewManager() {
const toast = useToast();
const [items, setItems] = useState([]);
const [originalItems, setOriginalItems] = useState([]);
const [etag, setEtag] = useState('');
const [, setEtag] = useState('');
const [lastModified, setLastModified] = useState('');
const [contentLength, setContentLength] = useState(null);
const [, setContentLength] = useState(null);
const [newItem, setNewItem] = useState({ domain: '', community: '' });
const [newInvalid, setNewInvalid] = useState({ domain: false, community: false });
const [, setNewInvalid] = useState({ domain: false, community: false });
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const searchTimer = useRef(null);
const _searchTimer = useRef(null);
const didInit = useRef(false);
const [editingValue, setEditingValue] = useState('');
const [editModalOpen, setEditModalOpen] = useState(false);
@@ -90,18 +93,18 @@ function DomainsNewManager() {
const signal = controller.signal;
(async () => {
try {
const res = await api.get(`/communities`, { signal });
setCommunities(Array.isArray(res.data) ? res.data : []);
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
const list = await listCommunities();
setCommunities(list);
} catch (err) {
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
}
})();
(async () => {
try {
const r = await api.get('/ws/url', { signal });
setWsUrl(String(r.data?.url || ''));
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
} catch (err) {
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
}
})();
return () => controller.abort();
@@ -121,20 +124,17 @@ function DomainsNewManager() {
const [wsOpen, setWsOpen] = useState(false);
const [wsUrl, setWsUrl] = useState('');
const fetchItems = async (abortSignal) => {
const fetchItems = useCallback(async (_abortSignal) => {
setLoading(true);
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const response = await api.get(`/domains-new`, { params: { offset: 0, limit: 0, format: 'std' }, ...opts });
const payload = Array.isArray(response.data?.items) ? response.data.items : [];
const payload = await fetchDomainsData();
const total = payload.length;
setItems(payload);
setOriginalItems(payload);
setTotalItems(Number.isFinite(total) ? total : 0);
setEtag(response.headers?.etag || '');
setLastModified(response.headers?.['last-modified'] || '');
const lengthHeader = response.headers?.['content-length-source'];
setContentLength(typeof lengthHeader !== 'undefined' ? Number(lengthHeader) : null);
setEtag('');
setLastModified(new Date().toISOString());
setContentLength(null);
setError('');
} catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
@@ -143,7 +143,7 @@ function DomainsNewManager() {
} finally {
setLoading(false);
}
};
}, []);
useEffect(() => {
if (!didInit.current) {
@@ -152,7 +152,7 @@ function DomainsNewManager() {
fetchItems(controller.signal);
return () => controller.abort();
}
}, []);
}, [fetchItems]);
const handleAddItem = (item) => {
// Если item передан (из модалки), используем его, иначе из state (старый способ)
@@ -273,27 +273,7 @@ function DomainsNewManager() {
setShowDiff(true);
};
const handleSaveChanges = async () => {
// подготовим diff и спросим подтверждение только при массовых изменениях (>10)
const valid = items.filter(i => i != null && isValidDomain(i.domain) && isValidCommunity(i.community))
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
const diffData = computeDiff(originalItems, unique);
setDiff(diffData);
// Умное подтверждение: показываем модалку только при массовых изменениях
const totalChanges = (diffData.added?.length || 0) + (diffData.removed?.length || 0) + (diffData.changed?.length || 0);
if (totalChanges > 10) {
// Массовое изменение - требуется подтверждение
setConfirmSaveOpen(true);
} else {
// Малое изменение - сохраняем сразу
performSave();
}
};
const performSave = async () => {
const performSave = useCallback(async () => {
setConfirmSaveOpen(false);
setLoading(true);
try {
@@ -301,30 +281,8 @@ function DomainsNewManager() {
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
// Подгружаем весь список, применяем изменения текущей страницы и сохраняем полные данные
const fullRes = await api.get(`/domains-new`, { params: { offset: 0, limit: 0, format: 'std' } });
const full = Array.isArray(fullRes.data?.items) ? fullRes.data.items : (Array.isArray(fullRes.data) ? fullRes.data : []);
const fullMap = new Map(full.filter(i => i != null).map(i => [String(i.domain || '').trim().toLowerCase(), { domain: String(i.domain || '').trim().toLowerCase(), community: String(i.community || '').trim() }]));
const originalPageMap = new Map(originalItems.map(i => [String(i.domain).trim().toLowerCase(), true]));
const uniqueMap = new Map(unique.map(i => [String(i.domain).trim().toLowerCase(), { domain: String(i.domain).trim().toLowerCase(), community: String(i.community).trim() }]));
// Удаления: всё, что было на странице, но отсутствует в изменённом наборе
for (const key of originalPageMap.keys()) {
if (!uniqueMap.has(key)) fullMap.delete(key);
}
// Добавления/изменения
for (const [key, val] of uniqueMap.entries()) fullMap.set(key, val);
const fullToSave = Array.from(fullMap.values());
const et = fullRes?.headers?.etag || etag;
const response = await api.post(`/domains-new`, { domains: fullToSave, etag: et }, { validateStatus: () => true });
if (response.status === 412) {
setError('Данные изменились в S3 (ETag mismatch). Обновите список и попробуйте снова.');
return;
}
if (response.status >= 400) {
throw new Error(`Save failed with status ${response.status}`);
}
setEtag(response.headers?.etag || et);
await saveDomainsData(unique, originalItems);
setEtag('');
// После сохранения перезагрузим текущую страницу для консистентности
await fetchItems();
setSuccess('Изменения успешно сохранены!');
@@ -336,10 +294,24 @@ function DomainsNewManager() {
} finally {
setLoading(false);
}
};
}, [items, originalItems, fetchItems]);
const handleSaveChanges = useCallback(async () => {
const valid = items.filter(i => i != null && isValidDomain(i.domain) && isValidCommunity(i.community))
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
const diffData = computeDiff(originalItems, unique);
setDiff(diffData);
const totalChanges = (diffData.added?.length || 0) + (diffData.removed?.length || 0) + (diffData.changed?.length || 0);
if (totalChanges > 10) {
setConfirmSaveOpen(true);
} else {
performSave();
}
}, [items, originalItems, performSave]);
// Drag&Drop импорт
const onDropImport = async (e) => {
const _onDropImport = async (e) => {
e.preventDefault();
const file = e.dataTransfer?.files?.[0];
if (!file) return;
@@ -366,9 +338,7 @@ function DomainsNewManager() {
const handleExport = async () => {
try {
const effectiveQ = searchTerm ? searchTerm : (filterCommunity ? filterCommunity : '');
const response = await api.get(`/domains-new`, { params: { q: effectiveQ, offset: 0, limit: 0, format: 'std' } });
const all = Array.isArray(response.data?.items) ? response.data.items : (Array.isArray(response.data) ? response.data : []);
const all = filtered;
const header = ['domain', 'community'];
const csv = [header, ...all.filter(i => i != null).map(i => [i.domain || '', i.community || ''])]
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
@@ -456,7 +426,7 @@ function DomainsNewManager() {
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [loading, items, originalItems, etag, sortField, sortOrder, filterCommunity]);
}, [loading, handleSaveChanges]);
const handleEditKeyDown = (e) => {
if (e.key === 'Enter') {
@@ -950,10 +920,10 @@ function DomainsNewManager() {
<button type="button" className="btn-close" onClick={() => setShowDiff(false)}></button>
<div className="modal-header"><h3 className="modal-title">Изменения</h3></div>
<div className="modal-body">
<div className="row g-3">
<div className="col-md-4"><div className="card"><div className="card-body"><strong>Добавлено</strong><div className="text-muted">{diff.added.length}</div></div></div></div>
<div className="col-md-4"><div className="card"><div className="card-body"><strong>Удалено</strong><div className="text-muted">{diff.removed.length}</div></div></div></div>
<div className="col-md-4"><div className="card"><div className="card-body"><strong>Изменено</strong><div className="text-muted">{diff.changed.length}</div></div></div></div>
<div className="row g-2 g-md-3">
<div className="col-6 col-md-4"><div className="card"><div className="card-body py-2"><strong>Добавлено</strong><div className="text-muted">{diff.added.length}</div></div></div></div>
<div className="col-6 col-md-4"><div className="card"><div className="card-body py-2"><strong>Удалено</strong><div className="text-muted">{diff.removed.length}</div></div></div></div>
<div className="col-6 col-md-4"><div className="card"><div className="card-body py-2"><strong>Изменено</strong><div className="text-muted">{diff.changed.length}</div></div></div></div>
</div>
</div>
<div className="modal-footer">
+66 -171
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import api from './lib/api.js';
import { usePing } from './contexts/PingContext.jsx';
@@ -42,7 +42,7 @@ function EasySwitchManager() {
// Поиск и фильтры
const [searchQuery, setSearchQuery] = useState('');
const [selectedRule, setSelectedRule] = useState('all'); // all | by-community
const [_selectedRule, _setSelectedRule] = useState('all'); // all | by-community
const [activeTab, setActiveTab] = useState('proxies'); // proxies | providers
const [showOnlyConfigured, setShowOnlyConfigured] = useState(true); // Показывать только настроенные
@@ -53,7 +53,7 @@ function EasySwitchManager() {
const [expandedServers, setExpandedServers] = useState(new Set()); // Развернутые серверы
const [groupByTags, setGroupByTags] = useState(true); // Группировка по тегам
// Единый источник пингов (кеш + API) общий с страницей фильтров
const { pingMap, requestPings } = usePing();
const { pingMap, requestPings, cancelScope } = usePing();
const location = useLocation();
useEffect(() => {
@@ -97,7 +97,7 @@ function EasySwitchManager() {
initData();
return () => controller.abort();
}, []);
}, [loadDataWithParams]);
// При переходе на раздел Easy Switch перезапрашиваем пинги (устаревшие по TTL обновятся)
useEffect(() => {
@@ -115,10 +115,21 @@ function EasySwitchManager() {
if (gw.ip) pingTasks.push({ routerId, gatewayIp: gw.ip });
});
});
requestPings(pingTasks);
requestPings(pingTasks, { scope: 'easy-switch' });
}, [location.pathname, servers, inventoryServers, requestPings]);
const loadDataWithParams = async (communities, inventory, ncGatewaysParam, signal) => {
useEffect(() => {
if (location.pathname === '/easy-switch') return;
cancelScope('easy-switch');
}, [location.pathname, cancelScope]);
useEffect(() => {
return () => {
cancelScope('easy-switch');
};
}, [cancelScope]);
const loadDataWithParams = useCallback(async (communities, inventory, ncGatewaysParam, signal) => {
setLoading(true);
setError('');
@@ -258,13 +269,13 @@ function EasySwitchManager() {
if (gw.ip) pingTasks.push({ routerId, gatewayIp: gw.ip });
});
});
requestPings(pingTasks);
requestPings(pingTasks, { scope: 'easy-switch' });
// Отладочная информация
try {
console.log('[EasySwitch] serversWithGateways:', serversWithGateways);
console.log('[EasySwitch] communitiesDirectory:', communities);
} catch (_) {}
} catch { /* no-op */ }
// Автоматически разворачиваем первый сервер
if (serversWithGateways.length > 0 && expandedServers.size === 0) {
@@ -289,7 +300,7 @@ function EasySwitchManager() {
try {
console.log('[EasySwitch] initialActiveGateways:', initialActive);
} catch (_) {}
} catch { /* no-op */ }
} catch (err) {
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
@@ -298,7 +309,8 @@ function EasySwitchManager() {
} finally {
setLoading(false);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- init-only, deps would cause unnecessary re-fetches
}, []);
const handleGatewaySelect = (serverId, community, gateway) => {
const key = `${serverId}:${community}`;
@@ -370,7 +382,7 @@ function EasySwitchManager() {
const currentFilters = Array.isArray(response.data) ? response.data : [];
// Создаем Set существующих communities в фильтрах
const existingCommunitiesSet = new Set(currentFilters.map(f => f.community));
const _existingCommunitiesSet = new Set(currentFilters.map(f => f.community));
// Обновляем существующие фильтры и добавляем новые
const updatedFilters = [];
@@ -505,7 +517,7 @@ function EasySwitchManager() {
if (loading && servers.length === 0) {
return (
<div className="container-xl">
<div className="easy-switch-page">
<PageHeader
title="Easy Switch"
icon={<IconWorld size={24} />}
@@ -525,7 +537,7 @@ function EasySwitchManager() {
}
return (
<div className="container-xl">
<div className="easy-switch-page">
<PageHeader
title="Easy Switch"
icon={<IconWorld size={24} />}
@@ -554,7 +566,7 @@ function EasySwitchManager() {
/>
{/* Вкладки */}
<div className="mb-3">
<div className="card card-tabs mb-3">
<ul className="nav nav-tabs" role="tablist">
<li className="nav-item" role="presentation">
<button
@@ -599,8 +611,9 @@ function EasySwitchManager() {
)}
{/* Панель фильтров */}
<div className="mb-3">
<div className="row g-2 align-items-center">
<div className="card mb-3">
<div className="card-body py-3">
<div className="row g-2 align-items-center">
{/* Переключатель "Только настроенные" */}
<div className="col-auto">
<label className="form-check form-switch mb-0">
@@ -679,17 +692,18 @@ function EasySwitchManager() {
</button>
</div>
<div className="col-auto">
<button className="btn btn-icon" title="Настройки">
<button className="btn btn-outline-secondary btn-icon" title="Настройки">
<IconSettings size={18} />
</button>
</div>
<div className="col-auto">
<button className="btn btn-icon" title="Быстрые действия">
<button className="btn btn-outline-secondary btn-icon" title="Быстрые действия">
<IconFilter size={18} />
</button>
</div>
</div>
</div>
</div>
{/* Список серверов с communities и шлюзами */}
{filteredServers.length === 0 ? (
@@ -726,12 +740,7 @@ function EasySwitchManager() {
<div key={server.id} className="card mb-3">
{/* Заголовок сервера (кликабельный) */}
<div
className="card-header py-2"
style={{
backgroundColor: 'rgba(32, 107, 196, 0.05)',
cursor: 'pointer',
userSelect: 'none'
}}
className="card-header py-2 easy-switch-server-header"
onClick={() => toggleServerExpanded(server.id)}
>
<div className="d-flex align-items-center">
@@ -742,7 +751,7 @@ function EasySwitchManager() {
<IconChevronDown size={20} className="me-2 text-muted" />
)}
{flag && <span className="me-2" style={{ fontSize: '1.5rem' }}>{flag}</span>}
{flag && <span className="me-2 easy-switch-flag">{flag}</span>}
<IconServer size={20} className="me-2 text-primary" />
<h3 className="card-title mb-0 me-2">{server.name}</h3>
{server.description && (
@@ -793,7 +802,7 @@ function EasySwitchManager() {
<div key={tag} className="mb-4">
{/* Заголовок группы тегов */}
<div className="mb-2 pb-2 border-bottom">
<span className="badge bg-purple-lt text-purple me-2" style={{ fontSize: '0.85rem', padding: '6px 10px' }}>
<span className="badge bg-purple-lt text-purple me-2">
{tag}
</span>
<span className="text-muted small">
@@ -813,12 +822,12 @@ function EasySwitchManager() {
<div className="mb-2">
{/* Заголовок community */}
<div className="mb-2">
<span className="badge bg-blue-lt text-blue me-2" style={{ fontSize: '0.8rem' }}>
<span className="badge bg-blue-lt text-blue me-2">
{comm.value}
</span>
<span className="fw-medium">{comm.name || comm.description || ''}</span>
{hasFilterForComm && (
<span className="badge bg-green-lt text-green ms-2" style={{ fontSize: '0.7rem' }}>
<span className="badge bg-green-lt text-green ms-2">
настроен
</span>
)}
@@ -842,50 +851,21 @@ function EasySwitchManager() {
return (
<div key={gw.name} className="col-12 col-sm-4">
<div
className={`card cursor-pointer h-100 ${isActive ? 'bg-primary text-white' : ''}`}
style={{
cursor: 'pointer',
transition: 'all 0.15s ease',
border: isActive ? '2px solid var(--tblr-primary)' : '1px solid rgba(0,0,0,0.1)',
borderRadius: '6px',
boxShadow: isActive ? '0 3px 8px rgba(32, 107, 196, 0.3)' : 'none',
position: 'relative'
}}
className={`card h-100 easy-switch-gateway-card ${isActive ? 'easy-switch-gateway-card-active bg-primary text-white' : ''}`}
onClick={() => handleGatewaySelect(server.id, comm.value, gw.name)}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.boxShadow = '0 2px 6px rgba(0,0,0,0.1)';
e.currentTarget.style.transform = 'translateY(-1px)';
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.transform = 'translateY(0)';
}
}}
>
<div className="card-body p-2">
{/* Галочка (абсолютное позиционирование) */}
{isActive && (
<div
className="bg-white text-primary rounded-circle d-flex align-items-center justify-content-center"
style={{
width: '20px',
height: '20px',
position: 'absolute',
top: '8px',
right: '8px'
}}
>
<div className="bg-white text-primary rounded-circle d-flex align-items-center justify-content-center easy-switch-active-check">
<IconCheck size={12} />
</div>
)}
{/* Метка Fastest */}
<div className="mb-1" style={{ minHeight: '18px' }}>
<div className="mb-1 easy-switch-fast-slot">
{isFastest && !isActive && (
<span className="badge bg-yellow-lt text-yellow d-inline-flex align-items-center" style={{ fontSize: '0.65rem', padding: '2px 6px' }}>
<span className="badge bg-yellow-lt text-yellow d-inline-flex align-items-center easy-switch-fast-badge">
<IconBolt size={10} className="me-1" />
Fast
</span>
@@ -895,22 +875,14 @@ function EasySwitchManager() {
{/* Флаг и название (комментарий) шлюза */}
<div className="d-flex align-items-center gap-2 mb-1">
<span
className="d-flex align-items-center justify-content-center flex-shrink-0 rounded"
style={{
width: '28px',
height: '28px',
fontSize: '1.25rem',
lineHeight: 1,
backgroundColor: isActive ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.06)',
}}
className={`d-flex align-items-center justify-content-center flex-shrink-0 rounded easy-switch-gateway-flag ${isActive ? 'easy-switch-gateway-flag-active' : ''}`}
title={gw.country || country || ''}
>
{countryToFlag(gw.country || country || '') || '🌐'}
</span>
<div className="flex-grow-1 min-w-0">
<div
className={`fw-bold ${isActive ? 'text-white' : ''}`}
style={{ fontSize: '0.85rem', lineHeight: '1.2', wordBreak: 'break-word' }}
className={`fw-bold easy-switch-gateway-title ${isActive ? 'text-white' : ''}`}
>
{gw.comment || gw.name}
</div>
@@ -919,17 +891,16 @@ function EasySwitchManager() {
{/* IP шлюза ниже */}
{gw.ip && (
<div
className={`small text-truncate ${
className={`small text-truncate easy-switch-gateway-ip ${
isActive ? 'text-white-75' : 'text-muted'
}`}
style={{ fontSize: '0.75rem' }}
>
{gw.ip}
</div>
)}
{/* Метрика (пинг) */}
<div className={`fw-bold mt-1 ${isActive ? 'text-white' : 'text-success'}`} style={{ fontSize: '1.1rem' }}>
<div className={`fw-bold mt-1 easy-switch-gateway-ping ${isActive ? 'text-white' : 'text-success'}`}>
{ping != null ? ping : '—'}
</div>
</div>
@@ -952,7 +923,7 @@ function EasySwitchManager() {
<div className="mb-4">
{/* Заголовок группы без тегов */}
<div className="mb-2 pb-2 border-bottom">
<span className="badge bg-gray-lt text-gray me-2" style={{ fontSize: '0.85rem', padding: '6px 10px' }}>
<span className="badge bg-gray-lt text-gray me-2">
Без тегов
</span>
<span className="text-muted small">
@@ -972,12 +943,12 @@ function EasySwitchManager() {
<div className="mb-2">
{/* Заголовок community */}
<div className="mb-2">
<span className="badge bg-blue-lt text-blue me-2" style={{ fontSize: '0.8rem' }}>
<span className="badge bg-blue-lt text-blue me-2">
{comm.value}
</span>
<span className="fw-medium">{comm.name || comm.description || ''}</span>
{hasFilterForComm && (
<span className="badge bg-green-lt text-green ms-2" style={{ fontSize: '0.7rem' }}>
<span className="badge bg-green-lt text-green ms-2">
настроен
</span>
)}
@@ -1001,50 +972,21 @@ function EasySwitchManager() {
return (
<div key={gw.name} className="col-12 col-sm-4">
<div
className={`card cursor-pointer h-100 ${isActive ? 'bg-primary text-white' : ''}`}
style={{
cursor: 'pointer',
transition: 'all 0.15s ease',
border: isActive ? '2px solid var(--tblr-primary)' : '1px solid rgba(0,0,0,0.1)',
borderRadius: '6px',
boxShadow: isActive ? '0 3px 8px rgba(32, 107, 196, 0.3)' : 'none',
position: 'relative'
}}
className={`card h-100 easy-switch-gateway-card ${isActive ? 'easy-switch-gateway-card-active bg-primary text-white' : ''}`}
onClick={() => handleGatewaySelect(server.id, comm.value, gw.name)}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.boxShadow = '0 2px 6px rgba(0,0,0,0.1)';
e.currentTarget.style.transform = 'translateY(-1px)';
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.transform = 'translateY(0)';
}
}}
>
<div className="card-body p-2">
{/* Галочка (абсолютное позиционирование) */}
{isActive && (
<div
className="bg-white text-primary rounded-circle d-flex align-items-center justify-content-center"
style={{
width: '20px',
height: '20px',
position: 'absolute',
top: '8px',
right: '8px'
}}
>
<div className="bg-white text-primary rounded-circle d-flex align-items-center justify-content-center easy-switch-active-check">
<IconCheck size={12} />
</div>
)}
{/* Метка Fastest */}
<div className="mb-1" style={{ minHeight: '18px' }}>
<div className="mb-1 easy-switch-fast-slot">
{isFastest && !isActive && (
<span className="badge bg-yellow-lt text-yellow d-inline-flex align-items-center" style={{ fontSize: '0.65rem', padding: '2px 6px' }}>
<span className="badge bg-yellow-lt text-yellow d-inline-flex align-items-center easy-switch-fast-badge">
<IconBolt size={10} className="me-1" />
Fast
</span>
@@ -1054,22 +996,14 @@ function EasySwitchManager() {
{/* Флаг и название (комментарий) шлюза */}
<div className="d-flex align-items-center gap-2 mb-1">
<span
className="d-flex align-items-center justify-content-center flex-shrink-0 rounded"
style={{
width: '28px',
height: '28px',
fontSize: '1.25rem',
lineHeight: 1,
backgroundColor: isActive ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.06)',
}}
className={`d-flex align-items-center justify-content-center flex-shrink-0 rounded easy-switch-gateway-flag ${isActive ? 'easy-switch-gateway-flag-active' : ''}`}
title={gw.country || country || ''}
>
{countryToFlag(gw.country || country || '') || '🌐'}
</span>
<div className="flex-grow-1 min-w-0">
<div
className={`fw-bold ${isActive ? 'text-white' : ''}`}
style={{ fontSize: '0.85rem', lineHeight: '1.2', wordBreak: 'break-word' }}
className={`fw-bold easy-switch-gateway-title ${isActive ? 'text-white' : ''}`}
>
{gw.comment || gw.name}
</div>
@@ -1078,17 +1012,16 @@ function EasySwitchManager() {
{/* IP шлюза ниже */}
{gw.ip && (
<div
className={`small text-truncate ${
className={`small text-truncate easy-switch-gateway-ip ${
isActive ? 'text-white-75' : 'text-muted'
}`}
style={{ fontSize: '0.75rem' }}
>
{gw.ip}
</div>
)}
{/* Метрика (пинг) */}
<div className={`fw-bold mt-1 ${isActive ? 'text-white' : 'text-success'}`} style={{ fontSize: '1.1rem' }}>
<div className={`fw-bold mt-1 easy-switch-gateway-ping ${isActive ? 'text-white' : 'text-success'}`}>
{ping != null ? ping : '—'}
</div>
</div>
@@ -1120,12 +1053,12 @@ function EasySwitchManager() {
<div className="mb-2">
{/* Заголовок community */}
<div className="mb-2">
<span className="badge bg-blue-lt text-blue me-2" style={{ fontSize: '0.8rem' }}>
<span className="badge bg-blue-lt text-blue me-2">
{comm.value}
</span>
<span className="fw-medium">{comm.name || comm.description || ''}</span>
{hasFilterForComm && (
<span className="badge bg-green-lt text-green ms-2" style={{ fontSize: '0.7rem' }}>
<span className="badge bg-green-lt text-green ms-2">
настроен
</span>
)}
@@ -1149,50 +1082,21 @@ function EasySwitchManager() {
return (
<div key={gw.name} className="col-12 col-sm-4">
<div
className={`card cursor-pointer h-100 ${isActive ? 'bg-primary text-white' : ''}`}
style={{
cursor: 'pointer',
transition: 'all 0.15s ease',
border: isActive ? '2px solid var(--tblr-primary)' : '1px solid rgba(0,0,0,0.1)',
borderRadius: '6px',
boxShadow: isActive ? '0 3px 8px rgba(32, 107, 196, 0.3)' : 'none',
position: 'relative'
}}
className={`card h-100 easy-switch-gateway-card ${isActive ? 'easy-switch-gateway-card-active bg-primary text-white' : ''}`}
onClick={() => handleGatewaySelect(server.id, comm.value, gw.name)}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.boxShadow = '0 2px 6px rgba(0,0,0,0.1)';
e.currentTarget.style.transform = 'translateY(-1px)';
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.transform = 'translateY(0)';
}
}}
>
<div className="card-body p-2">
{/* Галочка (абсолютное позиционирование) */}
{isActive && (
<div
className="bg-white text-primary rounded-circle d-flex align-items-center justify-content-center"
style={{
width: '20px',
height: '20px',
position: 'absolute',
top: '8px',
right: '8px'
}}
>
<div className="bg-white text-primary rounded-circle d-flex align-items-center justify-content-center easy-switch-active-check">
<IconCheck size={12} />
</div>
)}
{/* Метка Fastest */}
<div className="mb-1" style={{ minHeight: '18px' }}>
<div className="mb-1 easy-switch-fast-slot">
{isFastest && !isActive && (
<span className="badge bg-yellow-lt text-yellow d-inline-flex align-items-center" style={{ fontSize: '0.65rem', padding: '2px 6px' }}>
<span className="badge bg-yellow-lt text-yellow d-inline-flex align-items-center easy-switch-fast-badge">
<IconBolt size={10} className="me-1" />
Fast
</span>
@@ -1202,22 +1106,14 @@ function EasySwitchManager() {
{/* Флаг и название (комментарий) шлюза */}
<div className="d-flex align-items-center gap-2 mb-1">
<span
className="d-flex align-items-center justify-content-center flex-shrink-0 rounded"
style={{
width: '28px',
height: '28px',
fontSize: '1.25rem',
lineHeight: 1,
backgroundColor: isActive ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.06)',
}}
className={`d-flex align-items-center justify-content-center flex-shrink-0 rounded easy-switch-gateway-flag ${isActive ? 'easy-switch-gateway-flag-active' : ''}`}
title={gw.country || country || ''}
>
{countryToFlag(gw.country || country || '') || '🌐'}
</span>
<div className="flex-grow-1 min-w-0">
<div
className={`fw-bold ${isActive ? 'text-white' : ''}`}
style={{ fontSize: '0.85rem', lineHeight: '1.2', wordBreak: 'break-word' }}
className={`fw-bold easy-switch-gateway-title ${isActive ? 'text-white' : ''}`}
>
{gw.comment || gw.name}
</div>
@@ -1226,15 +1122,14 @@ function EasySwitchManager() {
{/* IP шлюза ниже */}
{gw.ip && (
<div
className={`small text-truncate mb-1 ${isActive ? 'text-white-75' : 'text-muted'}`}
style={{ fontSize: '0.75rem' }}
className={`small text-truncate mb-1 easy-switch-gateway-ip ${isActive ? 'text-white-75' : 'text-muted'}`}
>
{gw.ip}
</div>
)}
{/* Метрика (пинг) */}
<div className={`fw-bold mt-1 ${isActive ? 'text-white' : 'text-success'}`} style={{ fontSize: '1.1rem' }}>
<div className={`fw-bold mt-1 easy-switch-gateway-ping ${isActive ? 'text-white' : 'text-success'}`}>
{ping != null ? ping : '—'}
</div>
</div>
+22 -22
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { Link, useLocation } from 'react-router-dom';
import api from './lib/api.js';
import ConfirmDialog from './components/ConfirmDialog.jsx';
@@ -47,6 +47,7 @@ import {
import { CSS } from '@dnd-kit/utilities';
import { normalizeGateways, countryToFlag } from './utils/serverUtils.js';
import { usePing } from './contexts/PingContext.jsx';
import { listCommunities } from './lib/evobgpData.js';
// Компонент автокомплита для выбора Community
function CommunityAutocomplete({ label, value, onChange, communities = [], required = false, placeholder = 'Введите или выберите community...', baseAS = '65001' }) {
@@ -79,7 +80,7 @@ function CommunityAutocomplete({ label, value, onChange, communities = [], requi
// 3. Если ввели "65001:120", ищем "120" в справочнике
if (search.includes(':')) {
const searchParts = search.split(':');
const searchAS = searchParts[0];
const _searchAS = searchParts[0];
const searchNum = searchParts[1];
// Точное совпадение числовой части
@@ -246,7 +247,7 @@ function CommunityAutocomplete({ label, value, onChange, communities = [], requi
{Object.entries(groupedCommunities).map(([category, items]) => (
<div key={category}>
<div className="dropdown-header small text-muted bg-light">{category}</div>
{items.map((c, idx) => {
{items.map((c, _idx) => {
const globalIdx = filteredCommunities.indexOf(c);
return (
<div
@@ -773,7 +774,7 @@ function FilterManager() {
const { pingMap, requestPings } = usePing();
// Получить metadata сервера из inventory по имени (dns/hostName/ip или короткое имя типа "ihor")
const getInventoryMetaForServer = (serverName) => {
const getInventoryMetaForServer = useCallback((serverName) => {
if (!serverName) return null;
const name = String(serverName).trim();
return inventoryServers.find((srv) => {
@@ -781,20 +782,18 @@ function FilterManager() {
const hostName = String(srv.hostName || '').trim();
const ip = String(srv.ip || '').trim();
if (dns === name || hostName === name || ip === name) return true;
// На странице Фильтров selectedServer.name короткое имя (первая часть dns), например "ihor"
if (dns && dns.split('.')[0] === name) return true;
return false;
});
};
}, [inventoryServers]);
// Получить routerId для MikroTik (id/dns/ip), как на странице Easy Switch
const getRouterIdForServer = (server) => {
const getRouterIdForServer = useCallback((server) => {
if (!server) return null;
// Сначала ищем по полному имени (fullName/dns), затем по короткому (name)
const meta = getInventoryMetaForServer(server.fullName || server.dns || server.name) || getInventoryMetaForServer(server.name);
if (!meta) return null;
return meta.id || meta.dns || meta.ip || null;
};
}, [getInventoryMetaForServer]);
// Собираем gateways только для выбранного сервера
const selectedServerGateways = (() => {
@@ -893,7 +892,7 @@ function FilterManager() {
.filter((gw) => gw.ip)
.map((gw) => ({ routerId, gatewayIp: gw.ip }));
requestPings(tasks);
}, [location.pathname, selectedServer, selectedServerGateways, requestPings]);
}, [location.pathname, selectedServer, selectedServerGateways, requestPings, getRouterIdForServer]);
useEffect(() => {
const controller = new AbortController();
@@ -913,10 +912,11 @@ function FilterManager() {
(async () => {
try {
const res = await api.get(`/communities`, opts);
setCommunities(Array.isArray(res.data) ? res.data : []);
const rows = await listCommunities();
if (signal.aborted) return;
setCommunities(Array.isArray(rows) ? rows : []);
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
if (signal.aborted) return;
}
})();
@@ -990,7 +990,7 @@ function FilterManager() {
setTimeout(() => setSuccess(''), 3000);
return;
}
} catch (legacyError) {
} catch (_legacyError) {
// Фильтров по legacyId тоже нет - это нормально
console.log('No legacy filters found');
}
@@ -1165,7 +1165,7 @@ function FilterManager() {
}
};
const handleLoadServerConfig = async (serverId, legacyId = null) => {
const _handleLoadServerConfig = async (serverId, legacyId = null) => {
try {
const response = await api.get(`/server-configs/${serverId}`);
const config = response.data?.config;
@@ -1192,7 +1192,7 @@ function FilterManager() {
setTimeout(() => setSuccess(''), 3000);
return;
}
} catch (legacyError) {
} catch (_legacyError) {
console.log('No legacy config found');
}
}
@@ -1299,7 +1299,7 @@ function FilterManager() {
}
};
const handleDeployToAllServers = async () => {
const _handleDeployToAllServers = async () => {
if (!selectedServer) {
setError('Сначала выберите сервер с фильтрами для развертывания.');
return;
@@ -1521,7 +1521,7 @@ function FilterManager() {
setEditServerModalOpen(false);
setSuccess('Сервер успешно обновлён!');
setTimeout(() => setSuccess(''), 3000);
} catch (error) {
} catch (_error) {
setEditServerError('Не удалось сохранить изменения.');
}
};
@@ -1576,7 +1576,7 @@ function FilterManager() {
await navigator.clipboard.writeText(config);
setSuccess('Конфигурация скопирована в буфер обмена!');
setTimeout(() => setSuccess(''), 3000);
} catch (err) {
} catch (_err) {
setError('Не удалось скопировать конфигурацию');
}
} else {
@@ -1590,7 +1590,7 @@ function FilterManager() {
document.execCommand('copy');
setSuccess('Конфигурация скопирована в буфер обмена!');
setTimeout(() => setSuccess(''), 3000);
} catch (err) {
} catch (_err) {
setError('Не удалось скопировать конфигурацию');
}
document.body.removeChild(textArea);
@@ -1598,7 +1598,7 @@ function FilterManager() {
};
// Функция для экспорта конфигурации в S3
const exportConfigToS3 = async () => {
const _exportConfigToS3 = async () => {
setLoading(true);
try {
const response = await api.post(`/filters/export-config`);
@@ -2561,7 +2561,7 @@ function FilterManager() {
try {
await api.post(`/server-configs`, { servers: updatedServers });
setServers(updatedServers);
} catch (error) {
} catch (_error) {
setError('Не удалось обновить статус сервера');
}
}}
+6 -6
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react';
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';
@@ -250,7 +250,7 @@ export default function FirewallPage() {
const res = await api.get('/servers');
const list = Array.isArray(res.data) ? res.data : [];
if (!cancelled) setServers(list);
} catch (e) {
} catch (_e) {
if (!cancelled) setError('Не удалось загрузить список серверов.');
} finally {
if (!cancelled) setLoadingServers(false);
@@ -259,7 +259,7 @@ export default function FirewallPage() {
return () => { cancelled = true; };
}, []);
const fetchAddressLists = async () => {
const fetchAddressLists = useCallback(async () => {
if (!serverId) {
setData(null);
return;
@@ -281,12 +281,12 @@ export default function FirewallPage() {
} finally {
setLoading(false);
}
};
}, [serverId]);
useEffect(() => {
if (serverId) fetchAddressLists();
else setData(null);
}, [serverId]);
}, [serverId, fetchAddressLists]);
const runAnalysis = () => {
if (!data?.ban?.length) return;
@@ -319,7 +319,7 @@ export default function FirewallPage() {
[s.subnet]: { org: info.org, country: info.country, city: info.city },
}));
}
} catch (_) {
} catch {
if (!cancelled) setIpInfoMap((prev) => ({ ...prev, [s.subnet]: null }));
}
if (i < slice.length - 1) await new Promise((r) => setTimeout(r, 180));
+6 -7
View File
@@ -5,7 +5,6 @@ import {
MiniMap,
useEdgesState,
useNodesState,
addEdge,
MarkerType,
Handle,
Position,
@@ -224,7 +223,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
});
setSelectedNodeId(nodeId);
setTimeout(() => setSelectedNodeId(null), 2000);
} catch {}
} catch { /* no-op */ }
}, []);
// Сброс раскладки: очищаем localStorage и пересоздаём ноды
@@ -232,7 +231,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
if (typeof window !== 'undefined') {
try {
window.localStorage.removeItem(STORAGE_KEY);
} catch {}
} catch { /* no-op */ }
}
setNodes((current) => {
return current.map((n, i) => ({
@@ -244,7 +243,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
if (instanceRef.current) {
try {
instanceRef.current.fitView({ padding: 0.2 });
} catch {}
} catch { /* no-op */ }
}
}, 100);
}, [setNodes, computeGridPosition]);
@@ -378,7 +377,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
requestAnimationFrame(() => {
try {
inst.fitView({ padding: 0.2, includeHiddenNodes: true, minZoom: 0.2, maxZoom: 2 });
} catch {}
} catch { /* no-op */ }
});
}, []);
@@ -389,7 +388,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
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]);
@@ -403,7 +402,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
setTimeout(() => {
try {
instanceRef.current.fitView({ padding: 0.2 });
} catch {}
} catch { /* no-op */ }
}, 60);
}
};
+75 -99
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 CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx';
import CommunityBadge from './components/CommunityBadge.jsx';
@@ -44,21 +44,41 @@ import Pagination from './components/Pagination.jsx';
import AddItemModal from './components/AddItemModal.jsx';
import LastSaved from './components/LastSaved.jsx';
import FormModal from './components/FormModal.jsx';
import {
listCommunities,
fetchIpRangesData,
saveIpRangesData,
} from './lib/evobgpData.js';
const API_URL = '/api';
const isValidIPv4 = (ip) => {
const octets = ip.split('.');
if (octets.length !== 4) return false;
return octets.every(o => /^\d{1,3}$/.test(o) && Number(o) >= 0 && Number(o) <= 255);
};
const isValidCidr = (value) => {
const v = String(value).trim();
const parts = v.split('/');
if (parts.length !== 2) return false;
const [ip, mask] = parts;
if (!isValidIPv4(ip)) return false;
if (!/^\d{1,2}$/.test(mask)) return false;
const m = Number(mask);
return m >= 0 && m <= 32;
};
function IPRangesManager() {
const [items, setItems] = useState([]);
const [originalItems, setOriginalItems] = useState([]);
const [etag, setEtag] = useState('');
const [, setEtag] = useState('');
const [lastModified, setLastModified] = useState('');
const [contentLength, setContentLength] = useState(null);
const [, setContentLength] = useState(null);
const [newItem, setNewItem] = useState({ ipRange: '', community: '' });
const [newInvalid, setNewInvalid] = useState({ ipRange: false, community: false });
const [, setNewInvalid] = useState({ ipRange: false, community: false });
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const searchTimer = useRef(null);
const _searchTimer = useRef(null);
const didInit = useRef(false);
const [editingValue, setEditingValue] = useState('');
const [editModalOpen, setEditModalOpen] = useState(false);
@@ -80,9 +100,9 @@ function IPRangesManager() {
useEffect(() => {
(async () => {
try {
const res = await api.get(`/communities`);
setCommunities(Array.isArray(res.data) ? res.data : []);
} catch (e) {
const list = await listCommunities();
setCommunities(list);
} catch (_e) {
// тихо игнорируем
}
})();
@@ -90,23 +110,6 @@ function IPRangesManager() {
const renderCommunityBadge = (value) => (<CommunityBadge value={value} communities={communities} />);
const isValidIPv4 = (ip) => {
const octets = ip.split('.');
if (octets.length !== 4) return false;
return octets.every(o => /^\d{1,3}$/.test(o) && Number(o) >= 0 && Number(o) <= 255);
};
const isValidCidr = (value) => {
const v = String(value).trim();
const parts = v.split('/');
if (parts.length !== 2) return false;
const [ip, mask] = parts;
if (!isValidIPv4(ip)) return false;
if (!/^\d{1,2}$/.test(mask)) return false;
const m = Number(mask);
return m >= 0 && m <= 32;
};
// Допускаем как числовые, так и строковые (AS:NNN) community
const isValidCommunity = (value) => /^(\d+|\d+:\d+)$/.test(String(value).trim());
@@ -126,20 +129,17 @@ function IPRangesManager() {
return () => controller.abort();
}, []);
const fetchItems = async (abortSignal) => {
const fetchItems = useCallback(async (_abortSignal) => {
setLoading(true);
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const response = await api.get(`/ip-ranges`, { params: { offset: 0, limit: 0, format: 'std' }, ...opts });
const payload = Array.isArray(response.data?.items) ? response.data.items : [];
const payload = await fetchIpRangesData();
const total = payload.length;
setItems(payload);
setOriginalItems(payload);
setTotalItems(Number.isFinite(total) ? total : 0);
setEtag(response.headers?.etag || '');
setLastModified(response.headers?.['last-modified'] || '');
const lengthHeader = response.headers?.['content-length-source'];
setContentLength(typeof lengthHeader !== 'undefined' ? Number(lengthHeader) : null);
setEtag('');
setLastModified(new Date().toISOString());
setContentLength(null);
setError('');
} catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
@@ -148,7 +148,7 @@ function IPRangesManager() {
} finally {
setLoading(false);
}
};
}, []);
useEffect(() => {
if (!didInit.current) {
@@ -157,7 +157,7 @@ function IPRangesManager() {
fetchItems(controller.signal);
return () => controller.abort();
}
}, []);
}, [fetchItems]);
const handleAddItem = (item) => {
const ipRange = item?.ipRange || newItem.ipRange;
@@ -268,7 +268,7 @@ function IPRangesManager() {
});
};
const duplicateItem = (item) => {
const _duplicateItem = (item) => {
setItems([...items, { ...item, ipRange: `${item.ipRange.split('/')[0]}/24` }]);
window.notify?.success?.('Запись продублирована');
};
@@ -316,19 +316,19 @@ function IPRangesManager() {
return { added, removed, changed };
};
const [showDiff, setShowDiff] = useState(false);
const [_showDiff, setShowDiff] = useState(false);
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [clearCommunitiesOpen, setClearCommunitiesOpen] = useState(false);
// Аналитика
const [analyzeOpen, setAnalyzeOpen] = useState(false);
const [analyzeConfirmOpen, setAnalyzeConfirmOpen] = useState(false);
const [analysisResultsOpen, setAnalysisResultsOpen] = useState(false);
const [_analyzeOpen, _setAnalyzeOpen] = useState(false);
const [_analyzeConfirmOpen, _setAnalyzeConfirmOpen] = useState(false);
const [_analysisResultsOpen, setAnalysisResultsOpen] = useState(false);
const [analysisPreview, setAnalysisPreview] = useState(null);
const [overwriteConfirmOpen, setOverwriteConfirmOpen] = useState(false);
const [analyzeFilters, setAnalyzeFilters] = useState({ community: '', minMask: 0, maxMask: 32, type: 'any', supernet16: false });
const [_overwriteConfirmOpen, setOverwriteConfirmOpen] = useState(false);
const [analyzeFilters, _setAnalyzeFilters] = useState({ community: '', minMask: 0, maxMask: 32, type: 'any', supernet16: false });
const handlePreviewDiff = () => {
const valid = items.filter(i => i != null && isValidCidr(i.ipRange) && isValidCommunity(i.community))
@@ -338,27 +338,7 @@ function IPRangesManager() {
setShowDiff(true);
};
const handleSaveChanges = async () => {
// подготовим diff и спросим подтверждение только при массовых изменениях (>10)
const valid = items.filter(i => i != null && isValidCidr(i.ipRange) && isValidCommunity(i.community))
.map(i => ({ ipRange: i.ipRange.trim(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
const diffData = computeDiff(originalItems, unique);
setDiff(diffData);
// Умное подтверждение: показываем модалку только при массовых изменениях
const totalChanges = (diffData.added?.length || 0) + (diffData.removed?.length || 0) + (diffData.changed?.length || 0);
if (totalChanges > 10) {
// Массовое изменение - требуется подтверждение
setConfirmSaveOpen(true);
} else {
// Малое изменение - сохраняем сразу
performSave();
}
};
const performSave = async () => {
const performSave = useCallback(async () => {
setConfirmSaveOpen(false);
setLoading(true);
try {
@@ -366,28 +346,8 @@ function IPRangesManager() {
.map(i => ({ ipRange: i.ipRange.trim(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
// Загрузим полный список, совместим с изменениями текущей страницы и сохраним
const fullRes = await api.get(`/ip-ranges`, { params: { offset: 0, limit: 0, format: 'std' } });
const full = Array.isArray(fullRes.data?.items) ? fullRes.data.items : (Array.isArray(fullRes.data) ? fullRes.data : []);
const fullMap = new Map(full.map(i => [String(i.ipRange).trim(), { ipRange: String(i.ipRange).trim(), community: String(i.community || '').trim() }]));
const originalPageMap = new Map(originalItems.map(i => [String(i.ipRange).trim(), true]));
const uniqueMap = new Map(unique.map(i => [String(i.ipRange).trim(), { ipRange: String(i.ipRange).trim(), community: String(i.community).trim() }]));
for (const key of originalPageMap.keys()) {
if (!uniqueMap.has(key)) fullMap.delete(key);
}
for (const [key, val] of uniqueMap.entries()) fullMap.set(key, val);
const fullToSave = Array.from(fullMap.values());
const et = fullRes?.headers?.etag || etag;
const response = await api.post(`/ip-ranges`, { ipRanges: fullToSave, etag: et }, { validateStatus: () => true });
if (response.status === 412) {
setError('Данные изменились в S3 (ETag mismatch). Обновите список и попробуйте снова.');
return;
}
if (response.status >= 400) {
throw new Error(`Save failed with status ${response.status}`);
}
setEtag(response.headers?.etag || et);
await saveIpRangesData(unique, originalItems);
setEtag('');
await fetchItems();
setSuccess('Изменения успешно сохранены!');
setTimeout(() => setSuccess(''), 3000);
@@ -398,7 +358,21 @@ function IPRangesManager() {
} finally {
setLoading(false);
}
};
}, [items, originalItems, fetchItems]);
const handleSaveChanges = useCallback(async () => {
const valid = items.filter(i => i != null && isValidCidr(i.ipRange) && isValidCommunity(i.community))
.map(i => ({ ipRange: i.ipRange.trim(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
const diffData = computeDiff(originalItems, unique);
setDiff(diffData);
const totalChanges = (diffData.added?.length || 0) + (diffData.removed?.length || 0) + (diffData.changed?.length || 0);
if (totalChanges > 10) {
setConfirmSaveOpen(true);
} else {
performSave();
}
}, [items, originalItems, performSave]);
const handleImport = () => {
setImportOpen(true);
@@ -406,9 +380,7 @@ function IPRangesManager() {
const handleExport = async () => {
try {
const effectiveQ = searchTerm ? searchTerm : (filterCommunity ? filterCommunity : '');
const response = await api.get(`/ip-ranges`, { params: { q: effectiveQ, offset: 0, limit: 0, format: 'std' } });
const all = Array.isArray(response.data?.items) ? response.data.items : (Array.isArray(response.data) ? response.data : []);
const all = filtered;
const header = ['ipRange', 'community'];
const csv = [header, ...all.map(i => [i.ipRange, i.community])]
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
@@ -426,7 +398,7 @@ function IPRangesManager() {
};
// Drag&Drop импорт (оставим как быстрый путь)
const onDropImport = async (e) => {
const _onDropImport = async (e) => {
e.preventDefault();
const file = e.dataTransfer?.files?.[0];
if (!file) return;
@@ -532,7 +504,7 @@ function IPRangesManager() {
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [loading, items, originalItems, etag, sortField, sortOrder, filterCommunity]);
}, [loading, handleSaveChanges]);
const handleEditKeyDown = (e) => {
if (e.key === 'Enter') {
@@ -582,7 +554,7 @@ function IPRangesManager() {
return 'public';
};
const formatBigInt = (n) => {
const _formatBigInt = (n) => {
try {
const s = (n ?? 0n).toString();
return s.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
@@ -591,7 +563,7 @@ function IPRangesManager() {
}
};
const buildAnalysisConfirmText = () => {
const _buildAnalysisConfirmText = () => {
const parts = [];
parts.push(`Community: ${analyzeFilters.community || 'не выбрано'}`);
parts.push(`Маска: /${analyzeFilters.minMask}..../${analyzeFilters.maxMask}`);
@@ -705,7 +677,7 @@ function IPRangesManager() {
return Array.from(new Set(out));
};
const runAnalysis = () => {
const _runAnalysis = () => {
const community = String(analyzeFilters.community || '').trim();
if (!community) { window.notify?.error?.('Выберите community'); return; }
// исходные для community
@@ -744,7 +716,7 @@ function IPRangesManager() {
setAnalysisResultsOpen(true);
};
const applyAnalysisToItems = () => {
const _applyAnalysisToItems = () => {
if (!analysisPreview) return;
const { community, afterCidrs, unchangedCidrs } = analysisPreview;
const keptOthers = items.filter(i => i != null && i.community !== community);
@@ -770,10 +742,14 @@ function IPRangesManager() {
window.notify?.success?.('Диапазоны перезаписаны по результатам анализа. Не забудьте сохранить изменения.');
};
const exportAnalysisCsv = () => {
const _exportAnalysisCsv = () => {
if (!analysisPreview) return;
const { community, afterCidrs } = analysisPreview;
const totalAddr = afterCidrs?.reduce((s, c) => s + countAddresses(parseMask(c)), 0n) ?? 0n;
const header = ['community', 'ranges', 'totalAddresses'];
const lines = [header, ...analysisResult.map(r => [r.community, r.ranges, r.totalAddresses.toString()])]
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
const rows = [[community, String(afterCidrs?.length ?? 0), totalAddr.toString()]];
const lines = [header, ...rows]
.map(r => (Array.isArray(r) ? r : []).map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
+1 -1
View File
@@ -83,7 +83,7 @@ export default function InterfaceSpeedTest() {
return () => controller.abort();
}, [notify]);
const gateways = useMemo(
const _gateways = useMemo(
() =>
networkConfig?.gateways && Array.isArray(networkConfig.gateways)
? networkConfig.gateways
+60 -60
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
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';
@@ -156,13 +156,70 @@ function MikrotikBackupsManager() {
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) {
@@ -173,15 +230,13 @@ function MikrotikBackupsManager() {
setBackups([]);
setSelectedKeys([]);
}
}, [selectedServerId]);
}, [selectedServerId, fetchBackups]);
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],
@@ -319,61 +374,6 @@ function MikrotikBackupsManager() {
setBackupServerIds(new Set());
};
const fetchServers = 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);
}
};
const fetchUiSettings = 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 = 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);
}
};
const handleSelectKey = (key) => {
setSelectedKeys((prev) => {
if (prev.includes(key)) {
+4 -4
View File
@@ -82,7 +82,7 @@ function MikrotikTools() {
[networkConfig]
);
const handleSelectGatewayMeta = (meta) => {
const _handleSelectGatewayMeta = (meta) => {
setGatewayMeta(meta);
// Если цель не задана подставляем IP gateway как target
if (!target && meta && meta.ip) {
@@ -214,7 +214,7 @@ function MikrotikTools() {
[jumphostServers, serverId]
);
const interfacesForServer = useMemo(() => {
const _interfacesForServer = useMemo(() => {
if (!serverId || !interfaces.length) return [];
const ids = new Set(
[serverId, currentServer?.id, currentServer?.ip, currentServer?.dns]
@@ -391,7 +391,7 @@ function MikrotikTools() {
{/* Цели — карточки в стиле сервера/gateway */}
<div className="row row-cards g-1 mt-2">
{POPULAR_TARGETS.map(({ id: tid, name, target: tgt, Icon, color }) => {
{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';
@@ -404,7 +404,7 @@ function MikrotikTools() {
>
<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} />
<_Icon size={20} stroke={1.5} />
</span>
<div className="flex-grow-1 min-w-0">
<div className="fw-semibold text-truncate small">{name}</div>
+35 -16
View File
@@ -249,6 +249,7 @@ function NetworkConfigManager() {
fetchServers();
fetchIpsecPasswords();
fetchUiSettings();
// eslint-disable-next-line react-hooks/exhaustive-deps -- init-only, fetch functions defined below
}, []);
const fetchUiSettings = async () => {
@@ -393,6 +394,7 @@ function NetworkConfigManager() {
label: getServerLabel(id),
server: getServerInfo(id),
}));
// eslint-disable-next-line react-hooks/exhaustive-deps -- getServerInfo/getServerLabel are stable helpers
}, [config.gateways, config.tunnelInterfaces, config.ipPools, servers]);
// === Фильтрация ===
@@ -431,6 +433,7 @@ function NetworkConfigManager() {
}
return result;
// eslint-disable-next-line react-hooks/exhaustive-deps -- getServerInfo/getServerLabel/gatewayTypeFilter are stable
}, [config.gateways, providerFilter, serverFilter, typeFilter, searchTerm, servers]);
const filteredInterfaces = useMemo(() => {
@@ -456,6 +459,7 @@ function NetworkConfigManager() {
}
return result;
// eslint-disable-next-line react-hooks/exhaustive-deps -- getServerLabel is stable helper
}, [config.tunnelInterfaces, typeFilter, serverFilter, searchTerm, servers]);
const filteredPools = useMemo(() => {
@@ -468,7 +472,7 @@ function NetworkConfigManager() {
if (searchTerm) {
const term = searchTerm.toLowerCase();
result = result.filter(p => {
const server = getServerInfo(p.serverId);
const _server = getServerInfo(p.serverId);
return (
p.name?.toLowerCase().includes(term) ||
p.cidr?.toLowerCase().includes(term) ||
@@ -479,6 +483,7 @@ function NetworkConfigManager() {
}
return result;
// eslint-disable-next-line react-hooks/exhaustive-deps -- getServerInfo/getServerLabel are stable helpers
}, [config.ipPools, serverFilter, searchTerm, servers]);
// === Группировка по серверам ===
@@ -528,6 +533,7 @@ function NetworkConfigManager() {
});
return grouped;
// eslint-disable-next-line react-hooks/exhaustive-deps -- getServerCategory is stable helper
}, [gatewaysByServer, servers]);
const interfacesByCategory = useMemo(() => {
@@ -547,6 +553,7 @@ function NetworkConfigManager() {
});
return grouped;
// eslint-disable-next-line react-hooks/exhaustive-deps -- getServerCategory is stable helper
}, [interfacesByServer, servers]);
// === Сброс фильтров ===
@@ -913,7 +920,7 @@ function NetworkConfigManager() {
return;
}
const invalidPairs = templateServerPairs.filter((pair, index) => {
const invalidPairs = templateServerPairs.filter((pair, _index) => {
if (!pair.server1 || !pair.server2) {
return true;
}
@@ -1274,7 +1281,7 @@ function NetworkConfigManager() {
};
// === Генерация IP из пула ===
const generateIpFromPool = (cidr, usedIps, excludeIp = null, forRemote = false, pairedLocalIp = null) => {
const generateIpFromPool = (cidr, usedIps, _excludeIp = null, forRemote = false, pairedLocalIp = null) => {
// Парсим CIDR (например, "10.10.0.0/24")
const [network, prefixLength] = cidr.split('/');
if (!network || !prefixLength) {
@@ -1590,6 +1597,7 @@ function NetworkConfigManager() {
}
return a.type.localeCompare(b.type);
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- generatePtrZone/getServerInfo are stable helpers
}, [config.tunnelInterfaces, servers, globalPtrZoneReplaceFrom, globalPtrZoneReplaceTo]);
// === Фильтрация реестра IP ===
@@ -1650,7 +1658,7 @@ function NetworkConfigManager() {
};
// === CRUD для IPSec Passwords ===
const handleAddIpsecPassword = () => {
const _handleAddIpsecPassword = () => {
setEditingIpsecPassword({ name: '', password: '', description: '' });
setIpsecPasswordModalMode('add');
setIpsecPasswordModalOpen(true);
@@ -2021,7 +2029,7 @@ function NetworkConfigManager() {
};
// === Генерация кода MikroTik для рекурсивных маршрутов ===
const generateMikrotikRecursiveRoutes = async () => {
const _generateMikrotikRecursiveRoutes = async () => {
const recursiveGateways = (config.gateways || []).filter(gw => gw.type === 'recursive');
const blocks = [];
@@ -2073,7 +2081,7 @@ function NetworkConfigManager() {
}
// Обрабатываем каждый родительский gateway
parentGatewaysList.forEach((parentRef, parentIndex) => {
parentGatewaysList.forEach((parentRef, _parentIndex) => {
const parent = getParentGateway(parentRef.id);
if (!parent) {
@@ -2264,7 +2272,7 @@ function NetworkConfigManager() {
if (obj.t === 'log') console.log('[MikroTik apply]', obj.msg, obj);
else if (obj.t === 'result') finalData = obj;
else if (obj.t === 'error') finalData = { ok: false, error: obj.error, mikrotikRequests: obj.mikrotikRequests };
} catch (_) {}
} catch { /* no-op */ }
}
}
if (buffer.trim()) {
@@ -2272,7 +2280,7 @@ function NetworkConfigManager() {
const obj = JSON.parse(buffer);
if (obj.t === 'result') finalData = obj;
else if (obj.t === 'error') finalData = { ok: false, error: obj.error, mikrotikRequests: obj.mikrotikRequests };
} catch (_) {}
} catch { /* no-op */ }
}
data = finalData || {};
} else {
@@ -2403,7 +2411,7 @@ function NetworkConfigManager() {
let validRoutesCount = 0;
parentGatewaysList.forEach((parentRef, parentIndex) => {
parentGatewaysList.forEach((parentRef, _parentIndex) => {
const parent = getParentGateway(parentRef.id);
if (!parent) {
@@ -2542,7 +2550,7 @@ function NetworkConfigManager() {
try {
document.execCommand('copy');
notify.success('Код скопирован в буфер обмена!');
} catch (err) {
} catch (_err) {
notify.error('Не удалось скопировать код');
}
document.body.removeChild(textArea);
@@ -2589,7 +2597,7 @@ function NetworkConfigManager() {
}, [generatedMikrotikCode]);
// === Получение названия типа блока ===
const getBlockTypeLabel = (type) => {
const _getBlockTypeLabel = (type) => {
switch (type) {
case 'recursive-routes':
return 'Рекурсивные маршруты';
@@ -2967,11 +2975,11 @@ function NetworkConfigManager() {
// === Рендер карточки Gateway ===
const renderGatewayCard = (gateway) => {
const server = getServerInfo(gateway.serverId);
const provider = server?.provider || '';
const _provider = server?.provider || '';
const serverCountry = server?.country || '';
const gatewayCountry = gateway.country || '';
const displayCountry = gatewayCountry || serverCountry;
const gatewayType = GATEWAY_TYPES.find(t => t.value === gateway.type) || GATEWAY_TYPES[0];
const _gatewayType = GATEWAY_TYPES.find(t => t.value === gateway.type) || GATEWAY_TYPES[0];
// Поддержка старого формата (parentGatewayId) и нового (parentGateways)
const parentGatewaysList = gateway.type === 'recursive'
@@ -3135,7 +3143,7 @@ function NetworkConfigManager() {
const interfaceTypeColor = getInterfaceTypeColor(iface.type);
// Определяем отображаемый IP для зеленого блока (remote IP другого сервера)
const displayIp = displayRemoteIp !== '—' ? displayRemoteIp : displayLocalIp;
const _displayIp = displayRemoteIp !== '—' ? displayRemoteIp : displayLocalIp;
return (
<div className="card" style={{ borderRadius: '12px', maxWidth: '100%' }}>
@@ -3665,6 +3673,17 @@ function NetworkConfigManager() {
{/* Interfaces Tab */}
{activeTab === 'interfaces' && (
<>
<div className="card mb-3">
<div className="card-body d-flex flex-wrap align-items-center justify-content-between gap-2">
<div className="text-muted small">
Настройки OSPF вынесены в отдельный раздел проекта.
</div>
<a className="btn btn-outline-primary btn-sm" href="/ospf-tools">
Открыть Инструменты OSPF
</a>
</div>
</div>
{filteredInterfaces.length === 0 ? (
<div className="card">
<div className="card-body text-center py-5">
@@ -5520,7 +5539,7 @@ function NetworkConfigManager() {
<div className="row g-2">
{GATEWAY_TEMPLATES.map(template => {
// Генерируем предпросмотр IP адресов если есть базовый IP и сервер
const previewIps = template.gateways.map((gw, idx) => {
const previewIps = template.gateways.map((gw, _idx) => {
// Для прямых gateways показываем IP сервера, если выбран
if (gw.type === 'direct' && templateServerId) {
const server = getServerInfo(templateServerId);
@@ -5698,7 +5717,7 @@ function NetworkConfigManager() {
<label className="form-label small">Родительские gateway/интерфейсы</label>
<div className="d-flex flex-column gap-2">
{(gw.parentGateways || []).map((parent, parentIndex) => {
const parentInfo = getParentGateway(parent.id, templateGateways);
const _parentInfo = getParentGateway(parent.id, templateGateways);
return (
<div key={parentIndex} className="d-flex align-items-center gap-2">
<div className="flex-grow-1">
+49 -7
View File
@@ -76,6 +76,7 @@ export default function NetworkMapDashboard() {
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);
@@ -102,12 +103,17 @@ export default function NetworkMapDashboard() {
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);
@@ -195,14 +201,21 @@ export default function NetworkMapDashboard() {
/** Один ключ пинга на ребро (маршруты статичны — одна сторона) */
const edgePingKey = (a, b) => [String(a), String(b)].sort().join(':');
const requestPings = useCallback(async () => {
if (connections.length === 0) return;
/**
* Запросить пинг для всех соединений или для конкретного подмножества.
* Если передать 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 = [];
connections.forEach((c) => {
sourceConnections.forEach((c) => {
const from = String(c.from);
const to = String(c.to);
if (!from || !to || from === to) return;
@@ -214,7 +227,7 @@ export default function NetworkMapDashboard() {
srcTo && ['jumphost', 'home'].includes(String(srcTo.type || '').toLowerCase()) && c.internalToFrom;
const ekey = edgePingKey(from, to);
if (ttlMs > 0 && pingTimestampsRef.current[ekey] && Date.now() - pingTimestampsRef.current[ekey] < ttlMs) {
if (!force && ttlMs > 0 && pingTimestampsRef.current[ekey] && Date.now() - pingTimestampsRef.current[ekey] < ttlMs) {
return;
}
@@ -291,8 +304,15 @@ export default function NetworkMapDashboard() {
const speedKey = (key1, key2) =>
[String(key1), String(key2)].sort().join(':');
const requestSpeeds = useCallback(async () => {
const withSpeed = connections.filter(
/**
* Запросить скорость для всех соединений или подмножества.
* Если передать 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;
@@ -307,6 +327,7 @@ export default function NetworkMapDashboard() {
{
serverId: c.speedTestServerId,
interfaceName: c.interfaceName,
forceRefresh: true, // ручной запуск со страницы карты сети всегда должен делать новый замер
},
{ timeout: 120000 }
);
@@ -343,6 +364,22 @@ export default function NetworkMapDashboard() {
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);
@@ -357,7 +394,7 @@ export default function NetworkMapDashboard() {
};
const handleRefreshSpeeds = () => {
requestSpeeds();
requestSpeeds({ force: true });
};
if (loading) {
@@ -427,6 +464,11 @@ export default function NetworkMapDashboard() {
speedMap={speedMap}
pingStaleMap={pingStaleMap}
speedStaleMap={speedStaleMap}
onRefreshPingForConnection={handleRefreshPingForConnection}
onRefreshSpeedForConnection={handleRefreshSpeedForConnection}
pingLoading={pingLoading}
speedLoading={speedLoading}
alertSettings={alertSettings}
/>
)}
</div>
+643 -128
View File
@@ -4,14 +4,18 @@ import { IconRefresh, IconMaximize, IconMinimize, IconClock } from '@tabler/icon
const FLAG_CDN = 'https://flagcdn.com';
const UNIFI_BG = '#0f172a';
const UNIFI_BG_DEEP = '#0b1220';
const UNIFI_CARD_BG = '#1e293b';
const UNIFI_CARD_BORDER = '#334155';
const UNIFI_GREEN = '#22c55e';
const UNIFI_GREEN_SOFT = '#34d399';
const UNIFI_BLUE_SOFT = '#38bdf8';
const UNIFI_WARNING = '#f59e0b';
const UNIFI_TEXT = '#f1f5f9';
const UNIFI_TEXT_MUTED = '#94a3b8';
const NODE_WIDTH = 88;
const NODE_HEIGHT = 52;
const STORAGE_KEY = 'network-map-unifi-positions';
const NODE_WIDTH = 124;
const NODE_HEIGHT = 64;
const STORAGE_KEY = 'network-map-unifi-positions';
function computeInitialLayout(servers, size) {
const positions = {};
@@ -34,7 +38,7 @@ function computeInitialLayout(servers, size) {
const baseX = paddingX;
columns.forEach((col, colIdx) => {
// В пределах колонки сортируем по dns/ip для стабильности
// В пределах колонки сортируем по dns/ip для стабильности/
const sorted = [...col].sort((a, b) => {
const la = (a.dns || a.ip || '').toString();
const lb = (b.dns || b.ip || '').toString();
@@ -86,7 +90,62 @@ function speedKey(key1, key2) {
return [String(key1), String(key2)].sort().join(':');
}
export default function NetworkMapUnifi({ servers = [], connections = [], pingMap = {}, speedMap = {}, pingStaleMap = {}, speedStaleMap = {} }) {
function getNodeTypeMeta(type) {
const t = String(type || '').toLowerCase();
if (t === 'home') return { label: 'HOME', color: '#60a5fa' };
if (t === 'jumphost') return { label: 'JUMP', color: '#34d399' };
if (t === 'exit') return { label: 'EXIT', color: '#f59e0b' };
return { label: 'NODE', color: '#94a3b8' };
}
function getTunnelColor(tunnelType) {
const t = String(tunnelType || '').toLowerCase();
if (t.includes('wireguard') || t === 'wg') return UNIFI_BLUE_SOFT;
if (t.includes('ipsec')) return UNIFI_GREEN;
return UNIFI_GREEN_SOFT;
}
function sideVector(side) {
if (side === 'right') return { x: 1, y: 0 };
if (side === 'left') return { x: -1, y: 0 };
if (side === 'bottom') return { x: 0, y: 1 };
return { x: 0, y: -1 };
}
function anchorPoint(pos, side, slotOffset = 0) {
if (side === 'right') return { x: pos.x + NODE_WIDTH / 2, y: pos.y + slotOffset };
if (side === 'left') return { x: pos.x - NODE_WIDTH / 2, y: pos.y + slotOffset };
if (side === 'bottom') return { x: pos.x + slotOffset, y: pos.y + NODE_HEIGHT / 2 };
return { x: pos.x + slotOffset, y: pos.y - NODE_HEIGHT / 2 };
}
function clamp(val, min, max) {
return Math.max(min, Math.min(max, val));
}
function hash01(str) {
let h = 2166136261;
const s = String(str || '');
for (let i = 0; i < s.length; i += 1) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return (h >>> 0) / 4294967295;
}
export default function NetworkMapUnifi({
servers = [],
connections = [],
pingMap = {},
speedMap = {},
pingStaleMap = {},
speedStaleMap = {},
onRefreshPingForConnection,
onRefreshSpeedForConnection,
pingLoading = false,
speedLoading = false,
alertSettings = {},
}) {
const containerRef = useRef(null);
const svgRef = useRef(null);
const [size, setSize] = useState({ w: 800, h: 520 });
@@ -96,6 +155,33 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
const [hoveredEdgeKey, setHoveredEdgeKey] = useState(null);
const [selectedEdge, setSelectedEdge] = useState(null);
const mapSpeedThresholds = useMemo(() => {
const raw =
alertSettings?.networkMapNodeThresholds &&
typeof alertSettings.networkMapNodeThresholds === 'object'
? alertSettings.networkMapNodeThresholds
: {};
const low = Math.max(1, Number(raw.lowSpeedMbps) || 40);
const belowNorm = Math.max(low, Number(raw.belowNormSpeedMbps) || 80);
const normal = Math.max(belowNorm, Number(raw.normalSpeedMbps) || 120);
return { low, belowNorm, normal };
}, [alertSettings]);
const nodeMetaByIp = useMemo(() => {
const byIp = new Map();
servers.forEach((s) => {
const ip = String(s.ip || '');
if (!ip) return;
byIp.set(ip, {
ip,
dns: s.dns || '',
type: String(s.type || '').toLowerCase(),
nodeLabel: s.dns ? String(s.dns).split('.')[0] : ip,
});
});
return byIp;
}, [servers]);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
@@ -127,10 +213,10 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw) saved = JSON.parse(raw) || {};
} catch {}
} catch { /* no-op */ }
const initial = computeInitialLayout(servers, size);
const merged = {};
servers.forEach((s, i) => {
servers.forEach((s, _i) => {
const ip = String(s.ip);
if (saved[ip] && typeof saved[ip].x === 'number' && typeof saved[ip].y === 'number') {
merged[ip] = { x: saved[ip].x, y: saved[ip].y };
@@ -139,13 +225,13 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
}
});
setPositions(merged);
}, [servers.length, size.w, size.h]);
}, [servers, size]);
const savePositions = useCallback((next) => {
const _savePositions = useCallback((next) => {
setPositions(next);
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {}
} catch { /* no-op */ }
}, []);
const getSvgCoords = useCallback((clientX, clientY) => {
@@ -169,7 +255,7 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
const next = { ...prev, [drag.nodeId]: { x: drag.startX + (p.x - drag.mouseX), y: drag.startY + (p.y - drag.mouseY) } };
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {}
} catch { /* no-op */ }
return next;
});
};
@@ -187,7 +273,7 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
setPositions(initial);
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(initial));
} catch {}
} catch { /* no-op */ }
}, [servers, size]);
const onNodeMouseDown = useCallback(
@@ -220,16 +306,23 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
speed && (speed.tcpDownloadBps != null || speed.tcpUploadBps != null)
? formatSpeedShort(speed.tcpDownloadBps, speed.tcpUploadBps)
: null;
const compactEdgeLabel = pingLabel || speedLabel || null;
const edgeLabel =
pingLabel && speedLabel ? `${pingLabel}${speedLabel}` : (pingLabel || speedLabel);
return {
edgeKey: `${from}-${to}-${c.tunnelType || ''}`,
from,
to,
p1,
p2,
pingMs: typeof ms === 'number' ? ms : null,
pingLabel,
speedLabel,
compactEdgeLabel,
edgeLabel,
pingStale,
speedStale,
stale: pingStale || speedStale,
speed,
tunnelType: c.tunnelType,
raw: c,
@@ -237,77 +330,308 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
}).filter((e) => e.p1 && e.p2);
}, [connections, positions, pingMap, speedMap, pingStaleMap, speedStaleMap]);
// Вычисляем старт/финиш и одну контрольную точку:
// рёбра выходят из карточек под 90°, дальше сразу плавная дуга без прямых сегментов.
// Роутинг рёбер: раскладываем рёбра по "портам" на сторонах узла (эффект трезубца),
// а также даём параллельным связям разный изгиб.
const routedEdges = useMemo(() => {
const nodePortBuckets = new Map();
const edgeRoutes = new Map();
const pairBuckets = new Map();
const slotStep = 9;
const getEdgeSides = (e) => {
const dx = e.p2.x - e.p1.x;
const dy = e.p2.y - e.p1.y;
if (Math.abs(dx) >= Math.abs(dy)) {
return dx >= 0
? { fromSide: 'right', toSide: 'left' }
: { fromSide: 'left', toSide: 'right' };
}
return dy >= 0
? { fromSide: 'bottom', toSide: 'top' }
: { fromSide: 'top', toSide: 'bottom' };
};
const getSortValue = (side, targetPos) =>
side === 'left' || side === 'right' ? targetPos.y : targetPos.x;
edgesWithPing.forEach((e, idx) => {
const { fromSide, toSide } = getEdgeSides(e);
if (!nodePortBuckets.has(e.from)) nodePortBuckets.set(e.from, {});
if (!nodePortBuckets.has(e.to)) nodePortBuckets.set(e.to, {});
const fromBuckets = nodePortBuckets.get(e.from);
const toBuckets = nodePortBuckets.get(e.to);
fromBuckets[fromSide] = fromBuckets[fromSide] || [];
toBuckets[toSide] = toBuckets[toSide] || [];
fromBuckets[fromSide].push({
idx,
role: 'from',
sortValue: getSortValue(fromSide, e.p2),
});
toBuckets[toSide].push({
idx,
role: 'to',
sortValue: getSortValue(toSide, e.p1),
});
const pairKey = [e.from, e.to].sort().join(':');
if (!pairBuckets.has(pairKey)) pairBuckets.set(pairKey, []);
pairBuckets.get(pairKey).push(idx);
edgeRoutes.set(idx, {
fromSide,
toSide,
fromSlot: 0,
toSlot: 0,
bundleOffset: 0,
});
});
nodePortBuckets.forEach((sides) => {
Object.keys(sides).forEach((side) => {
const list = sides[side];
list.sort((a, b) => a.sortValue - b.sortValue);
const center = (list.length - 1) / 2;
list.forEach((item, order) => {
const slotOffset = (order - center) * slotStep;
const route = edgeRoutes.get(item.idx);
if (!route) return;
if (item.role === 'from') route.fromSlot = slotOffset;
else route.toSlot = slotOffset;
});
});
});
pairBuckets.forEach((idxList) => {
idxList.sort((a, b) => {
const ea = edgesWithPing[a];
const eb = edgesWithPing[b];
return String(ea.tunnelType || '').localeCompare(String(eb.tunnelType || '')) || ea.edgeKey.localeCompare(eb.edgeKey);
});
const center = (idxList.length - 1) / 2;
idxList.forEach((edgeIdx, order) => {
const route = edgeRoutes.get(edgeIdx);
if (!route) return;
route.bundleOffset = (order - center) * 14;
});
});
return edgesWithPing.map((e, idx) => ({
...e,
route: edgeRoutes.get(idx),
}));
}, [edgesWithPing]);
// Вычисляем геометрию ребра: выход из "порта" узла + короткий stem (трезубец) + плавная дуга.
const computeCurvePoints = useCallback((e) => {
const { p1, p2 } = e;
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const adx = Math.abs(dx);
const ady = Math.abs(dy);
const route = e.route || { fromSide: 'right', toSide: 'left', fromSlot: 0, toSlot: 0, bundleOffset: 0 };
const stem = 14;
let sx = p1.x;
let sy = p1.y;
let ex = p2.x;
let ey = p2.y;
const startAnchor = anchorPoint(p1, route.fromSide, route.fromSlot);
const endAnchor = anchorPoint(p2, route.toSide, route.toSlot);
const fromVec = sideVector(route.fromSide);
const toVec = sideVector(route.toSide);
if (adx >= ady) {
const sign = dx >= 0 ? 1 : -1;
sx = p1.x + sign * (NODE_WIDTH / 2);
sy = p1.y;
ex = p2.x - sign * (NODE_WIDTH / 2);
ey = p2.y;
} else {
const sign = dy >= 0 ? 1 : -1;
sx = p1.x;
sy = p1.y + sign * (NODE_HEIGHT / 2);
ex = p2.x;
ey = p2.y - sign * (NODE_HEIGHT / 2);
}
const sx = startAnchor.x;
const sy = startAnchor.y;
const ex = endAnchor.x;
const ey = endAnchor.y;
const sOutX = sx + fromVec.x * stem;
const sOutY = sy + fromVec.y * stem;
const eOutX = ex + toVec.x * stem;
const eOutY = ey + toVec.y * stem;
const ddx = ex - sx;
const ddy = ey - sy;
const ddx = eOutX - sOutX;
const ddy = eOutY - sOutY;
const len = Math.hypot(ddx, ddy) || 1;
const mx = (sx + ex) / 2;
const my = (sy + ey) / 2;
const baseOffset = Math.min(40, len / 2);
const perpX = (-ddy / len) * baseOffset;
const perpY = (ddx / len) * baseOffset;
const cpx = mx + perpX;
const cpy = my + perpY;
const perpLen = Math.hypot(perpX, perpY) || 1;
const ux = perpX / perpLen;
const uy = perpY / perpLen;
const baseOffset = Math.min(44, len / 2);
const bendOffset = baseOffset + (route.bundleOffset || 0);
const bendX = (-ddy / len) * bendOffset;
const bendY = (ddx / len) * bendOffset;
return { sx, sy, ex, ey, cpx, cpy, mx, my, perpX: ux, perpY: uy };
const cpx = (sOutX + eOutX) / 2 + bendX;
const cpy = (sOutY + eOutY) / 2 + bendY;
// Середина квадратичной кривой при t=0.5
const mx = 0.25 * sOutX + 0.5 * cpx + 0.25 * eOutX;
const my = 0.25 * sOutY + 0.5 * cpy + 0.25 * eOutY;
const perpLen = Math.hypot(-ddy, ddx) || 1;
const ux = (-ddy) / perpLen;
const uy = (ddx) / perpLen;
return {
sx,
sy,
ex,
ey,
sOutX,
sOutY,
eOutX,
eOutY,
cpx,
cpy,
mx,
my,
perpX: ux,
perpY: uy,
};
}, []);
const pathD = useCallback((e) => {
const { sx, sy, ex, ey, cpx, cpy } = computeCurvePoints(e);
return `M ${sx} ${sy} Q ${cpx} ${cpy} ${ex} ${ey}`;
const { sx, sy, ex, ey, sOutX, sOutY, eOutX, eOutY, cpx, cpy } = computeCurvePoints(e);
return `M ${sx} ${sy} L ${sOutX} ${sOutY} Q ${cpx} ${cpy} ${eOutX} ${eOutY} L ${ex} ${ey}`;
}, [computeCurvePoints]);
/** Точка для подписи: середина ребра + смещение по перпендикуляру (чтобы подпись была у ребра, а не уезжала) */
/** Точка подписи на кривой с небольшим jitter и ограничением в пределах карты */
const labelPlacement = useCallback((e, level = 0) => {
const { mx, my, perpX, perpY } = computeCurvePoints(e);
const baseOffset = 18;
const stepOffset = 26;
const x = mx + perpX * (baseOffset + level * stepOffset);
const y = my + perpY * (baseOffset + level * stepOffset);
return { x, y };
}, [computeCurvePoints]);
const geom = computeCurvePoints(e);
const jitter = (hash01(e.edgeKey) - 0.5) * 0.24;
const t = clamp(0.42 + jitter, 0.26, 0.74);
const omt = 1 - t;
const xCurve = omt * omt * geom.sOutX + 2 * omt * t * geom.cpx + t * t * geom.eOutX;
const yCurve = omt * omt * geom.sOutY + 2 * omt * t * geom.cpy + t * t * geom.eOutY;
const baseOffset = 16;
const stepOffset = 18;
const x = xCurve + geom.perpX * (baseOffset + level * stepOffset);
const y = yCurve + geom.perpY * (baseOffset + level * stepOffset);
const margin = 22;
return {
x: clamp(x, margin, Math.max(margin, size.w - margin)),
y: clamp(y, margin, Math.max(margin, size.h - margin)),
};
}, [computeCurvePoints, size.w, size.h]);
const problematicNodes = useMemo(() => {
const byIp = new Map();
servers.forEach((s) => {
byIp.set(String(s.ip), {
ip: String(s.ip),
dns: s.dns || '',
type: String(s.type || '').toLowerCase(),
linkCount: 0,
staleCount: 0,
maxPingMs: null,
maxPingPeerLabel: null,
minSpeedMbps: null,
minSpeedPeerLabel: null,
});
});
(connections || []).forEach((c) => {
const from = byIp.get(String(c.from));
const to = byIp.get(String(c.to));
if (from) from.linkCount += 1;
if (to) to.linkCount += 1;
});
routedEdges.forEach((e) => {
[e.from, e.to].forEach((nodeIp) => {
const node = byIp.get(String(nodeIp));
if (!node) return;
const peerIp = String(nodeIp) === String(e.from) ? String(e.to) : String(e.from);
const peerMeta = nodeMetaByIp.get(peerIp);
const peerLabel = peerMeta?.nodeLabel || peerIp;
if (e.stale) node.staleCount += 1;
if (typeof e.pingMs === 'number') {
if (node.maxPingMs == null || e.pingMs > node.maxPingMs) {
node.maxPingMs = e.pingMs;
node.maxPingPeerLabel = peerLabel;
}
}
const down = e.speed?.tcpDownloadBps != null ? e.speed.tcpDownloadBps / 1_000_000 : null;
const up = e.speed?.tcpUploadBps != null ? e.speed.tcpUploadBps / 1_000_000 : null;
const edgeMin = down != null && up != null ? Math.min(down, up) : (down ?? up);
if (edgeMin != null && Number.isFinite(edgeMin)) {
if (node.minSpeedMbps == null || edgeMin < node.minSpeedMbps) {
node.minSpeedMbps = edgeMin;
node.minSpeedPeerLabel = peerLabel;
}
}
});
});
return Array.from(byIp.values())
.map((n) => {
const issues = [];
let riskScore = 0;
if (n.maxPingMs != null) {
if (n.maxPingMs >= 120) {
issues.push(`Очень высокий ping${n.maxPingPeerLabel ? ` (с ${n.maxPingPeerLabel})` : ''}`);
riskScore += 3;
} else if (n.maxPingMs >= 80) {
issues.push(`Повышенный ping${n.maxPingPeerLabel ? ` (с ${n.maxPingPeerLabel})` : ''}`);
riskScore += 2;
}
}
if (n.minSpeedMbps != null) {
if (n.minSpeedMbps < mapSpeedThresholds.low) {
issues.push(`Низкая скорость${n.minSpeedPeerLabel ? ` (с ${n.minSpeedPeerLabel})` : ''}`);
riskScore += 3;
} else if (n.minSpeedMbps < mapSpeedThresholds.belowNorm) {
issues.push(`Скорость ниже нормы${n.minSpeedPeerLabel ? ` (с ${n.minSpeedPeerLabel})` : ''}`);
riskScore += 2;
} else if (n.minSpeedMbps < mapSpeedThresholds.normal) {
issues.push(`Норма скорости не достигнута${n.minSpeedPeerLabel ? ` (с ${n.minSpeedPeerLabel})` : ''}`);
riskScore += 1;
}
}
if (n.staleCount > 0) {
issues.push('Есть устаревшие метрики');
riskScore += n.staleCount >= 2 ? 2 : 1;
}
if (n.linkCount >= 7) {
issues.push('Высокая связанность');
riskScore += 1;
}
return {
...n,
issues,
riskScore,
nodeLabel: n.dns ? String(n.dns).split('.')[0] : n.ip,
affectedLink:
(n.maxPingPeerLabel && `${n.dns ? String(n.dns).split('.')[0] : n.ip}${n.maxPingPeerLabel}`) ||
(n.minSpeedPeerLabel && `${n.dns ? String(n.dns).split('.')[0] : n.ip}${n.minSpeedPeerLabel}`) ||
'—',
};
})
.filter((n) => n.issues.length > 0)
.sort((a, b) =>
b.riskScore - a.riskScore ||
(b.maxPingMs ?? -1) - (a.maxPingMs ?? -1) ||
(a.minSpeedMbps ?? Infinity) - (b.minSpeedMbps ?? Infinity)
);
}, [servers, connections, routedEdges, mapSpeedThresholds, nodeMetaByIp]);
const criticalNodes = useMemo(
() => problematicNodes.filter((n) => n.riskScore >= 4).slice(0, 8),
[problematicNodes]
);
const warningNodes = useMemo(
() => problematicNodes.filter((n) => n.riskScore >= 1 && n.riskScore < 4).slice(0, 12),
[problematicNodes]
);
if (servers.length === 0) return null;
return (
<>
<div
ref={containerRef}
className="network-map-unifi"
style={{
width: '100%',
height: 520,
minHeight: 400,
height: isFullscreen ? '100%' : '68vh',
minHeight: isFullscreen ? 400 : 620,
background: UNIFI_BG,
borderRadius: 12,
overflow: 'hidden',
@@ -323,17 +647,38 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
preserveAspectRatio="xMidYMid meet"
style={{ display: 'block' }}
>
{/* Рёбра: зелёные кривые (стиль UNIFI — проводные соединения) */}
<defs>
<linearGradient id="unifiBg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={UNIFI_BG} />
<stop offset="100%" stopColor={UNIFI_BG_DEEP} />
</linearGradient>
<filter id="edgeGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2.4" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="nodeShadow" x="-40%" y="-40%" width="180%" height="180%">
<feDropShadow dx="0" dy="1" stdDeviation="2" floodColor="#020617" floodOpacity="0.55" />
</filter>
</defs>
<rect x="0" y="0" width={size.w} height={size.h} fill="url(#unifiBg)" />
{/* Рёбра: сглаженные линии с цветовой дифференциацией туннеля */}
<g>
{(() => {
const used = [];
const LABEL_W = 100;
const LABEL_H = 18;
const LABEL_GAP = 6;
return edgesWithPing.map((e, idx) => {
const LABEL_W = 118;
const LABEL_H = 22;
const denseMode = routedEdges.length > 14;
return routedEdges.map((e, idx) => {
const dimFactor =
hoveredEdgeKey && hoveredEdgeKey !== e.edgeKey ? 0.25 : 1;
const strokeWidth = hoveredEdgeKey === e.edgeKey ? 3.2 : 2.5;
hoveredEdgeKey && hoveredEdgeKey !== e.edgeKey ? 0.16 : 1;
const strokeWidth = hoveredEdgeKey === e.edgeKey ? 3 : 1.8;
const edgeColor = getTunnelColor(e.tunnelType);
const isFocused =
hoveredEdgeKey === e.edgeKey || selectedEdge?.edgeKey === e.edgeKey;
const baseOpacity = denseMode ? 0.62 : 0.82;
return (
<g
key={e.edgeKey || `${e.from}-${e.to}-${idx}`}
@@ -347,19 +692,19 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
<path
d={pathD(e)}
fill="none"
stroke={UNIFI_GREEN}
stroke={edgeColor}
strokeWidth={strokeWidth}
strokeLinecap="round"
opacity={dimFactor}
opacity={baseOpacity * dimFactor}
filter="url(#edgeGlow)"
/>
{(() => {
const labels = [
e.pingLabel ? { text: e.pingLabel, stale: e.pingStale } : null,
e.speedLabel ? { text: e.speedLabel, stale: e.speedStale } : null,
].filter(Boolean);
if (labels.length === 0) return null;
const totalH = labels.length * LABEL_H + (labels.length - 1) * LABEL_GAP;
const minDist = 70;
const compactOnly = denseMode && !isFocused && !e.stale;
const labelText = compactOnly
? (e.compactEdgeLabel || e.edgeLabel)
: e.edgeLabel;
if (!labelText) return null;
const minDist = denseMode ? 52 : 70;
let level = 0;
let x; let y;
while (level < 10) {
@@ -374,54 +719,51 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
}
used.push({ x, y });
const pad = 8;
const staleColor = '#f59e0b';
const pad = 10;
const isStale = e.stale === true;
const staleColor = UNIFI_WARNING;
const textWidth = labelText.length * (compactOnly ? 5.9 : 6.2);
const staleDotRadius = 4.2;
const staleDotSpace = isStale ? 18 : 0;
const minW = compactOnly ? 76 : LABEL_W;
const maxW = compactOnly ? 160 : 210;
const w = Math.max(minW, Math.min(textWidth + pad * 2 + staleDotSpace, maxW));
const h = isStale ? LABEL_H + 2 : LABEL_H;
const textAnchor = isStale ? 'start' : 'middle';
const textX = isStale ? (x - w / 2 + pad + staleDotSpace) : x;
return (
<g>
{labels.map((label, i) => {
const yy = y + i * (LABEL_H + LABEL_GAP) + LABEL_H / 2;
const isStale = label.stale === true;
const textWidth = label.text.length * 6;
const iconWidth = isStale ? 14 : 0;
const w = Math.max(LABEL_W, Math.min(textWidth + pad * 2 + iconWidth, 180));
const h = LABEL_H + 4;
const textX = x - (iconWidth / 2);
return (
<g key={i}>
<rect
x={x - w / 2}
y={yy - h / 2}
width={w}
height={h}
rx={4}
fill="rgba(15,23,42,0.92)"
stroke={isStale ? staleColor : UNIFI_GREEN}
strokeWidth={1}
strokeDasharray={isStale ? '2,2' : 'none'}
/>
{isStale && (
<circle
cx={x - w / 2 + 10}
cy={yy}
r={5}
fill={staleColor}
opacity={0.9}
/>
)}
<text
x={textX}
y={yy}
textAnchor="middle"
dominantBaseline="middle"
fill={isStale ? staleColor : UNIFI_GREEN}
fontSize={i === 0 ? 12 : 11}
fontWeight={600}
>
{label.text}
</text>
</g>
);
})}
<rect
x={x - w / 2}
y={y - h / 2}
width={w}
height={h}
rx={7}
fill={isFocused ? 'rgba(2,6,23,0.94)' : 'rgba(2,6,23,0.85)'}
stroke={isStale ? staleColor : edgeColor}
strokeWidth={isFocused ? 1.3 : 1}
strokeDasharray={isStale ? '2,2' : 'none'}
/>
{isStale && (
<circle
cx={x - w / 2 + pad + staleDotRadius}
cy={y}
r={staleDotRadius}
fill={staleColor}
opacity={0.95}
/>
)}
<text
x={textX}
y={y}
textAnchor={textAnchor}
dominantBaseline="middle"
fill={isStale ? staleColor : edgeColor}
fontSize={compactOnly ? 10.2 : 11}
fontWeight={600}
>
{labelText}
</text>
</g>
);
})()}
@@ -436,6 +778,7 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
const pos = positions[String(s.ip)];
if (!pos) return null;
const label = s.dns?.split('.')[0] || s.ip || '?';
const typeMeta = getNodeTypeMeta(s.type);
const isDragging = drag?.nodeId === s.ip;
return (
<g
@@ -452,6 +795,7 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
fill={UNIFI_CARD_BG}
stroke={UNIFI_CARD_BORDER}
strokeWidth={1}
filter="url(#nodeShadow)"
/>
<g transform={`translate(${NODE_WIDTH / 2}, ${NODE_HEIGHT / 2})`}>
{/* Флаг страны в левом верхнем углу карточки (картинка, т.к. эмодзи в SVG часто отображаются как буквы) */}
@@ -477,29 +821,50 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
})()}
{/* Иконка сервера в стиле UNIFI: прямоугольник с «портами» */}
<g transform="translate(-10, -16)">
<rect x={2} y={0} width={16} height={12} rx={2} fill="none" stroke={UNIFI_GREEN} strokeWidth={1.5} />
<line x1={5} y1={4} x2={15} y2={4} stroke={UNIFI_GREEN} strokeWidth={1} opacity={0.8} />
<line x1={5} y1={8} x2={15} y2={8} stroke={UNIFI_GREEN} strokeWidth={1} opacity={0.8} />
<rect x={2} y={0} width={16} height={12} rx={2} fill="none" stroke={typeMeta.color} strokeWidth={1.5} />
<line x1={5} y1={4} x2={15} y2={4} stroke={typeMeta.color} strokeWidth={1} opacity={0.8} />
<line x1={5} y1={8} x2={15} y2={8} stroke={typeMeta.color} strokeWidth={1} opacity={0.8} />
</g>
<text
x={0}
y={8}
textAnchor="middle"
fill={UNIFI_TEXT}
fontSize={12}
fontSize={11.5}
fontWeight={600}
>
{label.length > 12 ? label.slice(0, 11) + '…' : label}
{label.length > 14 ? `${label.slice(0, 13)}` : label}
</text>
<text
x={0}
y={22}
textAnchor="middle"
fill={UNIFI_TEXT_MUTED}
fontSize={10}
fontSize={9.5}
>
{s.ip}
</text>
<rect
x={NODE_WIDTH / 2 - 42}
y={-NODE_HEIGHT / 2 + 5}
width={36}
height={14}
rx={7}
fill="rgba(15,23,42,0.9)"
stroke={typeMeta.color}
strokeWidth={1}
/>
<text
x={NODE_WIDTH / 2 - 24}
y={-NODE_HEIGHT / 2 + 15}
textAnchor="middle"
fill={typeMeta.color}
fontSize={8}
fontWeight={700}
letterSpacing={0.5}
>
{typeMeta.label}
</text>
</g>
</g>
);
@@ -514,6 +879,17 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
const sk = speedKey(selectedEdge.raw?.fromKey, selectedEdge.raw?.toKey);
const speed = speedMap[sk] ?? selectedEdge.speed;
const speedStale = speedStaleMap[sk] === true;
const canPingFrom =
fromServer &&
['jumphost', 'home'].includes(String(fromServer.type || '').toLowerCase()) &&
selectedEdge.raw?.internalFromTo;
const canPingTo =
toServer &&
['jumphost', 'home'].includes(String(toServer.type || '').toLowerCase()) &&
selectedEdge.raw?.internalToFrom;
const canPing = Boolean(canPingFrom || canPingTo);
const canSpeed =
Boolean(selectedEdge.raw?.speedTestServerId && selectedEdge.raw?.interfaceName);
return (
<div
className="network-map-edge-modal-backdrop"
@@ -635,6 +1011,32 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
</div>
</div>
)}
<div className="mt-3 d-flex flex-wrap gap-2 justify-content-end">
<button
type="button"
className="btn btn-outline-primary btn-sm"
disabled={!canPing || pingLoading || !onRefreshPingForConnection}
onClick={() => onRefreshPingForConnection && onRefreshPingForConnection(selectedEdge.raw)}
>
<IconRefresh
size={14}
className={pingLoading ? 'spin me-1' : 'me-1'}
/>
{pingLoading ? 'Пинг…' : 'Проверить пинг'}
</button>
<button
type="button"
className="btn btn-outline-primary btn-sm"
disabled={!canSpeed || speedLoading || !onRefreshSpeedForConnection}
onClick={() => onRefreshSpeedForConnection && onRefreshSpeedForConnection(selectedEdge.raw)}
>
<IconRefresh
size={14}
className={speedLoading ? 'spin me-1' : 'me-1'}
/>
{speedLoading ? 'Скорость…' : 'Проверить скорость'}
</button>
</div>
</div>
</div>
</div>
@@ -688,5 +1090,118 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
</div>
</div>
</div>
<div className="row g-3 mt-2">
<div className="col-12 col-xl-6">
<div className="card">
<div className="card-header d-flex align-items-center">
<h3 className="card-title mb-0">Критичные узлы</h3>
<div className="card-actions ms-auto">
<span className="badge bg-red-lt text-red">{criticalNodes.length}</span>
</div>
</div>
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0 table-sm table-striped">
<thead>
<tr>
<th scope="col">Узел</th>
<th scope="col">Тип</th>
<th scope="col">Ping max</th>
<th scope="col">Speed min</th>
<th scope="col">Связи</th>
<th scope="col">Проблемная связь</th>
<th scope="col">Проблемы</th>
</tr>
</thead>
<tbody>
{criticalNodes.length === 0 ? (
<tr>
<td colSpan={7} className="text-muted">Критичных узлов не найдено</td>
</tr>
) : (
criticalNodes.map((n) => (
<tr key={`critical-${n.ip}`}>
<td>
<div className="fw-semibold">{n.nodeLabel}</div>
<div className="text-muted small">{n.ip}</div>
</td>
<td>
<span className="badge bg-secondary-lt text-secondary">
{String(n.type || 'node').toUpperCase()}
</span>
</td>
<td>{n.maxPingMs != null ? `${n.maxPingMs} ms` : '—'}</td>
<td>{n.minSpeedMbps != null ? `${n.minSpeedMbps.toFixed(1)} Мбит/с` : '—'}</td>
<td>{n.linkCount}</td>
<td>
<span className="text-muted">{n.affectedLink}</span>
</td>
<td>
<span className="badge bg-orange-lt text-orange">
{n.issues.slice(0, 2).join(', ')}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
<div className="col-12 col-xl-6">
<div className="card">
<div className="card-header d-flex align-items-center">
<h3 className="card-title mb-0">Требуют внимания</h3>
<div className="card-actions ms-auto">
<span className="badge bg-yellow-lt text-yellow">{warningNodes.length}</span>
</div>
</div>
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0 table-sm table-striped">
<thead>
<tr>
<th scope="col">Узел</th>
<th scope="col">Ping max</th>
<th scope="col">Speed min</th>
<th scope="col">Stale</th>
<th scope="col">Проблемная связь</th>
<th scope="col">Проблемы</th>
</tr>
</thead>
<tbody>
{warningNodes.length === 0 ? (
<tr>
<td colSpan={6} className="text-muted">Узлов с предупреждениями не найдено</td>
</tr>
) : (
warningNodes.map((n) => (
<tr key={`warn-${n.ip}`}>
<td>
<div className="fw-semibold">{n.nodeLabel}</div>
<div className="text-muted small">{n.ip}</div>
</td>
<td>{n.maxPingMs != null ? `${n.maxPingMs} ms` : '—'}</td>
<td>{n.minSpeedMbps != null ? `${n.minSpeedMbps.toFixed(1)} Мбит/с` : '—'}</td>
<td>
<span className={n.staleCount > 0 ? 'badge bg-yellow-lt text-yellow' : 'text-muted'}>
{n.staleCount}
</span>
</td>
<td>
<span className="text-muted">{n.affectedLink}</span>
</td>
<td>
<span className="text-muted">{n.issues.slice(0, 2).join(', ')}</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
</>
);
}
+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>
);
}
+2 -2
View File
@@ -308,12 +308,12 @@ export default function PingServicesManager() {
</tr>
) : (
filtered.map((item) => {
const iconInfo = getIconById(item.icon);
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`}>
<iconInfo.Icon size={18} stroke={1.5} />
<Icon size={18} stroke={1.5} />
</span>
</td>
<td><code className="small">{item.id}</code></td>
+3 -1
View File
@@ -8,6 +8,8 @@ import {
} 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;
@@ -149,7 +151,7 @@ export default function ResourceStatsPage() {
const fetchStats = useCallback(async (abortSignal) => {
setLoading(true);
setError(null);
const opts = abortSignal ? { signal: abortSignal } : {};
const opts = { timeout: REQUEST_TIMEOUT_MS, noRetry: true, ...(abortSignal ? { signal: abortSignal } : {}) };
try {
const res = await api.get('/resources/stats', opts);
const data = res?.data;
+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">
Кандидаты HomeJumphost не найдены.
</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>HomeJumphost</th>
<th>JumphostExit</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>
HJ: ping {r.homeToJumphost?.pingMs ?? '—'} ms, {fmtSpeed(
r.homeToJumphost?.speedDownloadMbps,
r.homeToJumphost?.speedUploadMbps,
r.homeToJumphost?.speedMbps
)}
</div>
<div className="text-muted">
JE: 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>
);
}
+20 -19
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { useState, useEffect, useMemo } from 'react';
import api from './lib/api.js';
import ConfirmDialog from './components/ConfirmDialog.jsx';
import ConfirmModal from './components/ConfirmModal.jsx';
@@ -147,6 +147,7 @@ function ServerManager() {
fetchConnections(signal);
fetchNetworkConfig(signal);
return () => controller.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps -- init-only, fetch functions defined below
}, []);
useEffect(() => {
@@ -303,7 +304,7 @@ function ServerManager() {
useEffect(() => {
if (currentPage > totalPages && totalPages >= 1) setCurrentPage(1);
}, [viewMode, totalPages]);
}, [viewMode, totalPages, currentPage]);
// === Handlers ===
const handleSort = (field) => {
@@ -501,7 +502,7 @@ function ServerManager() {
return `${urlSettings.baseUrl}?${params.toString()}`;
};
const handleGenerateLink = (server) => {
const _handleGenerateLink = (server) => {
setLinkGeneratorServer(server);
setLinkGeneratorOpen(true);
};
@@ -511,7 +512,7 @@ function ServerManager() {
setLinkGeneratorServer(null);
};
const applyPreset = (name) => {
const _applyPreset = (name) => {
const p = presets[name];
if (!p) return;
setUrlSettings(prev => ({ ...prev, ...p }));
@@ -529,7 +530,7 @@ function ServerManager() {
try {
document.execCommand('copy');
notify.success('Скопировано в буфер обмена!');
} catch (err) {
} catch (_err) {
notify.error('Не удалось скопировать');
}
document.body.removeChild(textArea);
@@ -541,14 +542,14 @@ function ServerManager() {
await navigator.clipboard.writeText(text);
notify.success('Скопировано в буфер обмена!');
return;
} catch (err) {
} catch (_err) {
// fallback
}
}
fallbackCopyToClipboard(text);
};
const quickCopyLink = async (server) => {
const _quickCopyLink = async (server) => {
const url = generateServerUrl(server);
await copyToClipboard(url);
};
@@ -800,8 +801,8 @@ function ServerManager() {
{/* Toolbar */}
<div className="card mb-3">
<div className="card-body py-2">
<div className="d-flex align-items-center justify-content-between">
<div className="d-flex align-items-center gap-2">
<div className="d-flex flex-column flex-sm-row flex-wrap align-items-stretch align-items-sm-center justify-content-between gap-2">
<div className="d-flex flex-wrap align-items-center gap-2">
{/* Открыть фильтры (offcanvas) */}
<button
type="button"
@@ -1087,22 +1088,22 @@ function ServerManager() {
<h3 className="card-title mb-0">Добавить связь между серверами</h3>
</div>
<div className="card-body">
<div className="row g-3">
<div className="col-md-6">
<div className="row g-2 g-md-3">
<div className="col-12 col-md-6">
<label className="form-label">Сервер A</label>
<select className="form-select" value={newConnection.from} onChange={e => setNewConnection({ ...newConnection, from: e.target.value })}>
<option value="">Выберите сервер</option>
{servers.map(s => <option key={s.ip} value={s.ip}>{s.ip} ({s.dns})</option>)}
</select>
</div>
<div className="col-md-6">
<div className="col-12 col-md-6">
<label className="form-label">Сервер B</label>
<select className="form-select" value={newConnection.to} onChange={e => setNewConnection({ ...newConnection, to: e.target.value })}>
<option value="">Выберите сервер</option>
{servers.map(s => <option key={s.ip} value={s.ip}>{s.ip} ({s.dns})</option>)}
</select>
</div>
<div className="col-md-4">
<div className="col-12 col-md-4">
<label className="form-label">Тип туннеля</label>
<select className="form-select" value={newConnection.tunnelType} onChange={e => setNewConnection({ ...newConnection, tunnelType: e.target.value })}>
<option value="GRE">GRE</option>
@@ -1111,11 +1112,11 @@ function ServerManager() {
<option value="OpenVPN">OpenVPN</option>
</select>
</div>
<div className="col-md-4">
<div className="col-12 col-md-4">
<label className="form-label">IP туннеля A</label>
<input className="form-control" value={newConnection.ipA} onChange={e => setNewConnection({ ...newConnection, ipA: e.target.value })} placeholder="10.10.100.1" />
</div>
<div className="col-md-4">
<div className="col-12 col-md-4">
<label className="form-label">IP туннеля B</label>
<input className="form-control" value={newConnection.ipB} onChange={e => setNewConnection({ ...newConnection, ipB: e.target.value })} placeholder="10.10.100.2" />
</div>
@@ -1255,8 +1256,8 @@ function ServerManager() {
<span className="badge bg-blue-lt text-blue">{newConnection.to || 'B'}</span>
</div>
</div>
<div className="row g-3">
<div className="col-md-4">
<div className="row g-2 g-md-3">
<div className="col-12 col-md-4">
<FormField
label="Туннель"
name="tunnelType"
@@ -1272,7 +1273,7 @@ function ServerManager() {
]}
/>
</div>
<div className="col-md-4">
<div className="col-12 col-md-4">
<FormField
label="IP сервера A"
name="ipA"
@@ -1282,7 +1283,7 @@ function ServerManager() {
required
/>
</div>
<div className="col-md-4">
<div className="col-12 col-md-4">
<FormField
label="IP сервера B"
name="ipB"
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -33,6 +33,7 @@ function formatBytes(bytes) {
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;
@@ -184,7 +185,7 @@ export default function TrafficDashboard() {
const fetchStats = useCallback(async (abortSignal) => {
setLoading(true);
setError(null);
const opts = abortSignal ? { signal: abortSignal } : {};
const opts = { timeout: REQUEST_TIMEOUT_MS, ...(abortSignal ? { signal: abortSignal } : {}) };
try {
const [statsRes, settingsRes] = await Promise.all([
api.get('/traffic/interface-stats', opts),
+48 -5
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import api from './lib/api.js';
import {
IconClock,
@@ -33,14 +34,14 @@ function loadHistoryFromStorage() {
if (!raw) return {};
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') return parsed;
} catch (_) {}
} catch { /* no-op */ }
return {};
}
function saveHistoryToStorage(historyMap) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(historyMap));
} catch (_) {}
} catch { /* no-op */ }
}
/** Группировка истории по дням: массив { day, dayLabel, incidents } */
@@ -81,6 +82,7 @@ function historyToResponseTimeData(history) {
}
export default function UptimeMonitorPage() {
const location = useLocation();
const [servers, setServers] = useState([]);
const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false);
@@ -94,6 +96,8 @@ export default function UptimeMonitorPage() {
const [currentCheckingServerIds, setCurrentCheckingServerIds] = useState([]);
const intervalRef = useRef(null);
const checkingRef = useRef(false);
const activeRunIdRef = useRef(0);
const checkControllersRef = useRef(new Set());
const jumphosts = useMemo(
() =>
@@ -160,6 +164,7 @@ export default function UptimeMonitorPage() {
const runChecks = useCallback(async () => {
if (jumphosts.length === 0) return;
if (checkingRef.current) return;
const runId = ++activeRunIdRef.current;
checkingRef.current = true;
setChecking(true);
setCurrentCheckingServerIds([]);
@@ -171,18 +176,28 @@ export default function UptimeMonitorPage() {
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 });
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 (_) {
} 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 };
@@ -196,6 +211,7 @@ export default function UptimeMonitorPage() {
}
};
await Promise.all(Array.from({ length: PARALLEL_CHECKS }, checkOne));
if (activeRunIdRef.current !== runId) return;
setCurrentCheckingServerIds([]);
checkingRef.current = false;
setChecking(false);
@@ -217,6 +233,30 @@ export default function UptimeMonitorPage() {
};
}, [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);
@@ -225,7 +265,10 @@ export default function UptimeMonitorPage() {
const selectedServer = selectedServerId
? jumphosts.find((s) => (s.id || s.dns || s.ip) === selectedServerId)
: null;
const selectedHistory = selectedServerId ? historyMap[selectedServerId] || [] : [];
const selectedHistory = useMemo(
() => (selectedServerId ? historyMap[selectedServerId] || [] : []),
[selectedServerId, historyMap]
);
const responseTimeData = useMemo(
() => historyToResponseTimeData(selectedHistory),
[selectedHistory]
+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>
);
}
+3 -3
View File
@@ -8,14 +8,14 @@ import FloatingBulkActionsBar from './FloatingBulkActionsBar.jsx';
*/
function BulkActionsBar({
selectedCount = 0,
totalCount = 0,
onSelectAll = () => {},
totalCount: _totalCount = 0,
onSelectAll: _onSelectAll = () => {},
onDeselectAll = () => {},
onDelete = null,
onEdit = null,
onExport = null,
customActions = [],
className = '',
className: _className = '',
}) {
const actions = [];
@@ -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,
@@ -105,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"
@@ -114,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">
+1 -1
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import React from 'react';
import { IconArrowUp, IconArrowDown, IconEdit, IconTrash, IconCopy, IconCheck, IconX } from '@tabler/icons-react';
import TableSkeleton, { TableEmpty } from './TableSkeleton.jsx';
import EmptyState from './EmptyState.jsx';
+3 -3
View File
@@ -16,14 +16,14 @@ class ErrorBoundary extends Component {
};
}
static getDerivedStateFromError(error) {
static getDerivedStateFromError(_error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// Логируем ошибку
console.error('ErrorBoundary caught an error:', error, errorInfo);
// Отправляем в мониторинг (если настроен)
this.logErrorToService(error, errorInfo);
@@ -93,7 +93,7 @@ class ErrorBoundary extends Component {
{this.state.error?.message || 'Что-то пошло не так. Попробуйте обновить страницу.'}
</p>
{process.env.NODE_ENV === 'development' && this.state.errorInfo && (
{import.meta.env.DEV && this.state.errorInfo && (
<div className="card mt-3">
<div className="card-body">
<h3 className="card-title">Детали ошибки (только в dev режиме)</h3>
+1 -1
View File
@@ -40,7 +40,7 @@ export default function HistoryModal({ resource, show, onClose, onRolledBack })
notify.success('Откат выполнен успешно');
onRolledBack?.(res.data || {});
onClose?.();
} catch (e) {
} catch (_e) {
notify.error('Не удалось выполнить откат');
} finally {
setLoading(false);
+21 -11
View File
@@ -1,17 +1,20 @@
/* eslint-disable react-refresh/only-export-components */
import { useState, useMemo, useRef, useEffect } from 'react';
import BRAND_ICONS, { getIconById } from '../lib/brandIcons.js';
import { IconSearch } from '@tabler/icons-react';
import { IconSearch, IconMinus } from '@tabler/icons-react';
/**
* Выбор иконки бренда с поиском и предпросмотром.
* value id иконки (например IconBrandGoogle), onChange(id) при выборе.
*/
export default function IconPicker({ value, onChange, disabled = false, label = 'Иконка бренда' }) {
export default function IconPicker({ value, onChange, disabled = false, label = 'Иконка бренда', allowNone = false }) {
const [search, setSearch] = useState('');
const [open, setOpen] = useState(false);
const selected = getIconById(value);
const filtered = useMemo(() => {
const selected = allowNone && !value
? { id: '', label: 'Без иконки', Icon: IconMinus }
: getIconById(value);
const filteredIcons = useMemo(() => {
const q = (search || '').trim().toLowerCase();
if (!q) return BRAND_ICONS;
return BRAND_ICONS.filter(
@@ -19,6 +22,13 @@ export default function IconPicker({ value, onChange, disabled = false, label =
item.label.toLowerCase().includes(q) || item.id.toLowerCase().includes(q)
);
}, [search]);
const filtered = useMemo(() => {
if (!allowNone) return filteredIcons;
const q = (search || '').trim().toLowerCase();
const noneMatches = q.length === 0 || 'без иконки'.includes(q) || 'none'.includes(q);
if (!noneMatches) return filteredIcons;
return [{ id: '', label: 'Без иконки', Icon: IconMinus }, ...filteredIcons];
}, [allowNone, filteredIcons, search]);
const handleSelect = (item) => {
onChange?.(item.id);
@@ -44,19 +54,19 @@ export default function IconPicker({ value, onChange, disabled = false, label =
<div className="d-flex align-items-center gap-2 flex-wrap" ref={panelRef}>
<button
type="button"
className="btn btn-outline-secondary btn-sm d-flex align-items-center gap-2"
className="btn btn-outline-secondary d-flex align-items-center gap-2"
onClick={() => setOpen((v) => !v)}
disabled={disabled}
aria-expanded={open}
aria-haspopup="listbox"
>
<span className="avatar avatar-sm bg-primary-lt text-primary rounded d-flex align-items-center justify-content-center">
<selected.Icon size={18} stroke={1.5} />
<span className="avatar bg-primary-lt text-primary rounded d-flex align-items-center justify-content-center" style={{ width: 34, height: 34 }}>
<selected.Icon size={20} stroke={1.5} />
</span>
<span>{selected.label}</span>
</button>
{open && (
<div className="border rounded p-2 shadow-sm bg-body" style={{ minWidth: 280, maxWidth: 360 }}>
<div className="border rounded p-3 shadow-sm bg-body" style={{ minWidth: 560, maxWidth: 640 }}>
<div className="input-group input-group-sm mb-2">
<span className="input-group-text">
<IconSearch size={14} />
@@ -72,7 +82,7 @@ export default function IconPicker({ value, onChange, disabled = false, label =
</div>
<div
className="d-flex flex-wrap gap-1 overflow-auto"
style={{ maxHeight: 200 }}
style={{ maxHeight: 320 }}
role="listbox"
>
{filtered.map((item) => (
@@ -82,13 +92,13 @@ export default function IconPicker({ value, onChange, disabled = false, label =
className={`btn btn-sm d-flex align-items-center justify-content-center rounded ${
value === item.id ? 'btn-primary' : 'btn-outline-secondary'
}`}
style={{ width: 40, height: 40 }}
style={{ width: 56, height: 56 }}
onClick={() => handleSelect(item)}
title={item.label}
role="option"
aria-selected={value === item.id}
>
<item.Icon size={22} stroke={1.5} />
<item.Icon size={26} stroke={1.5} />
</button>
))}
</div>
+2 -2
View File
@@ -178,7 +178,7 @@ function ImportModal({
{/* Stats */}
<div className="row g-2">
<div className="col-md-6">
<div className="col-12 col-md-6">
<div className="card bg-green-lt">
<div className="card-body py-2">
<div className="d-flex align-items-center">
@@ -191,7 +191,7 @@ function ImportModal({
</div>
</div>
</div>
<div className="col-md-6">
<div className="col-12 col-md-6">
<div className="card bg-red-lt">
<div className="card-body py-2">
<div className="d-flex align-items-center">
+2 -2
View File
@@ -19,12 +19,12 @@ function MobileCardView({
// минимальная дистанция свайпа в px
const minSwipeDistance = 50
const onTouchStart = (e, item) => {
const onTouchStart = (e, _item) => {
setTouchEnd(null)
setTouchStart(e.targetTouches[0].clientX)
}
const onTouchMove = (e, item) => {
const onTouchMove = (e, _item) => {
setTouchEnd(e.targetTouches[0].clientX)
}
+2 -1
View File
@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { IconCheck, IconAlertTriangle, IconInfoCircle, IconX } from '@tabler/icons-react';
@@ -98,7 +99,7 @@ export function notifyMutationSuccess(message, details) {
if (forceToast && typeof window !== 'undefined' && window.notify?.success) {
window.notify.success(text, details);
}
} catch {}
} catch { /* no-op */ }
}
function colorByType(type) {
+1 -1
View File
@@ -29,7 +29,7 @@ function PageHeader({ title, icon, pretitle, actions, meta }) {
</div>
{actions && (
<div className="row mt-2">
<div className="col d-print-none">
<div className="col d-print-none overflow-auto">
{actions}
</div>
</div>
+5 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
function getBody() {
@@ -20,7 +20,7 @@ function PortalDropdown({
const [coords, setCoords] = useState({ top: 0, left: 0, minWidth: 0 });
const BASE_Z = 1035; // above navbar (1030), below modal-backdrop (1040) and modal (1050)
const updatePosition = () => {
const updatePosition = useCallback(() => {
const btn = buttonRef.current;
if (!btn) return;
const rect = btn.getBoundingClientRect();
@@ -32,12 +32,11 @@ function PortalDropdown({
left = Math.max(0, rect.right - (menuWidth || rect.width));
}
setCoords({ top: rect.bottom + window.scrollY, left: left + window.scrollX, minWidth });
};
}, [align]);
useLayoutEffect(() => {
if (open) updatePosition();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
}, [open, updatePosition]);
useEffect(() => {
if (!open) return;
@@ -60,7 +59,7 @@ function PortalDropdown({
document.removeEventListener('keydown', onKey);
document.removeEventListener('mousedown', onClickOutside);
};
}, [open]);
}, [open, updatePosition]);
const body = getBody();
+1 -1
View File
@@ -59,7 +59,7 @@ function QuickAddBar({
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<div className="row g-3 mt-2">
<div className="row g-2 g-md-3 mt-2">
<div className="col">
<div className="card"><div className="card-body p-2">
<div className="text-muted">Готово</div>
+1 -1
View File
@@ -14,7 +14,7 @@ function SavedFilters({
const [savedFilters, setSavedFilters] = useState([])
const [showSaveDialog, setShowSaveDialog] = useState(false)
const [filterName, setFilterName] = useState('')
const [showList, setShowList] = useState(false)
const [_showList, setShowList] = useState(false)
const storageKey = `savedFilters_${pageKey}`
+1 -1
View File
@@ -96,7 +96,7 @@ export default function SettingsModal({ open, onClose }) {
const e = settingsRes?.headers?.etag || settingsRes?.headers?.ETag || '';
setEtag(e ? String(e) : '');
setServersList(Array.isArray(serversRes?.data) ? serversRes.data : []);
} catch (e) {
} catch (_e) {
setError('Не удалось загрузить настройки');
} finally {
setLoading(false);
+15 -14
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, createContext, useContext } from 'react'
/* eslint-disable react-refresh/only-export-components */
import { useState, useEffect, createContext, useContext, useCallback, useMemo } from 'react'
import {
IconCheck,
IconX,
@@ -32,17 +33,17 @@ const DEFAULT_TITLES = {
function Toast({ toast, onClose }) {
const [isLeaving, setIsLeaving] = useState(false)
const handleClose = useCallback(() => {
setIsLeaving(true)
setTimeout(() => onClose(toast.id), 300)
}, [onClose, toast.id])
useEffect(() => {
if (toast.duration && toast.duration > 0) {
const timer = setTimeout(() => handleClose(), toast.duration)
return () => clearTimeout(timer)
}
}, [toast.duration])
const handleClose = () => {
setIsLeaving(true)
setTimeout(() => onClose(toast.id), 300)
}
}, [toast.duration, handleClose])
const getIcon = () => {
switch (toast.type) {
@@ -121,7 +122,7 @@ function Toast({ toast, onClose }) {
export function ToastContainer({ children }) {
const [toasts, setToasts] = useState([])
const addToast = (type, message, options = {}) => {
const addToast = useCallback((type, message, options = {}) => {
const id = Date.now()
const toast = {
id,
@@ -136,23 +137,23 @@ export function ToastContainer({ children }) {
}
setToasts(prev => [...prev, toast])
return id
}
}, [])
const removeToast = (id) => {
const removeToast = useCallback((id) => {
setToasts(prev => prev.filter(t => t.id !== id))
}
}, [])
const contextValue = {
const contextValue = useMemo(() => ({
success: (message, options) => addToast('success', message, options),
error: (message, options) => addToast('error', message, options),
warning: (message, options) => addToast('warning', message, options),
info: (message, options) => addToast('info', message, options),
}
}), [addToast])
// Глобальный доступ через window.toast (для обратной совместимости)
useEffect(() => {
window.toast = contextValue
}, [])
}, [contextValue])
return (
<ToastContext.Provider value={contextValue}>
+4 -4
View File
@@ -7,7 +7,7 @@ import { IconTrophy, IconMapPin, IconCloud, IconHash, IconChartBar } from '@tabl
function TopNStats({ data, loading }) {
const [topCountries, setTopCountries] = useState([]);
const [topProviders, setTopProviders] = useState([]);
const [topCommunities, setTopCommunities] = useState([]);
const [_topCommunities, _setTopCommunities] = useState([]);
useEffect(() => {
if (!data || !Array.isArray(data.servers)) return;
@@ -58,9 +58,9 @@ function TopNStats({ data, loading }) {
};
return (
<div className="row g-3">
<div className="row g-2 g-md-3">
{/* Top Страны */}
<div className="col-md-6">
<div className="col-12 col-md-6">
<div className="card h-100">
<div className="card-header">
<h3 className="card-title">
@@ -107,7 +107,7 @@ function TopNStats({ data, loading }) {
</div>
{/* Top Провайдеры */}
<div className="col-md-6">
<div className="col-12 col-md-6">
<div className="card h-100">
<div className="card-header">
<h3 className="card-title">
+2 -2
View File
@@ -58,7 +58,7 @@ function ValidatedInput({
message: validationResult.message || '',
isValidating: false
})
} catch (error) {
} catch (_error) {
setValidationState({
valid: false,
message: 'Ошибка валидации',
@@ -84,7 +84,7 @@ function ValidatedInput({
// Запускаем валидацию сразу при потере фокуса
if (validate && !validateOnChange) {
const result = validate(value)
const validationResult = result instanceof Promise ? result.then(r => {
const _validationResult = result instanceof Promise ? result.then(r => {
setValidationState({
valid: r.valid,
message: r.message || '',
+1 -1
View File
@@ -1,4 +1,4 @@
import { useRef, useMemo } from 'react';
import { useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { IconArrowUp, IconArrowDown, IconEdit, IconTrash, IconCopy } from '@tabler/icons-react';
import TableSkeleton from './TableSkeleton.jsx';
+3 -3
View File
@@ -64,12 +64,12 @@ function WsUpdateModal({ show, url, onClose }) {
ws.onerror = () => setStatus('error');
ws.onclose = () => setStatus('closed');
} catch (e) {
} catch (_e) {
setStatus('error');
}
return () => {
try { wsRef.current?.close(); } catch {}
try { wsRef.current?.close(); } catch { /* no-op */ }
wsRef.current = null;
};
}, [show, url]);
@@ -96,7 +96,7 @@ function WsUpdateModal({ show, url, onClose }) {
const copyLog = async () => {
try {
await navigator.clipboard.writeText(plainLog);
} catch {}
} catch { /* no-op */ }
};
const clearLog = () => {
@@ -42,10 +42,6 @@ function AddFilterModal({ show, newFilter, onNewFilterChange, onAddFilter, onClo
onChange={(e) => {
const v = e.target.value;
onNewFilterChange({ ...newFilter, community: v });
const found = communities.find(c => c.value === v);
if (found && found.gatewayDefault && !newFilter.gateway) {
onNewFilterChange({ ...newFilter, community: v, gateway: found.gatewayDefault });
}
}}
required
/>
@@ -9,19 +9,18 @@ function AddServerModal({ show, newServer, customProvider, onNewServerChange, on
const modalRef = useRef(null);
useEffect(() => {
if (window.Tabler && window.Tabler.Modal && modalRef.current) {
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current);
const modalEl = modalRef.current;
if (window.Tabler && window.Tabler.Modal && modalEl) {
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalEl);
if (show) {
modalInstance.show();
} else {
modalInstance.hide();
}
const handler = () => onClose && onClose();
modalRef.current.addEventListener('hide.bs.modal', handler);
modalEl.addEventListener('hide.bs.modal', handler);
return () => {
if (modalRef.current) {
modalRef.current.removeEventListener('hide.bs.modal', handler);
}
modalEl.removeEventListener('hide.bs.modal', handler);
};
}
}, [show, onClose]);
@@ -8,19 +8,18 @@ function DeleteServerModal({ show, server, onDelete, onClose }) {
const modalRef = useRef(null);
useEffect(() => {
if (window.Tabler && window.Tabler.Modal && modalRef.current) {
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current);
const modalEl = modalRef.current;
if (window.Tabler && window.Tabler.Modal && modalEl) {
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalEl);
if (show) {
modalInstance.show();
} else {
modalInstance.hide();
}
const handler = () => onClose && onClose();
modalRef.current.addEventListener('hide.bs.modal', handler);
modalEl.addEventListener('hide.bs.modal', handler);
return () => {
if (modalRef.current) {
modalRef.current.removeEventListener('hide.bs.modal', handler);
}
modalEl.removeEventListener('hide.bs.modal', handler);
};
}
}, [show, onClose]);
@@ -40,19 +40,18 @@ function EditServerModal({ show, server, onChange, onSave, onClose }) {
}, [show, server]);
useEffect(() => {
if (window.Tabler && window.Tabler.Modal && modalRef.current) {
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current);
const modalEl = modalRef.current;
if (window.Tabler && window.Tabler.Modal && modalEl) {
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalEl);
if (show) {
modalInstance.show();
} else {
modalInstance.hide();
}
const handler = () => onClose && onClose();
modalRef.current.addEventListener('hide.bs.modal', handler);
modalEl.addEventListener('hide.bs.modal', handler);
return () => {
if (modalRef.current) {
modalRef.current.removeEventListener('hide.bs.modal', handler);
}
modalEl.removeEventListener('hide.bs.modal', handler);
};
}
}, [show, onClose]);

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