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 }}"
+34 -4
View File
@@ -4,6 +4,11 @@ on:
push: push:
branches: branches:
- main - main
- v4
- v5
- tabler
- v3
workflow_dispatch:
jobs: jobs:
build-and-push: build-and-push:
@@ -25,11 +30,35 @@ jobs:
username: ${{ gitea.actor }} username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT }} 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 uses: docker/build-push-action@v5
with: with:
context: . 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 push: true
platforms: linux/amd64 platforms: linux/amd64
cache-from: type=gha cache-from: type=gha
@@ -38,8 +67,8 @@ jobs:
git.shts.su/${{ gitea.repository }}:latest git.shts.su/${{ gitea.repository }}:latest
git.shts.su/${{ gitea.repository }}:${{ gitea.sha }} git.shts.su/${{ gitea.repository }}:${{ gitea.sha }}
labels: | labels: |
org.opencontainers.image.title=S3 Lists Manager org.opencontainers.image.title=RouterOS Manager
org.opencontainers.image.description=S3 Lists Manager for domains and AS management org.opencontainers.image.description=RouterOS Manager for domains and AS management
org.opencontainers.image.version=latest org.opencontainers.image.version=latest
org.opencontainers.image.revision=${{ gitea.sha }} org.opencontainers.image.revision=${{ gitea.sha }}
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }} org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
@@ -47,5 +76,6 @@ jobs:
BUILDKIT_INLINE_CACHE=1 BUILDKIT_INLINE_CACHE=1
- name: Trigger container update webhook - name: Trigger container update webhook
continue-on-error: true
run: | 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 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) Замените `[repository]` на путь репозитория в Gitea (например `denozord/router-lists-ui`).
- `git.shts.su/[repository]:latest` - Стабильная версия с базовым интерфейсом
- `git.shts.su/[repository]:[commit-sha]` - Версия с конкретным коммитом
### Tabler версия (tabler branch) | Ветка (триггер push) | Образы в реестре |
- `git.shts.su/[repository]:tabler` - Версия с современным Tabler UI |----------------------|-------------------|
- `git.shts.su/[repository]:tabler-[commit-sha]` - Tabler версия с конкретным коммитом | **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 ```bash
docker run -d \ docker run -d \
--name s3-lists-manager \ --name router-lists-ui \
-p 3001:3001 \ -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 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 ```bash
docker run -d \ docker run -d \
--name s3-lists-manager-tabler \ --name router-lists-ui-tabler \
-p 3001:3001 \ -p 3003:3001 \
-e ENCRYPTION_KEY=your-64-hex-character-key-here \ --env-file ./backend/.env \
-v router-lists-tabler-data:/data \
git.shts.su/[repository]:tabler git.shts.su/[repository]:tabler
``` ```
## 🔄 Обновление ## Обновление образа
### latest
### Основная версия
```bash ```bash
docker pull git.shts.su/[repository]:latest docker pull git.shts.su/[repository]:latest
docker stop s3-lists-manager docker stop router-lists-ui && docker rm router-lists-ui
docker rm s3-lists-manager # затем снова docker run ... как выше
docker run -d \
--name s3-lists-manager \
-p 3001:3001 \
-e ENCRYPTION_KEY=your-64-hex-character-key-here \
git.shts.su/[repository]:latest
``` ```
### Tabler версия ### v5
```bash ```bash
docker pull git.shts.su/[repository]:tabler docker pull git.shts.su/[repository]:v5
docker stop s3-lists-manager-tabler docker stop router-lists-ui-v5 && docker rm router-lists-ui-v5
docker rm s3-lists-manager-tabler docker run -d ... git.shts.su/[repository]:v5
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 Compose ## Docker Compose
Создайте файл `docker-compose.yml`: Пример **одной** службы на `latest` с томом данных (подставьте свой `image:` и при необходимости `env_file`):
```yaml ```yaml
version: '3.8'
services: services:
s3-lists-manager: router-lists-ui:
image: git.shts.su/[repository]:latest image: git.shts.su/[repository]:latest
container_name: s3-lists-manager container_name: router-lists-ui
ports: ports:
- "3001:3001" - "3001:3001"
restart: unless-stopped restart: unless-stopped
environment: environment:
- NODE_ENV=production NODE_ENV: production
- ENCRYPTION_KEY=${ENCRYPTION_KEY} # Обязательно! Используйте .env файл или secrets 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: volumes:
image: git.shts.su/[repository]:tabler router-lists-data:
container_name: s3-lists-manager-tabler
ports:
- "3002:3001"
restart: unless-stopped
environment:
- NODE_ENV=production
- ENCRYPTION_KEY=${ENCRYPTION_KEY} # Обязательно! Используйте .env файл или secrets
``` ```
**Создайте файл `.env` в той же директории:** Отдельный сервис для **v5** — тот же фрагмент, но `image: git.shts.su/[repository]:v5`, другой `container_name` и при желании отдельный volume.
```bash
ENCRYPTION_KEY=your-64-hex-character-key-here ## Переменные окружения (контейнер)
| Переменная | Описание | Обязательная |
|------------|----------|----------------|
| `ENCRYPTION_KEY` | AES-256 для IPSec и паролей MikroTik (64 hex) | **Да** |
| `EVOBGP_API_URL` | Базовый URL API EvoBGP (серверный прокси `/api/evobgp` и бэкенд-справочники) | По сценарию |
| `EVOBGP_API_TOKEN` | Bearer к EvoBGP | По сценарию |
| `SQLITE_PATH` | Путь к файлу SQLite внутри контейнера (персистентный том) | Рекомендуется явно задать, напр. `/data/router-lists.db` |
| `MIKROTIK_BACKUP_DIR` | Каталог бэкапов `.rsc` на диске | Рекомендуется с томом, напр. `/data/backups/mikrotik` |
| `PORT` | Порт HTTP внутри контейнера | Нет (по умолчанию `3001`) |
| `CORS_ORIGINS` | Список Origin через запятую | Нет |
| `LOG_LEVEL` | Уровень логов pino | Нет |
`ENCRYPTION_KEY` должен быть **один и тот же** между перезапусками, иначе не расшифровать уже сохранённые секреты.
### EvoBGP и reverse proxy (nginx)
Запросы UI идут на тот же origin: `/api/evobgp/…` обрабатывает **Node** внутри образа. Если перед приложением стоит **nginx**, нужно проксировать префикс `/api` на Node **без потери** сегмента `/api` в URI.
**Рекомендуемый вариант** (путь `/api/…` сохраняется):
```nginx
location /api/ {
proxy_pass http://127.0.0.1:3001/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
``` ```
Запуск: **Типичная ошибка**: `proxy_pass http://127.0.0.1:3001/;` — nginx срезает `/api`. В коде есть запасной маршрут на `/evobgp`, но лучше исправить nginx.
```bash
docker-compose up -d
```
## 🔧 Переменные окружения **Проверка**: `GET http://<хост>:<порт>/api/evobgp` → JSON вида `{"ok":true,"proxy":true,"evobgpConfigured":true}`.
| Переменная | Описание | По умолчанию | Обязательная | ## Генерация `ENCRYPTION_KEY`
|------------|----------|--------------|--------------|
| `NODE_ENV` | Окружение Node.js | `production` | Нет |
| `PORT` | Порт для запуска сервера | `3001` | Нет |
| `ENCRYPTION_KEY` | Ключ шифрования для IPSec паролей (64 hex символа) | - | **Да** |
### ENCRYPTION_KEY
**Важно**: `ENCRYPTION_KEY` является обязательным параметром для работы с IPSec паролями. Этот ключ используется для шифрования/дешифрования паролей, хранящихся в S3.
**Генерация ключа:**
```bash ```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
``` ```
**Пример запуска с ENCRYPTION_KEY:** ## Мониторинг
```bash ```bash
docker run -d \ docker logs -f router-lists-ui
--name s3-lists-manager \ docker stats router-lists-ui
-p 3001:3001 \
-e ENCRYPTION_KEY=your-64-hex-character-key-here \
git.shts.su/[repository]:latest
``` ```
**⚠️ Внимание**: Используйте один и тот же `ENCRYPTION_KEY` для всех запусков контейнера. При изменении ключа старые зашифрованные пароли не смогут быть расшифрованы. ## Локальная сборка (как в CI)
## 📊 Мониторинг
### Просмотр логов
```bash ```bash
# Основная версия docker build -f Dockerfile.fast -t router-lists-ui:local .
docker logs s3-lists-manager docker run --rm -p 3001:3001 --env-file ./backend/.env -v $(pwd)/data:/data \
-e SQLITE_PATH=/data/router-lists.db \
# Tabler версия -e MIKROTIK_BACKUP_DIR=/data/backups/mikrotik \
docker logs s3-lists-manager-tabler 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 ```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 ```bash
# Основная версия docker run -d --memory=512m --cpus=0.5 ... git.shts.su/[repository]:latest
docker exec -it s3-lists-manager /bin/bash
# Tabler версия
docker exec -it s3-lists-manager-tabler /bin/bash
``` ```
### Проверка процессов ## Устранение неполадок
```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 ```bash
docker ps -a --filter name=router-lists-ui
docker restart router-lists-ui
docker image prune -f 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/routes ./routes
COPY backend/services ./services COPY backend/services ./services
COPY backend/utils ./utils COPY backend/utils ./utils
COPY backend/db ./db
COPY --from=frontend-builder /app/frontend/dist ./public 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 должен быть установлен через переменные окружения при запуске контейнера # ENCRYPTION_KEY должен быть установлен через переменные окружения при запуске контейнера
# Это обязательный параметр для работы с IPSec паролями # Это обязательный параметр для работы с IPSec паролями
# Пример: docker run -e ENCRYPTION_KEY=your-64-hex-character-key ... # Пример: docker run -e ENCRYPTION_KEY=your-64-hex-character-key ...
+76 -294
View File
@@ -1,347 +1,129 @@
# Router Lists UI # 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+ - Node.js 18+
- S3-совместимое хранилище (Yandex Object Storage) - npm
- Доступы AWS: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `S3_BUCKET_NAME`, `AWS_REGION`
### Запуск backend ### Запуск backend
```powershell ```powershell
cd backend cd backend
npm i npm install
# создайте .env и заполните (пример ниже) # создайте .env и заполните (пример ниже)
npm start npm start
``` ```
Сервис поднимется на `http://localhost:3001`.
Backend поднимется на `http://localhost:3001`.
### Запуск frontend ### Запуск frontend
```bash
```powershell
cd frontend cd frontend
npm i npm install
npm run dev npm run dev
``` ```
Frontend доступен на `http://localhost:5173` (по умолчанию). Production-сборка: `npm run build`.
## Главные особенности Frontend доступен на `http://localhost:5173`.
- Единая UX/UI библиотека Tabler, адаптивные панели действий и навигация.
- Онлайн-обновление (WebSocket) BGP c потоковым логом и фоновое обновление (HTTP POST) из UI.
- Блокировки (soft-lock) ресурсов с TTL, чтобы избежать гонок при одновременном редактировании.
- Версионирование данных (история/откат), если включено версии в бакете.
- Генерация конфигурации MikroTik из фильтров (`/api/filters/generate-config`) и экспорт в S3.
- Метрики Prometheus: `/metrics`, health/ready: `/health`, `/ready`.
### Важные переменные окружения ## Важные переменные окружения
- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, `S3_BUCKET_NAME` — доступ к Object Storage
- `ENCRYPTION_KEY` — ключ шифрования (64 hex символа) для IPSec и MikroTik паролей - `SQLITE_PATH` — путь к файлу SQLite (по умолчанию `backend/data/router-lists.db`).
- `CORS_ORIGINS` — список разрешённых Origin через запятую (если пусто — разрешены все) - `MIKROTIK_BACKUP_DIR` — каталог для локальных `.rsc` backup-файлов MikroTik.
- `LOG_LEVEL` — уровень логов pino (`info` по умолчанию) - `ENCRYPTION_KEY` — 64 hex-символа для шифрования секретов (IPSec/MikroTik).
- `PORT` — порт backend (по умолчанию 3001) - `EVOBGP_API_URL`, `EVOBGP_API_TOKEN` — если используете интеграцию EvoBGP.
- `BGP_BACKGROUND_URL` — адрес фонового обновления BGP, например: - `CORS_ORIGINS`, `LOG_LEVEL`, `PORT` — эксплуатационные настройки сервиса.
- `http://77.232.38.173:8080/api/update_bgp/background?api_key=...` - `BGP_BACKGROUND_URL` — URL фонового обновления BGP для прокси-эндпоинта `POST /api/update-bgp/background`.
- Используется эндпоинтом прокси `POST /api/update-bgp/background` для обхода CORS
Пример `.env`: Пример `.env`:
```dotenv ```dotenv
PORT=3001 PORT=3001
LOG_LEVEL=info LOG_LEVEL=info
AWS_ACCESS_KEY_ID=... SQLITE_PATH=./data/router-lists.db
AWS_SECRET_ACCESS_KEY=... MIKROTIK_BACKUP_DIR=./data/backups/mikrotik
AWS_REGION=ru-central1 ENCRYPTION_KEY=<64-hex>
S3_BUCKET_NAME=...
CORS_ORIGINS=http://localhost:5173 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) ## API (backend)
Все ответы об ошибке имеют единый формат: Формат ошибок:
```json ```json
{ "code": "E_*", "message": "...", "details": {}, "requestId": "..." } { "code": "E_*", "message": "...", "details": {}, "requestId": "..." }
``` ```
Успешные POST/PUT возвращают:
Формат успешных `POST/PUT`:
```json ```json
{ "ok": true, "etag": "...", "lastModified": "ISO", "contentLength": 123 } { "ok": true, "etag": "...", "lastModified": "ISO", "contentLength": 123 }
``` ```
И всегда выставляют заголовки `ETag`, `Last-Modified`, `Content-Length-Source` (если есть). GET поддерживают `countOnly=true` там, где это логично.
### Данные ### Примечание по legacy-терминам
- GET `/api/domains-new` — список доменов `{ domain, community }`
- `?q=`, `?offset=`, `?limit=`, `?countOnly=true`, `?format=std`
- POST `/api/domains-new``{ domains: [{domain, community}], etag }`
- GET `/api/ip-ranges` — список `[{ ipRange, community }]` - `GET /api/s3/last-modified` и функции вида `readS3*`/`writeS3*` — это **legacy-названия**.
- POST `/api/ip-ranges``{ ipRanges: [{ipRange, community}], etag }` - Фактически эти операции работают с SQLite-backed хранилищем.
- GET `/api/asns` — список `[{ domain, type }]` (domain = AS, type = community) ### Основные эндпоинты
- POST `/api/asns``{ domains: [{domain, type}], etag }`
- GET `/api/communities` — справочник community (JSON) - Данные: `/api/domains-new`, `/api/ip-ranges`, `/api/asns`, `/api/communities`
- POST `/api/communities` `{ communities: [...] }` (валидация уникальности value) - Фильтры/конфиги: `/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`
### Фильтры и конфигурации ## Docker
- 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`).
- GET `/api/server-configs` / POST `/api/server-configs` — список серверов (id, name, ...). Полная инструкция: [DOCKER.md](DOCKER.md).
- GET `/api/server-configs/:serverId` / POST `/api/server-configs/:serverId` — конфиг конкретного сервера.
- DELETE `/api/server-configs/:serverId` — удалить конфиг.
- DELETE `/api/server-configs/:serverId/complete` — удалить конфиг и фильтры.
- GET `/api/server-filters/:serverId` — фильтры сервера. Критично для production:
- 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.
### Прочее - Обязательно смонтировать volume для пути с `SQLITE_PATH`.
- GET `/api/servers` / POST `/api/servers` — список серверов. - Обязательно смонтировать volume для `MIKROTIK_BACKUP_DIR`, иначе `.rsc` backup-файлы потеряются при пересоздании контейнера.
- GET `/api/billing` / POST `/api/billing` — биллинг (ноды, статусы и т.п.). - Использовать стабильный `ENCRYPTION_KEY` между перезапусками.
- 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`.
## Frontend Пример запуска:
- Vite + React, Tabler CSS/JS (`@tabler/core`).
- Общий компонент `PageHeaderActions` — единый toolbar на страницах данных.
- Модалка `WsUpdateModal` — поток логов online-обновления (ws).
- Доступность: роли, подписи, фокус-кольца, hot-path без мыши.
### Скрипты ```powershell
```bash docker run -d `
npm run dev # dev-сервер --name router-lists-ui `
npm run build # продакшн сборка -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 `
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 \
git.shts.su/[repository]:latest git.shts.su/[repository]:latest
``` ```
#### Tabler версия ## Чек-лист проверки соответствия (S3 -> SQLite)
```bash
docker run -d \ 1. В `backend/package.json` нет зависимостей AWS SDK.
--name s3-lists-manager-tabler \ 2. Приложение стартует с `SQLITE_PATH` и создает/использует файл БД.
-p 3002:3001 \ 3. CRUD по основным данным (`/api/domains-new`, `/api/ip-ranges`, `/api/asns`) работает после перезапуска контейнера с тем же volume.
--env-file ./backend/.env \ 4. Созданный MikroTik backup появляется как локальный `.rsc` файл в каталоге `MIKROTIK_BACKUP_DIR`.
git.shts.su/[repository]:tabler 5. После перезапуска контейнера с тем же volume backup-файл остается доступным.
6. `GET /api/s3/last-modified` возвращает метаданные, но интерпретируется как legacy endpoint поверх SQLite.
## Структура репозитория
```text
backend/ Express API
frontend/ Vite + React UI
``` ```
### Локальная сборка ## Лицензия
Для сборки образа выполните команду в корневой директории проекта: MIT
```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`. |
+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", "license": "ISC",
"description": "", "description": "",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.687.0",
"ajv": "^8.17.1", "ajv": "^8.17.1",
"better-sqlite3": "^11.6.0",
"compression": "^1.7.4", "compression": "^1.7.4",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.0.1", "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,
};
+80 -86
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 { sendError, sendOk } = require('../middleware/errorHandler');
const { GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
const { streamToString } = require('../services/s3Service');
const validators = require('../lib/validators'); 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 // GET /api/communities
async function getCommunities(req, res) { async function getCommunities(req, res) {
await getS3JsonWithHeaders(S3_KEY, req, res, { const err = requireEvobgp(res);
transform: (communities) => { if (err) return err;
// Нормализация try {
return communities const communities = await evobgpClient.listCommunities();
.filter((c) => c && typeof c.value === 'string' && c.value.trim().length > 0) const normalized = communities
.map((c) => ({ .filter((c) => c && typeof c.value === 'string' && c.value.trim().length > 0)
value: String(c.value).trim(), .map((c) => ({
name: c.name ? String(c.name) : '', value: String(c.value).trim(),
description: c.description ? String(c.description) : '', name: c.name ? String(c.name) : '',
tags: Array.isArray(c.tags) ? c.tags.map(String) : [], description: c.description ? String(c.description) : '',
gatewayDefault: c.gatewayDefault ? String(c.gatewayDefault) : '', tags: Array.isArray(c.tags) ? c.tags.map(String) : [],
color: c.color ? String(c.color) : '' color: c.color ? String(c.color) : '',
})); icon: c.icon ? String(c.icon) : '',
}, }));
defaultValue: [] res.json(normalized);
}); } catch (e) {
console.error('getCommunities', e);
return sendError(res, 500, e.message || 'EvoBGP error', 'E_EVOBGP');
}
} }
// POST /api/communities // POST /api/communities
async function postCommunities(req, res) { async function postCommunities(req, res) {
const err = requireEvobgp(res);
if (err) return err;
const { communities } = req.body; const { communities } = req.body;
if (!Array.isArray(communities)) { if (!Array.isArray(communities)) {
@@ -68,8 +82,8 @@ async function postCommunities(req, res) {
name: entry.name ? String(entry.name) : '', name: entry.name ? String(entry.name) : '',
description: entry.description ? String(entry.description) : '', description: entry.description ? String(entry.description) : '',
tags: Array.isArray(entry.tags) ? entry.tags.map(String) : [], tags: Array.isArray(entry.tags) ? entry.tags.map(String) : [],
gatewayDefault: entry.gatewayDefault ? String(entry.gatewayDefault) : '',
color: entry.color ? String(entry.color) : '', color: entry.color ? String(entry.color) : '',
icon: entry.icon ? String(entry.icon) : '',
category: entry.category ? String(entry.category) : '', category: entry.category ? String(entry.category) : '',
priority: typeof entry.priority === 'number' ? entry.priority : 0, priority: typeof entry.priority === 'number' ? entry.priority : 0,
enabled: typeof entry.enabled === 'boolean' ? entry.enabled : true, enabled: typeof entry.enabled === 'boolean' ? entry.enabled : true,
@@ -81,91 +95,72 @@ async function postCommunities(req, res) {
} }
try { try {
await s3.send(new PutObjectCommand({ await evobgpClient.saveCommunitiesData(normalized);
Bucket: BUCKET_NAME, return sendOk(res, {
Key: S3_KEY, etag: null,
Body: JSON.stringify(normalized, null, 2), lastModified: new Date().toISOString(),
ContentType: 'application/json', contentLength: null,
})); });
invalidateCacheForKey(S3_KEY);
const { headMeta } = require('../services/s3Service');
const meta = await headMeta(S3_KEY);
return sendOk(res, meta);
} catch (error) { } catch (error) {
console.error('Error writing communities to S3:', error); console.error('Error writing communities to EvoBGP:', error);
return sendError(res, 500, 'Error writing communities to S3', 'E_S3'); 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 // GET /api/communities/stats
async function getCommunityStats(req, res) { async function getCommunityStats(req, res) {
const err = requireEvobgp(res);
if (err) return err;
try { try {
const [domainsRes, ipRangesRes, asnsRes, filtersRes] = await Promise.allSettled([ const [domains, ipRanges, asns] = await Promise.allSettled([
s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt' })), evobgpClient.fetchDomainsData(),
s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/ips.txt' })), evobgpClient.fetchIpRangesData(),
s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/asns.txt' })), evobgpClient.fetchAsnsData(),
s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'filter-manager/simple-filters.json' })),
]); ]);
const communityUsage = new Map(); const communityUsage = new Map();
// Подсчет использования в доменах if (domains.status === 'fulfilled') {
if (domainsRes.status === 'fulfilled') { const m = countCommunityInLines(domains.value, (r) => r.community);
const text = await streamToString(domainsRes.value.Body); for (const [k, v] of m) communityUsage.set(k, (communityUsage.get(k) || 0) + v);
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);
}
});
} }
// Подсчет в IP ranges if (ipRanges.status === 'fulfilled') {
if (ipRangesRes.status === 'fulfilled') { const m = countCommunityInLines(ipRanges.value, (r) => r.community);
const text = await streamToString(ipRangesRes.value.Body); for (const [k, v] of m) communityUsage.set(k, (communityUsage.get(k) || 0) + v);
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);
}
});
} }
// Подсчет в ASNs if (asns.status === 'fulfilled') {
if (asnsRes.status === 'fulfilled') { const m = countCommunityInLines(asns.value, (r) => r.community);
const text = await streamToString(asnsRes.value.Body); for (const [k, v] of m) communityUsage.set(k, (communityUsage.get(k) || 0) + v);
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);
}
});
} }
// Подсчет в фильтрах try {
if (filtersRes.status === 'fulfilled') { const simpleFilters = filterRulesStorage.listRules('simple');
try { for (const f of simpleFilters) {
const text = await streamToString(filtersRes.value.Body); if (f.community) {
const filters = JSON.parse(text); communityUsage.set(f.community, (communityUsage.get(f.community) || 0) + 1);
if (Array.isArray(filters)) {
filters.forEach(f => {
if (f.community) {
communityUsage.set(f.community, (communityUsage.get(f.community) || 0) + 1);
}
});
} }
} catch {} }
} } catch (_) {}
const stats = Array.from(communityUsage.entries()).map(([community, count]) => ({ const stats = Array.from(communityUsage.entries())
community, .map(([community, count]) => ({ community, count }))
count, .sort((a, b) => b.count - a.count);
})).sort((a, b) => b.count - a.count);
res.json({ stats, total: stats.reduce((sum, s) => sum + s.count, 0) }); res.json({ stats, total: stats.reduce((sum, s) => sum + s.count, 0) });
} catch (error) { } catch (error) {
console.error('Error getting community stats:', 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, postCommunities,
getCommunityStats, 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 * Роуты для фильтров и генерации конфигураций MikroTik
*/ */
const { s3, BUCKET_NAME, writeS3TextObject, headMeta } = require('../services/s3Service'); const { writeS3TextObject } = require('../services/s3Service');
const { sendError, sendOk } = require('../middleware/errorHandler'); const filterRulesStorage = require('../services/filterRulesStorage');
const { GetObjectCommand } = require('@aws-sdk/client-s3'); const { sendError, checkIfNoneMatch } = require('../middleware/errorHandler');
const { streamToString } = require('../services/s3Service');
const { generateMikrotikConfig } = require('../utils/mikrotikConfig'); const { generateMikrotikConfig } = require('../utils/mikrotikConfig');
const { getS3JsonWithHeaders } = require('../utils/s3Helpers');
// GET /api/filters/generate-config?format=text|json // GET /api/filters/generate-config?format=text|json
async function generateConfig(req, res) { async function generateConfig(req, res) {
@@ -16,13 +14,16 @@ async function generateConfig(req, res) {
if (!['text', 'json'].includes(format)) { if (!['text', 'json'].includes(format)) {
return sendError(res, 400, 'format must be "text" or "json"', 'E_BAD_REQUEST'); return sendError(res, 400, 'format must be "text" or "json"', 'E_BAD_REQUEST');
} }
await getS3JsonWithHeaders('filters.json', req, res, { const head = filterRulesStorage.headMetaForScope('global');
transform: (filters) => { if (head.etag) res.set('ETag', String(head.etag));
const result = generateMikrotikConfig(filters, { format }); if (head.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString());
return format === 'json' ? result : { config: result }; if (typeof head.contentLength === 'number') {
}, res.set('Content-Length-Source', String(head.contentLength));
defaultValue: [], }
}); 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) { } catch (error) {
console.error(error); console.error(error);
return sendError(res, 500, 'Error generating configuration', 'E_S3'); return sendError(res, 500, 'Error generating configuration', 'E_S3');
@@ -32,17 +33,7 @@ async function generateConfig(req, res) {
// POST /api/filters/export-config // POST /api/filters/export-config
async function exportConfig(req, res) { async function exportConfig(req, res) {
try { try {
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'filters.json' })); const filters = filterRulesStorage.listRules('global');
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 = [];
}
if (filters.length === 0) { if (filters.length === 0) {
return res.json({ success: false, message: 'Нет фильтров для экспорта' }); return res.json({ success: false, message: 'Нет фильтров для экспорта' });
@@ -50,10 +41,10 @@ async function exportConfig(req, res) {
const config = generateMikrotikConfig(filters); const config = generateMikrotikConfig(filters);
await writeS3TextObject('mikrotik-frouting-config.txt', config); await writeS3TextObject('mikrotik-frouting-config.txt', config);
res.json({ success: true, message: 'Конфигурация экспортирована в S3' }); res.json({ success: true, message: 'Конфигурация экспортирована в локальное хранилище' });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return sendError(res, 500, 'Error exporting configuration', 'E_S3'); return sendError(res, 500, 'Error exporting configuration', 'E_STORAGE');
} }
} }
+23 -39
View File
@@ -2,14 +2,12 @@
* Общие роуты для JSON данных (servers, filters, billing и т.д.) * Общие роуты для 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 { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
const { streamToString } = require('../services/s3Service');
/** /**
* Создать GET эндпоинт для JSON данных * Создать GET эндпоинт для JSON данных
* @param {string} s3Key - Ключ в S3 * @param {string} s3Key - Ключ объекта (логический, как раньше в S3)
* @param {Object} options - Опции * @param {Object} options - Опции
* @param {boolean} options.singleObject - Если true, возвращает объект вместо массива * @param {boolean} options.singleObject - Если true, возвращает объект вместо массива
* @param {any} options.defaultValue - Значение по умолчанию ([] для массива, {} для объекта) * @param {any} options.defaultValue - Значение по умолчанию ([] для массива, {} для объекта)
@@ -20,24 +18,31 @@ function createJsonDataGET(s3Key, options = {}) {
return async (req, res) => { return async (req, res) => {
try { try {
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null); const head = await headMeta(s3Key);
const etag = head?.ETag || null; const etag = head?.etag || null;
if (etag) res.set('ETag', String(etag)); if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); 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 (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
if (checkIfNoneMatch(req, res, etag)) return; if (checkIfNoneMatch(req, res, etag)) return;
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })); let fileContent;
const fileContent = await streamToString(data.Body); try {
const data = await readS3TextObject(s3Key);
fileContent = data.body;
} catch (e) {
if (e?.code === 'NoSuchKey') {
return res.json(fallback);
}
throw e;
}
let items = fallback; let items = fallback;
try { try {
const parsed = JSON.parse(fileContent); const parsed = JSON.parse(fileContent);
if (singleObject) { 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 { } else {
// Для массива
items = Array.isArray(parsed) ? parsed : fallback; items = Array.isArray(parsed) ? parsed : fallback;
} }
} catch (parseError) { } catch (parseError) {
@@ -47,36 +52,26 @@ function createJsonDataGET(s3Key, options = {}) {
res.json(items); res.json(items);
} catch (error) { } catch (error) {
if (error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) { console.error(error);
res.json(fallback); return sendError(res, 500, 'Error reading storage', 'E_STORAGE');
} else {
console.error(error);
return sendError(res, 500, 'Error reading from S3', 'E_S3');
}
} }
}; };
} }
/** /**
* Создать POST эндпоинт для JSON данных * Создать POST эндпоинт для JSON данных
* @param {string} s3Key - Ключ в S3
* @param {Function} validateItem - Функция валидации (для массива - валидирует каждый элемент, для объекта - весь объект)
* @param {Object} options - Опции
* @param {boolean} options.singleObject - Если true, ожидает объект вместо массива
*/ */
function createJsonDataPOST(s3Key, validateItem = null, options = {}) { function createJsonDataPOST(s3Key, validateItem = null, options = {}) {
const { singleObject = false } = options; const { singleObject = false } = options;
return async (req, res) => { return async (req, res) => {
const { domains: items } = req.body; // Используем 'domains' для обратной совместимости const { domains: items } = req.body;
if (singleObject) { if (singleObject) {
// Для одиночного объекта
if (typeof items !== 'object' || items === null || Array.isArray(items)) { if (typeof items !== 'object' || items === null || Array.isArray(items)) {
return sendError(res, 400, 'Data must be an object', 'E_BAD_REQUEST'); return sendError(res, 400, 'Data must be an object', 'E_BAD_REQUEST');
} }
// Валидация объекта целиком
if (validateItem) { if (validateItem) {
const error = validateItem(items); const error = validateItem(items);
if (error) { if (error) {
@@ -84,12 +79,10 @@ function createJsonDataPOST(s3Key, validateItem = null, options = {}) {
} }
} }
} else { } else {
// Для массива
if (!Array.isArray(items)) { if (!Array.isArray(items)) {
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST'); return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
} }
// Валидация каждого элемента массива
if (validateItem) { if (validateItem) {
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
const error = validateItem(items[i], i); const error = validateItem(items[i], i);
@@ -105,23 +98,15 @@ function createJsonDataPOST(s3Key, validateItem = null, options = {}) {
return sendOk(res, meta); return sendOk(res, meta);
} catch (error) { } catch (error) {
console.error(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 = {}) { function createJsonDataRoutes(s3Key, validateItem = null, options = {}) {
return { return {
get: createJsonDataGET(s3Key, options), get: createJsonDataGET(s3Key, options),
post: createJsonDataPOST(s3Key, validateItem, options) post: createJsonDataPOST(s3Key, validateItem, options),
}; };
} }
@@ -130,4 +115,3 @@ module.exports = {
createJsonDataGET, createJsonDataGET,
createJsonDataPOST, createJsonDataPOST,
}; };
+18 -19
View File
@@ -1,8 +1,8 @@
/** /**
* Роуты для бэкапов конфигурации MikroTik * Роуты для бэкапов конфигурации 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 crypto = require('crypto');
const { sendError, sendOk } = require('../middleware/errorHandler'); 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 { readServersFromS3 } = require('./serversRoutes');
const { createRosClient, fetchExportViaFile } = require('../services/mikrotikApplyService'); const { createRosClient, fetchExportViaFile } = require('../services/mikrotikApplyService');
const { encrypt, decrypt } = require('../utils/encryption'); 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/, '')}`; let content = `${headerLines.join('\n')}\n${String(config || '').replace(/^\uFEFF/, '')}`;
// Шифруем содержимое на S3, если задан ENCRYPTION_KEY // Шифруем содержимое файла, если задан ENCRYPTION_KEY
if (process.env.ENCRYPTION_KEY) { if (process.env.ENCRYPTION_KEY) {
try { try {
content = ENCRYPTED_PREFIX + encrypt(content); content = ENCRYPTED_PREFIX + encrypt(content);
@@ -71,15 +71,15 @@ async function saveBackupForServer(serverId, config, { source, comment } = {}) {
} }
} }
const meta = await writeS3TextObject(key, content, 'text/plain'); const meta = await writeBackupFile(key, content, 'text/plain');
return { key, createdAt: now.toISOString(), s3: meta }; return { key, createdAt: now.toISOString(), storage: meta };
} }
/** /**
* POST /api/mikrotik/backups * POST /api/mikrotik/backups
* Body: { serverId: string, config: string, source?: string, comment?: string } * Body: { serverId: string, config: string, source?: string, comment?: string }
* *
* Сохраняет текст конфигурации (обычно вывод /export) в S3. * Сохраняет текст конфигурации (обычно вывод /export) в каталог бэкапов на диске.
* Автоматический бэкап можно организовать внешним планировщиком, дергающим этот эндпоинт. * Автоматический бэкап можно организовать внешним планировщиком, дергающим этот эндпоинт.
*/ */
async function createBackup(req, res) { 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'); 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({ return res.json({
ok: true, ok: true,
key, key,
serverId, serverId,
createdAt, createdAt,
s3, storage,
}); });
} catch (error) { } catch (error) {
console.error('createBackup error:', 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'); return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
} }
const prefix = `${BACKUP_PREFIX}/${String(serverId).replace(/[^a-zA-Z0-9._-]/g, '_')}/`; const safeId = String(serverId).replace(/[^a-zA-Z0-9._-]/g, '_');
const objects = await listS3Objects(prefix, { maxKeys: 200 }); const objects = await listBackupObjects(safeId, 200);
const items = objects const items = objects
.filter(o => o.key && o.key.startsWith(prefix)) .map((o) => ({
.map(o => ({
key: o.key, key: o.key,
serverId, serverId,
createdAt: o.lastModified, createdAt: o.lastModified,
@@ -156,7 +155,7 @@ async function getBackup(req, res) {
return sendError(res, 400, 'Invalid backup key', 'E_BAD_REQUEST'); 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 || ''); const config = decryptBackupContent(data.body || '');
return res.json({ return res.json({
@@ -191,8 +190,8 @@ async function diffBackups(req, res) {
} }
const [a, b] = await Promise.all([ const [a, b] = await Promise.all([
readS3TextObject(keyA), readBackupFile(keyA),
readS3TextObject(keyB), readBackupFile(keyB),
]); ]);
const bodyA = decryptBackupContent(a.body || ''); const bodyA = decryptBackupContent(a.body || '');
@@ -265,7 +264,7 @@ async function getMikrotikClientForServerId(serverId) {
* POST /api/mikrotik/backups/run * POST /api/mikrotik/backups/run
* Body: { serverId: string } * Body: { serverId: string }
* *
* Немедленно делает /rest/export compact на указанном MikroTik и сохраняет результат в S3. * Немедленно делает /rest/export compact на указанном MikroTik и сохраняет результат на диск.
*/ */
async function runBackupNow(req, res) { async function runBackupNow(req, res) {
try { try {
@@ -293,7 +292,7 @@ async function runBackupNow(req, res) {
return sendError(res, 502, 'Empty export result from MikroTik', 'E_EMPTY_EXPORT'); 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', source: 'manual',
comment: 'Manual backup from UI (/api/mikrotik/backups/run)', comment: 'Manual backup from UI (/api/mikrotik/backups/run)',
}); });
@@ -303,7 +302,7 @@ async function runBackupNow(req, res) {
key, key,
serverId, serverId,
createdAt, createdAt,
s3, storage,
}); });
} catch (error) { } catch (error) {
console.error('runBackupNow error:', error?.message, error?.stack, error?.response?.data); console.error('runBackupNow error:', error?.message, error?.stack, error?.response?.data);
+400 -46
View File
@@ -4,8 +4,7 @@
*/ */
const crypto = require('crypto'); const crypto = require('crypto');
const { GetObjectCommand } = require('@aws-sdk/client-s3'); const { readS3TextObject, writeS3JsonObject } = require('../services/s3Service');
const { s3, BUCKET_NAME, streamToString, readS3TextObject, writeS3JsonObject } = require('../services/s3Service');
const { sendError, sendOk } = require('../middleware/errorHandler'); const { sendError, sendOk } = require('../middleware/errorHandler');
const { decrypt } = require('../utils/encryption'); const { decrypt } = require('../utils/encryption');
const { readServersFromS3 } = require('./serversRoutes'); const { readServersFromS3 } = require('./serversRoutes');
@@ -15,7 +14,7 @@ const {
buildMikrotikRecursiveRoutes, buildMikrotikRecursiveRoutes,
getParentGateway, getParentGateway,
} = require('../utils/mikrotikInterfaceGenerator'); } = 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 IPSEC_PASSWORDS_KEY = 'network-config/ipsec-passwords.json';
const NETWORK_CONFIG_KEY = 'network-config.json'; const NETWORK_CONFIG_KEY = 'network-config.json';
@@ -54,12 +53,11 @@ function speedTestCacheKey(serverId, interfaceName) {
/** Загрузить network-config из S3 */ /** Загрузить network-config из S3 */
async function loadNetworkConfig() { async function loadNetworkConfig() {
try { try {
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: NETWORK_CONFIG_KEY })); const data = await readS3TextObject(NETWORK_CONFIG_KEY);
const body = await streamToString(data.Body); const parsed = JSON.parse(data.body || '{}');
const parsed = JSON.parse(body || '{}');
return parsed && typeof parsed === 'object' ? parsed : {}; return parsed && typeof parsed === 'object' ? parsed : {};
} catch (e) { } catch (e) {
if (e?.name === 'NoSuchKey' || e?.$metadata?.httpStatusCode === 404) return {}; if (e?.code === 'NoSuchKey') return {};
throw e; throw e;
} }
} }
@@ -264,32 +262,44 @@ function updateUptimeCache(serverId, entry) {
} }
/** /**
* GET /api/uptime/cache * Получить данные кеша Uptime Monitor (для оповещений и др.).
* Возвращает закешированные результаты проверок, если кеш младше TTL из настроек. * Учитывает TTL из ui-settings.uptimeMonitorCacheSeconds.
* @returns {Promise<{ results: Record<string, { ok, lastCheckTs, ms }>, updatedAt: number|null }>}
*/ */
async function getUptimeCache(req, res) { async function getUptimeCacheData() {
try { try {
const uiSettings = await loadUiSettings(); const uiSettings = await loadUiSettings();
const ttlSec = Math.max(0, parseInt(uiSettings.uptimeMonitorCacheSeconds, 10) || 120); const ttlSec = Math.max(0, parseInt(uiSettings.uptimeMonitorCacheSeconds, 10) || 120);
if (ttlSec === 0) { if (ttlSec === 0) return { results: {}, updatedAt: null };
return res.json({ results: {}, updatedAt: null });
}
const data = await readS3TextObject(UPTIME_CACHE_KEY).catch(() => null); const data = await readS3TextObject(UPTIME_CACHE_KEY).catch(() => null);
if (!data?.body) { if (!data?.body) return { results: {}, updatedAt: null };
return res.json({ results: {}, updatedAt: null });
}
let cache = { results: {}, updatedAt: null }; let cache = { results: {}, updatedAt: null };
try { try {
const parsed = JSON.parse(data.body); const parsed = JSON.parse(data.body);
if (parsed && typeof parsed === 'object') cache = parsed; if (parsed && typeof parsed === 'object') cache = parsed;
} catch (_) {} } catch (_) {}
const now = Date.now(); const now = Date.now();
if (!cache.updatedAt || now - cache.updatedAt > ttlSec * 1000) { if (!cache.updatedAt || now - cache.updatedAt > ttlSec * 1000) return { results: {}, updatedAt: null };
return res.json({ results: {}, updatedAt: null }); return {
}
return res.json({
results: cache.results && typeof cache.results === 'object' ? cache.results : {}, results: cache.results && typeof cache.results === 'object' ? cache.results : {},
updatedAt: cache.updatedAt, 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) { } catch (e) {
console.error('[uptime] getUptimeCache', 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_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 * POST /api/mikrotik/address-lists/apply-summary
@@ -1475,7 +1846,9 @@ async function applyAddressListSummary(req, res) {
if (!serverId) { if (!serverId) {
return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST'); 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) const entries = Array.isArray(addEntries)
? addEntries.filter((e) => e && (e.address || e.list)) ? addEntries.filter((e) => e && (e.address || e.list))
: []; : [];
@@ -1495,32 +1868,10 @@ async function applyAddressListSummary(req, res) {
let removed = 0; let removed = 0;
let added = 0; let added = 0;
for (const id of ids) { removed = await removeAddressListIds(client, 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);
}
}
for (const entry of entries) { const addResult = await addAddressListEntries(client, entries);
try { added = addResult.added;
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);
}
}
return res.json({ ok: true, removed, added }); return res.json({ ok: true, removed, added });
} catch (error) { } catch (error) {
@@ -1544,7 +1895,10 @@ module.exports = {
speedTestViaTunnel, speedTestViaTunnel,
loadNetworkConfig, loadNetworkConfig,
getAddressLists, getAddressLists,
getOspfInterfaceTemplates,
applyOspfInterfaceTemplates,
applyAddressListSummary, applyAddressListSummary,
uptimeCheck, uptimeCheck,
getUptimeCache, getUptimeCache,
getUptimeCacheData,
}; };
+131 -156
View File
@@ -2,10 +2,18 @@
* Разные утилитарные роуты (auto-urls, history, s3 meta, availability, etc.) * Разные утилитарные роуты (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 { sendError, sendOk } = require('../middleware/errorHandler');
const { resourceToKey, toIso } = require('../utils/helpers'); const { resourceToKey, toIso } = require('../utils/helpers');
const { GetObjectCommand, PutObjectCommand, ListObjectVersionsCommand, CopyObjectCommand } = require('@aws-sdk/client-s3');
const validators = require('../lib/validators'); const validators = require('../lib/validators');
const net = require('net'); const net = require('net');
const http = require('http'); const http = require('http');
@@ -19,66 +27,97 @@ async function getS3LastModified(req, res) {
{ name: 'domainsNew', key: 'bgp_data/domains_community.txt' }, { name: 'domainsNew', key: 'bgp_data/domains_community.txt' },
{ name: 'asns', key: 'bgp_data/asns.txt' }, { name: 'asns', key: 'bgp_data/asns.txt' },
{ name: 'servers', key: 'servers.json' }, { name: 'servers', key: 'servers.json' },
{ name: 'filters', key: 'filters.json' }, { name: 'filters', key: null },
{ name: 'ipRanges', key: 'bgp_data/ips.txt' }, { name: 'ipRanges', key: 'bgp_data/ips.txt' },
{ name: 'uiSettings', key: 'bgp_data/rt_ui_settings.json' } { name: 'uiSettings', key: 'bgp_data/rt_ui_settings.json' }
]; ];
const results = await Promise.allSettled(
keys.map(k => headMeta(k.key))
);
const out = {}; const out = {};
results.forEach((r, idx) => { for (const k of keys) {
const name = keys[idx].name; if (k.name === 'filters') {
if (r.status === 'fulfilled' && r.value) { try {
out[name] = r.value; const head = filterRulesStorage.headMetaForScope('global');
} else { out.filters = {
out[name] = null; 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); res.json(out);
} catch (error) { } catch (error) {
console.error('Error fetching last modified dates from S3:', error); console.error('Error fetching last modified dates:', error);
return sendError(res, 500, 'Error fetching last modified dates from S3', 'E_S3'); 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 // GET /api/history/:resource
async function getHistory(req, res) { async function getHistory(req, res) {
const { resource } = req.params; const { resource } = req.params;
const { countOnly, format } = req.query || {}; 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); const key = resourceToKey(resource);
if (!key) return sendError(res, 400, 'Unknown resource', 'E_RESOURCE'); if (!key) return sendError(res, 400, 'Unknown resource', 'E_RESOURCE');
try { try {
const out = await s3.send(new ListObjectVersionsCommand({ const rows = listBlobVersionsForKey(key, 50);
Bucket: BUCKET_NAME, const versionsAll = rows.map((r) => ({
Prefix: key, id: r.id,
MaxKeys: 50 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') { if (countOnly === 'true') {
const total = versionsAll.length; const total = versionsAll.length;
return res.json(format === 'std' ? { items: [], total, meta: {} } : { total }); return res.json(format === 'std' ? { items: [], total, meta: {} } : { total });
} }
const versions = versionsAll.slice(0, 10).map(v => ({ const versions = versionsAll.slice(0, 10).map((v, idx) => ({
versionId: v.VersionId, versionId: String(v.id),
isLatest: v.IsLatest, isLatest: idx === 0,
lastModified: toIso(v.LastModified), lastModified: toIso(v.created_at),
size: v.Size, size: v.byte_size,
etag: v.ETag etag: v.etag,
})); }));
return res.json(format === 'std' ? { items: versions, total: versionsAll.length, meta: {} } : { items: versions }); return res.json(format === 'std' ? { items: versions, total: versionsAll.length, meta: {} } : { items: versions });
} catch (e) { } catch (e) {
console.error('history error', 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,6 +125,14 @@ async function getHistory(req, res) {
async function postRollback(req, res) { async function postRollback(req, res) {
const { resource } = req.params; const { resource } = req.params;
const { versionId } = req.body || {}; 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); const key = resourceToKey(resource);
if (!key || !versionId) { if (!key || !versionId) {
@@ -93,118 +140,61 @@ async function postRollback(req, res) {
} }
try { try {
await s3.send(new CopyObjectCommand({ rollbackBlobVersion(key, versionId);
Bucket: BUCKET_NAME,
CopySource: `/${BUCKET_NAME}/${encodeURIComponent(key)}?versionId=${encodeURIComponent(versionId)}`,
Key: key
}));
const meta = await headMeta(key); const meta = await headMeta(key);
return sendOk(res, meta); return sendOk(res, meta);
} catch (e) { } catch (e) {
console.error('rollback error', 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 // GET /api/auto-urls
async function getAutoUrls(req, res) { async function getAutoUrls(req, res) {
const { checkIfNoneMatch } = require('../middleware/errorHandler');
try { try {
const { HeadObjectCommand } = require('@aws-sdk/client-s3'); if (!evobgpClient.isConfigured()) {
const head = await s3.send(new HeadObjectCommand({ return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
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');
} }
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 // POST /api/auto-urls
async function postAutoUrls(req, res) { async function postAutoUrls(req, res) {
const { urls } = req.body; 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 { try {
const meta = await writeS3TextObject('bgp_data/auto_url/urls.txt', fileContent); if (!evobgpClient.isConfigured()) {
return sendOk(res, meta); 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) { } catch (error) {
console.error(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 // POST /api/auto-urls/process
async function processAutoUrls(req, res) { async function processAutoUrls(req, res) {
try { try {
// Загрузка URL списков if (!evobgpClient.isConfigured()) {
let urls = []; return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
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;
} }
const urls = await evobgpClient.fetchAutoUrlsData();
if (urls.length === 0) { if (urls.length === 0) {
return sendError(res, 400, 'No URLs to process', 'E_BAD_REQUEST'); return sendError(res, 400, 'No URLs to process', 'E_BAD_REQUEST');
} }
// Загрузка текущих IP и доменов const currentIps = await evobgpClient.fetchIpRangesData();
let currentIps = []; const currentDomains = await evobgpClient.fetchDomainsData();
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;
}
// Обработка URL // Обработка URL
const newIps = []; const newIps = [];
@@ -256,22 +246,8 @@ async function processAutoUrls(req, res) {
const uniqueNewDomains = newDomains.filter(d => !existingDomains.has(d.domain)); const uniqueNewDomains = newDomains.filter(d => !existingDomains.has(d.domain));
const allDomains = [...currentDomains, ...uniqueNewDomains]; const allDomains = [...currentDomains, ...uniqueNewDomains];
// Сохранение await evobgpClient.saveIpRangesData(allIps, currentIps);
const updatedIpsContent = allIps.map(i => `${i.ipRange} ${i.community}`).join('\n'); await evobgpClient.saveDomainsData(allDomains, currentDomains);
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'
}));
const msg = `Обработано ${urls.length} URL, добавлено ${uniqueNewIps.length} IP и ${uniqueNewDomains.length} доменов`; const msg = `Обработано ${urls.length} URL, добавлено ${uniqueNewIps.length} IP и ${uniqueNewDomains.length} доменов`;
return res.json({ return res.json({
@@ -285,7 +261,7 @@ async function processAutoUrls(req, res) {
}); });
} catch (error) { } catch (error) {
console.error('Error processing auto URLs:', 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 и др.) */ /** Загрузить UI-настройки из S3 (для pingServicesSource и др.) */
async function loadUiSettingsSync() { async function loadUiSettingsSync() {
try { try {
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/rt_ui_settings.json' })); const data = await readS3TextObject('bgp_data/rt_ui_settings.json');
const jsonText = await streamToString(data.Body); const parsed = JSON.parse(data.body || '{}');
const parsed = JSON.parse(jsonText || '{}');
return parsed && typeof parsed === 'object' ? parsed : {}; return parsed && typeof parsed === 'object' ? parsed : {};
} catch { } catch {
return {}; return {};
@@ -598,10 +573,10 @@ async function getServersAvailability(req, res) {
return res.json({ ...availabilityCache.data, cached: true }); return res.json({ ...availabilityCache.data, cached: true });
} }
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'servers.json' }));
let servers = []; let servers = [];
try { try {
servers = JSON.parse(await streamToString(data.Body)); const data = await readS3TextObject('servers.json');
servers = JSON.parse(data.body);
if (!Array.isArray(servers)) servers = []; if (!Array.isArray(servers)) servers = [];
} catch { } catch {
servers = []; servers = [];
@@ -683,12 +658,13 @@ async function updateBgpBackground(req, res) {
// GET /api/ws/url // GET /api/ws/url
async function getWsUrl(req, res) { async function getWsUrl(req, res) {
try { try {
const settings = await s3.send(new GetObjectCommand({ let settings = {};
Bucket: BUCKET_NAME, try {
Key: 'bgp_data/rt_ui_settings.json' const d = await readS3TextObject('bgp_data/rt_ui_settings.json');
})).then(async (d) => { settings = JSON.parse(d.body || '{}');
try { return JSON.parse(await streamToString(d.Body)); } catch { return {}; } } catch (_) {
}).catch(() => ({})); settings = {};
}
const url = settings?.wsUpdateUrl || process.env.WS_UPDATE_URL || ''; const url = settings?.wsUpdateUrl || process.env.WS_UPDATE_URL || '';
return res.json({ url }); return res.json({ url });
@@ -702,23 +678,20 @@ async function getUiSettings(req, res) {
const { checkIfNoneMatch } = require('../middleware/errorHandler'); const { checkIfNoneMatch } = require('../middleware/errorHandler');
try { try {
const { HeadObjectCommand } = require('@aws-sdk/client-s3'); const head = await headMeta('bgp_data/rt_ui_settings.json');
const head = await s3.send(new HeadObjectCommand({ const etag = head?.etag || null;
Bucket: BUCKET_NAME,
Key: 'bgp_data/rt_ui_settings.json'
})).catch(() => null);
const etag = head?.ETag || null;
if (etag) res.set('ETag', String(etag)); if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); 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 (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
if (checkIfNoneMatch(req, res, etag)) return; if (checkIfNoneMatch(req, res, etag)) return;
const data = await s3.send(new GetObjectCommand({ let jsonText = '{}';
Bucket: BUCKET_NAME, try {
Key: 'bgp_data/rt_ui_settings.json' const data = await readS3TextObject('bgp_data/rt_ui_settings.json');
})); jsonText = data.body;
const jsonText = await streamToString(data.Body); } catch (e) {
if (e?.code !== 'NoSuchKey') throw e;
}
let settings = {}; let settings = {};
try { try {
const parsed = JSON.parse(jsonText); const parsed = JSON.parse(jsonText);
@@ -728,11 +701,11 @@ async function getUiSettings(req, res) {
} }
return res.json(settings); return res.json(settings);
} catch (error) { } catch (error) {
if (error.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) { if (error?.code === 'NoSuchKey') {
return res.json({}); return res.json({});
} }
console.error('Error reading ui settings from S3:', error); console.error('Error reading ui settings:', error);
return sendError(res, 500, 'Error reading UI settings from S3', 'E_S3'); 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); const meta = await writeS3JsonObject('bgp_data/rt_ui_settings.json', payload);
return sendOk(res, meta); return sendOk(res, meta);
} catch (error) { } catch (error) {
console.error('Error writing UI settings to S3:', error); console.error('Error writing UI settings:', error);
return sendError(res, 500, 'Error writing UI settings to S3', 'E_S3', { error: String(error?.message || error) }); return sendError(res, 500, 'Error writing UI settings', 'E_STORAGE', { error: String(error?.message || error) });
} }
} }
@@ -776,5 +749,7 @@ module.exports = {
getPingServicesList, getPingServicesList,
getPingServices, getPingServices,
refreshPingServicesCache, refreshPingServicesCache,
checkOneServerFast,
loadUiSettingsSync,
}; };
+16 -7
View File
@@ -74,13 +74,11 @@ function parseResource(raw) {
} }
/** /**
* GET /api/resources/stats * Внутренняя функция: возвращает данные по ресурсам роутеров (без HTTP).
* По каждому jumphost/home с MikroTik — запрос system/resource, ответ с группировкой по роутерам.
*/ */
async function getResourceStats(req, res) { async function getResourceStatsData() {
try { const servers = await readServersFromS3();
const servers = await readServersFromS3(); const routers = (Array.isArray(servers) ? servers : []).filter(
const routers = (Array.isArray(servers) ? servers : []).filter(
(s) => (s) =>
s && s &&
(String(s.type || '').toLowerCase() === 'jumphost' || (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) { } catch (error) {
console.error('getResourceStats:', error); console.error('getResourceStats:', error);
return sendError( return sendError(
@@ -146,4 +154,5 @@ async function getResourceStats(req, res) {
module.exports = { module.exports = {
getResourceStats, 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 серверами * Это нужно для 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 { 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 { getS3TextWithHeaders, getS3JsonWithHeaders } = require('../utils/s3Helpers');
const { isValidServerId } = require('../lib/validators'); const { isValidServerId } = require('../lib/validators');
@@ -41,15 +39,20 @@ function validateJumphostServer(server, i) {
// GET /api/server-configs (список только jumphost серверов) // GET /api/server-configs (список только jumphost серверов)
async function getServerConfigsList(req, res) { async function getServerConfigsList(req, res) {
try { try {
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null); const head = await headMeta(S3_KEY);
const etag = head?.ETag || null; const etag = head?.etag || null;
if (etag) res.set('ETag', String(etag)); if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); 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 (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
if (checkIfNoneMatch(req, res, etag)) return; if (checkIfNoneMatch(req, res, etag)) return;
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })); let fileContent = '[]';
const fileContent = await streamToString(data.Body); try {
const data = await readS3TextObject(S3_KEY);
fileContent = data.body;
} catch (e) {
if (e?.code !== 'NoSuchKey') throw e;
}
let allServers = []; let allServers = [];
try { try {
@@ -113,12 +116,10 @@ async function postServerConfigsList(req, res) {
// Читаем текущие данные // Читаем текущие данные
let allServers = []; let allServers = [];
try { try {
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })); const data = await readS3TextObject(S3_KEY);
const fileContent = await streamToString(data.Body); allServers = JSON.parse(data.body);
allServers = JSON.parse(fileContent);
if (!Array.isArray(allServers)) allServers = []; if (!Array.isArray(allServers)) allServers = [];
} catch (e) { } catch (e) {
// Файл не существует или пустой - это нормально
allServers = []; allServers = [];
} }
+22 -29
View File
@@ -3,8 +3,7 @@
* Для jumphost и home (входной роутер): mikrotikHost, mikrotikPort, mikrotikUser, encryptedMikrotikPassword * Для jumphost и home (входной роутер): mikrotikHost, mikrotikPort, mikrotikUser, encryptedMikrotikPassword
*/ */
const { GetObjectCommand, PutObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3'); const { readS3TextObject, writeS3JsonObject, headMeta } = require('../services/s3Service');
const { s3, BUCKET_NAME, streamToString } = require('../services/s3Service');
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler'); const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
const { encrypt, decrypt } = require('../utils/encryption'); const { encrypt, decrypt } = require('../utils/encryption');
@@ -28,7 +27,7 @@ function validateServer(server, i) {
if (!Array.isArray(server.gateways) || server.gateways.length === 0) { if (!Array.isArray(server.gateways) || server.gateways.length === 0) {
return `Server at index ${i} must have gateways for type ${normalizedType}`; 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) { if (primaries.length !== 1) {
return `Server at index ${i} must have exactly one primary gateway`; return `Server at index ${i} must have exactly one primary gateway`;
} }
@@ -45,24 +44,17 @@ function validateServer(server, i) {
async function readServersFromS3() { async function readServersFromS3() {
try { try {
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })); const data = await readS3TextObject(S3_KEY);
const body = await streamToString(data.Body); const parsed = JSON.parse(data.body || '[]');
const parsed = JSON.parse(body || '[]');
return Array.isArray(parsed) ? parsed : []; return Array.isArray(parsed) ? parsed : [];
} catch (e) { } catch (e) {
if (e?.name === 'NoSuchKey' || e?.$metadata?.httpStatusCode === 404) return []; if (e?.code === 'NoSuchKey') return [];
throw e; throw e;
} }
} }
async function writeServersToS3(servers) { async function writeServersToS3(servers) {
const body = JSON.stringify(servers, null, 2); await writeS3JsonObject(S3_KEY, servers);
await s3.send(new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: S3_KEY,
Body: body,
ContentType: 'application/json',
}));
} }
/** /**
@@ -70,15 +62,15 @@ async function writeServersToS3(servers) {
*/ */
async function getServers(req, res) { async function getServers(req, res) {
try { try {
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null); const head = await headMeta(S3_KEY);
const etag = head?.ETag || null; const etag = head?.etag || null;
if (etag) res.set('ETag', String(etag)); if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); 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 (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
if (checkIfNoneMatch(req, res, etag)) return; if (checkIfNoneMatch(req, res, etag)) return;
const servers = await readServersFromS3(); const servers = await readServersFromS3();
const sanitized = servers.map(s => { const sanitized = servers.map((s) => {
const out = { ...s }; const out = { ...s };
const hasMikrotikApi = s.type === 'jumphost' || s.type === 'home'; const hasMikrotikApi = s.type === 'jumphost' || s.type === 'home';
if (hasMikrotikApi && out.encryptedMikrotikPassword) { if (hasMikrotikApi && out.encryptedMikrotikPassword) {
@@ -91,11 +83,11 @@ async function getServers(req, res) {
}); });
res.json(sanitized); res.json(sanitized);
} catch (error) { } catch (error) {
if (error?.name === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) { if (error?.code === 'NoSuchKey') {
return res.json([]); return res.json([]);
} }
console.error(error); 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 { try {
const currentServers = await readServersFromS3(); const currentServers = await readServersFromS3();
const findCurrent = (srv) => currentServers.find(c => const findCurrent = (srv) =>
(srv.id && c.id === srv.id) || (srv.dns && c.dns === srv.dns) || (srv.ip && c.ip === srv.ip) 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 toSave = items.map((srv) => {
const current = findCurrent(srv); const current = findCurrent(srv);
@@ -140,15 +133,15 @@ async function postServers(req, res) {
}); });
await writeServersToS3(toSave); 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, { return sendOk(res, {
etag: head?.ETag || null, etag: meta?.etag || null,
lastModified: head?.LastModified ? head.LastModified.toISOString() : null, lastModified: meta?.lastModified || null,
contentLength: typeof head?.ContentLength === 'number' ? head.ContentLength : null, contentLength: typeof meta?.contentLength === 'number' ? meta.contentLength : null,
}); });
} catch (error) { } catch (error) {
console.error(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');
} }
} }
+26 -61
View File
@@ -1,14 +1,10 @@
/** /**
* Общие роуты для текстовых данных (domains, asns, ip-ranges) * Общие роуты для текстовых данных (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 { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
const { splitWhitespace } = require('../utils/helpers'); 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 запросов // Кэш для countOnly запросов
const countOnlyCache = { map: new Map(), ttlMs: 10_000 }; const countOnlyCache = { map: new Map(), ttlMs: 10_000 };
@@ -27,13 +23,6 @@ function setCountOnlyCache(cacheKey, value) {
countOnlyCache.map.set(cacheKey, { value, at: Date.now() }); 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) { function createTextDataGET(s3Key, mapLine, validate, cachePrefix) {
return async (req, res) => { return async (req, res) => {
const { q = '', offset, limit, countOnly, format } = req.query || {}; const { q = '', offset, limit, countOnly, format } = req.query || {};
@@ -54,23 +43,31 @@ function createTextDataGET(s3Key, mapLine, validate, cachePrefix) {
q, q,
offset: Number(offset) || 0, offset: Number(offset) || 0,
limit: Number(limit) || 0, limit: Number(limit) || 0,
mapLine mapLine,
}); });
if (!validate(items)) { if (!validate(items)) {
return sendError(res, 500, 'Invalid data format', 'E_SCHEMA'); return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
} }
return res.json(format === 'std' ? { items, total, meta: {} } : { items, total }); return res.json(format === 'std' ? { items, total, meta: {} } : { items, total });
} else { } else {
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null); const head = await headMeta(s3Key);
const etag = head?.ETag || null; const etag = head?.etag || null;
if (etag) res.set('ETag', String(etag)); if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); 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 (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
if (checkIfNoneMatch(req, res, etag)) return; if (checkIfNoneMatch(req, res, etag)) return;
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })); let fileContent;
const fileContent = await streamToString(data.Body); try {
const items = fileContent.split('\n').filter(line => line).map(line => mapLine(line.trim())); 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)) { if (!validate(items)) {
return sendError(res, 500, 'Invalid data format', 'E_SCHEMA'); return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
@@ -85,24 +82,16 @@ function createTextDataGET(s3Key, mapLine, validate, cachePrefix) {
res.json([]); res.json([]);
} else { } else {
console.error(error); 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) { function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) {
return async (req, res) => { return async (req, res) => {
const { domains, ipRanges, items, etag } = req.body; // 'domains' основной ключ, но поддерживаем и альтернативные const { domains, ipRanges, items, etag } = req.body;
// Поддержка обратной совместимости: принимаем domains | ipRanges | items
const payload = Array.isArray(domains) const payload = Array.isArray(domains)
? domains ? domains
: Array.isArray(ipRanges) : Array.isArray(ipRanges)
@@ -115,12 +104,10 @@ function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) {
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST'); return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
} }
// Валидация с помощью AJV
if (!validate(payload || [])) { if (!validate(payload || [])) {
return sendError(res, 400, 'Invalid payload format', 'E_SCHEMA'); return sendError(res, 400, 'Invalid payload format', 'E_SCHEMA');
} }
// Дополнительная валидация элементов, если предоставлена
if (validateItem) { if (validateItem) {
const validationErrors = []; const validationErrors = [];
for (let i = 0; i < payload.length; i++) { for (let i = 0; i < payload.length; i++) {
@@ -132,7 +119,7 @@ function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) {
if (validationErrors.length > 0) { if (validationErrors.length > 0) {
return sendError(res, 400, 'Validation errors', 'E_VALIDATION', { return sendError(res, 400, 'Validation errors', 'E_VALIDATION', {
errors: validationErrors.slice(0, 10) errors: validationErrors.slice(0, 10),
}); });
} }
} }
@@ -140,51 +127,30 @@ function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) {
const fileContent = (payload || []).map(formatLine).filter(Boolean).join('\n'); const fileContent = (payload || []).map(formatLine).filter(Boolean).join('\n');
try { try {
// Проверка ETag для optimistic concurrency
let current = null; 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 { try {
current = await headS3ObjectEtag(s3Key); current = await headS3ObjectEtag(s3Key);
} catch {} } catch (_) {}
if (current && (ifMatch || etag) && current !== String(ifMatch || etag)) { if (current && (ifMatch || etag) && current !== String(ifMatch || etag)) {
const { headMeta } = require('../services/s3Service'); return sendError(res, 412, 'ETag mismatch', 'E_ETAG_MISMATCH');
const meta = await headMeta(s3Key);
return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', {
currentEtag: current,
meta
});
} }
} catch {}
try {
const meta = await writeS3TextObject(s3Key, fileContent); const meta = await writeS3TextObject(s3Key, fileContent);
return sendOk(res, meta); return sendOk(res, meta);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return sendError(res, 500, 'Error writing to S3', 'E_S3', { return sendError(res, 500, 'Error writing storage', 'E_STORAGE');
error: String(error?.message || error)
});
} }
}; };
} }
/**
* Создать роуты для текстовых данных (GET + POST)
*/
function createTextDataRoutes(config) { function createTextDataRoutes(config) {
const { const { s3Key, mapLine, formatLine, validate, validateItem, cachePrefix } = config;
s3Key,
mapLine,
formatLine,
validate,
validateItem,
cachePrefix
} = config;
return { return {
get: createTextDataGET(s3Key, mapLine, validate, cachePrefix), 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, createTextDataGET,
createTextDataPOST, createTextDataPOST,
}; };
+128 -95
View File
@@ -15,9 +15,16 @@ const crypto = require('crypto');
// Импорт модулей // Импорт модулей
const { sendError, sendOk, errorHandler } = require('./middleware/errorHandler'); const { sendError, sendOk, errorHandler } = require('./middleware/errorHandler');
const { getLockStatus, acquireLock, releaseLock } = require('./middleware/lockManager'); const { getLockStatus, acquireLock, releaseLock } = require('./middleware/lockManager');
const { createTextDataRoutes } = require('./routes/textDataRoutes');
const { createJsonDataRoutes } = require('./routes/jsonDataRoutes'); 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 validators = require('./lib/validators');
const mikrotikValidator = require('./lib/mikrotik-validator'); const mikrotikValidator = require('./lib/mikrotik-validator');
@@ -31,11 +38,14 @@ const mikrotikConfigRoutes = require('./routes/mikrotikConfigRoutes');
const mikrotikBackupRoutes = require('./routes/mikrotikBackupRoutes'); const mikrotikBackupRoutes = require('./routes/mikrotikBackupRoutes');
const trafficRoutes = require('./routes/trafficRoutes'); const trafficRoutes = require('./routes/trafficRoutes');
const resourceStatsRoutes = require('./routes/resourceStatsRoutes'); const resourceStatsRoutes = require('./routes/resourceStatsRoutes');
const alertsRoutes = require('./routes/alertsRoutes');
const routeOptimizerRoutes = require('./routes/routeOptimizerRoutes');
const { initMikrotikBackupScheduler } = require('./services/mikrotikBackupScheduler'); const { initMikrotikBackupScheduler } = require('./services/mikrotikBackupScheduler');
const { initNetworkMapScheduler } = require('./services/networkMapScheduler'); const { initNetworkMapScheduler } = require('./services/networkMapScheduler');
const { initPingServicesScheduler } = require('./services/pingServicesScheduler'); const { initPingServicesScheduler } = require('./services/pingServicesScheduler');
const { initUptimeMonitorScheduler } = require('./services/uptimeMonitorScheduler'); const { initUptimeMonitorScheduler } = require('./services/uptimeMonitorScheduler');
const schedulerRoutes = require('./routes/schedulerRoutes'); const schedulerRoutes = require('./routes/schedulerRoutes');
const { createEvobgpProxy, normalizeEvobgpBaseUrl } = require('./routes/evobgpProxyRoutes');
const app = express(); const app = express();
const port = Number(process.env.PORT) || 3001; 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 === // === Cache TTL ===
const DEFAULT_CACHE_TTL = Math.max(0, Math.min(300, Number(process.env.CACHE_TTL_SECONDS) || 30)); const DEFAULT_CACHE_TTL = Math.max(0, Math.min(300, Number(process.env.CACHE_TTL_SECONDS) || 30));
app.use((req, res, next) => { app.use((req, res, next) => {
@@ -211,99 +238,100 @@ const validateDomainsNew = ajv.compile(schemaDomainsNew);
const validateAsns = ajv.compile(schemaAsns); const validateAsns = ajv.compile(schemaAsns);
const validateIpRanges = ajv.compile(schemaIpRanges); const validateIpRanges = ajv.compile(schemaIpRanges);
// === Domains (старая версия) === // === Domains (старая версия, поля domain+type — тот же источник, что ASNs) ===
const domainsOldRoutes = createTextDataRoutes({ const asnValidateItem = (asn, i) => {
s3Key: 'bgp_data/domains.txt', const errors = [];
mapLine: (line) => { if (!validators.isValidASN(asn.domain)) {
const parts = splitWhitespace(line); errors.push(`Элемент ${i}: неверный ASN "${asn.domain}"`);
return { domain: parts[0] || '', type: parts[1] || '' }; }
}, if (!validators.isValidCommunity(asn.type)) {
formatLine: (d) => `${String(d.domain || '').trim()} ${String(d.type || '').trim()}`.trim(), errors.push(`Элемент ${i}: неверный community "${asn.type}"`);
validate: validateAsns, }
cachePrefix: 'domains' return errors;
}); };
const domainsOldRoutes = {
get: createEvobgpLegacyDomainsGET({ validate: validateAsns }),
post: createEvobgpAsnsPOST({ validate: validateAsns, validateItem: asnValidateItem }),
};
app.get('/api/domains', domainsOldRoutes.get); app.get('/api/domains', domainsOldRoutes.get);
app.post('/api/domains', domainsOldRoutes.post); app.post('/api/domains', writeLimiter, domainsOldRoutes.post);
// === ASNs === // === ASNs (EvoBGP) ===
const asnsRoutes = createTextDataRoutes({ const asnsRoutes = {
s3Key: 'bgp_data/asns.txt', get: createEvobgpListGET({
mapLine: (line) => { cachePrefix: 'asns',
const parts = splitWhitespace(line); validate: validateAsns,
return { domain: parts[0] || '', type: parts[1] || '' }; fetchItems: async () => {
}, const rows = await evobgpClient.fetchAsnsData();
formatLine: (a) => `${String(a.domain || '').trim().toUpperCase()} ${String(a.type || '').trim()}`.trim(), return rows.map((r) => ({ domain: r.asn, type: r.community }));
validate: validateAsns, },
validateItem: (asn, i) => { }),
const errors = []; post: createEvobgpAsnsPOST({ validate: validateAsns, validateItem: asnValidateItem }),
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'
});
app.get('/api/asns', asnsRoutes.get); app.get('/api/asns', asnsRoutes.get);
app.post('/api/asns', writeLimiter, asnsRoutes.post); app.post('/api/asns', writeLimiter, asnsRoutes.post);
// === Domains New === // === Domains New (EvoBGP) ===
const domainsNewRoutes = createTextDataRoutes({ const domainsNewRoutes = {
s3Key: 'bgp_data/domains_community.txt', get: createEvobgpListGET({
mapLine: (line) => { cachePrefix: 'domains-new',
const parts = splitWhitespace(line); validate: validateDomainsNew,
return { domain: parts[0] || '', community: parts[1] || '' }; fetchItems: async () => {
}, const rows = await evobgpClient.fetchDomainsData();
formatLine: (d) => `${String(d.domain || '').trim().toLowerCase()} ${String(d.community || '').trim()}`.trim(), return rows.map(({ domain, community }) => ({ domain, community }));
validate: validateDomainsNew, },
validateItem: (d, i) => { }),
const errors = []; post: createEvobgpDomainsNewPOST({
if (!validators.isValidDomain(d.domain) && !validators.isValidWildcardDomain(d.domain)) { validate: validateDomainsNew,
errors.push(`Элемент ${i}: неверный домен "${d.domain}"`); validateItem: (d, i) => {
} const errors = [];
if (!validators.isValidCommunity(d.community)) { if (!validators.isValidDomain(d.domain) && !validators.isValidWildcardDomain(d.domain)) {
errors.push(`Элемент ${i}: неверный community "${d.community}"`); errors.push(`Элемент ${i}: неверный домен "${d.domain}"`);
} }
if (!validators.isSafeXSSString(d.domain)) { if (!validators.isValidCommunity(d.community)) {
errors.push(`Элемент ${i}: домен содержит потенциально опасные символы`); errors.push(`Элемент ${i}: неверный community "${d.community}"`);
} }
return errors; if (!validators.isSafeXSSString(d.domain)) {
}, errors.push(`Элемент ${i}: домен содержит потенциально опасные символы`);
cachePrefix: 'domains-new' }
}); return errors;
},
}),
};
app.get('/api/domains-new', domainsNewRoutes.get); app.get('/api/domains-new', domainsNewRoutes.get);
app.post('/api/domains-new', writeLimiter, domainsNewRoutes.post); app.post('/api/domains-new', writeLimiter, domainsNewRoutes.post);
// === IP Ranges === // === IP Ranges (EvoBGP) ===
const ipRangesRoutes = createTextDataRoutes({ const ipRangesRoutes = {
s3Key: 'bgp_data/ips.txt', get: createEvobgpListGET({
mapLine: (line) => { cachePrefix: 'ip-ranges',
const parts = splitWhitespace(line); validate: validateIpRanges,
return { ipRange: parts[0] || '', community: parts[1] || '' }; fetchItems: async () => {
}, const rows = await evobgpClient.fetchIpRangesData();
formatLine: (ip) => `${String(ip.ipRange || '').trim()} ${String(ip.community || '').trim()}`.trim(), return rows.map(({ ipRange, community }) => ({ ipRange, community }));
validate: validateIpRanges, },
validateItem: (ip, i) => { }),
const errors = []; post: createEvobgpIpRangesPOST({
const ipStr = String(ip.ipRange || '').trim(); validate: validateIpRanges,
const isValidCIDR = validators.isValidCIDRv4(ipStr) || validators.isValidCIDRv6(ipStr); validateItem: (ip, i) => {
const isValidIP = validators.isValidIPv4(ipStr) || validators.isValidIPv6(ipStr); const errors = [];
const ipStr = String(ip.ipRange || '').trim();
if (!isValidCIDR && !isValidIP) { const isValidCIDR = validators.isValidCIDRv4(ipStr) || validators.isValidCIDRv6(ipStr);
errors.push(`Элемент ${i}: неверный IP/CIDR "${ipStr}"`); const isValidIP = validators.isValidIPv4(ipStr) || validators.isValidIPv6(ipStr);
} if (!isValidCIDR && !isValidIP) {
if (!validators.isValidCommunity(ip.community)) { errors.push(`Элемент ${i}: неверный IP/CIDR "${ipStr}"`);
errors.push(`Элемент ${i}: неверный community "${ip.community}"`); }
} if (!validators.isValidCommunity(ip.community)) {
return errors; errors.push(`Элемент ${i}: неверный community "${ip.community}"`);
}, }
cachePrefix: 'ip-ranges' return errors;
}); },
}),
};
app.get('/api/ip-ranges', ipRangesRoutes.get); app.get('/api/ip-ranges', ipRangesRoutes.get);
app.post('/api/ip-ranges', writeLimiter, ipRangesRoutes.post); 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.get('/api/billing', billingRoutes.get);
app.post('/api/billing', billingRoutes.post); app.post('/api/billing', billingRoutes.post);
// Filters // Filters (SQL app_filter_rules, scope global)
const filtersRoutes2 = createJsonDataRoutes('filters.json', (filter, i) => { const filterItemValidator = (filter, i) => {
if (!filter.community || !filter.gateway) { if (!filter.community || !filter.gateway) {
return `Filter at index ${i} is missing required fields`; return `Filter at index ${i} is missing required fields`;
} }
return null; return null;
}); };
const filtersRoutes2 = createFilterRulesRoutes('global', filterItemValidator);
app.get('/api/filters', filtersRoutes2.get); app.get('/api/filters', filtersRoutes2.get);
app.post('/api/filters', filtersRoutes2.post); app.post('/api/filters', filtersRoutes2.post);
// Simple Filters // Simple Filters (app_filter_rules, scope simple)
const simpleFiltersRoutes = createJsonDataRoutes('filter-manager/simple-filters.json', (filter, i) => { const simpleFiltersRoutes = createFilterRulesRoutes('simple', filterItemValidator);
if (!filter.community || !filter.gateway) {
return `Filter at index ${i} is missing required fields`;
}
return null;
});
app.get('/api/simple-filters', simpleFiltersRoutes.get); app.get('/api/simple-filters', simpleFiltersRoutes.get);
app.post('/api/simple-filters', simpleFiltersRoutes.post); 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/apply', mikrotikConfigRoutes.applyMikrotikConfig);
app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript); app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript);
app.get('/api/mikrotik/address-lists', mikrotikConfigRoutes.getAddressLists); 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); app.post('/api/mikrotik/address-lists/apply-summary', writeLimiter, mikrotikConfigRoutes.applyAddressListSummary);
// === UPTIME MONITOR (проверка доступности: http / internal-ping / external-ping из настроек) === // === UPTIME MONITOR (проверка доступности: http / internal-ping / external-ping из настроек) ===
@@ -483,7 +510,10 @@ app.get('/api/traffic/interface-stats', trafficRoutes.getInterfaceStats);
// === RESOURCE STATS (RAM/CPU/HDD по роутерам) === // === RESOURCE STATS (RAM/CPU/HDD по роутерам) ===
app.get('/api/resources/stats', resourceStatsRoutes.getResourceStats); 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.post('/api/mikrotik/backups', writeLimiter, mikrotikBackupRoutes.createBackup);
app.get('/api/mikrotik/backups', mikrotikBackupRoutes.listBackups); app.get('/api/mikrotik/backups', mikrotikBackupRoutes.listBackups);
app.get('/api/mikrotik/backups/item', mikrotikBackupRoutes.getBackup); app.get('/api/mikrotik/backups/item', mikrotikBackupRoutes.getBackup);
@@ -493,6 +523,9 @@ app.post('/api/mikrotik/backups/run', writeLimiter, mikrotikBackupRoutes.runBack
// === NETWORK MAP CACHE (для быстрой загрузки карты сети) === // === NETWORK MAP CACHE (для быстрой загрузки карты сети) ===
app.get('/api/network-map-cache', schedulerRoutes.getNetworkMapCache); app.get('/api/network-map-cache', schedulerRoutes.getNetworkMapCache);
// === LOCAL AI ROUTE OPTIMIZER (rule-based) ===
app.get('/api/route-optimizer', routeOptimizerRoutes.getRouteOptimizer);
// === SCHEDULER (карта сети: пинг и скорость по расписанию) === // === SCHEDULER (карта сети: пинг и скорость по расписанию) ===
app.get('/api/scheduler/network-map/settings', schedulerRoutes.getNetworkMapSchedulerSettings); app.get('/api/scheduler/network-map/settings', schedulerRoutes.getNetworkMapSchedulerSettings);
app.patch('/api/scheduler/network-map/settings', writeLimiter, schedulerRoutes.patchNetworkMapSchedulerSettings); 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: * Работает внутри Node-процесса backend:
* - периодически обходит jumphost-сервера из servers.json * - периодически обходит jumphost-сервера из servers.json
* - вызывает RouterOS REST API /rest/export (compact) * - вызывает RouterOS REST API /rest/export (compact)
* - сохраняет конфиг в S3 через saveBackupForServer * - сохраняет конфиг на диск через saveBackupForServer
* *
* Управляется через переменные окружения: * Управляется через переменные окружения:
* - MIKROTIK_BACKUP_ENABLED=true|false (по умолчанию true) * - MIKROTIK_BACKUP_ENABLED=true|false (по умолчанию true)
@@ -131,7 +131,7 @@ async function runBackupOnce(logger) {
log.info( log.info(
{ component: 'mikrotik-backup', serverId: id, key: result.key }, { component: 'mikrotik-backup', serverId: id, key: result.key },
'Automatic backup saved to S3', 'Automatic backup saved to disk',
); );
} catch (err) { } catch (err) {
log.error( 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 UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json'; const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json';
const NEXT_RUN_AT_KEY = 'networkMapSchedulerNextRunAt';
const DEFAULT_INTERVAL_MINUTES = 5; const DEFAULT_INTERVAL_MINUTES = 5;
const DEFAULT_LOG_MAX_LINES = 500; const DEFAULT_LOG_MAX_LINES = 500;
const RESTART_RECOVERY_DELAY_MS = 10 * 60 * 1000;
/** Кольцевой буфер логов */ /** Кольцевой буфер логов */
class LogBuffer { class LogBuffer {
@@ -329,16 +331,34 @@ async function writeCache(pingMap, speedMap) {
} }
let schedulerTimer = null; let schedulerTimer = null;
let schedulerStartTimeout = null;
/** Время последнего запуска по расписанию (для расчёта следующего) */ /** Время последнего запуска по расписанию (для расчёта следующего) */
let lastScheduledRunAt = 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) { function getSettingsFromUiSettings(uiSettings) {
const persistedNextRunAtRaw = uiSettings?.[NEXT_RUN_AT_KEY];
const persistedNextRunAt = Number(persistedNextRunAtRaw);
return { return {
enabled: Boolean(uiSettings.networkMapSchedulerEnabled), enabled: Boolean(uiSettings.networkMapSchedulerEnabled),
intervalMinutes: Math.max(1, Math.min(1440, parseInt(uiSettings.networkMapSchedulerIntervalMinutes, 10) || DEFAULT_INTERVAL_MINUTES)), intervalMinutes: Math.max(1, Math.min(1440, parseInt(uiSettings.networkMapSchedulerIntervalMinutes, 10) || DEFAULT_INTERVAL_MINUTES)),
runPing: uiSettings.networkMapSchedulerRunPing !== false, runPing: uiSettings.networkMapSchedulerRunPing !== false,
runSpeedTest: uiSettings.networkMapSchedulerRunSpeedTest !== false, runSpeedTest: uiSettings.networkMapSchedulerRunSpeedTest !== false,
logMaxLines: Math.max(100, Math.min(2000, parseInt(uiSettings.networkMapSchedulerLogMaxLines, 10) || DEFAULT_LOG_MAX_LINES)), 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 log = logger || console;
const baseUrl = `http://127.0.0.1:${port}`; const baseUrl = `http://127.0.0.1:${port}`;
async function tick() { async function tick(source = 'interval') {
lastScheduledRunAt = Date.now(); if (source === 'interval') {
lastScheduledRunAt = Date.now();
if (activeIntervalMs) {
nextScheduledRunAt = lastScheduledRunAt + activeIntervalMs;
persistNextRunAt(nextScheduledRunAt);
}
}
let uiSettings; let uiSettings;
try { try {
const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' })); const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
@@ -369,6 +395,7 @@ function initNetworkMapScheduler(logger, port) {
baseUrl, baseUrl,
runPing: settings.runPing, runPing: settings.runPing,
runSpeedTest: settings.runSpeedTest, runSpeedTest: settings.runSpeedTest,
trigger: source === 'startup-delay' ? 'startup-delay' : 'scheduled',
log: (msg) => { log: (msg) => {
logBuffer.push(msg); logBuffer.push(msg);
log.info({ component: 'network-map-scheduler' }, msg); log.info({ component: 'network-map-scheduler' }, msg);
@@ -389,29 +416,62 @@ function initNetworkMapScheduler(logger, port) {
} }
const settings = getSettingsFromUiSettings(uiSettings); const settings = getSettingsFromUiSettings(uiSettings);
if (!settings.enabled) { if (!settings.enabled) {
activeIntervalMs = null;
nextScheduledRunAt = null;
persistNextRunAt(null);
log.info({ component: 'network-map-scheduler' }, 'Scheduler disabled in settings'); log.info({ component: 'network-map-scheduler' }, 'Scheduler disabled in settings');
return; return;
} }
logBuffer.setMaxLines(settings.logMaxLines); logBuffer.setMaxLines(settings.logMaxLines);
const intervalMs = settings.intervalMinutes * 60 * 1000; 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( 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 settings.intervalMinutes
); );
schedulerTimer = setInterval(tick, intervalMs); schedulerStartTimeout = setTimeout(() => {
// Первый запуск через 10 сек, чтобы кеш появился вскоре после старта сервера tick('interval');
setTimeout(() => tick(), 10 * 1000); schedulerTimer = setInterval(() => tick('interval'), intervalMs);
}, firstDelayMs);
} }
schedule(); schedule();
} }
function stopNetworkMapScheduler() { function stopNetworkMapScheduler() {
if (schedulerStartTimeout) {
clearTimeout(schedulerStartTimeout);
schedulerStartTimeout = null;
}
if (schedulerTimer) { if (schedulerTimer) {
clearInterval(schedulerTimer); clearInterval(schedulerTimer);
schedulerTimer = null; schedulerTimer = null;
} }
activeIntervalMs = null;
nextScheduledRunAt = null;
persistNextRunAt(null);
} }
function getLogs() { function getLogs() {
@@ -439,9 +499,19 @@ function pushLog(msg) {
*/ */
function getNextRunAt(settings) { function getNextRunAt(settings) {
if (!settings || !settings.enabled) return null; if (!settings || !settings.enabled) return null;
const intervalMs = (settings.intervalMinutes || DEFAULT_INTERVAL_MINUTES) * 60 * 1000; if (typeof nextScheduledRunAt === 'number' && Number.isFinite(nextScheduledRunAt)) {
const from = lastScheduledRunAt || Date.now(); return nextScheduledRunAt;
return from + intervalMs; }
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 = { 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 { openDatabase } = require('../db/sqliteDb');
const { NodeHttpHandler } = require('@smithy/node-http-handler'); const blobStorage = require('./blobStorage');
const http = require('http');
const https = require('https');
// S3 Client setup openDatabase();
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,
}
});
const BUCKET_NAME = process.env.S3_BUCKET_NAME; const BUCKET_NAME = process.env.SQLITE_BUCKET_LABEL || 'sqlite';
// In-memory cache
const cache = { const cache = {
text: new Map(), text: new Map(),
head: new Map(), head: new Map(),
max: 100, max: 100,
ttlMs: 30_000 ttlMs: 30_000,
}; };
/**
* Конвертирует stream в строку
*/
async function streamToString(stream) { async function streamToString(stream) {
if (!stream) return ''; if (!stream) return '';
if (typeof stream.transformToString === 'function') { if (typeof stream.transformToString === 'function') {
return await stream.transformToString(); return await stream.transformToString();
} }
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
let chunks = []; const chunks = [];
stream.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(String(c)))); stream.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(String(c))));
stream.once('error', reject); stream.once('error', reject);
stream.once('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); stream.once('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
}); });
} }
/**
* Получить значение из кэша
*/
function getCache(map, key) { function getCache(map, key) {
const v = map.get(key); const v = map.get(key);
if (!v) return null; if (!v) return null;
@@ -63,9 +40,6 @@ function getCache(map, key) {
return v.value; return v.value;
} }
/**
* Установить значение в кэш
*/
function setCache(map, key, value) { function setCache(map, key, value) {
if (map.size >= cache.max) { if (map.size >= cache.max) {
const firstKey = map.keys().next().value; const firstKey = map.keys().next().value;
@@ -74,213 +48,115 @@ function setCache(map, key, value) {
map.set(key, { value, at: Date.now() }); map.set(key, { value, at: Date.now() });
} }
/**
* Инвалидировать кэш для ключа
*/
function invalidateCacheForKey(key) { 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 { try {
const h = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: key })); cache.text.delete(key);
return { } catch (_) {}
etag: h.ETag || null, try {
lastModified: h.LastModified ? new Date(h.LastModified).toISOString() : null, cache.head.delete(key);
contentLength: typeof h.ContentLength === 'number' ? h.ContentLength : null, } catch (_) {}
}; }
} catch (e) {
return { etag: null, lastModified: null, contentLength: null }; async function readS3TextObject(key) {
const cached = getCache(cache.text, key);
if (cached) return cached;
const row = blobStorage.readBlob(key);
if (!row) {
const err = new Error('NoSuchKey');
err.code = 'NoSuchKey';
throw err;
} }
} const out = {
body: row.body,
/** etag: row.etag,
* Записать текстовый объект в S3 lastModified: row.lastModified,
*/ contentLength: row.contentLength,
async function writeS3TextObject(key, content, contentType = 'text/plain') { };
await s3.send(new PutObjectCommand({ setCache(cache.text, key, out);
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;
}
return out; return out;
} }
/** async function headS3ObjectEtag(key) {
* Потоковое чтение с пагинацией больших текстовых файлов const cached = getCache(cache.head, key);
*/ if (cached && cached.etag) return cached.etag;
async function streamPaginatedText({ key, mapLine, q, offset = 0, limit = 0 }) { const etag = blobStorage.headEtag(key);
return new Promise(async (resolve, reject) => { if (etag) setCache(cache.head, key, { etag });
let total = 0; return etag;
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 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 = { module.exports = {
s3, s3,
BUCKET_NAME, BUCKET_NAME,
@@ -294,5 +170,6 @@ module.exports = {
streamPaginatedText, streamPaginatedText,
invalidateCacheForKey, invalidateCacheForKey,
listS3Objects, 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) { function resourceToKey(resource) {
switch (resource) { switch (resource) {
case 'domains-new': return 'bgp_data/domains_community.txt'; case 'servers':
case 'ip-ranges': return 'bgp_data/ips.txt'; return 'servers.json';
case 'asns': return 'bgp_data/asns.txt'; default:
default: return null; return null;
} }
} }
+36 -50
View File
@@ -1,41 +1,36 @@
/** /**
* Универсальные хелперы для работы с S3 * Универсальные хелперы для JSON/текста из локального storage (SQLite)
* Устраняют дублирование кода в routes
*/ */
const { s3, BUCKET_NAME } = require('../services/s3Service'); const { headMeta, readS3TextObject } = require('../services/s3Service');
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
const { streamToString } = require('../services/s3Service');
const { sendError, checkIfNoneMatch } = require('../middleware/errorHandler'); const { sendError, checkIfNoneMatch } = require('../middleware/errorHandler');
/** /**
* Универсальный GET для JSON объектов из S3 с заголовками * Универсальный GET для JSON объектов с заголовками
* @param {string} s3Key - Ключ S3
* @param {object} req - Express request
* @param {object} res - Express response
* @param {object} options - Опции
* @param {function} options.transform - Функция трансформации данных
* @param {*} options.defaultValue - Значение по умолчанию при отсутствии файла
*/ */
async function getS3JsonWithHeaders(s3Key, req, res, options = {}) { async function getS3JsonWithHeaders(s3Key, req, res, options = {}) {
const { transform = (data) => data, defaultValue = [] } = options; const { transform = (data) => data, defaultValue = [] } = options;
try { try {
// Получаем HEAD для установки заголовков const head = await headMeta(s3Key);
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null); const etag = head?.etag || null;
const etag = head?.ETag || null;
// Устанавливаем заголовки
if (etag) res.set('ETag', String(etag)); if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); 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 (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
// Проверяем If-None-Match
if (checkIfNoneMatch(req, res, etag)) return; if (checkIfNoneMatch(req, res, etag)) return;
// Получаем объект let fileContent;
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })); try {
const fileContent = await streamToString(data.Body); const data = await readS3TextObject(s3Key);
fileContent = data.body;
} catch (e) {
if (e?.code === 'NoSuchKey') {
return res.json(transform(defaultValue));
}
throw e;
}
let parsed; let parsed;
try { try {
@@ -48,54 +43,46 @@ async function getS3JsonWithHeaders(s3Key, req, res, options = {}) {
parsed = defaultValue; parsed = defaultValue;
} }
// Применяем трансформацию
const result = transform(parsed); const result = transform(parsed);
res.json(result); res.json(result);
} catch (error) { } catch (error) {
if (error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) { console.error(error);
res.json(defaultValue); return sendError(res, 500, 'Error reading storage', 'E_STORAGE');
} else {
console.error(error);
return sendError(res, 500, 'Error reading from S3', 'E_S3');
}
} }
} }
/** /**
* Универсальный GET для текстовых объектов из S3 с заголовками * Универсальный GET для текстовых объектов с заголовками
* @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 - Значение по умолчанию при отсутствии файла
*/ */
async function getS3TextWithHeaders(s3Key, req, res, options = {}) { async function getS3TextWithHeaders(s3Key, req, res, options = {}) {
const { transform = (text) => ({ config: text }), defaultValue = '' } = options; const { transform = (text) => ({ config: text }), defaultValue = '' } = options;
try { try {
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null); const head = await headMeta(s3Key);
const etag = head?.ETag || null; const etag = head?.etag || null;
if (etag) res.set('ETag', String(etag)); if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); 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 (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
if (checkIfNoneMatch(req, res, etag)) return; if (checkIfNoneMatch(req, res, etag)) return;
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })); let text;
const text = await streamToString(data.Body); 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); const result = transform(text);
res.json(result); res.json(result);
} catch (error) { } catch (error) {
if (error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) { console.error(error);
res.json(transform(defaultValue)); return sendError(res, 500, 'Error reading storage', 'E_STORAGE');
} else {
console.error(error);
return sendError(res, 500, 'Error reading from S3', 'E_S3');
}
} }
} }
@@ -103,4 +90,3 @@ module.exports = {
getS3JsonWithHeaders, getS3JsonWithHeaders,
getS3TextWithHeaders, getS3TextWithHeaders,
}; };
+2 -2
View File
@@ -1,4 +1,4 @@
# S3 Lists Manager - Frontend # RouterOS Manager - Frontend
Современный веб-интерфейс для управления списками доменов и AS с использованием Tabler UI. Современный веб-интерфейс для управления списками доменов и 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 Улучшения - Документация
Этот документ описывает все внедренные UX/UI улучшения в проект S3 Lists Manager. Этот документ описывает все внедренные UX/UI улучшения в проект RouterOS Manager.
## 📋 Содержание ## 📋 Содержание
+3 -2
View File
@@ -15,7 +15,7 @@ export default defineConfig([
], ],
languageOptions: { languageOptions: {
ecmaVersion: 2020, ecmaVersion: 2020,
globals: globals.browser, globals: { ...globals.browser, process: 'readonly' },
parserOptions: { parserOptions: {
ecmaVersion: 'latest', ecmaVersion: 'latest',
ecmaFeatures: { jsx: true }, ecmaFeatures: { jsx: true },
@@ -23,7 +23,8 @@ export default defineConfig([
}, },
}, },
rules: { 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" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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" /> <meta name="theme-color" content="#206bc4" />
<!-- Inter Font --> <!-- Inter Font -->
@@ -12,7 +12,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+10
View File
@@ -20,6 +20,7 @@
"diff": "^8.0.3", "diff": "^8.0.3",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"react-router-dom": "^6.30.1", "react-router-dom": "^6.30.1",
"recharts": "^3.7.0" "recharts": "^3.7.0"
}, },
@@ -2798,6 +2799,15 @@
"react": "^19.1.0" "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": { "node_modules/react-is": {
"version": "19.2.4", "version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
+1
View File
@@ -24,6 +24,7 @@
"diff": "^8.0.3", "diff": "^8.0.3",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"react-router-dom": "^6.30.1", "react-router-dom": "^6.30.1",
"recharts": "^3.7.0" "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 api from './lib/api.js';
import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx'; import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx';
import CommunityBadge from './components/CommunityBadge.jsx'; import CommunityBadge from './components/CommunityBadge.jsx';
@@ -43,21 +43,24 @@ import AddItemModal from './components/AddItemModal.jsx';
import FormModal from './components/FormModal.jsx'; import FormModal from './components/FormModal.jsx';
import { getAsnName, getAsnNameSync } from './lib/asn.js'; import { getAsnName, getAsnNameSync } from './lib/asn.js';
import LastSaved from './components/LastSaved.jsx'; import LastSaved from './components/LastSaved.jsx';
import {
const API_URL = '/api'; listCommunities,
fetchAsnsData,
saveAsnsData,
} from './lib/evobgpData.js';
function ASNsNewManager() { function ASNsNewManager() {
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [originalItems, setOriginalItems] = useState([]); const [originalItems, setOriginalItems] = useState([]);
const [etag, setEtag] = useState(''); const [, setEtag] = useState('');
const [lastModified, setLastModified] = useState(''); const [lastModified, setLastModified] = useState('');
const [contentLength, setContentLength] = useState(null); const [, setContentLength] = useState(null);
const [newItem, setNewItem] = useState({ asn: '', community: '' }); 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 [error, setError] = useState('');
const [success, setSuccess] = useState(''); const [success, setSuccess] = useState('');
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const searchTimer = useRef(null); const _searchTimer = useRef(null);
const didInit = useRef(false); const didInit = useRef(false);
const [editingValue, setEditingValue] = useState(''); const [editingValue, setEditingValue] = useState('');
const [editModalOpen, setEditModalOpen] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false);
@@ -80,9 +83,9 @@ function ASNsNewManager() {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
const res = await api.get(`/communities`); const list = await listCommunities();
setCommunities(Array.isArray(res.data) ? res.data : []); setCommunities(list);
} catch (e) { } catch (_e) {
// тихо игнорируем // тихо игнорируем
} }
})(); })();
@@ -112,21 +115,17 @@ function ASNsNewManager() {
return () => controller.abort(); return () => controller.abort();
}, []); }, []);
const fetchItems = async (abortSignal) => { const fetchItems = useCallback(async (_abortSignal) => {
setLoading(true); setLoading(true);
const opts = abortSignal ? { signal: abortSignal } : {};
try { try {
const response = await api.get(`/asns`, { params: { offset: 0, limit: 0, format: 'std' }, ...opts }); const mapped = await fetchAsnsData();
const payload = Array.isArray(response.data?.items) ? response.data.items : [];
const mapped = payload.map(item => ({ asn: String(item.domain), community: String(item.type) }));
const total = mapped.length; const total = mapped.length;
setItems(mapped); setItems(mapped);
setOriginalItems(mapped); setOriginalItems(mapped);
setTotalItems(Number.isFinite(total) ? total : 0); setTotalItems(Number.isFinite(total) ? total : 0);
setEtag(response.headers?.etag || ''); setEtag('');
setLastModified(response.headers?.['last-modified'] || ''); setLastModified(new Date().toISOString());
const lengthHeader = response.headers?.['content-length-source']; setContentLength(null);
setContentLength(typeof lengthHeader !== 'undefined' ? Number(lengthHeader) : null);
setError(''); setError('');
} catch (error) { } catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return; if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
@@ -135,7 +134,7 @@ function ASNsNewManager() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, []);
useEffect(() => { useEffect(() => {
if (!didInit.current) { if (!didInit.current) {
@@ -144,7 +143,7 @@ function ASNsNewManager() {
fetchItems(controller.signal); fetchItems(controller.signal);
return () => controller.abort(); return () => controller.abort();
} }
}, []); }, [fetchItems]);
const handleAddItem = (item) => { const handleAddItem = (item) => {
const asn = item?.asn || newItem.asn; const asn = item?.asn || newItem.asn;
@@ -298,7 +297,7 @@ function ASNsNewManager() {
return { added, removed, changed }; return { added, removed, changed };
}; };
const [showDiff, setShowDiff] = useState(false); const [_showDiff, setShowDiff] = useState(false);
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] }); const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false); const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false); const [historyOpen, setHistoryOpen] = useState(false);
@@ -312,7 +311,29 @@ function ASNsNewManager() {
setShowDiff(true); 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) // подготовим diff и спросим подтверждение только при массовых изменениях (>10)
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community)) const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
.map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() })); .map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() }));
@@ -330,49 +351,7 @@ function ASNsNewManager() {
// Малое изменение - сохраняем сразу // Малое изменение - сохраняем сразу
performSave(); performSave();
} }
}; }, [items, originalItems, 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);
}
};
const handleImport = () => { const handleImport = () => {
setImportOpen(true); setImportOpen(true);
@@ -380,10 +359,7 @@ function ASNsNewManager() {
const handleExport = async () => { const handleExport = async () => {
try { try {
const effectiveQ = searchTerm ? searchTerm : (filterCommunity ? filterCommunity : ''); const all = filtered;
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 header = ['asn', 'community']; const header = ['asn', 'community'];
const csv = [header, ...all.map(i => [i.asn, i.community])] const csv = [header, ...all.map(i => [i.asn, i.community])]
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')) .map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
@@ -401,7 +377,7 @@ function ASNsNewManager() {
}; };
// Drag&Drop импорт (быстрый путь) // Drag&Drop импорт (быстрый путь)
const onDropImport = async (e) => { const _onDropImport = async (e) => {
e.preventDefault(); e.preventDefault();
const file = e.dataTransfer?.files?.[0]; const file = e.dataTransfer?.files?.[0];
if (!file) return; if (!file) return;
@@ -469,6 +445,7 @@ function ASNsNewManager() {
}); });
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const paginatedItems = filtered.slice((currentPage - 1) * pageSize, (currentPage) * pageSize); const paginatedItems = filtered.slice((currentPage - 1) * pageSize, (currentPage) * pageSize);
const paginatedAsns = paginatedItems.map(i => i?.asn).filter(Boolean).join(',');
// Подгружаем имена ASN из кэша/внешних API (после вычисления paginatedItems) // Подгружаем имена ASN из кэша/внешних API (после вычисления paginatedItems)
useEffect(() => { useEffect(() => {
@@ -482,7 +459,7 @@ function ASNsNewManager() {
const name = await getAsnName(asn); const name = await getAsnName(asn);
if (name !== null) setAsnNameMap(prev => ({ ...prev, [asn]: name })); if (name !== null) setAsnNameMap(prev => ({ ...prev, [asn]: name }));
}); });
}, [JSON.stringify(paginatedItems)]); }, [paginatedAsns, paginatedItems]);
// Для фильтра - список всех уникальных community // Для фильтра - список всех уникальных community
const allCommunities = Array.from(new Set(items.filter(i => i != null).map(i => String(i.community || '')).filter(c => c !== ''))); 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); window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [loading, items, originalItems, etag, sortField, sortOrder, filterCommunity]); }, [loading, handleSaveChanges]);
const handleEditKeyDown = (e) => { const handleEditKeyDown = (e) => {
if (e.key === 'Enter') { 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) */ /* Layout: Tabler Fluid Vertical (https://preview.tabler.io/layout-fluid-vertical.html) */
/* --- Fluid Vertical: page structure --- */ /* --- Fluid Vertical: page structure --- */
@@ -72,6 +72,7 @@
.navbar-vertical .navbar-sidebar-footer { .navbar-vertical .navbar-sidebar-footer {
flex-shrink: 0; flex-shrink: 0;
border-top-color: rgba(255, 255, 255, 0.1) !important; border-top-color: rgba(255, 255, 255, 0.1) !important;
overflow: visible;
} }
.navbar-vertical .navbar-sidebar-footer .nav-link.btn-icon { .navbar-vertical .navbar-sidebar-footer .nav-link.btn-icon {
padding: 0.35rem; padding: 0.35rem;
@@ -92,6 +93,23 @@
.navbar-vertical .navbar-sidebar-footer .dropdown-toggle::after { .navbar-vertical .navbar-sidebar-footer .dropdown-toggle::after {
display: none; 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), не у обычных ссылок и не у бренда */ /* Стрелка только у пунктов с подменю (dropdown-toggle), не у обычных ссылок и не у бренда */
.navbar-vertical .nav-link:not(.dropdown-toggle)::after { .navbar-vertical .nav-link:not(.dropdown-toggle)::after {
display: none !important; display: none !important;
@@ -115,8 +133,9 @@
width: 1.25rem; width: 1.25rem;
height: 1.25rem; height: 1.25rem;
} }
/* Sidebar dropdown: inline submenu (no popup) */ /* Sidebar dropdown: inline submenu (no popup) только для основного списка меню,
.navbar-vertical .nav-item.dropdown .dropdown-menu { не затрагиваем иконки в нижнем футере сайдбара (язык, тема, колокол и т.д.) */
.navbar-vertical .navbar-nav > .nav-item.dropdown .dropdown-menu {
position: static; position: static;
float: none; float: none;
border: none; border: none;
@@ -125,12 +144,12 @@
padding: 0 0 0.5rem 0; padding: 0 0 0.5rem 0;
margin: 0; margin: 0;
} }
.navbar-vertical .nav-item.dropdown .dropdown-item { .navbar-vertical .navbar-nav > .nav-item.dropdown .dropdown-item {
padding-left: 1rem; padding-left: 1rem;
padding-top: 0.35rem; padding-top: 0.35rem;
padding-bottom: 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; width: 1.25rem;
height: 1.25rem; height: 1.25rem;
flex-shrink: 0; flex-shrink: 0;
@@ -138,7 +157,7 @@
align-items: center; align-items: center;
justify-content: 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; width: 1rem;
height: 1rem; height: 1rem;
} }
@@ -347,6 +366,96 @@
padding-left: 0; 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 */ /* Responsive navbar */
@media (max-width: 991.98px) { @media (max-width: 991.98px) {
.navbar-collapse { .navbar-collapse {
@@ -461,7 +570,7 @@
/* Responsive sizing for small screens */ /* Responsive sizing for small screens */
@media (max-width: 992px) { @media (max-width: 992px) {
.header-actions { gap: 0.4rem; } .header-actions { gap: 0.4rem; flex-wrap: wrap; }
.header-actions .btn { .header-actions .btn {
padding: 0.35rem 0.6rem; padding: 0.35rem 0.6rem;
font-size: 0.875rem; font-size: 0.875rem;
@@ -474,6 +583,30 @@
.header-actions { width: 100%; } .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 */ /* Custom responsive adjustments */
@media (max-width: 992px) { @media (max-width: 992px) {
.page-wrapper { .page-wrapper {
@@ -564,6 +697,82 @@
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); 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 для кнопок */ /* Ripple effect для кнопок */
@keyframes ripple { @keyframes ripple {
to { 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 { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { import {
BrowserRouter as Router, BrowserRouter as Router,
@@ -27,7 +27,8 @@ import {
IconChartBar, IconChartBar,
IconClock, IconClock,
IconClockPlay, IconClockPlay,
IconShield IconShield,
IconRoute2
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import ServerManager from './ServerManager'; import ServerManager from './ServerManager';
import FilterManager from './FilterManager'; import FilterManager from './FilterManager';
@@ -51,6 +52,8 @@ import PingServicesManager from './PingServicesManager.jsx';
import FirewallPage from './FirewallPage.jsx'; import FirewallPage from './FirewallPage.jsx';
import SettingsPage from './SettingsPage.jsx'; import SettingsPage from './SettingsPage.jsx';
import ResourceStatsPage from './ResourceStatsPage.jsx'; import ResourceStatsPage from './ResourceStatsPage.jsx';
import RouteOptimizerPage from './RouteOptimizerPage.jsx';
import OspfToolsPage from './OspfToolsPage.jsx';
import './App.css'; import './App.css';
import { NotifyProvider } from './components/NotifyProvider.jsx'; import { NotifyProvider } from './components/NotifyProvider.jsx';
import ToastContainer from './components/ToastContainer.jsx'; import ToastContainer from './components/ToastContainer.jsx';
@@ -58,6 +61,8 @@ import CommandPalette, { KeyboardShortcutsButton } from './components/CommandPal
import ErrorBoundary from './components/ErrorBoundary.jsx'; import ErrorBoundary from './components/ErrorBoundary.jsx';
import NetworkErrorHandler from './components/NetworkErrorHandler.jsx'; import NetworkErrorHandler from './components/NetworkErrorHandler.jsx';
import { PingProvider } from './contexts/PingContext.jsx'; import { PingProvider } from './contexts/PingContext.jsx';
import { AlertsProvider } from './contexts/AlertsContext.jsx';
import AlertsBell from './components/AlertsBell.jsx';
// --- Simple i18n (RU/EN) --- // --- Simple i18n (RU/EN) ---
const LanguageContext = createContext({ lang: 'ru', setLang: () => {}, t: (k) => k }); const LanguageContext = createContext({ lang: 'ru', setLang: () => {}, t: (k) => k });
@@ -69,16 +74,18 @@ function LanguageProvider({ children }) {
ru: { ru: {
home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты', home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты',
dashboard: 'Панель', trafficTraffic: 'Расход трафика', resourceStats: 'Статистика ресурсов', networkMap: 'Карта сети', uptimeMonitor: 'Uptime Monitor', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS', dashboard: 'Панель', trafficTraffic: 'Расход трафика', resourceStats: 'Статистика ресурсов', networkMap: 'Карта сети', uptimeMonitor: 'Uptime Monitor', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
routeOptimizer: 'Оптимизация маршрутов ИИ',
communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL', 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: 'Тёмная', light: 'Светлая', dark: 'Тёмная',
layoutSidebar: 'Сайдбар', layoutHorizontal: 'Верхнее меню' layoutSidebar: 'Сайдбар', layoutHorizontal: 'Верхнее меню'
}, },
en: { en: {
home: 'Home', data: 'Data', management: 'Management', tools: 'Tools', 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', 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', 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', light: 'Light', dark: 'Dark',
layoutSidebar: 'Sidebar', layoutHorizontal: 'Top menu' layoutSidebar: 'Sidebar', layoutHorizontal: 'Top menu'
} }
@@ -124,12 +131,14 @@ function App() {
<LanguageProvider> <LanguageProvider>
<ThemeProvider> <ThemeProvider>
<PingProvider> <PingProvider>
<ToastContainer> <AlertsProvider>
<NotifyProvider> <ToastContainer>
<NetworkErrorHandler /> <NotifyProvider>
<MainLayout /> <NetworkErrorHandler />
</NotifyProvider> <MainLayout />
</ToastContainer> </NotifyProvider>
</ToastContainer>
</AlertsProvider>
</PingProvider> </PingProvider>
</ThemeProvider> </ThemeProvider>
</LanguageProvider> </LanguageProvider>
@@ -142,16 +151,58 @@ function App() {
const LAYOUT_FLUID = 'fluid'; const LAYOUT_FLUID = 'fluid';
const LAYOUT_HORIZONTAL = 'horizontal'; const LAYOUT_HORIZONTAL = 'horizontal';
const SIDEBAR_COLLAPSE_ID = 'sidebar-menu';
const MOBILE_BREAKPOINT = 768;
function MainLayout() { function MainLayout() {
const location = useLocation(); const location = useLocation();
const { lang, setLang, t } = useContext(LanguageContext); const { lang, setLang, t } = useContext(LanguageContext);
const { theme, setTheme } = useContext(ThemeContext); const { theme, setTheme } = useContext(ThemeContext);
const [layout, setLayout] = useState(() => localStorage.getItem('layout') || LAYOUT_FLUID); const [layout, setLayout] = useState(() => localStorage.getItem('layout') || LAYOUT_FLUID);
const [sidebarBackdrop, setSidebarBackdrop] = useState(false);
const sidebarTogglerRef = useRef(null);
useEffect(() => { useEffect(() => {
localStorage.setItem('layout', layout); localStorage.setItem('layout', 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({ const [dropdownStates, setDropdownStates] = useState({
data: false, data: false,
@@ -205,6 +256,7 @@ function MainLayout() {
{ id: 'traffic', title: t('trafficTraffic'), path: '/traffic', icon: IconChartPie }, { id: 'traffic', title: t('trafficTraffic'), path: '/traffic', icon: IconChartPie },
{ id: 'resource-stats', title: t('resourceStats'), path: '/resource-stats', icon: IconChartBar }, { id: 'resource-stats', title: t('resourceStats'), path: '/resource-stats', icon: IconChartBar },
{ id: 'network-map', title: t('networkMap'), path: '/network-map', icon: IconNetwork }, { id: 'network-map', title: t('networkMap'), path: '/network-map', icon: IconNetwork },
{ id: 'route-optimizer', title: t('routeOptimizer'), path: '/route-optimizer', icon: IconRoute2 },
{ id: 'uptime-monitor', title: t('uptimeMonitor'), path: '/uptime-monitor', icon: IconClock } { id: 'uptime-monitor', title: t('uptimeMonitor'), path: '/uptime-monitor', icon: IconClock }
] ]
}, },
@@ -238,6 +290,7 @@ function MainLayout() {
items: [ items: [
{ id: 'auto-urls', title: t('autoUrls'), path: '/auto-urls', icon: IconDownload }, { id: 'auto-urls', title: t('autoUrls'), path: '/auto-urls', icon: IconDownload },
{ id: 'mikrotik-backups', title: t('mikrotikBackups'), path: '/mikrotik-backups', icon: IconDatabase }, { id: '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: 'mikrotik-tools', title: 'MikroTik Инструменты', path: '/mikrotik-tools', icon: IconNetwork },
{ id: 'firewall', title: t('firewall'), path: '/firewall', icon: IconShield }, { id: 'firewall', title: t('firewall'), path: '/firewall', icon: IconShield },
{ id: 'interface-speed', title: 'Скорость интерфейсов', path: '/interface-speed', icon: IconNetwork }, { 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"> <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"> <Link to="/" className="navbar-brand d-flex align-items-center">
<IconBrandTabler className="navbar-brand-icon me-2" /> <IconBrandTabler className="navbar-brand-icon me-2" />
<span className="fw-bold">S3 Lists Manager</span> <span className="fw-bold">RouterOS Manager</span>
</Link> </Link>
</div> </div>
<button <button
@@ -365,12 +418,12 @@ function MainLayout() {
</div> </div>
</div> </div>
<div className="nav-item dropdown me-1"> <div className="nav-item dropdown me-1">
<a href="#" className="nav-link px-2 dropdown-toggle" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()}> <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="badge">{lang.toUpperCase()}</span> <span className="fs-5" aria-hidden>{lang === 'ru' ? '🇷🇺' : '🇺🇸'}</span>
</a> </a>
<div className="dropdown-menu dropdown-menu-end"> <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 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 ${lang === 'en' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('en'); }}>English</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> </div>
<div className="nav-item dropdown me-1"> <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> <a href="#" className={`dropdown-item ${theme === 'dark' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setTheme('dark'); }}>🌙 {t('dark')}</a>
</div> </div>
</div> </div>
<div className="nav-item me-1">
<AlertsBell />
</div>
<div className="nav-item me-1"> <div className="nav-item me-1">
<KeyboardShortcutsButton className="nav-link px-2" /> <KeyboardShortcutsButton className="nav-link px-2" />
</div> </div>
@@ -399,6 +455,7 @@ function MainLayout() {
<Route path="/traffic" element={<TrafficDashboard />} /> <Route path="/traffic" element={<TrafficDashboard />} />
<Route path="/resource-stats" element={<ResourceStatsPage />} /> <Route path="/resource-stats" element={<ResourceStatsPage />} />
<Route path="/network-map" element={<NetworkMapDashboard />} /> <Route path="/network-map" element={<NetworkMapDashboard />} />
<Route path="/route-optimizer" element={<RouteOptimizerPage />} />
<Route path="/uptime-monitor" element={<UptimeMonitorPage />} /> <Route path="/uptime-monitor" element={<UptimeMonitorPage />} />
<Route path="/domains" element={<DomainsNewManager />} /> <Route path="/domains" element={<DomainsNewManager />} />
<Route path="/ip-ranges" element={<IPRangesManager />} /> <Route path="/ip-ranges" element={<IPRangesManager />} />
@@ -411,6 +468,7 @@ function MainLayout() {
<Route path="/network-config" element={<NetworkConfigManager />} /> <Route path="/network-config" element={<NetworkConfigManager />} />
<Route path="/easy-switch" element={<EasySwitchManager />} /> <Route path="/easy-switch" element={<EasySwitchManager />} />
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} /> <Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
<Route path="/ospf-tools" element={<OspfToolsPage />} />
<Route path="/mikrotik-tools" element={<MikrotikTools />} /> <Route path="/mikrotik-tools" element={<MikrotikTools />} />
<Route path="/firewall" element={<FirewallPage />} /> <Route path="/firewall" element={<FirewallPage />} />
<Route path="/interface-speed" element={<InterfaceSpeedTest />} /> <Route path="/interface-speed" element={<InterfaceSpeedTest />} />
@@ -441,22 +499,23 @@ function MainLayout() {
<div className="container-fluid"> <div className="container-fluid">
<Link to="/" className="navbar-brand navbar-brand-autodark"> <Link to="/" className="navbar-brand navbar-brand-autodark">
<IconBrandTabler className="navbar-brand-icon" /> <IconBrandTabler className="navbar-brand-icon" />
<span className="fw-bold">S3 Lists Manager</span> <span className="fw-bold">RouterOS Manager</span>
</Link> </Link>
<div className="navbar-nav flex-row d-md-none"> <div className="navbar-nav flex-row d-md-none">
<button <button
ref={sidebarTogglerRef}
className="navbar-toggler" className="navbar-toggler"
type="button" type="button"
data-bs-toggle="collapse" data-bs-toggle="collapse"
data-bs-target="#sidebar-menu" data-bs-target={`#${SIDEBAR_COLLAPSE_ID}`}
aria-controls="sidebar-menu" aria-controls={SIDEBAR_COLLAPSE_ID}
aria-expanded="false" aria-expanded="false"
aria-label="Открыть меню" aria-label="Открыть меню"
> >
<IconMenu2 /> <IconMenu2 />
</button> </button>
</div> </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"> <ul className="navbar-nav pt-lg-3">
{navCategories.map(category => ( {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' : ''}`}> <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="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="d-flex align-items-center justify-content-center gap-1 py-2 px-2">
<div className="nav-item dropdown dropup"> <div className="nav-item dropdown dropup">
<a href="#" className="nav-link btn-icon rounded" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()} title="Язык"> <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="badge bg-secondary">{lang.toUpperCase()}</span> <span className="fs-5" aria-hidden>{lang === 'ru' ? '🇷🇺' : '🇺🇸'}</span>
</a> </a>
<div className="dropdown-menu dropdown-menu-end"> <div className="dropdown-menu dropdown-menu-start">
<a href="#" className={`dropdown-item ${lang === 'ru' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('ru'); }}>Русский</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 ${lang === 'en' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('en'); }}>English</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> </div>
<div className="nav-item dropdown dropup"> <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')}> <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} />} {layout === LAYOUT_FLUID ? <IconLayoutSidebarLeftExpand size={20} /> : <IconLayoutNavbar size={20} />}
</a> </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); }}> <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')} <IconLayoutSidebarLeftExpand className="me-2" size={18} /> {t('layoutSidebar')}
</a> </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')}> <a href="#" className="nav-link btn-icon rounded" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()} title={t('light') + ' / ' + t('dark')}>
{theme === 'dark' ? '🌙' : '☀️'} {theme === 'dark' ? '🌙' : '☀️'}
</a> </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 === '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> <a href="#" className={`dropdown-item ${theme === 'dark' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setTheme('dark'); }}>🌙 {t('dark')}</a>
</div> </div>
</div> </div>
<div className="nav-item">
<AlertsBell dropup />
</div>
<div className="nav-item"> <div className="nav-item">
<KeyboardShortcutsButton className="nav-link btn-icon rounded" title="Горячие клавиши" /> <KeyboardShortcutsButton className="nav-link btn-icon rounded" title="Горячие клавиши" />
</div> </div>
@@ -553,6 +615,15 @@ function MainLayout() {
</div> </div>
</header> </header>
{/* Бэкдроп мобильного меню: закрытие по клику */}
{sidebarBackdrop && (
<div
className="sidebar-backdrop d-md-none"
aria-hidden="true"
onClick={() => sidebarTogglerRef.current?.click?.()}
/>
)}
<div className="page-wrapper"> <div className="page-wrapper">
<div className="page-body"> <div className="page-body">
<div className="container-fluid"> <div className="container-fluid">
@@ -562,6 +633,7 @@ function MainLayout() {
<Route path="/traffic" element={<TrafficDashboard />} /> <Route path="/traffic" element={<TrafficDashboard />} />
<Route path="/resource-stats" element={<ResourceStatsPage />} /> <Route path="/resource-stats" element={<ResourceStatsPage />} />
<Route path="/network-map" element={<NetworkMapDashboard />} /> <Route path="/network-map" element={<NetworkMapDashboard />} />
<Route path="/route-optimizer" element={<RouteOptimizerPage />} />
<Route path="/uptime-monitor" element={<UptimeMonitorPage />} /> <Route path="/uptime-monitor" element={<UptimeMonitorPage />} />
<Route path="/domains" element={<DomainsNewManager />} /> <Route path="/domains" element={<DomainsNewManager />} />
<Route path="/ip-ranges" element={<IPRangesManager />} /> <Route path="/ip-ranges" element={<IPRangesManager />} />
@@ -574,6 +646,7 @@ function MainLayout() {
<Route path="/network-config" element={<NetworkConfigManager />} /> <Route path="/network-config" element={<NetworkConfigManager />} />
<Route path="/easy-switch" element={<EasySwitchManager />} /> <Route path="/easy-switch" element={<EasySwitchManager />} />
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} /> <Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
<Route path="/ospf-tools" element={<OspfToolsPage />} />
<Route path="/mikrotik-tools" element={<MikrotikTools />} /> <Route path="/mikrotik-tools" element={<MikrotikTools />} />
<Route path="/firewall" element={<FirewallPage />} /> <Route path="/firewall" element={<FirewallPage />} />
<Route path="/interface-speed" element={<InterfaceSpeedTest />} /> <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 api from './lib/api.js';
import { import {
IconPlus, IconPlus,
@@ -101,7 +101,7 @@ function AutoUrlManager() {
const hasAtLeastOneValidRow = urls.some(u => isValidHttpUrl(u.url) && isValidCommunity(u.community)); const hasAtLeastOneValidRow = urls.some(u => isValidHttpUrl(u.url) && isValidCommunity(u.community));
const saveUrls = async () => { const saveUrls = useCallback(async () => {
try { try {
setSaving(true); setSaving(true);
setError(''); setError('');
@@ -124,7 +124,7 @@ function AutoUrlManager() {
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; }, [urls]);
const processUrls = async () => { const processUrls = async () => {
try { try {
@@ -158,7 +158,7 @@ function AutoUrlManager() {
} }
setSuccess('Пример формата скопирован'); setSuccess('Пример формата скопирован');
setTimeout(() => setSuccess(''), 2000); setTimeout(() => setSuccess(''), 2000);
} catch (e) { } catch (_e) {
setError('Не удалось скопировать пример'); setError('Не удалось скопировать пример');
setTimeout(() => setError(''), 2000); setTimeout(() => setError(''), 2000);
} }
@@ -619,7 +619,7 @@ function AutoUrlManager() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredAndSortedUrls.map((url, displayIndex) => { {filteredAndSortedUrls.map((url, _displayIndex) => {
const originalIndex = urls.findIndex(u => u === url); const originalIndex = urls.findIndex(u => u === url);
const v = getRowValidity(url); const v = getRowValidity(url);
const isValid = v.url && v.community; 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 api from './lib/api.js';
import axios from 'axios'; import axios from 'axios';
import FormModal from './components/FormModal.jsx'; import FormModal from './components/FormModal.jsx';
@@ -41,8 +41,43 @@ import {
import BulkActionsBar from './components/BulkActionsBar.jsx'; import BulkActionsBar from './components/BulkActionsBar.jsx';
import { BillingCard } from './components/billing/index.js'; 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() { function BillingManager() {
const [billingData, setBillingData] = useState([]); const [rawBillingData, setRawBillingData] = useState([]);
const [servers, setServers] = useState([]); const [servers, setServers] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -51,7 +86,7 @@ function BillingManager() {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [sortField, setSortField] = useState('nextPaymentDate'); const [sortField, setSortField] = useState('nextPaymentDate');
const [sortOrder, setSortOrder] = useState('asc'); const [sortOrder, setSortOrder] = useState('asc');
const [filterStatus, setFilterStatus] = useState(''); const [filterStatus, _setFilterStatus] = useState('');
const [filterUrgency, setFilterUrgency] = useState(''); const [filterUrgency, setFilterUrgency] = useState('');
const [viewMode, setViewMode] = useState('cards'); // 'cards' | 'table' const [viewMode, setViewMode] = useState('cards'); // 'cards' | 'table'
const [activeTab, setActiveTab] = useState('subscriptions'); // 'subscriptions' | 'payments' const [activeTab, setActiveTab] = useState('subscriptions'); // 'subscriptions' | 'payments'
@@ -76,14 +111,14 @@ function BillingManager() {
const [editingPayment, setEditingPayment] = useState(null); const [editingPayment, setEditingPayment] = useState(null);
const [showDeletePaymentModal, setShowDeletePaymentModal] = useState(false); const [showDeletePaymentModal, setShowDeletePaymentModal] = useState(false);
const [paymentToDelete, setPaymentToDelete] = useState(null); const [paymentToDelete, setPaymentToDelete] = useState(null);
const [futureDateConfirm, setFutureDateConfirm] = useState(false); const [_futureDateConfirm, _setFutureDateConfirm] = useState(false);
// История платежей // История платежей
const [paymentSearchTerm, setPaymentSearchTerm] = useState(''); const [paymentSearchTerm, _setPaymentSearchTerm] = useState('');
const [paymentSortField, setPaymentSortField] = useState('date'); const [_paymentSortField, _setPaymentSortField] = useState('date');
const [paymentSortOrder, setPaymentSortOrder] = useState('desc'); const [_paymentSortOrder, _setPaymentSortOrder] = useState('desc');
const [selectedPayments, setSelectedPayments] = useState(new Set()); const [_selectedPayments, _setSelectedPayments] = useState(new Set());
const [paymentPage, setPaymentPage] = useState(1); const [_paymentPage, _setPaymentPage] = useState(1);
const paymentPageSize = 10; const paymentPageSize = 10;
// Курсы валют // Курсы валют
@@ -92,7 +127,7 @@ function BillingManager() {
EUR: 1, EUR: 1,
RUB: 1 RUB: 1
}); });
const [ratesLoading, setRatesLoading] = useState(false); const [_ratesLoading, setRatesLoading] = useState(false);
// Новый элемент // Новый элемент
const [newItem, setNewItem] = useState({ const [newItem, setNewItem] = useState({
@@ -112,6 +147,73 @@ function BillingManager() {
serverId: '', 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(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
const signal = controller.signal; const signal = controller.signal;
@@ -119,13 +221,19 @@ function BillingManager() {
fetchExchangeRates(signal); fetchExchangeRates(signal);
fetchServers(signal); fetchServers(signal);
return () => controller.abort(); return () => controller.abort();
}, []); }, [fetchBillingData, fetchExchangeRates, fetchServers]);
// Нормализация с учётом курсов валют (без повторных запросов)
const billingData = useMemo(
() => normalizeBillingItems(rawBillingData, exchangeRates),
[rawBillingData, exchangeRates]
);
// Автоматическая синхронизация: добавляем серверы без записей в биллинге // Автоматическая синхронизация: добавляем серверы без записей в биллинге
useEffect(() => { useEffect(() => {
if (servers.length === 0 || loading) return; if (servers.length === 0 || loading) return;
setBillingData((prev) => { setRawBillingData((prev) => {
const existingServerIds = new Set( const existingServerIds = new Set(
prev.map(b => b.serverId).filter(Boolean) prev.map(b => b.serverId).filter(Boolean)
); );
@@ -165,118 +273,14 @@ function BillingManager() {
}, [servers, loading]); }, [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 () => { const handleSaveChanges = async () => {
setLoading(true); setLoading(true);
try { try {
// Убираем служебное поле _external перед сохранением // Убираем служебное поле _external перед сохранением
const payload = billingData.map(({ _external, ...rest }) => rest); const payload = billingData.map(({ _external: _ext, ...rest }) => rest);
await api.post(`/billing`, { domains: payload }); await api.post(`/billing`, { domains: payload });
// После сохранения снимаем флаг _external со всех записей // После сохранения снимаем флаг _external со всех записей
setBillingData(prev => prev.map(item => ({ ...item, _external: false }))); setRawBillingData(prev => prev.map(item => ({ ...item, _external: false })));
setSuccess('Данные успешно сохранены'); setSuccess('Данные успешно сохранены');
setTimeout(() => setSuccess(''), 3000); setTimeout(() => setSuccess(''), 3000);
} catch (err) { } catch (err) {
@@ -466,7 +470,7 @@ function BillingManager() {
const confirmDelete = () => { const confirmDelete = () => {
if (selectedItem) { if (selectedItem) {
setBillingData(prev => prev.filter(item => item.id !== selectedItem.id)); setRawBillingData(prev => prev.filter(item => item.id !== selectedItem.id));
setShowDeleteModal(false); setShowDeleteModal(false);
setSelectedItem(null); setSelectedItem(null);
} }
@@ -479,7 +483,7 @@ function BillingManager() {
} }
const newId = Math.max(...billingData.map(item => parseInt(item.id) || 0), 0) + 1; 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); setShowAddModal(false);
setSuccess('Элемент добавлен'); setSuccess('Элемент добавлен');
setTimeout(() => setSuccess(''), 3000); setTimeout(() => setSuccess(''), 3000);
@@ -492,7 +496,7 @@ function BillingManager() {
} }
// Снимаем флаг _external при редактировании (запись теперь заполнена) // Снимаем флаг _external при редактировании (запись теперь заполнена)
setBillingData(prev => prev.map(item => setRawBillingData(prev => prev.map(item =>
item.id === editingItem.id ? { ...editingItem, _external: false } : item item.id === editingItem.id ? { ...editingItem, _external: false } : item
)); ));
setShowEditModal(false); setShowEditModal(false);
@@ -504,7 +508,7 @@ function BillingManager() {
const handleDeletePayment = () => { const handleDeletePayment = () => {
if (!paymentToDelete) return; if (!paymentToDelete) return;
const { serverId, index } = paymentToDelete; const { serverId, index } = paymentToDelete;
setBillingData(prev => prev.map(s => { setRawBillingData(prev => prev.map(s => {
if (s.id !== serverId) return s; if (s.id !== serverId) return s;
const payments = [...(s.payments || [])]; const payments = [...(s.payments || [])];
payments.splice(index, 1); payments.splice(index, 1);
@@ -1151,7 +1155,7 @@ function BillingManager() {
} }
if (editingPayment) { if (editingPayment) {
setBillingData(prev => prev.map(s => { setRawBillingData(prev => prev.map(s => {
if (s.id !== editingPayment.serverId) return s; if (s.id !== editingPayment.serverId) return s;
const payments = [...(s.payments || [])]; const payments = [...(s.payments || [])];
payments[editingPayment.index] = { payments[editingPayment.index] = {
@@ -1166,7 +1170,7 @@ function BillingManager() {
})); }));
setEditingPayment(null); setEditingPayment(null);
} else { } else {
setBillingData(prev => prev.map(s => { setRawBillingData(prev => prev.map(s => {
if (s.id !== paymentDraft.serverId) return s; if (s.id !== paymentDraft.serverId) return s;
const payments = [...(s.payments || []), { const payments = [...(s.payments || []), {
date: paymentDraft.date, date: paymentDraft.date,
+158 -96
View File
@@ -11,13 +11,13 @@ import {
IconFilter, IconFilter,
IconChartBar, IconChartBar,
IconList, IconList,
IconLink,
IconRefresh, IconRefresh,
IconSortAscending, IconSortAscending,
IconSortDescending, IconSortDescending,
IconUpload, IconUpload,
IconDownload, IconDownload,
IconDeviceFloppy IconDeviceFloppy,
IconPlugConnected
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import PageHeader from './components/PageHeader.jsx'; import PageHeader from './components/PageHeader.jsx';
import PortalDropdown from './components/PortalDropdown.jsx'; import PortalDropdown from './components/PortalDropdown.jsx';
@@ -29,8 +29,9 @@ import ConfirmModal from './components/ConfirmModal.jsx';
import FormField from './components/FormField.jsx'; import FormField from './components/FormField.jsx';
import ErrorAlert from './components/ErrorAlert.jsx'; import ErrorAlert from './components/ErrorAlert.jsx';
import Pagination from './components/Pagination.jsx'; import Pagination from './components/Pagination.jsx';
import IconPicker from './components/IconPicker.jsx';
const API_URL = '/api'; import { getIconById } from './lib/brandIcons.js';
import { listCommunities, saveCommunitiesData, normalizeEvobgpApiBaseUrl } from './lib/evobgpData.js';
const normalizeCommunityValue = (value) => { const normalizeCommunityValue = (value) => {
const trimmed = String(value || '').trim(); const trimmed = String(value || '').trim();
@@ -53,14 +54,25 @@ function CommunitiesManager() {
// Состояние для переключения между списком и статистикой // Состояние для переключения между списком и статистикой
const [activeTab, setActiveTab] = useState('list'); // 'list' | 'stats' 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 [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 [filterUsage, setFilterUsage] = useState({});
const [filterUsageLoading, setFilterUsageLoading] = useState(false); const [filterUsageLoading, setFilterUsageLoading] = useState(false);
const [filterUsageError, setFilterUsageError] = useState(''); const [filterUsageError, setFilterUsageError] = useState('');
const [showOnlyUsedInFilters, setShowOnlyUsedInFilters] = useState(false); 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 [addModalOpen, setAddModalOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false);
@@ -72,6 +84,15 @@ function CommunitiesManager() {
fetchFilterUsage(); fetchFilterUsage();
}, []); }, []);
useEffect(() => {
try {
setEvobgpUrlDraft(String(window.localStorage?.getItem('evobgp_api_url') || ''));
setEvobgpTokenDraft(String(window.localStorage?.getItem('evobgp_api_token') || ''));
} catch {
/* no-op */
}
}, []);
useEffect(() => { useEffect(() => {
if (!filterUsage) return; if (!filterUsage) return;
setItems((prev) => { setItems((prev) => {
@@ -79,7 +100,7 @@ function CommunitiesManager() {
Object.keys(filterUsage).forEach((value) => { Object.keys(filterUsage).forEach((value) => {
if (!value) return; if (!value) return;
if (!map.has(value)) { 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()); return Array.from(map.values());
@@ -89,57 +110,12 @@ function CommunitiesManager() {
const fetchItems = async () => { const fetchItems = async () => {
setLoading(true); setLoading(true);
try { try {
const [dictRes, asnsRes, ipRes, domRes] = await Promise.all([ const rows = await listCommunities();
api.get(`/communities`), const normalized = rows.map((item) => ({
api.get(`/asns`, { params: { offset: 0, limit: 0 } }).catch(() => ({ data: [] })), ...item,
api.get(`/ip-ranges`, { params: { offset: 0, limit: 0 } }).catch(() => ({ data: [] })), value: normalizeCommunityValue(item.value),
api.get(`/domains-new`, { params: { offset: 0, limit: 0 } }).catch(() => ({ data: [] })), }));
]); setItems(normalized);
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()));
setError(''); setError('');
} catch (e) { } catch (e) {
console.error('Error fetching communities:', e); console.error('Error fetching communities:', e);
@@ -188,9 +164,7 @@ function CommunitiesManager() {
const saveAll = async (data) => { const saveAll = async (data) => {
setLoading(true); setLoading(true);
try { try {
// не отправляем служебное поле _external await saveCommunitiesData(data);
const payload = data.map(({ _external, ...rest }) => rest);
await api.post(`/communities`, { communities: payload });
setSuccess('Справочник сохранён!'); setSuccess('Справочник сохранён!');
setTimeout(() => setSuccess(''), 3000); setTimeout(() => setSuccess(''), 3000);
} catch (e) { } catch (e) {
@@ -215,10 +189,10 @@ function CommunitiesManager() {
name: String(newItem.name || ''), name: String(newItem.name || ''),
description: String(newItem.description || ''), description: String(newItem.description || ''),
tags: toTagsArray(newItem.tags), tags: toTagsArray(newItem.tags),
gatewayDefault: String(newItem.gatewayDefault || ''),
color: String(newItem.color || ''), color: String(newItem.color || ''),
icon: String(newItem.icon || ''),
}]); }]);
setNewItem({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '' }); setNewItem({ value: '', name: '', description: '', tags: '', color: '', icon: '' });
setAddModalOpen(false); setAddModalOpen(false);
setSuccess('Community успешно добавлен!'); setSuccess('Community успешно добавлен!');
setTimeout(() => setSuccess(''), 3000); setTimeout(() => setSuccess(''), 3000);
@@ -231,8 +205,8 @@ function CommunitiesManager() {
name: item.name || '', name: item.name || '',
description: item.description || '', description: item.description || '',
tags: fromTagsArray(item.tags), tags: fromTagsArray(item.tags),
gatewayDefault: item.gatewayDefault || '',
color: item.color || '', color: item.color || '',
icon: item.icon || '',
}); });
setEditModalOpen(true); setEditModalOpen(true);
}; };
@@ -247,8 +221,8 @@ function CommunitiesManager() {
name: String(editingDraft.name || ''), name: String(editingDraft.name || ''),
description: String(editingDraft.description || ''), description: String(editingDraft.description || ''),
tags: toTagsArray(editingDraft.tags), tags: toTagsArray(editingDraft.tags),
gatewayDefault: String(editingDraft.gatewayDefault || ''),
color: String(editingDraft.color || ''), color: String(editingDraft.color || ''),
icon: String(editingDraft.icon || ''),
} : i); } : i);
setItems(next); setItems(next);
setEditingValue(null); setEditingValue(null);
@@ -278,7 +252,7 @@ function CommunitiesManager() {
}; };
const handleImport = () => { 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; if (!text) return;
try { try {
const arr = JSON.parse(text); const arr = JSON.parse(text);
@@ -295,13 +269,13 @@ function CommunitiesManager() {
name: String(x.name || ''), name: String(x.name || ''),
description: String(x.description || ''), description: String(x.description || ''),
tags: Array.isArray(x.tags) ? x.tags : [], tags: Array.isArray(x.tags) ? x.tags : [],
gatewayDefault: String(x.gatewayDefault || ''),
color: String(x.color || ''), color: String(x.color || ''),
icon: String(x.icon || ''),
}); });
}); });
return Array.from(map.values()); return Array.from(map.values());
}); });
} catch (e) { } catch (_e) {
setError('Неверный JSON.'); setError('Неверный JSON.');
} }
}; };
@@ -318,6 +292,43 @@ function CommunitiesManager() {
const handleSave = () => saveAll(items); 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 sorted = useMemo(() => {
const arr = [...items]; const arr = [...items];
arr.sort((a, b) => { arr.sort((a, b) => {
@@ -359,6 +370,61 @@ function CommunitiesManager() {
icon={<IconFilter size={24} />} 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 && ( {error && (
<ErrorAlert message={error} onClose={() => setError('')} onRetry={fetchItems} /> <ErrorAlert message={error} onClose={() => setError('')} onRetry={fetchItems} />
@@ -553,6 +619,7 @@ function CommunitiesManager() {
<table className="table card-table table-vcenter table-nowrap mb-0"> <table className="table card-table table-vcenter table-nowrap mb-0">
<thead> <thead>
<tr> <tr>
<th style={{ width: '8%' }}>Иконка</th>
<th className="cursor-pointer user-select-none" onClick={()=>changeSort('value')}> <th className="cursor-pointer user-select-none" onClick={()=>changeSort('value')}>
<div className="d-flex align-items-center"> <div className="d-flex align-items-center">
<IconHash size={16} className="me-1 text-muted" /> <IconHash size={16} className="me-1 text-muted" />
@@ -577,7 +644,6 @@ function CommunitiesManager() {
</th> </th>
<th>Описание</th> <th>Описание</th>
<th>Теги</th> <th>Теги</th>
<th>Gateway</th>
<th style={{ width: '10%' }}>Исп. в фильтрах</th> <th style={{ width: '10%' }}>Исп. в фильтрах</th>
<th className="text-end">Действия</th> <th className="text-end">Действия</th>
</tr> </tr>
@@ -600,8 +666,18 @@ function CommunitiesManager() {
</td></tr> </td></tr>
) : paginated.map((item) => { ) : paginated.map((item) => {
const usageCount = filterUsage[item.value] || 0; const usageCount = filterUsage[item.value] || 0;
const iconInfo = item.icon ? getIconById(item.icon) : null;
return ( return (
<tr key={item.value}> <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> <td>
<code className="text-blue">{item.value}</code> <code className="text-blue">{item.value}</code>
{item._external && ( {item._external && (
@@ -617,9 +693,6 @@ function CommunitiesManager() {
<span key={t} className="badge bg-blue-lt text-blue me-1">{t}</span> <span key={t} className="badge bg-blue-lt text-blue me-1">{t}</span>
))} ))}
</td> </td>
<td>
<span className="text-muted">{item.gatewayDefault || '—'}</span>
</td>
<td> <td>
{usageCount > 0 ? ( {usageCount > 0 ? (
<span className="badge bg-blue-lt text-blue">{usageCount} в фильтрах</span> <span className="badge bg-blue-lt text-blue">{usageCount} в фильтрах</span>
@@ -629,13 +702,6 @@ function CommunitiesManager() {
</td> </td>
<td className="text-end"> <td className="text-end">
<div className="btn-list"> <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 <button
className="btn btn-outline-primary btn-icon btn-sm" className="btn btn-outline-primary btn-icon btn-sm"
onClick={()=>startEdit(item)} onClick={()=>startEdit(item)}
@@ -681,7 +747,7 @@ function CommunitiesManager() {
show={addModalOpen} show={addModalOpen}
onClose={() => { onClose={() => {
setAddModalOpen(false); setAddModalOpen(false);
setNewItem({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '' }); setNewItem({ value: '', name: '', description: '', tags: '', color: '', icon: '' });
setError(''); setError('');
}} }}
onSubmit={addItem} onSubmit={addItem}
@@ -742,14 +808,6 @@ function CommunitiesManager() {
helpText="Разделяйте теги запятыми" helpText="Разделяйте теги запятыми"
/> />
<FormField
label="Gateway по умолчанию"
name="gatewayDefault"
value={newItem.gatewayDefault}
onChange={(val) => setNewItem({...newItem, gatewayDefault: val})}
placeholder="SWE-HIPHOST"
/>
<FormField <FormField
label="Цвет бейджа (опционально)" label="Цвет бейджа (опционально)"
name="color" name="color"
@@ -758,6 +816,12 @@ function CommunitiesManager() {
placeholder="blue, green, red ..." placeholder="blue, green, red ..."
helpText="Цвет для отображения бейджа в интерфейсе" helpText="Цвет для отображения бейджа в интерфейсе"
/> />
<IconPicker
value={newItem.icon}
onChange={(id) => setNewItem({...newItem, icon: id})}
label="Иконка бренда (опционально)"
allowNone
/>
</FormModal> </FormModal>
{/* Модальное окно редактирования */} {/* Модальное окно редактирования */}
@@ -824,14 +888,6 @@ function CommunitiesManager() {
helpText="Разделяйте теги запятыми" helpText="Разделяйте теги запятыми"
/> />
<FormField
label="Gateway по умолчанию"
name="gatewayDefault"
value={editingDraft.gatewayDefault}
onChange={(val) => setEditingDraft({...editingDraft, gatewayDefault: val})}
placeholder="SWE-HIPHOST"
/>
<FormField <FormField
label="Цвет бейджа (опционально)" label="Цвет бейджа (опционально)"
name="color" name="color"
@@ -840,6 +896,12 @@ function CommunitiesManager() {
placeholder="blue, green, red ..." placeholder="blue, green, red ..."
helpText="Цвет для отображения бейджа в интерфейсе" helpText="Цвет для отображения бейджа в интерфейсе"
/> />
<IconPicker
value={editingDraft.icon}
onChange={(id) => setEditingDraft({...editingDraft, icon: id})}
label="Иконка бренда (опционально)"
allowNone
/>
</FormModal> </FormModal>
{/* Модальное окно подтверждения удаления */} {/* Модальное окно подтверждения удаления */}
+87 -19
View File
@@ -18,6 +18,11 @@ import {
IconSearch, IconSearch,
IconChevronDown, IconChevronDown,
IconChevronUp, IconChevronUp,
IconBell,
IconCpu,
IconDeviceDesktop,
IconDatabase,
IconServerOff,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { getIconById } from './lib/brandIcons.js'; import { getIconById } from './lib/brandIcons.js';
import PageHeader from './components/PageHeader.jsx'; import PageHeader from './components/PageHeader.jsx';
@@ -26,13 +31,14 @@ import TrendIndicator from './components/TrendIndicator.jsx';
import LastSaved from './components/LastSaved.jsx'; import LastSaved from './components/LastSaved.jsx';
import Tooltip from './components/Tooltip.jsx'; import Tooltip from './components/Tooltip.jsx';
import Sparkline from './components/Sparkline.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 ( return (
<div className="card h-100 position-relative card-hover"> <div className="card h-100 position-relative card-hover">
<div className="card-body d-flex align-items-center"> <div className="card-body d-flex align-items-center">
<span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0`}> <span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0`}>
<Icon size={32} /> <_Icon size={32} />
</span> </span>
<div className="flex-grow-1"> <div className="flex-grow-1">
<div className="d-flex align-items-baseline gap-2 mb-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 ( return (
<div className="card h-100 position-relative"> <div className="card h-100 position-relative">
<div className="card-body d-flex align-items-center"> <div className="card-body d-flex align-items-center">
<span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0`}> <span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0`}>
<Icon size={32} /> <_Icon size={32} />
</span> </span>
<div className="flex-grow-1"> <div className="flex-grow-1">
<div className="h3 mb-0 fw-bold"> <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() { function Dashboard() {
const { alerts, loading: alertsLoading, refresh: refreshAlerts } = useAlerts();
// Глобальный поиск удалён по требованию UX // Глобальный поиск удалён по требованию UX
const [stats, setStats] = useState({ const [stats, setStats] = useState({
domainsCount: null, domainsCount: null,
@@ -357,12 +372,12 @@ function Dashboard() {
title="Панель" title="Панель"
icon={<IconCloud size={24} />} icon={<IconCloud size={24} />}
actions={( 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 && ( {lastFetchTime && (
<LastSaved timestamp={lastFetchTime} variant="compact" /> <LastSaved timestamp={lastFetchTime} variant="compact" />
)} )}
<Tooltip content="Обновить данные" shortcut="⌘R"> <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" /> Обновить <IconRefresh className="me-2" /> Обновить
</button> </button>
</Tooltip> </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> <h3 className="mb-0">Пинг сервисов</h3>
<Tooltip content="Принудительно обновить пинг (без учёта кэша), история и тренд накапливаются"> <Tooltip content="Принудительно обновить пинг (без учёта кэша), история и тренд накапливаются">
<button <button
type="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)} onClick={() => fetchPingServices(true)}
disabled={pingLoading} disabled={pingLoading}
> >
@@ -389,7 +404,7 @@ function Dashboard() {
{pingServicesConfig.map((config) => ( {pingServicesConfig.map((config) => (
<div <div
key={config.id} 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 <PingServiceCard
config={config} config={config}
@@ -407,9 +422,62 @@ function Dashboard() {
))} ))}
</div> </div>
{/* Активные оповещения */}
<div className="mb-4">
<div className="d-flex flex-column flex-sm-row align-items-start align-items-sm-center justify-content-between gap-2 mb-2">
<h3 className="mb-0 d-flex align-items-center gap-2">
<IconBell size={24} />
Активные оповещения
{alerts.length > 0 && (
<span className="badge bg-danger rounded-pill">{alerts.length}</span>
)}
</h3>
<button
type="button"
className="btn btn-outline-primary btn-sm"
onClick={() => refreshAlerts()}
disabled={alertsLoading}
>
<IconRefresh className={alertsLoading ? 'spin me-1' : 'me-1'} size={16} />
Обновить
</button>
</div>
{alerts.length === 0 && !alertsLoading && (
<div className="card">
<div className="card-body text-center text-muted py-4">
Нет активных оповещений
</div>
</div>
)}
{alerts.length > 0 && (
<div className="card">
<div className="list-group list-group-flush">
{alerts.map((alert) => {
const Icon = ALERT_ICONS[alert.type] || IconAlertTriangle;
return (
<Link
key={alert.id}
to={alert.link || '/resource-stats'}
className="list-group-item list-group-item-action d-flex align-items-center gap-3"
>
<span className={`avatar avatar-sm flex-shrink-0 bg-${alert.severity === 'critical' ? 'danger' : 'warning'}-lt text-${alert.severity === 'critical' ? 'danger' : 'warning'}`}>
<Icon size={20} />
</span>
<div className="min-w-0 flex-grow-1">
<div className="fw-semibold">{alert.title}</div>
<div className="text-muted small">{alert.description}</div>
</div>
</Link>
);
})}
</div>
</div>
)}
</div>
{/* Основные метрики */} {/* Основные метрики */}
<div className="row g-3 mb-4"> <div className="row g-2 g-md-3 mb-4">
<div className="col-md-3"> <div className="col-6 col-lg-3">
<div className="animate-in"> <div className="animate-in">
<StatCard <StatCard
icon={IconWorld} icon={IconWorld}
@@ -423,7 +491,7 @@ function Dashboard() {
/> />
</div> </div>
</div> </div>
<div className="col-md-3"> <div className="col-6 col-lg-3">
<div className="animate-in" style={{animationDelay: '0.1s'}}> <div className="animate-in" style={{animationDelay: '0.1s'}}>
<StatCard <StatCard
icon={IconNetwork} icon={IconNetwork}
@@ -437,7 +505,7 @@ function Dashboard() {
/> />
</div> </div>
</div> </div>
<div className="col-md-3"> <div className="col-6 col-lg-3">
<div className="animate-in" style={{animationDelay: '0.2s'}}> <div className="animate-in" style={{animationDelay: '0.2s'}}>
<StatCard <StatCard
icon={IconNetwork} icon={IconNetwork}
@@ -451,7 +519,7 @@ function Dashboard() {
/> />
</div> </div>
</div> </div>
<div className="col-md-3"> <div className="col-6 col-lg-3">
<div className="animate-in" style={{animationDelay: '0.3s'}}> <div className="animate-in" style={{animationDelay: '0.3s'}}>
<StatCard <StatCard
icon={IconServer} icon={IconServer}
@@ -468,8 +536,8 @@ function Dashboard() {
</div> </div>
{/* Дополнительные метрики */} {/* Дополнительные метрики */}
<div className="row g-3 mb-4 justify-content-center"> <div className="row g-2 g-md-3 mb-4 justify-content-center">
<div className="col-sm-6 col-md-3"> <div className="col-6 col-md-6 col-lg-3">
<MetricCard <MetricCard
title="Стран" title="Стран"
value={loading ? '...' : stats.countriesCount ?? '—'} value={loading ? '...' : stats.countriesCount ?? '—'}
@@ -478,7 +546,7 @@ function Dashboard() {
description="Географическое покрытие" description="Географическое покрытие"
/> />
</div> </div>
<div className="col-sm-6 col-md-3"> <div className="col-6 col-md-6 col-lg-3">
<MetricCard <MetricCard
title="Провайдеров" title="Провайдеров"
value={loading ? '...' : stats.providersCount ?? '—'} value={loading ? '...' : stats.providersCount ?? '—'}
@@ -487,7 +555,7 @@ function Dashboard() {
description="Облачные провайдеры" description="Облачные провайдеры"
/> />
</div> </div>
<div className="col-sm-6 col-md-3"> <div className="col-6 col-md-6 col-lg-3">
<MetricCard <MetricCard
title="Онлайн серверов" title="Онлайн серверов"
value={loading ? '...' : `${stats.onlineServers ?? '—'}/${stats.totalServers ?? '—'}`} value={loading ? '...' : `${stats.onlineServers ?? '—'}/${stats.totalServers ?? '—'}`}
@@ -496,7 +564,7 @@ function Dashboard() {
description="Активные серверы" description="Активные серверы"
/> />
</div> </div>
<div className="col-sm-6 col-md-3"> <div className="col-6 col-md-6 col-lg-3">
<MetricCard <MetricCard
title="Последнее обновление" title="Последнее обновление"
value={loading ? '...' : (stats.lastModified ? stats.lastModified : '—')} 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 api from './lib/api.js';
import { import {
IconPlus, IconPlus,
@@ -39,11 +39,7 @@ function DataManager({ entityName, entityKey, placeholder }) {
const pageSize = 10; const pageSize = 10;
const [confirmState, setConfirmState] = useState({ open: false, text: '', onConfirm: null }); const [confirmState, setConfirmState] = useState({ open: false, text: '', onConfirm: null });
useEffect(() => { const fetchItems = useCallback(async () => {
fetchItems();
}, [entityKey]);
const fetchItems = async () => {
setLoading(true); setLoading(true);
try { try {
const response = await api.get(`/${entityKey}`); const response = await api.get(`/${entityKey}`);
@@ -55,7 +51,11 @@ function DataManager({ entityName, entityKey, placeholder }) {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [entityKey, entityName]);
useEffect(() => {
fetchItems();
}, [fetchItems]);
const handleAddItem = () => { const handleAddItem = () => {
if (newItem.domain.trim() === '') { if (newItem.domain.trim() === '') {
@@ -212,8 +212,8 @@ function DataManager({ entityName, entityKey, placeholder }) {
</div> </div>
)} )}
<div className="row"> <div className="row g-2 g-lg-3">
<div className="col-lg-4"> <div className="col-12 col-lg-4">
{/* Add New Item Card */} {/* Add New Item Card */}
<div className="card card-md"> <div className="card card-md">
<div className="card-header"> <div className="card-header">
@@ -333,11 +333,11 @@ function DataManager({ entityName, entityKey, placeholder }) {
</div> </div>
</div> </div>
<div className="col-lg-8"> <div className="col-12 col-lg-8">
<div className="card"> <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> <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); }}> <select className="form-select w-auto" value={filterType} onChange={e => { setFilterType(e.target.value); setCurrentPage(1); }}>
<option value="">Все шлюзы</option> <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 api from './lib/api.js';
import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx'; import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx';
import CommunityBadge from './components/CommunityBadge.jsx'; import CommunityBadge from './components/CommunityBadge.jsx';
@@ -49,22 +49,25 @@ import AddItemModal from './components/AddItemModal.jsx';
import LastSaved from './components/LastSaved.jsx'; import LastSaved from './components/LastSaved.jsx';
import FormModal from './components/FormModal.jsx'; import FormModal from './components/FormModal.jsx';
import { useToast } from './components/ToastContainer.jsx'; import { useToast } from './components/ToastContainer.jsx';
import {
const API_URL = '/api'; listCommunities,
fetchDomainsData,
saveDomainsData,
} from './lib/evobgpData.js';
function DomainsNewManager() { function DomainsNewManager() {
const toast = useToast(); const toast = useToast();
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [originalItems, setOriginalItems] = useState([]); const [originalItems, setOriginalItems] = useState([]);
const [etag, setEtag] = useState(''); const [, setEtag] = useState('');
const [lastModified, setLastModified] = useState(''); const [lastModified, setLastModified] = useState('');
const [contentLength, setContentLength] = useState(null); const [, setContentLength] = useState(null);
const [newItem, setNewItem] = useState({ domain: '', community: '' }); 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 [error, setError] = useState('');
const [success, setSuccess] = useState(''); const [success, setSuccess] = useState('');
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const searchTimer = useRef(null); const _searchTimer = useRef(null);
const didInit = useRef(false); const didInit = useRef(false);
const [editingValue, setEditingValue] = useState(''); const [editingValue, setEditingValue] = useState('');
const [editModalOpen, setEditModalOpen] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false);
@@ -90,18 +93,18 @@ function DomainsNewManager() {
const signal = controller.signal; const signal = controller.signal;
(async () => { (async () => {
try { try {
const res = await api.get(`/communities`, { signal }); const list = await listCommunities();
setCommunities(Array.isArray(res.data) ? res.data : []); setCommunities(list);
} catch (e) { } catch (err) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return; if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
} }
})(); })();
(async () => { (async () => {
try { try {
const r = await api.get('/ws/url', { signal }); const r = await api.get('/ws/url', { signal });
setWsUrl(String(r.data?.url || '')); setWsUrl(String(r.data?.url || ''));
} catch (e) { } catch (err) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return; if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
} }
})(); })();
return () => controller.abort(); return () => controller.abort();
@@ -121,20 +124,17 @@ function DomainsNewManager() {
const [wsOpen, setWsOpen] = useState(false); const [wsOpen, setWsOpen] = useState(false);
const [wsUrl, setWsUrl] = useState(''); const [wsUrl, setWsUrl] = useState('');
const fetchItems = async (abortSignal) => { const fetchItems = useCallback(async (_abortSignal) => {
setLoading(true); setLoading(true);
const opts = abortSignal ? { signal: abortSignal } : {};
try { try {
const response = await api.get(`/domains-new`, { params: { offset: 0, limit: 0, format: 'std' }, ...opts }); const payload = await fetchDomainsData();
const payload = Array.isArray(response.data?.items) ? response.data.items : [];
const total = payload.length; const total = payload.length;
setItems(payload); setItems(payload);
setOriginalItems(payload); setOriginalItems(payload);
setTotalItems(Number.isFinite(total) ? total : 0); setTotalItems(Number.isFinite(total) ? total : 0);
setEtag(response.headers?.etag || ''); setEtag('');
setLastModified(response.headers?.['last-modified'] || ''); setLastModified(new Date().toISOString());
const lengthHeader = response.headers?.['content-length-source']; setContentLength(null);
setContentLength(typeof lengthHeader !== 'undefined' ? Number(lengthHeader) : null);
setError(''); setError('');
} catch (error) { } catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return; if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
@@ -143,7 +143,7 @@ function DomainsNewManager() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, []);
useEffect(() => { useEffect(() => {
if (!didInit.current) { if (!didInit.current) {
@@ -152,7 +152,7 @@ function DomainsNewManager() {
fetchItems(controller.signal); fetchItems(controller.signal);
return () => controller.abort(); return () => controller.abort();
} }
}, []); }, [fetchItems]);
const handleAddItem = (item) => { const handleAddItem = (item) => {
// Если item передан (из модалки), используем его, иначе из state (старый способ) // Если item передан (из модалки), используем его, иначе из state (старый способ)
@@ -273,27 +273,7 @@ function DomainsNewManager() {
setShowDiff(true); setShowDiff(true);
}; };
const handleSaveChanges = async () => { const performSave = useCallback(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 () => {
setConfirmSaveOpen(false); setConfirmSaveOpen(false);
setLoading(true); setLoading(true);
try { try {
@@ -301,30 +281,8 @@ function DomainsNewManager() {
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() })); .map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
const unique = deduplicate(valid); const unique = deduplicate(valid);
// Подгружаем весь список, применяем изменения текущей страницы и сохраняем полные данные await saveDomainsData(unique, originalItems);
const fullRes = await api.get(`/domains-new`, { params: { offset: 0, limit: 0, format: 'std' } }); setEtag('');
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 fetchItems(); await fetchItems();
setSuccess('Изменения успешно сохранены!'); setSuccess('Изменения успешно сохранены!');
@@ -336,10 +294,24 @@ function DomainsNewManager() {
} finally { } finally {
setLoading(false); 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 импорт // Drag&Drop импорт
const onDropImport = async (e) => { const _onDropImport = async (e) => {
e.preventDefault(); e.preventDefault();
const file = e.dataTransfer?.files?.[0]; const file = e.dataTransfer?.files?.[0];
if (!file) return; if (!file) return;
@@ -366,9 +338,7 @@ function DomainsNewManager() {
const handleExport = async () => { const handleExport = async () => {
try { try {
const effectiveQ = searchTerm ? searchTerm : (filterCommunity ? filterCommunity : ''); const all = filtered;
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 header = ['domain', 'community']; const header = ['domain', 'community'];
const csv = [header, ...all.filter(i => i != null).map(i => [i.domain || '', i.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(',')) .map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
@@ -456,7 +426,7 @@ function DomainsNewManager() {
}; };
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [loading, items, originalItems, etag, sortField, sortOrder, filterCommunity]); }, [loading, handleSaveChanges]);
const handleEditKeyDown = (e) => { const handleEditKeyDown = (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
@@ -950,10 +920,10 @@ function DomainsNewManager() {
<button type="button" className="btn-close" onClick={() => setShowDiff(false)}></button> <button type="button" className="btn-close" onClick={() => setShowDiff(false)}></button>
<div className="modal-header"><h3 className="modal-title">Изменения</h3></div> <div className="modal-header"><h3 className="modal-title">Изменения</h3></div>
<div className="modal-body"> <div className="modal-body">
<div className="row g-3"> <div className="row g-2 g-md-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-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-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-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-md-4"><div className="card"><div className="card-body"><strong>Изменено</strong><div className="text-muted">{diff.changed.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> </div>
<div className="modal-footer"> <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 { useLocation } from 'react-router-dom';
import api from './lib/api.js'; import api from './lib/api.js';
import { usePing } from './contexts/PingContext.jsx'; import { usePing } from './contexts/PingContext.jsx';
@@ -42,7 +42,7 @@ function EasySwitchManager() {
// Поиск и фильтры // Поиск и фильтры
const [searchQuery, setSearchQuery] = useState(''); 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 [activeTab, setActiveTab] = useState('proxies'); // proxies | providers
const [showOnlyConfigured, setShowOnlyConfigured] = useState(true); // Показывать только настроенные const [showOnlyConfigured, setShowOnlyConfigured] = useState(true); // Показывать только настроенные
@@ -53,7 +53,7 @@ function EasySwitchManager() {
const [expandedServers, setExpandedServers] = useState(new Set()); // Развернутые серверы const [expandedServers, setExpandedServers] = useState(new Set()); // Развернутые серверы
const [groupByTags, setGroupByTags] = useState(true); // Группировка по тегам const [groupByTags, setGroupByTags] = useState(true); // Группировка по тегам
// Единый источник пингов (кеш + API) общий с страницей фильтров // Единый источник пингов (кеш + API) общий с страницей фильтров
const { pingMap, requestPings } = usePing(); const { pingMap, requestPings, cancelScope } = usePing();
const location = useLocation(); const location = useLocation();
useEffect(() => { useEffect(() => {
@@ -97,7 +97,7 @@ function EasySwitchManager() {
initData(); initData();
return () => controller.abort(); return () => controller.abort();
}, []); }, [loadDataWithParams]);
// При переходе на раздел Easy Switch перезапрашиваем пинги (устаревшие по TTL обновятся) // При переходе на раздел Easy Switch перезапрашиваем пинги (устаревшие по TTL обновятся)
useEffect(() => { useEffect(() => {
@@ -115,10 +115,21 @@ function EasySwitchManager() {
if (gw.ip) pingTasks.push({ routerId, gatewayIp: gw.ip }); if (gw.ip) pingTasks.push({ routerId, gatewayIp: gw.ip });
}); });
}); });
requestPings(pingTasks); requestPings(pingTasks, { scope: 'easy-switch' });
}, [location.pathname, servers, inventoryServers, requestPings]); }, [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); setLoading(true);
setError(''); setError('');
@@ -258,13 +269,13 @@ function EasySwitchManager() {
if (gw.ip) pingTasks.push({ routerId, gatewayIp: gw.ip }); if (gw.ip) pingTasks.push({ routerId, gatewayIp: gw.ip });
}); });
}); });
requestPings(pingTasks); requestPings(pingTasks, { scope: 'easy-switch' });
// Отладочная информация // Отладочная информация
try { try {
console.log('[EasySwitch] serversWithGateways:', serversWithGateways); console.log('[EasySwitch] serversWithGateways:', serversWithGateways);
console.log('[EasySwitch] communitiesDirectory:', communities); console.log('[EasySwitch] communitiesDirectory:', communities);
} catch (_) {} } catch { /* no-op */ }
// Автоматически разворачиваем первый сервер // Автоматически разворачиваем первый сервер
if (serversWithGateways.length > 0 && expandedServers.size === 0) { if (serversWithGateways.length > 0 && expandedServers.size === 0) {
@@ -289,7 +300,7 @@ function EasySwitchManager() {
try { try {
console.log('[EasySwitch] initialActiveGateways:', initialActive); console.log('[EasySwitch] initialActiveGateways:', initialActive);
} catch (_) {} } catch { /* no-op */ }
} catch (err) { } catch (err) {
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return; if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
@@ -298,7 +309,8 @@ function EasySwitchManager() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; // eslint-disable-next-line react-hooks/exhaustive-deps -- init-only, deps would cause unnecessary re-fetches
}, []);
const handleGatewaySelect = (serverId, community, gateway) => { const handleGatewaySelect = (serverId, community, gateway) => {
const key = `${serverId}:${community}`; const key = `${serverId}:${community}`;
@@ -370,7 +382,7 @@ function EasySwitchManager() {
const currentFilters = Array.isArray(response.data) ? response.data : []; const currentFilters = Array.isArray(response.data) ? response.data : [];
// Создаем Set существующих communities в фильтрах // Создаем Set существующих communities в фильтрах
const existingCommunitiesSet = new Set(currentFilters.map(f => f.community)); const _existingCommunitiesSet = new Set(currentFilters.map(f => f.community));
// Обновляем существующие фильтры и добавляем новые // Обновляем существующие фильтры и добавляем новые
const updatedFilters = []; const updatedFilters = [];
@@ -505,7 +517,7 @@ function EasySwitchManager() {
if (loading && servers.length === 0) { if (loading && servers.length === 0) {
return ( return (
<div className="container-xl"> <div className="easy-switch-page">
<PageHeader <PageHeader
title="Easy Switch" title="Easy Switch"
icon={<IconWorld size={24} />} icon={<IconWorld size={24} />}
@@ -525,7 +537,7 @@ function EasySwitchManager() {
} }
return ( return (
<div className="container-xl"> <div className="easy-switch-page">
<PageHeader <PageHeader
title="Easy Switch" title="Easy Switch"
icon={<IconWorld size={24} />} 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"> <ul className="nav nav-tabs" role="tablist">
<li className="nav-item" role="presentation"> <li className="nav-item" role="presentation">
<button <button
@@ -599,8 +611,9 @@ function EasySwitchManager() {
)} )}
{/* Панель фильтров */} {/* Панель фильтров */}
<div className="mb-3"> <div className="card mb-3">
<div className="row g-2 align-items-center"> <div className="card-body py-3">
<div className="row g-2 align-items-center">
{/* Переключатель "Только настроенные" */} {/* Переключатель "Только настроенные" */}
<div className="col-auto"> <div className="col-auto">
<label className="form-check form-switch mb-0"> <label className="form-check form-switch mb-0">
@@ -679,17 +692,18 @@ function EasySwitchManager() {
</button> </button>
</div> </div>
<div className="col-auto"> <div className="col-auto">
<button className="btn btn-icon" title="Настройки"> <button className="btn btn-outline-secondary btn-icon" title="Настройки">
<IconSettings size={18} /> <IconSettings size={18} />
</button> </button>
</div> </div>
<div className="col-auto"> <div className="col-auto">
<button className="btn btn-icon" title="Быстрые действия"> <button className="btn btn-outline-secondary btn-icon" title="Быстрые действия">
<IconFilter size={18} /> <IconFilter size={18} />
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div>
{/* Список серверов с communities и шлюзами */} {/* Список серверов с communities и шлюзами */}
{filteredServers.length === 0 ? ( {filteredServers.length === 0 ? (
@@ -726,12 +740,7 @@ function EasySwitchManager() {
<div key={server.id} className="card mb-3"> <div key={server.id} className="card mb-3">
{/* Заголовок сервера (кликабельный) */} {/* Заголовок сервера (кликабельный) */}
<div <div
className="card-header py-2" className="card-header py-2 easy-switch-server-header"
style={{
backgroundColor: 'rgba(32, 107, 196, 0.05)',
cursor: 'pointer',
userSelect: 'none'
}}
onClick={() => toggleServerExpanded(server.id)} onClick={() => toggleServerExpanded(server.id)}
> >
<div className="d-flex align-items-center"> <div className="d-flex align-items-center">
@@ -742,7 +751,7 @@ function EasySwitchManager() {
<IconChevronDown size={20} className="me-2 text-muted" /> <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" /> <IconServer size={20} className="me-2 text-primary" />
<h3 className="card-title mb-0 me-2">{server.name}</h3> <h3 className="card-title mb-0 me-2">{server.name}</h3>
{server.description && ( {server.description && (
@@ -793,7 +802,7 @@ function EasySwitchManager() {
<div key={tag} className="mb-4"> <div key={tag} className="mb-4">
{/* Заголовок группы тегов */} {/* Заголовок группы тегов */}
<div className="mb-2 pb-2 border-bottom"> <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} {tag}
</span> </span>
<span className="text-muted small"> <span className="text-muted small">
@@ -813,12 +822,12 @@ function EasySwitchManager() {
<div className="mb-2"> <div className="mb-2">
{/* Заголовок community */} {/* Заголовок community */}
<div className="mb-2"> <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} {comm.value}
</span> </span>
<span className="fw-medium">{comm.name || comm.description || ''}</span> <span className="fw-medium">{comm.name || comm.description || ''}</span>
{hasFilterForComm && ( {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> </span>
)} )}
@@ -842,50 +851,21 @@ function EasySwitchManager() {
return ( return (
<div key={gw.name} className="col-12 col-sm-4"> <div key={gw.name} className="col-12 col-sm-4">
<div <div
className={`card cursor-pointer h-100 ${isActive ? 'bg-primary text-white' : ''}`} className={`card h-100 easy-switch-gateway-card ${isActive ? 'easy-switch-gateway-card-active 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'
}}
onClick={() => handleGatewaySelect(server.id, comm.value, gw.name)} 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"> <div className="card-body p-2">
{/* Галочка (абсолютное позиционирование) */} {/* Галочка (абсолютное позиционирование) */}
{isActive && ( {isActive && (
<div <div className="bg-white text-primary rounded-circle d-flex align-items-center justify-content-center easy-switch-active-check">
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'
}}
>
<IconCheck size={12} /> <IconCheck size={12} />
</div> </div>
)} )}
{/* Метка Fastest */} {/* Метка Fastest */}
<div className="mb-1" style={{ minHeight: '18px' }}> <div className="mb-1 easy-switch-fast-slot">
{isFastest && !isActive && ( {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" /> <IconBolt size={10} className="me-1" />
Fast Fast
</span> </span>
@@ -895,22 +875,14 @@ function EasySwitchManager() {
{/* Флаг и название (комментарий) шлюза */} {/* Флаг и название (комментарий) шлюза */}
<div className="d-flex align-items-center gap-2 mb-1"> <div className="d-flex align-items-center gap-2 mb-1">
<span <span
className="d-flex align-items-center justify-content-center flex-shrink-0 rounded" className={`d-flex align-items-center justify-content-center flex-shrink-0 rounded easy-switch-gateway-flag ${isActive ? 'easy-switch-gateway-flag-active' : ''}`}
style={{
width: '28px',
height: '28px',
fontSize: '1.25rem',
lineHeight: 1,
backgroundColor: isActive ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.06)',
}}
title={gw.country || country || ''} title={gw.country || country || ''}
> >
{countryToFlag(gw.country || country || '') || '🌐'} {countryToFlag(gw.country || country || '') || '🌐'}
</span> </span>
<div className="flex-grow-1 min-w-0"> <div className="flex-grow-1 min-w-0">
<div <div
className={`fw-bold ${isActive ? 'text-white' : ''}`} className={`fw-bold easy-switch-gateway-title ${isActive ? 'text-white' : ''}`}
style={{ fontSize: '0.85rem', lineHeight: '1.2', wordBreak: 'break-word' }}
> >
{gw.comment || gw.name} {gw.comment || gw.name}
</div> </div>
@@ -919,17 +891,16 @@ function EasySwitchManager() {
{/* IP шлюза ниже */} {/* IP шлюза ниже */}
{gw.ip && ( {gw.ip && (
<div <div
className={`small text-truncate ${ className={`small text-truncate easy-switch-gateway-ip ${
isActive ? 'text-white-75' : 'text-muted' isActive ? 'text-white-75' : 'text-muted'
}`} }`}
style={{ fontSize: '0.75rem' }}
> >
{gw.ip} {gw.ip}
</div> </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 : '—'} {ping != null ? ping : '—'}
</div> </div>
</div> </div>
@@ -952,7 +923,7 @@ function EasySwitchManager() {
<div className="mb-4"> <div className="mb-4">
{/* Заголовок группы без тегов */} {/* Заголовок группы без тегов */}
<div className="mb-2 pb-2 border-bottom"> <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>
<span className="text-muted small"> <span className="text-muted small">
@@ -972,12 +943,12 @@ function EasySwitchManager() {
<div className="mb-2"> <div className="mb-2">
{/* Заголовок community */} {/* Заголовок community */}
<div className="mb-2"> <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} {comm.value}
</span> </span>
<span className="fw-medium">{comm.name || comm.description || ''}</span> <span className="fw-medium">{comm.name || comm.description || ''}</span>
{hasFilterForComm && ( {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> </span>
)} )}
@@ -1001,50 +972,21 @@ function EasySwitchManager() {
return ( return (
<div key={gw.name} className="col-12 col-sm-4"> <div key={gw.name} className="col-12 col-sm-4">
<div <div
className={`card cursor-pointer h-100 ${isActive ? 'bg-primary text-white' : ''}`} className={`card h-100 easy-switch-gateway-card ${isActive ? 'easy-switch-gateway-card-active 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'
}}
onClick={() => handleGatewaySelect(server.id, comm.value, gw.name)} 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"> <div className="card-body p-2">
{/* Галочка (абсолютное позиционирование) */} {/* Галочка (абсолютное позиционирование) */}
{isActive && ( {isActive && (
<div <div className="bg-white text-primary rounded-circle d-flex align-items-center justify-content-center easy-switch-active-check">
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'
}}
>
<IconCheck size={12} /> <IconCheck size={12} />
</div> </div>
)} )}
{/* Метка Fastest */} {/* Метка Fastest */}
<div className="mb-1" style={{ minHeight: '18px' }}> <div className="mb-1 easy-switch-fast-slot">
{isFastest && !isActive && ( {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" /> <IconBolt size={10} className="me-1" />
Fast Fast
</span> </span>
@@ -1054,22 +996,14 @@ function EasySwitchManager() {
{/* Флаг и название (комментарий) шлюза */} {/* Флаг и название (комментарий) шлюза */}
<div className="d-flex align-items-center gap-2 mb-1"> <div className="d-flex align-items-center gap-2 mb-1">
<span <span
className="d-flex align-items-center justify-content-center flex-shrink-0 rounded" className={`d-flex align-items-center justify-content-center flex-shrink-0 rounded easy-switch-gateway-flag ${isActive ? 'easy-switch-gateway-flag-active' : ''}`}
style={{
width: '28px',
height: '28px',
fontSize: '1.25rem',
lineHeight: 1,
backgroundColor: isActive ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.06)',
}}
title={gw.country || country || ''} title={gw.country || country || ''}
> >
{countryToFlag(gw.country || country || '') || '🌐'} {countryToFlag(gw.country || country || '') || '🌐'}
</span> </span>
<div className="flex-grow-1 min-w-0"> <div className="flex-grow-1 min-w-0">
<div <div
className={`fw-bold ${isActive ? 'text-white' : ''}`} className={`fw-bold easy-switch-gateway-title ${isActive ? 'text-white' : ''}`}
style={{ fontSize: '0.85rem', lineHeight: '1.2', wordBreak: 'break-word' }}
> >
{gw.comment || gw.name} {gw.comment || gw.name}
</div> </div>
@@ -1078,17 +1012,16 @@ function EasySwitchManager() {
{/* IP шлюза ниже */} {/* IP шлюза ниже */}
{gw.ip && ( {gw.ip && (
<div <div
className={`small text-truncate ${ className={`small text-truncate easy-switch-gateway-ip ${
isActive ? 'text-white-75' : 'text-muted' isActive ? 'text-white-75' : 'text-muted'
}`} }`}
style={{ fontSize: '0.75rem' }}
> >
{gw.ip} {gw.ip}
</div> </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 : '—'} {ping != null ? ping : '—'}
</div> </div>
</div> </div>
@@ -1120,12 +1053,12 @@ function EasySwitchManager() {
<div className="mb-2"> <div className="mb-2">
{/* Заголовок community */} {/* Заголовок community */}
<div className="mb-2"> <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} {comm.value}
</span> </span>
<span className="fw-medium">{comm.name || comm.description || ''}</span> <span className="fw-medium">{comm.name || comm.description || ''}</span>
{hasFilterForComm && ( {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> </span>
)} )}
@@ -1149,50 +1082,21 @@ function EasySwitchManager() {
return ( return (
<div key={gw.name} className="col-12 col-sm-4"> <div key={gw.name} className="col-12 col-sm-4">
<div <div
className={`card cursor-pointer h-100 ${isActive ? 'bg-primary text-white' : ''}`} className={`card h-100 easy-switch-gateway-card ${isActive ? 'easy-switch-gateway-card-active 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'
}}
onClick={() => handleGatewaySelect(server.id, comm.value, gw.name)} 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"> <div className="card-body p-2">
{/* Галочка (абсолютное позиционирование) */} {/* Галочка (абсолютное позиционирование) */}
{isActive && ( {isActive && (
<div <div className="bg-white text-primary rounded-circle d-flex align-items-center justify-content-center easy-switch-active-check">
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'
}}
>
<IconCheck size={12} /> <IconCheck size={12} />
</div> </div>
)} )}
{/* Метка Fastest */} {/* Метка Fastest */}
<div className="mb-1" style={{ minHeight: '18px' }}> <div className="mb-1 easy-switch-fast-slot">
{isFastest && !isActive && ( {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" /> <IconBolt size={10} className="me-1" />
Fast Fast
</span> </span>
@@ -1202,22 +1106,14 @@ function EasySwitchManager() {
{/* Флаг и название (комментарий) шлюза */} {/* Флаг и название (комментарий) шлюза */}
<div className="d-flex align-items-center gap-2 mb-1"> <div className="d-flex align-items-center gap-2 mb-1">
<span <span
className="d-flex align-items-center justify-content-center flex-shrink-0 rounded" className={`d-flex align-items-center justify-content-center flex-shrink-0 rounded easy-switch-gateway-flag ${isActive ? 'easy-switch-gateway-flag-active' : ''}`}
style={{
width: '28px',
height: '28px',
fontSize: '1.25rem',
lineHeight: 1,
backgroundColor: isActive ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.06)',
}}
title={gw.country || country || ''} title={gw.country || country || ''}
> >
{countryToFlag(gw.country || country || '') || '🌐'} {countryToFlag(gw.country || country || '') || '🌐'}
</span> </span>
<div className="flex-grow-1 min-w-0"> <div className="flex-grow-1 min-w-0">
<div <div
className={`fw-bold ${isActive ? 'text-white' : ''}`} className={`fw-bold easy-switch-gateway-title ${isActive ? 'text-white' : ''}`}
style={{ fontSize: '0.85rem', lineHeight: '1.2', wordBreak: 'break-word' }}
> >
{gw.comment || gw.name} {gw.comment || gw.name}
</div> </div>
@@ -1226,15 +1122,14 @@ function EasySwitchManager() {
{/* IP шлюза ниже */} {/* IP шлюза ниже */}
{gw.ip && ( {gw.ip && (
<div <div
className={`small text-truncate mb-1 ${isActive ? 'text-white-75' : 'text-muted'}`} className={`small text-truncate mb-1 easy-switch-gateway-ip ${isActive ? 'text-white-75' : 'text-muted'}`}
style={{ fontSize: '0.75rem' }}
> >
{gw.ip} {gw.ip}
</div> </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 : '—'} {ping != null ? ping : '—'}
</div> </div>
</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 { Link, useLocation } from 'react-router-dom';
import api from './lib/api.js'; import api from './lib/api.js';
import ConfirmDialog from './components/ConfirmDialog.jsx'; import ConfirmDialog from './components/ConfirmDialog.jsx';
@@ -47,6 +47,7 @@ import {
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { normalizeGateways, countryToFlag } from './utils/serverUtils.js'; import { normalizeGateways, countryToFlag } from './utils/serverUtils.js';
import { usePing } from './contexts/PingContext.jsx'; import { usePing } from './contexts/PingContext.jsx';
import { listCommunities } from './lib/evobgpData.js';
// Компонент автокомплита для выбора Community // Компонент автокомплита для выбора Community
function CommunityAutocomplete({ label, value, onChange, communities = [], required = false, placeholder = 'Введите или выберите community...', baseAS = '65001' }) { 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" в справочнике // 3. Если ввели "65001:120", ищем "120" в справочнике
if (search.includes(':')) { if (search.includes(':')) {
const searchParts = search.split(':'); const searchParts = search.split(':');
const searchAS = searchParts[0]; const _searchAS = searchParts[0];
const searchNum = searchParts[1]; const searchNum = searchParts[1];
// Точное совпадение числовой части // Точное совпадение числовой части
@@ -246,7 +247,7 @@ function CommunityAutocomplete({ label, value, onChange, communities = [], requi
{Object.entries(groupedCommunities).map(([category, items]) => ( {Object.entries(groupedCommunities).map(([category, items]) => (
<div key={category}> <div key={category}>
<div className="dropdown-header small text-muted bg-light">{category}</div> <div className="dropdown-header small text-muted bg-light">{category}</div>
{items.map((c, idx) => { {items.map((c, _idx) => {
const globalIdx = filteredCommunities.indexOf(c); const globalIdx = filteredCommunities.indexOf(c);
return ( return (
<div <div
@@ -773,7 +774,7 @@ function FilterManager() {
const { pingMap, requestPings } = usePing(); const { pingMap, requestPings } = usePing();
// Получить metadata сервера из inventory по имени (dns/hostName/ip или короткое имя типа "ihor") // Получить metadata сервера из inventory по имени (dns/hostName/ip или короткое имя типа "ihor")
const getInventoryMetaForServer = (serverName) => { const getInventoryMetaForServer = useCallback((serverName) => {
if (!serverName) return null; if (!serverName) return null;
const name = String(serverName).trim(); const name = String(serverName).trim();
return inventoryServers.find((srv) => { return inventoryServers.find((srv) => {
@@ -781,20 +782,18 @@ function FilterManager() {
const hostName = String(srv.hostName || '').trim(); const hostName = String(srv.hostName || '').trim();
const ip = String(srv.ip || '').trim(); const ip = String(srv.ip || '').trim();
if (dns === name || hostName === name || ip === name) return true; if (dns === name || hostName === name || ip === name) return true;
// На странице Фильтров selectedServer.name короткое имя (первая часть dns), например "ihor"
if (dns && dns.split('.')[0] === name) return true; if (dns && dns.split('.')[0] === name) return true;
return false; return false;
}); });
}; }, [inventoryServers]);
// Получить routerId для MikroTik (id/dns/ip), как на странице Easy Switch // Получить routerId для MikroTik (id/dns/ip), как на странице Easy Switch
const getRouterIdForServer = (server) => { const getRouterIdForServer = useCallback((server) => {
if (!server) return null; if (!server) return null;
// Сначала ищем по полному имени (fullName/dns), затем по короткому (name)
const meta = getInventoryMetaForServer(server.fullName || server.dns || server.name) || getInventoryMetaForServer(server.name); const meta = getInventoryMetaForServer(server.fullName || server.dns || server.name) || getInventoryMetaForServer(server.name);
if (!meta) return null; if (!meta) return null;
return meta.id || meta.dns || meta.ip || null; return meta.id || meta.dns || meta.ip || null;
}; }, [getInventoryMetaForServer]);
// Собираем gateways только для выбранного сервера // Собираем gateways только для выбранного сервера
const selectedServerGateways = (() => { const selectedServerGateways = (() => {
@@ -893,7 +892,7 @@ function FilterManager() {
.filter((gw) => gw.ip) .filter((gw) => gw.ip)
.map((gw) => ({ routerId, gatewayIp: gw.ip })); .map((gw) => ({ routerId, gatewayIp: gw.ip }));
requestPings(tasks); requestPings(tasks);
}, [location.pathname, selectedServer, selectedServerGateways, requestPings]); }, [location.pathname, selectedServer, selectedServerGateways, requestPings, getRouterIdForServer]);
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
@@ -913,10 +912,11 @@ function FilterManager() {
(async () => { (async () => {
try { try {
const res = await api.get(`/communities`, opts); const rows = await listCommunities();
setCommunities(Array.isArray(res.data) ? res.data : []); if (signal.aborted) return;
setCommunities(Array.isArray(rows) ? rows : []);
} catch (e) { } 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); setTimeout(() => setSuccess(''), 3000);
return; return;
} }
} catch (legacyError) { } catch (_legacyError) {
// Фильтров по legacyId тоже нет - это нормально // Фильтров по legacyId тоже нет - это нормально
console.log('No legacy filters found'); 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 { try {
const response = await api.get(`/server-configs/${serverId}`); const response = await api.get(`/server-configs/${serverId}`);
const config = response.data?.config; const config = response.data?.config;
@@ -1192,7 +1192,7 @@ function FilterManager() {
setTimeout(() => setSuccess(''), 3000); setTimeout(() => setSuccess(''), 3000);
return; return;
} }
} catch (legacyError) { } catch (_legacyError) {
console.log('No legacy config found'); console.log('No legacy config found');
} }
} }
@@ -1299,7 +1299,7 @@ function FilterManager() {
} }
}; };
const handleDeployToAllServers = async () => { const _handleDeployToAllServers = async () => {
if (!selectedServer) { if (!selectedServer) {
setError('Сначала выберите сервер с фильтрами для развертывания.'); setError('Сначала выберите сервер с фильтрами для развертывания.');
return; return;
@@ -1521,7 +1521,7 @@ function FilterManager() {
setEditServerModalOpen(false); setEditServerModalOpen(false);
setSuccess('Сервер успешно обновлён!'); setSuccess('Сервер успешно обновлён!');
setTimeout(() => setSuccess(''), 3000); setTimeout(() => setSuccess(''), 3000);
} catch (error) { } catch (_error) {
setEditServerError('Не удалось сохранить изменения.'); setEditServerError('Не удалось сохранить изменения.');
} }
}; };
@@ -1576,7 +1576,7 @@ function FilterManager() {
await navigator.clipboard.writeText(config); await navigator.clipboard.writeText(config);
setSuccess('Конфигурация скопирована в буфер обмена!'); setSuccess('Конфигурация скопирована в буфер обмена!');
setTimeout(() => setSuccess(''), 3000); setTimeout(() => setSuccess(''), 3000);
} catch (err) { } catch (_err) {
setError('Не удалось скопировать конфигурацию'); setError('Не удалось скопировать конфигурацию');
} }
} else { } else {
@@ -1590,7 +1590,7 @@ function FilterManager() {
document.execCommand('copy'); document.execCommand('copy');
setSuccess('Конфигурация скопирована в буфер обмена!'); setSuccess('Конфигурация скопирована в буфер обмена!');
setTimeout(() => setSuccess(''), 3000); setTimeout(() => setSuccess(''), 3000);
} catch (err) { } catch (_err) {
setError('Не удалось скопировать конфигурацию'); setError('Не удалось скопировать конфигурацию');
} }
document.body.removeChild(textArea); document.body.removeChild(textArea);
@@ -1598,7 +1598,7 @@ function FilterManager() {
}; };
// Функция для экспорта конфигурации в S3 // Функция для экспорта конфигурации в S3
const exportConfigToS3 = async () => { const _exportConfigToS3 = async () => {
setLoading(true); setLoading(true);
try { try {
const response = await api.post(`/filters/export-config`); const response = await api.post(`/filters/export-config`);
@@ -2561,7 +2561,7 @@ function FilterManager() {
try { try {
await api.post(`/server-configs`, { servers: updatedServers }); await api.post(`/server-configs`, { servers: updatedServers });
setServers(updatedServers); setServers(updatedServers);
} catch (error) { } catch (_error) {
setError('Не удалось обновить статус сервера'); 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 api from './lib/api.js';
import PageHeader from './components/PageHeader.jsx'; import PageHeader from './components/PageHeader.jsx';
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx'; import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
@@ -250,7 +250,7 @@ export default function FirewallPage() {
const res = await api.get('/servers'); const res = await api.get('/servers');
const list = Array.isArray(res.data) ? res.data : []; const list = Array.isArray(res.data) ? res.data : [];
if (!cancelled) setServers(list); if (!cancelled) setServers(list);
} catch (e) { } catch (_e) {
if (!cancelled) setError('Не удалось загрузить список серверов.'); if (!cancelled) setError('Не удалось загрузить список серверов.');
} finally { } finally {
if (!cancelled) setLoadingServers(false); if (!cancelled) setLoadingServers(false);
@@ -259,7 +259,7 @@ export default function FirewallPage() {
return () => { cancelled = true; }; return () => { cancelled = true; };
}, []); }, []);
const fetchAddressLists = async () => { const fetchAddressLists = useCallback(async () => {
if (!serverId) { if (!serverId) {
setData(null); setData(null);
return; return;
@@ -281,12 +281,12 @@ export default function FirewallPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [serverId]);
useEffect(() => { useEffect(() => {
if (serverId) fetchAddressLists(); if (serverId) fetchAddressLists();
else setData(null); else setData(null);
}, [serverId]); }, [serverId, fetchAddressLists]);
const runAnalysis = () => { const runAnalysis = () => {
if (!data?.ban?.length) return; if (!data?.ban?.length) return;
@@ -319,7 +319,7 @@ export default function FirewallPage() {
[s.subnet]: { org: info.org, country: info.country, city: info.city }, [s.subnet]: { org: info.org, country: info.country, city: info.city },
})); }));
} }
} catch (_) { } catch {
if (!cancelled) setIpInfoMap((prev) => ({ ...prev, [s.subnet]: null })); if (!cancelled) setIpInfoMap((prev) => ({ ...prev, [s.subnet]: null }));
} }
if (i < slice.length - 1) await new Promise((r) => setTimeout(r, 180)); if (i < slice.length - 1) await new Promise((r) => setTimeout(r, 180));
+6 -7
View File
@@ -5,7 +5,6 @@ import {
MiniMap, MiniMap,
useEdgesState, useEdgesState,
useNodesState, useNodesState,
addEdge,
MarkerType, MarkerType,
Handle, Handle,
Position, Position,
@@ -224,7 +223,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
}); });
setSelectedNodeId(nodeId); setSelectedNodeId(nodeId);
setTimeout(() => setSelectedNodeId(null), 2000); setTimeout(() => setSelectedNodeId(null), 2000);
} catch {} } catch { /* no-op */ }
}, []); }, []);
// Сброс раскладки: очищаем localStorage и пересоздаём ноды // Сброс раскладки: очищаем localStorage и пересоздаём ноды
@@ -232,7 +231,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
try { try {
window.localStorage.removeItem(STORAGE_KEY); window.localStorage.removeItem(STORAGE_KEY);
} catch {} } catch { /* no-op */ }
} }
setNodes((current) => { setNodes((current) => {
return current.map((n, i) => ({ return current.map((n, i) => ({
@@ -244,7 +243,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
if (instanceRef.current) { if (instanceRef.current) {
try { try {
instanceRef.current.fitView({ padding: 0.2 }); instanceRef.current.fitView({ padding: 0.2 });
} catch {} } catch { /* no-op */ }
} }
}, 100); }, 100);
}, [setNodes, computeGridPosition]); }, [setNodes, computeGridPosition]);
@@ -378,7 +377,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
try { try {
inst.fitView({ padding: 0.2, includeHiddenNodes: true, minZoom: 0.2, maxZoom: 2 }); 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(() => { const t = setTimeout(() => {
try { try {
i.fitView({ padding: 0.2, includeHiddenNodes: true, minZoom: 0.2, maxZoom: 2 }); i.fitView({ padding: 0.2, includeHiddenNodes: true, minZoom: 0.2, maxZoom: 2 });
} catch {} } catch { /* no-op */ }
}, 50); }, 50);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [servers, connections]); }, [servers, connections]);
@@ -403,7 +402,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
setTimeout(() => { setTimeout(() => {
try { try {
instanceRef.current.fitView({ padding: 0.2 }); instanceRef.current.fitView({ padding: 0.2 });
} catch {} } catch { /* no-op */ }
}, 60); }, 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 api from './lib/api.js';
import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx'; import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx';
import CommunityBadge from './components/CommunityBadge.jsx'; import CommunityBadge from './components/CommunityBadge.jsx';
@@ -44,21 +44,41 @@ import Pagination from './components/Pagination.jsx';
import AddItemModal from './components/AddItemModal.jsx'; import AddItemModal from './components/AddItemModal.jsx';
import LastSaved from './components/LastSaved.jsx'; import LastSaved from './components/LastSaved.jsx';
import FormModal from './components/FormModal.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() { function IPRangesManager() {
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [originalItems, setOriginalItems] = useState([]); const [originalItems, setOriginalItems] = useState([]);
const [etag, setEtag] = useState(''); const [, setEtag] = useState('');
const [lastModified, setLastModified] = useState(''); const [lastModified, setLastModified] = useState('');
const [contentLength, setContentLength] = useState(null); const [, setContentLength] = useState(null);
const [newItem, setNewItem] = useState({ ipRange: '', community: '' }); 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 [error, setError] = useState('');
const [success, setSuccess] = useState(''); const [success, setSuccess] = useState('');
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const searchTimer = useRef(null); const _searchTimer = useRef(null);
const didInit = useRef(false); const didInit = useRef(false);
const [editingValue, setEditingValue] = useState(''); const [editingValue, setEditingValue] = useState('');
const [editModalOpen, setEditModalOpen] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false);
@@ -80,9 +100,9 @@ function IPRangesManager() {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
const res = await api.get(`/communities`); const list = await listCommunities();
setCommunities(Array.isArray(res.data) ? res.data : []); setCommunities(list);
} catch (e) { } catch (_e) {
// тихо игнорируем // тихо игнорируем
} }
})(); })();
@@ -90,23 +110,6 @@ function IPRangesManager() {
const renderCommunityBadge = (value) => (<CommunityBadge value={value} communities={communities} />); 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 // Допускаем как числовые, так и строковые (AS:NNN) community
const isValidCommunity = (value) => /^(\d+|\d+:\d+)$/.test(String(value).trim()); const isValidCommunity = (value) => /^(\d+|\d+:\d+)$/.test(String(value).trim());
@@ -126,20 +129,17 @@ function IPRangesManager() {
return () => controller.abort(); return () => controller.abort();
}, []); }, []);
const fetchItems = async (abortSignal) => { const fetchItems = useCallback(async (_abortSignal) => {
setLoading(true); setLoading(true);
const opts = abortSignal ? { signal: abortSignal } : {};
try { try {
const response = await api.get(`/ip-ranges`, { params: { offset: 0, limit: 0, format: 'std' }, ...opts }); const payload = await fetchIpRangesData();
const payload = Array.isArray(response.data?.items) ? response.data.items : [];
const total = payload.length; const total = payload.length;
setItems(payload); setItems(payload);
setOriginalItems(payload); setOriginalItems(payload);
setTotalItems(Number.isFinite(total) ? total : 0); setTotalItems(Number.isFinite(total) ? total : 0);
setEtag(response.headers?.etag || ''); setEtag('');
setLastModified(response.headers?.['last-modified'] || ''); setLastModified(new Date().toISOString());
const lengthHeader = response.headers?.['content-length-source']; setContentLength(null);
setContentLength(typeof lengthHeader !== 'undefined' ? Number(lengthHeader) : null);
setError(''); setError('');
} catch (error) { } catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return; if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
@@ -148,7 +148,7 @@ function IPRangesManager() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, []);
useEffect(() => { useEffect(() => {
if (!didInit.current) { if (!didInit.current) {
@@ -157,7 +157,7 @@ function IPRangesManager() {
fetchItems(controller.signal); fetchItems(controller.signal);
return () => controller.abort(); return () => controller.abort();
} }
}, []); }, [fetchItems]);
const handleAddItem = (item) => { const handleAddItem = (item) => {
const ipRange = item?.ipRange || newItem.ipRange; 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` }]); setItems([...items, { ...item, ipRange: `${item.ipRange.split('/')[0]}/24` }]);
window.notify?.success?.('Запись продублирована'); window.notify?.success?.('Запись продублирована');
}; };
@@ -316,19 +316,19 @@ function IPRangesManager() {
return { added, removed, changed }; return { added, removed, changed };
}; };
const [showDiff, setShowDiff] = useState(false); const [_showDiff, setShowDiff] = useState(false);
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] }); const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false); const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false); const [historyOpen, setHistoryOpen] = useState(false);
const [clearCommunitiesOpen, setClearCommunitiesOpen] = useState(false); const [clearCommunitiesOpen, setClearCommunitiesOpen] = useState(false);
// Аналитика // Аналитика
const [analyzeOpen, setAnalyzeOpen] = useState(false); const [_analyzeOpen, _setAnalyzeOpen] = useState(false);
const [analyzeConfirmOpen, setAnalyzeConfirmOpen] = useState(false); const [_analyzeConfirmOpen, _setAnalyzeConfirmOpen] = useState(false);
const [analysisResultsOpen, setAnalysisResultsOpen] = useState(false); const [_analysisResultsOpen, setAnalysisResultsOpen] = useState(false);
const [analysisPreview, setAnalysisPreview] = useState(null); const [analysisPreview, setAnalysisPreview] = useState(null);
const [overwriteConfirmOpen, setOverwriteConfirmOpen] = useState(false); const [_overwriteConfirmOpen, setOverwriteConfirmOpen] = useState(false);
const [analyzeFilters, setAnalyzeFilters] = useState({ community: '', minMask: 0, maxMask: 32, type: 'any', supernet16: false }); const [analyzeFilters, _setAnalyzeFilters] = useState({ community: '', minMask: 0, maxMask: 32, type: 'any', supernet16: false });
const handlePreviewDiff = () => { const handlePreviewDiff = () => {
const valid = items.filter(i => i != null && isValidCidr(i.ipRange) && isValidCommunity(i.community)) const valid = items.filter(i => i != null && isValidCidr(i.ipRange) && isValidCommunity(i.community))
@@ -338,27 +338,7 @@ function IPRangesManager() {
setShowDiff(true); setShowDiff(true);
}; };
const handleSaveChanges = async () => { const performSave = useCallback(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 () => {
setConfirmSaveOpen(false); setConfirmSaveOpen(false);
setLoading(true); setLoading(true);
try { try {
@@ -366,28 +346,8 @@ function IPRangesManager() {
.map(i => ({ ipRange: i.ipRange.trim(), community: String(i.community).trim() })); .map(i => ({ ipRange: i.ipRange.trim(), community: String(i.community).trim() }));
const unique = deduplicate(valid); const unique = deduplicate(valid);
// Загрузим полный список, совместим с изменениями текущей страницы и сохраним await saveIpRangesData(unique, originalItems);
const fullRes = await api.get(`/ip-ranges`, { params: { offset: 0, limit: 0, format: 'std' } }); setEtag('');
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 fetchItems(); await fetchItems();
setSuccess('Изменения успешно сохранены!'); setSuccess('Изменения успешно сохранены!');
setTimeout(() => setSuccess(''), 3000); setTimeout(() => setSuccess(''), 3000);
@@ -398,7 +358,21 @@ function IPRangesManager() {
} finally { } finally {
setLoading(false); 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 = () => { const handleImport = () => {
setImportOpen(true); setImportOpen(true);
@@ -406,9 +380,7 @@ function IPRangesManager() {
const handleExport = async () => { const handleExport = async () => {
try { try {
const effectiveQ = searchTerm ? searchTerm : (filterCommunity ? filterCommunity : ''); const all = filtered;
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 header = ['ipRange', 'community']; const header = ['ipRange', 'community'];
const csv = [header, ...all.map(i => [i.ipRange, i.community])] const csv = [header, ...all.map(i => [i.ipRange, i.community])]
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')) .map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
@@ -426,7 +398,7 @@ function IPRangesManager() {
}; };
// Drag&Drop импорт (оставим как быстрый путь) // Drag&Drop импорт (оставим как быстрый путь)
const onDropImport = async (e) => { const _onDropImport = async (e) => {
e.preventDefault(); e.preventDefault();
const file = e.dataTransfer?.files?.[0]; const file = e.dataTransfer?.files?.[0];
if (!file) return; if (!file) return;
@@ -532,7 +504,7 @@ function IPRangesManager() {
}; };
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [loading, items, originalItems, etag, sortField, sortOrder, filterCommunity]); }, [loading, handleSaveChanges]);
const handleEditKeyDown = (e) => { const handleEditKeyDown = (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
@@ -582,7 +554,7 @@ function IPRangesManager() {
return 'public'; return 'public';
}; };
const formatBigInt = (n) => { const _formatBigInt = (n) => {
try { try {
const s = (n ?? 0n).toString(); const s = (n ?? 0n).toString();
return s.replace(/\B(?=(\d{3})+(?!\d))/g, ' '); return s.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
@@ -591,7 +563,7 @@ function IPRangesManager() {
} }
}; };
const buildAnalysisConfirmText = () => { const _buildAnalysisConfirmText = () => {
const parts = []; const parts = [];
parts.push(`Community: ${analyzeFilters.community || 'не выбрано'}`); parts.push(`Community: ${analyzeFilters.community || 'не выбрано'}`);
parts.push(`Маска: /${analyzeFilters.minMask}..../${analyzeFilters.maxMask}`); parts.push(`Маска: /${analyzeFilters.minMask}..../${analyzeFilters.maxMask}`);
@@ -705,7 +677,7 @@ function IPRangesManager() {
return Array.from(new Set(out)); return Array.from(new Set(out));
}; };
const runAnalysis = () => { const _runAnalysis = () => {
const community = String(analyzeFilters.community || '').trim(); const community = String(analyzeFilters.community || '').trim();
if (!community) { window.notify?.error?.('Выберите community'); return; } if (!community) { window.notify?.error?.('Выберите community'); return; }
// исходные для community // исходные для community
@@ -744,7 +716,7 @@ function IPRangesManager() {
setAnalysisResultsOpen(true); setAnalysisResultsOpen(true);
}; };
const applyAnalysisToItems = () => { const _applyAnalysisToItems = () => {
if (!analysisPreview) return; if (!analysisPreview) return;
const { community, afterCidrs, unchangedCidrs } = analysisPreview; const { community, afterCidrs, unchangedCidrs } = analysisPreview;
const keptOthers = items.filter(i => i != null && i.community !== community); const keptOthers = items.filter(i => i != null && i.community !== community);
@@ -770,10 +742,14 @@ function IPRangesManager() {
window.notify?.success?.('Диапазоны перезаписаны по результатам анализа. Не забудьте сохранить изменения.'); 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 header = ['community', 'ranges', 'totalAddresses'];
const lines = [header, ...analysisResult.map(r => [r.community, r.ranges, r.totalAddresses.toString()])] const rows = [[community, String(afterCidrs?.length ?? 0), totalAddr.toString()]];
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')) const lines = [header, ...rows]
.map(r => (Array.isArray(r) ? r : []).map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
.join('\n'); .join('\n');
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' }); const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
+1 -1
View File
@@ -83,7 +83,7 @@ export default function InterfaceSpeedTest() {
return () => controller.abort(); return () => controller.abort();
}, [notify]); }, [notify]);
const gateways = useMemo( const _gateways = useMemo(
() => () =>
networkConfig?.gateways && Array.isArray(networkConfig.gateways) networkConfig?.gateways && Array.isArray(networkConfig.gateways)
? 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 * as Diff from 'diff';
import api from './lib/api.js'; import api from './lib/api.js';
import { useNotify } from './components/NotifyProvider.jsx'; import { useNotify } from './components/NotifyProvider.jsx';
@@ -156,13 +156,70 @@ function MikrotikBackupsManager() {
const [backupPage, setBackupPage] = useState(1); const [backupPage, setBackupPage] = useState(1);
const backupPageSize = 10; 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(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
const signal = controller.signal; const signal = controller.signal;
fetchServers(signal); fetchServers(signal);
fetchUiSettings(signal); fetchUiSettings(signal);
return () => controller.abort(); return () => controller.abort();
}, []); }, [fetchServers, fetchUiSettings]);
useEffect(() => { useEffect(() => {
if (selectedServerId) { if (selectedServerId) {
@@ -173,15 +230,13 @@ function MikrotikBackupsManager() {
setBackups([]); setBackups([]);
setSelectedKeys([]); setSelectedKeys([]);
} }
}, [selectedServerId]); }, [selectedServerId, fetchBackups]);
const jumphostServers = useMemo( const jumphostServers = useMemo(
() => (servers || []).filter((s) => String(s.type || '').toLowerCase() === 'jumphost'), () => (servers || []).filter((s) => String(s.type || '').toLowerCase() === 'jumphost'),
[servers], [servers],
); );
const makeServerId = (s) => (s.id || s.dns || s.ip || '').toString();
const jumphostInputServers = useMemo( const jumphostInputServers = useMemo(
() => jumphostServers.map((s) => ({ ...s, id: makeServerId(s) })), () => jumphostServers.map((s) => ({ ...s, id: makeServerId(s) })),
[jumphostServers], [jumphostServers],
@@ -319,61 +374,6 @@ function MikrotikBackupsManager() {
setBackupServerIds(new Set()); 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) => { const handleSelectKey = (key) => {
setSelectedKeys((prev) => { setSelectedKeys((prev) => {
if (prev.includes(key)) { if (prev.includes(key)) {
+4 -4
View File
@@ -82,7 +82,7 @@ function MikrotikTools() {
[networkConfig] [networkConfig]
); );
const handleSelectGatewayMeta = (meta) => { const _handleSelectGatewayMeta = (meta) => {
setGatewayMeta(meta); setGatewayMeta(meta);
// Если цель не задана подставляем IP gateway как target // Если цель не задана подставляем IP gateway как target
if (!target && meta && meta.ip) { if (!target && meta && meta.ip) {
@@ -214,7 +214,7 @@ function MikrotikTools() {
[jumphostServers, serverId] [jumphostServers, serverId]
); );
const interfacesForServer = useMemo(() => { const _interfacesForServer = useMemo(() => {
if (!serverId || !interfaces.length) return []; if (!serverId || !interfaces.length) return [];
const ids = new Set( const ids = new Set(
[serverId, currentServer?.id, currentServer?.ip, currentServer?.dns] [serverId, currentServer?.id, currentServer?.ip, currentServer?.dns]
@@ -391,7 +391,7 @@ function MikrotikTools() {
{/* Цели — карточки в стиле сервера/gateway */} {/* Цели — карточки в стиле сервера/gateway */}
<div className="row row-cards g-1 mt-2"> <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 isSelected = target === tgt;
const bgClass = color === 'red' ? 'bg-red-lt' : color === 'blue' ? 'bg-blue-lt' : 'bg-orange-lt'; 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'; 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"> <div className="card-body py-2 px-2 d-flex align-items-center gap-2">
<span className={`avatar avatar-sm ${bgClass} ${textClass}`}> <span className={`avatar avatar-sm ${bgClass} ${textClass}`}>
<Icon size={20} stroke={1.5} /> <_Icon size={20} stroke={1.5} />
</span> </span>
<div className="flex-grow-1 min-w-0"> <div className="flex-grow-1 min-w-0">
<div className="fw-semibold text-truncate small">{name}</div> <div className="fw-semibold text-truncate small">{name}</div>
+35 -16
View File
@@ -249,6 +249,7 @@ function NetworkConfigManager() {
fetchServers(); fetchServers();
fetchIpsecPasswords(); fetchIpsecPasswords();
fetchUiSettings(); fetchUiSettings();
// eslint-disable-next-line react-hooks/exhaustive-deps -- init-only, fetch functions defined below
}, []); }, []);
const fetchUiSettings = async () => { const fetchUiSettings = async () => {
@@ -393,6 +394,7 @@ function NetworkConfigManager() {
label: getServerLabel(id), label: getServerLabel(id),
server: getServerInfo(id), server: getServerInfo(id),
})); }));
// eslint-disable-next-line react-hooks/exhaustive-deps -- getServerInfo/getServerLabel are stable helpers
}, [config.gateways, config.tunnelInterfaces, config.ipPools, servers]); }, [config.gateways, config.tunnelInterfaces, config.ipPools, servers]);
// === Фильтрация === // === Фильтрация ===
@@ -431,6 +433,7 @@ function NetworkConfigManager() {
} }
return result; return result;
// eslint-disable-next-line react-hooks/exhaustive-deps -- getServerInfo/getServerLabel/gatewayTypeFilter are stable
}, [config.gateways, providerFilter, serverFilter, typeFilter, searchTerm, servers]); }, [config.gateways, providerFilter, serverFilter, typeFilter, searchTerm, servers]);
const filteredInterfaces = useMemo(() => { const filteredInterfaces = useMemo(() => {
@@ -456,6 +459,7 @@ function NetworkConfigManager() {
} }
return result; return result;
// eslint-disable-next-line react-hooks/exhaustive-deps -- getServerLabel is stable helper
}, [config.tunnelInterfaces, typeFilter, serverFilter, searchTerm, servers]); }, [config.tunnelInterfaces, typeFilter, serverFilter, searchTerm, servers]);
const filteredPools = useMemo(() => { const filteredPools = useMemo(() => {
@@ -468,7 +472,7 @@ function NetworkConfigManager() {
if (searchTerm) { if (searchTerm) {
const term = searchTerm.toLowerCase(); const term = searchTerm.toLowerCase();
result = result.filter(p => { result = result.filter(p => {
const server = getServerInfo(p.serverId); const _server = getServerInfo(p.serverId);
return ( return (
p.name?.toLowerCase().includes(term) || p.name?.toLowerCase().includes(term) ||
p.cidr?.toLowerCase().includes(term) || p.cidr?.toLowerCase().includes(term) ||
@@ -479,6 +483,7 @@ function NetworkConfigManager() {
} }
return result; return result;
// eslint-disable-next-line react-hooks/exhaustive-deps -- getServerInfo/getServerLabel are stable helpers
}, [config.ipPools, serverFilter, searchTerm, servers]); }, [config.ipPools, serverFilter, searchTerm, servers]);
// === Группировка по серверам === // === Группировка по серверам ===
@@ -528,6 +533,7 @@ function NetworkConfigManager() {
}); });
return grouped; return grouped;
// eslint-disable-next-line react-hooks/exhaustive-deps -- getServerCategory is stable helper
}, [gatewaysByServer, servers]); }, [gatewaysByServer, servers]);
const interfacesByCategory = useMemo(() => { const interfacesByCategory = useMemo(() => {
@@ -547,6 +553,7 @@ function NetworkConfigManager() {
}); });
return grouped; return grouped;
// eslint-disable-next-line react-hooks/exhaustive-deps -- getServerCategory is stable helper
}, [interfacesByServer, servers]); }, [interfacesByServer, servers]);
// === Сброс фильтров === // === Сброс фильтров ===
@@ -913,7 +920,7 @@ function NetworkConfigManager() {
return; return;
} }
const invalidPairs = templateServerPairs.filter((pair, index) => { const invalidPairs = templateServerPairs.filter((pair, _index) => {
if (!pair.server1 || !pair.server2) { if (!pair.server1 || !pair.server2) {
return true; return true;
} }
@@ -1274,7 +1281,7 @@ function NetworkConfigManager() {
}; };
// === Генерация IP из пула === // === Генерация 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") // Парсим CIDR (например, "10.10.0.0/24")
const [network, prefixLength] = cidr.split('/'); const [network, prefixLength] = cidr.split('/');
if (!network || !prefixLength) { if (!network || !prefixLength) {
@@ -1590,6 +1597,7 @@ function NetworkConfigManager() {
} }
return a.type.localeCompare(b.type); return a.type.localeCompare(b.type);
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps -- generatePtrZone/getServerInfo are stable helpers
}, [config.tunnelInterfaces, servers, globalPtrZoneReplaceFrom, globalPtrZoneReplaceTo]); }, [config.tunnelInterfaces, servers, globalPtrZoneReplaceFrom, globalPtrZoneReplaceTo]);
// === Фильтрация реестра IP === // === Фильтрация реестра IP ===
@@ -1650,7 +1658,7 @@ function NetworkConfigManager() {
}; };
// === CRUD для IPSec Passwords === // === CRUD для IPSec Passwords ===
const handleAddIpsecPassword = () => { const _handleAddIpsecPassword = () => {
setEditingIpsecPassword({ name: '', password: '', description: '' }); setEditingIpsecPassword({ name: '', password: '', description: '' });
setIpsecPasswordModalMode('add'); setIpsecPasswordModalMode('add');
setIpsecPasswordModalOpen(true); setIpsecPasswordModalOpen(true);
@@ -2021,7 +2029,7 @@ function NetworkConfigManager() {
}; };
// === Генерация кода MikroTik для рекурсивных маршрутов === // === Генерация кода MikroTik для рекурсивных маршрутов ===
const generateMikrotikRecursiveRoutes = async () => { const _generateMikrotikRecursiveRoutes = async () => {
const recursiveGateways = (config.gateways || []).filter(gw => gw.type === 'recursive'); const recursiveGateways = (config.gateways || []).filter(gw => gw.type === 'recursive');
const blocks = []; const blocks = [];
@@ -2073,7 +2081,7 @@ function NetworkConfigManager() {
} }
// Обрабатываем каждый родительский gateway // Обрабатываем каждый родительский gateway
parentGatewaysList.forEach((parentRef, parentIndex) => { parentGatewaysList.forEach((parentRef, _parentIndex) => {
const parent = getParentGateway(parentRef.id); const parent = getParentGateway(parentRef.id);
if (!parent) { if (!parent) {
@@ -2264,7 +2272,7 @@ function NetworkConfigManager() {
if (obj.t === 'log') console.log('[MikroTik apply]', obj.msg, obj); if (obj.t === 'log') console.log('[MikroTik apply]', obj.msg, obj);
else if (obj.t === 'result') finalData = obj; else if (obj.t === 'result') finalData = obj;
else if (obj.t === 'error') finalData = { ok: false, error: obj.error, mikrotikRequests: obj.mikrotikRequests }; else if (obj.t === 'error') finalData = { ok: false, error: obj.error, mikrotikRequests: obj.mikrotikRequests };
} catch (_) {} } catch { /* no-op */ }
} }
} }
if (buffer.trim()) { if (buffer.trim()) {
@@ -2272,7 +2280,7 @@ function NetworkConfigManager() {
const obj = JSON.parse(buffer); const obj = JSON.parse(buffer);
if (obj.t === 'result') finalData = obj; if (obj.t === 'result') finalData = obj;
else if (obj.t === 'error') finalData = { ok: false, error: obj.error, mikrotikRequests: obj.mikrotikRequests }; else if (obj.t === 'error') finalData = { ok: false, error: obj.error, mikrotikRequests: obj.mikrotikRequests };
} catch (_) {} } catch { /* no-op */ }
} }
data = finalData || {}; data = finalData || {};
} else { } else {
@@ -2403,7 +2411,7 @@ function NetworkConfigManager() {
let validRoutesCount = 0; let validRoutesCount = 0;
parentGatewaysList.forEach((parentRef, parentIndex) => { parentGatewaysList.forEach((parentRef, _parentIndex) => {
const parent = getParentGateway(parentRef.id); const parent = getParentGateway(parentRef.id);
if (!parent) { if (!parent) {
@@ -2542,7 +2550,7 @@ function NetworkConfigManager() {
try { try {
document.execCommand('copy'); document.execCommand('copy');
notify.success('Код скопирован в буфер обмена!'); notify.success('Код скопирован в буфер обмена!');
} catch (err) { } catch (_err) {
notify.error('Не удалось скопировать код'); notify.error('Не удалось скопировать код');
} }
document.body.removeChild(textArea); document.body.removeChild(textArea);
@@ -2589,7 +2597,7 @@ function NetworkConfigManager() {
}, [generatedMikrotikCode]); }, [generatedMikrotikCode]);
// === Получение названия типа блока === // === Получение названия типа блока ===
const getBlockTypeLabel = (type) => { const _getBlockTypeLabel = (type) => {
switch (type) { switch (type) {
case 'recursive-routes': case 'recursive-routes':
return 'Рекурсивные маршруты'; return 'Рекурсивные маршруты';
@@ -2967,11 +2975,11 @@ function NetworkConfigManager() {
// === Рендер карточки Gateway === // === Рендер карточки Gateway ===
const renderGatewayCard = (gateway) => { const renderGatewayCard = (gateway) => {
const server = getServerInfo(gateway.serverId); const server = getServerInfo(gateway.serverId);
const provider = server?.provider || ''; const _provider = server?.provider || '';
const serverCountry = server?.country || ''; const serverCountry = server?.country || '';
const gatewayCountry = gateway.country || ''; const gatewayCountry = gateway.country || '';
const displayCountry = gatewayCountry || serverCountry; 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) // Поддержка старого формата (parentGatewayId) и нового (parentGateways)
const parentGatewaysList = gateway.type === 'recursive' const parentGatewaysList = gateway.type === 'recursive'
@@ -3135,7 +3143,7 @@ function NetworkConfigManager() {
const interfaceTypeColor = getInterfaceTypeColor(iface.type); const interfaceTypeColor = getInterfaceTypeColor(iface.type);
// Определяем отображаемый IP для зеленого блока (remote IP другого сервера) // Определяем отображаемый IP для зеленого блока (remote IP другого сервера)
const displayIp = displayRemoteIp !== '—' ? displayRemoteIp : displayLocalIp; const _displayIp = displayRemoteIp !== '—' ? displayRemoteIp : displayLocalIp;
return ( return (
<div className="card" style={{ borderRadius: '12px', maxWidth: '100%' }}> <div className="card" style={{ borderRadius: '12px', maxWidth: '100%' }}>
@@ -3665,6 +3673,17 @@ function NetworkConfigManager() {
{/* Interfaces Tab */} {/* Interfaces Tab */}
{activeTab === 'interfaces' && ( {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 ? ( {filteredInterfaces.length === 0 ? (
<div className="card"> <div className="card">
<div className="card-body text-center py-5"> <div className="card-body text-center py-5">
@@ -5520,7 +5539,7 @@ function NetworkConfigManager() {
<div className="row g-2"> <div className="row g-2">
{GATEWAY_TEMPLATES.map(template => { {GATEWAY_TEMPLATES.map(template => {
// Генерируем предпросмотр IP адресов если есть базовый IP и сервер // Генерируем предпросмотр IP адресов если есть базовый IP и сервер
const previewIps = template.gateways.map((gw, idx) => { const previewIps = template.gateways.map((gw, _idx) => {
// Для прямых gateways показываем IP сервера, если выбран // Для прямых gateways показываем IP сервера, если выбран
if (gw.type === 'direct' && templateServerId) { if (gw.type === 'direct' && templateServerId) {
const server = getServerInfo(templateServerId); const server = getServerInfo(templateServerId);
@@ -5698,7 +5717,7 @@ function NetworkConfigManager() {
<label className="form-label small">Родительские gateway/интерфейсы</label> <label className="form-label small">Родительские gateway/интерфейсы</label>
<div className="d-flex flex-column gap-2"> <div className="d-flex flex-column gap-2">
{(gw.parentGateways || []).map((parent, parentIndex) => { {(gw.parentGateways || []).map((parent, parentIndex) => {
const parentInfo = getParentGateway(parent.id, templateGateways); const _parentInfo = getParentGateway(parent.id, templateGateways);
return ( return (
<div key={parentIndex} className="d-flex align-items-center gap-2"> <div key={parentIndex} className="d-flex align-items-center gap-2">
<div className="flex-grow-1"> <div className="flex-grow-1">
+49 -7
View File
@@ -76,6 +76,7 @@ export default function NetworkMapDashboard() {
const lastSuccessfulPingRef = useRef({}); const lastSuccessfulPingRef = useRef({});
const lastSuccessfulSpeedRef = useRef({}); const lastSuccessfulSpeedRef = useRef({});
const [pingCacheSeconds, setPingCacheSeconds] = useState(0); const [pingCacheSeconds, setPingCacheSeconds] = useState(0);
const [alertSettings, setAlertSettings] = useState({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [pingLoading, setPingLoading] = useState(false); const [pingLoading, setPingLoading] = useState(false);
const [speedLoading, setSpeedLoading] = useState(false); const [speedLoading, setSpeedLoading] = useState(false);
@@ -102,12 +103,17 @@ export default function NetworkMapDashboard() {
const config = configRes?.data || {}; const config = configRes?.data || {};
const tunnelInterfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : []; const tunnelInterfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : [];
const uiSettings = uiSettingsRes?.data || {}; const uiSettings = uiSettingsRes?.data || {};
const uiAlertSettings =
uiSettings.alertSettings && typeof uiSettings.alertSettings === 'object'
? uiSettings.alertSettings
: {};
const cache = cacheRes?.data || {}; const cache = cacheRes?.data || {};
const ttlSeconds = Math.max( const ttlSeconds = Math.max(
0, 0,
parseInt(uiSettings.networkMapPingCacheSeconds, 10) || 0 parseInt(uiSettings.networkMapPingCacheSeconds, 10) || 0
); );
setPingCacheSeconds(ttlSeconds); setPingCacheSeconds(ttlSeconds);
setAlertSettings(uiAlertSettings);
const getServer = (serverId) => const getServer = (serverId) =>
serversList.find((s) => s.id === serverId || s.ip === serverId || s.dns === 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 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; pingAbortRef.current = false;
setPingLoading(true); setPingLoading(true);
const ttlMs = Math.max(0, (pingCacheSeconds || 0) * 1000); const ttlMs = Math.max(0, (pingCacheSeconds || 0) * 1000);
const tasks = []; const tasks = [];
connections.forEach((c) => { sourceConnections.forEach((c) => {
const from = String(c.from); const from = String(c.from);
const to = String(c.to); const to = String(c.to);
if (!from || !to || from === to) return; if (!from || !to || from === to) return;
@@ -214,7 +227,7 @@ export default function NetworkMapDashboard() {
srcTo && ['jumphost', 'home'].includes(String(srcTo.type || '').toLowerCase()) && c.internalToFrom; srcTo && ['jumphost', 'home'].includes(String(srcTo.type || '').toLowerCase()) && c.internalToFrom;
const ekey = edgePingKey(from, to); 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; return;
} }
@@ -291,8 +304,15 @@ export default function NetworkMapDashboard() {
const speedKey = (key1, key2) => const speedKey = (key1, key2) =>
[String(key1), String(key2)].sort().join(':'); [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 (c) => c.speedTestServerId && c.interfaceName
); );
if (withSpeed.length === 0) return; if (withSpeed.length === 0) return;
@@ -307,6 +327,7 @@ export default function NetworkMapDashboard() {
{ {
serverId: c.speedTestServerId, serverId: c.speedTestServerId,
interfaceName: c.interfaceName, interfaceName: c.interfaceName,
forceRefresh: true, // ручной запуск со страницы карты сети всегда должен делать новый замер
}, },
{ timeout: 120000 } { timeout: 120000 }
); );
@@ -343,6 +364,22 @@ export default function NetworkMapDashboard() {
setSpeedLoading(false); setSpeedLoading(false);
}, [connections]); }, [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(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
fetchData(controller.signal); fetchData(controller.signal);
@@ -357,7 +394,7 @@ export default function NetworkMapDashboard() {
}; };
const handleRefreshSpeeds = () => { const handleRefreshSpeeds = () => {
requestSpeeds(); requestSpeeds({ force: true });
}; };
if (loading) { if (loading) {
@@ -427,6 +464,11 @@ export default function NetworkMapDashboard() {
speedMap={speedMap} speedMap={speedMap}
pingStaleMap={pingStaleMap} pingStaleMap={pingStaleMap}
speedStaleMap={speedStaleMap} speedStaleMap={speedStaleMap}
onRefreshPingForConnection={handleRefreshPingForConnection}
onRefreshSpeedForConnection={handleRefreshSpeedForConnection}
pingLoading={pingLoading}
speedLoading={speedLoading}
alertSettings={alertSettings}
/> />
)} )}
</div> </div>
+642 -127
View File
@@ -4,13 +4,17 @@ import { IconRefresh, IconMaximize, IconMinimize, IconClock } from '@tabler/icon
const FLAG_CDN = 'https://flagcdn.com'; const FLAG_CDN = 'https://flagcdn.com';
const UNIFI_BG = '#0f172a'; const UNIFI_BG = '#0f172a';
const UNIFI_BG_DEEP = '#0b1220';
const UNIFI_CARD_BG = '#1e293b'; const UNIFI_CARD_BG = '#1e293b';
const UNIFI_CARD_BORDER = '#334155'; const UNIFI_CARD_BORDER = '#334155';
const UNIFI_GREEN = '#22c55e'; 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 = '#f1f5f9';
const UNIFI_TEXT_MUTED = '#94a3b8'; const UNIFI_TEXT_MUTED = '#94a3b8';
const NODE_WIDTH = 88; const NODE_WIDTH = 124;
const NODE_HEIGHT = 52; const NODE_HEIGHT = 64;
const STORAGE_KEY = 'network-map-unifi-positions'; const STORAGE_KEY = 'network-map-unifi-positions';
function computeInitialLayout(servers, size) { function computeInitialLayout(servers, size) {
@@ -34,7 +38,7 @@ function computeInitialLayout(servers, size) {
const baseX = paddingX; const baseX = paddingX;
columns.forEach((col, colIdx) => { columns.forEach((col, colIdx) => {
// В пределах колонки сортируем по dns/ip для стабильности // В пределах колонки сортируем по dns/ip для стабильности/
const sorted = [...col].sort((a, b) => { const sorted = [...col].sort((a, b) => {
const la = (a.dns || a.ip || '').toString(); const la = (a.dns || a.ip || '').toString();
const lb = (b.dns || b.ip || '').toString(); const lb = (b.dns || b.ip || '').toString();
@@ -86,7 +90,62 @@ function speedKey(key1, key2) {
return [String(key1), String(key2)].sort().join(':'); 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 containerRef = useRef(null);
const svgRef = useRef(null); const svgRef = useRef(null);
const [size, setSize] = useState({ w: 800, h: 520 }); 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 [hoveredEdgeKey, setHoveredEdgeKey] = useState(null);
const [selectedEdge, setSelectedEdge] = 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(() => { useEffect(() => {
const el = containerRef.current; const el = containerRef.current;
if (!el) return; if (!el) return;
@@ -127,10 +213,10 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
try { try {
const raw = window.localStorage.getItem(STORAGE_KEY); const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw) saved = JSON.parse(raw) || {}; if (raw) saved = JSON.parse(raw) || {};
} catch {} } catch { /* no-op */ }
const initial = computeInitialLayout(servers, size); const initial = computeInitialLayout(servers, size);
const merged = {}; const merged = {};
servers.forEach((s, i) => { servers.forEach((s, _i) => {
const ip = String(s.ip); const ip = String(s.ip);
if (saved[ip] && typeof saved[ip].x === 'number' && typeof saved[ip].y === 'number') { if (saved[ip] && typeof saved[ip].x === 'number' && typeof saved[ip].y === 'number') {
merged[ip] = { x: saved[ip].x, y: saved[ip].y }; merged[ip] = { x: saved[ip].x, y: saved[ip].y };
@@ -139,13 +225,13 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
} }
}); });
setPositions(merged); setPositions(merged);
}, [servers.length, size.w, size.h]); }, [servers, size]);
const savePositions = useCallback((next) => { const _savePositions = useCallback((next) => {
setPositions(next); setPositions(next);
try { try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {} } catch { /* no-op */ }
}, []); }, []);
const getSvgCoords = useCallback((clientX, clientY) => { 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) } }; const next = { ...prev, [drag.nodeId]: { x: drag.startX + (p.x - drag.mouseX), y: drag.startY + (p.y - drag.mouseY) } };
try { try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {} } catch { /* no-op */ }
return next; return next;
}); });
}; };
@@ -187,7 +273,7 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
setPositions(initial); setPositions(initial);
try { try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(initial)); window.localStorage.setItem(STORAGE_KEY, JSON.stringify(initial));
} catch {} } catch { /* no-op */ }
}, [servers, size]); }, [servers, size]);
const onNodeMouseDown = useCallback( const onNodeMouseDown = useCallback(
@@ -220,16 +306,23 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
speed && (speed.tcpDownloadBps != null || speed.tcpUploadBps != null) speed && (speed.tcpDownloadBps != null || speed.tcpUploadBps != null)
? formatSpeedShort(speed.tcpDownloadBps, speed.tcpUploadBps) ? formatSpeedShort(speed.tcpDownloadBps, speed.tcpUploadBps)
: null; : null;
const compactEdgeLabel = pingLabel || speedLabel || null;
const edgeLabel =
pingLabel && speedLabel ? `${pingLabel}${speedLabel}` : (pingLabel || speedLabel);
return { return {
edgeKey: `${from}-${to}-${c.tunnelType || ''}`, edgeKey: `${from}-${to}-${c.tunnelType || ''}`,
from, from,
to, to,
p1, p1,
p2, p2,
pingMs: typeof ms === 'number' ? ms : null,
pingLabel, pingLabel,
speedLabel, speedLabel,
compactEdgeLabel,
edgeLabel,
pingStale, pingStale,
speedStale, speedStale,
stale: pingStale || speedStale,
speed, speed,
tunnelType: c.tunnelType, tunnelType: c.tunnelType,
raw: c, raw: c,
@@ -237,77 +330,308 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
}).filter((e) => e.p1 && e.p2); }).filter((e) => e.p1 && e.p2);
}, [connections, positions, pingMap, speedMap, pingStaleMap, speedStaleMap]); }, [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 computeCurvePoints = useCallback((e) => {
const { p1, p2 } = e; const { p1, p2 } = e;
const dx = p2.x - p1.x; const route = e.route || { fromSide: 'right', toSide: 'left', fromSlot: 0, toSlot: 0, bundleOffset: 0 };
const dy = p2.y - p1.y; const stem = 14;
const adx = Math.abs(dx);
const ady = Math.abs(dy);
let sx = p1.x; const startAnchor = anchorPoint(p1, route.fromSide, route.fromSlot);
let sy = p1.y; const endAnchor = anchorPoint(p2, route.toSide, route.toSlot);
let ex = p2.x; const fromVec = sideVector(route.fromSide);
let ey = p2.y; const toVec = sideVector(route.toSide);
if (adx >= ady) { const sx = startAnchor.x;
const sign = dx >= 0 ? 1 : -1; const sy = startAnchor.y;
sx = p1.x + sign * (NODE_WIDTH / 2); const ex = endAnchor.x;
sy = p1.y; const ey = endAnchor.y;
ex = p2.x - sign * (NODE_WIDTH / 2); const sOutX = sx + fromVec.x * stem;
ey = p2.y; const sOutY = sy + fromVec.y * stem;
} else { const eOutX = ex + toVec.x * stem;
const sign = dy >= 0 ? 1 : -1; const eOutY = ey + toVec.y * stem;
sx = p1.x;
sy = p1.y + sign * (NODE_HEIGHT / 2);
ex = p2.x;
ey = p2.y - sign * (NODE_HEIGHT / 2);
}
const ddx = ex - sx; const ddx = eOutX - sOutX;
const ddy = ey - sy; const ddy = eOutY - sOutY;
const len = Math.hypot(ddx, ddy) || 1; const len = Math.hypot(ddx, ddy) || 1;
const mx = (sx + ex) / 2; const baseOffset = Math.min(44, len / 2);
const my = (sy + ey) / 2; const bendOffset = baseOffset + (route.bundleOffset || 0);
const baseOffset = Math.min(40, len / 2); const bendX = (-ddy / len) * bendOffset;
const perpX = (-ddy / len) * baseOffset; const bendY = (ddx / len) * bendOffset;
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;
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 pathD = useCallback((e) => {
const { sx, sy, ex, ey, cpx, cpy } = computeCurvePoints(e); const { sx, sy, ex, ey, sOutX, sOutY, eOutX, eOutY, cpx, cpy } = computeCurvePoints(e);
return `M ${sx} ${sy} Q ${cpx} ${cpy} ${ex} ${ey}`; return `M ${sx} ${sy} L ${sOutX} ${sOutY} Q ${cpx} ${cpy} ${eOutX} ${eOutY} L ${ex} ${ey}`;
}, [computeCurvePoints]); }, [computeCurvePoints]);
/** Точка для подписи: середина ребра + смещение по перпендикуляру (чтобы подпись была у ребра, а не уезжала) */ /** Точка подписи на кривой с небольшим jitter и ограничением в пределах карты */
const labelPlacement = useCallback((e, level = 0) => { const labelPlacement = useCallback((e, level = 0) => {
const { mx, my, perpX, perpY } = computeCurvePoints(e); const geom = computeCurvePoints(e);
const baseOffset = 18; const jitter = (hash01(e.edgeKey) - 0.5) * 0.24;
const stepOffset = 26; const t = clamp(0.42 + jitter, 0.26, 0.74);
const x = mx + perpX * (baseOffset + level * stepOffset); const omt = 1 - t;
const y = my + perpY * (baseOffset + level * stepOffset); const xCurve = omt * omt * geom.sOutX + 2 * omt * t * geom.cpx + t * t * geom.eOutX;
return { x, y }; const yCurve = omt * omt * geom.sOutY + 2 * omt * t * geom.cpy + t * t * geom.eOutY;
}, [computeCurvePoints]); 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; if (servers.length === 0) return null;
return ( return (
<>
<div <div
ref={containerRef} ref={containerRef}
className="network-map-unifi" className="network-map-unifi"
style={{ style={{
width: '100%', width: '100%',
height: 520, height: isFullscreen ? '100%' : '68vh',
minHeight: 400, minHeight: isFullscreen ? 400 : 620,
background: UNIFI_BG, background: UNIFI_BG,
borderRadius: 12, borderRadius: 12,
overflow: 'hidden', overflow: 'hidden',
@@ -323,17 +647,38 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
preserveAspectRatio="xMidYMid meet" preserveAspectRatio="xMidYMid meet"
style={{ display: 'block' }} 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> <g>
{(() => { {(() => {
const used = []; const used = [];
const LABEL_W = 100; const LABEL_W = 118;
const LABEL_H = 18; const LABEL_H = 22;
const LABEL_GAP = 6; const denseMode = routedEdges.length > 14;
return edgesWithPing.map((e, idx) => { return routedEdges.map((e, idx) => {
const dimFactor = const dimFactor =
hoveredEdgeKey && hoveredEdgeKey !== e.edgeKey ? 0.25 : 1; hoveredEdgeKey && hoveredEdgeKey !== e.edgeKey ? 0.16 : 1;
const strokeWidth = hoveredEdgeKey === e.edgeKey ? 3.2 : 2.5; 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 ( return (
<g <g
key={e.edgeKey || `${e.from}-${e.to}-${idx}`} key={e.edgeKey || `${e.from}-${e.to}-${idx}`}
@@ -347,19 +692,19 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
<path <path
d={pathD(e)} d={pathD(e)}
fill="none" fill="none"
stroke={UNIFI_GREEN} stroke={edgeColor}
strokeWidth={strokeWidth} strokeWidth={strokeWidth}
strokeLinecap="round" strokeLinecap="round"
opacity={dimFactor} opacity={baseOpacity * dimFactor}
filter="url(#edgeGlow)"
/> />
{(() => { {(() => {
const labels = [ const compactOnly = denseMode && !isFocused && !e.stale;
e.pingLabel ? { text: e.pingLabel, stale: e.pingStale } : null, const labelText = compactOnly
e.speedLabel ? { text: e.speedLabel, stale: e.speedStale } : null, ? (e.compactEdgeLabel || e.edgeLabel)
].filter(Boolean); : e.edgeLabel;
if (labels.length === 0) return null; if (!labelText) return null;
const totalH = labels.length * LABEL_H + (labels.length - 1) * LABEL_GAP; const minDist = denseMode ? 52 : 70;
const minDist = 70;
let level = 0; let level = 0;
let x; let y; let x; let y;
while (level < 10) { while (level < 10) {
@@ -374,54 +719,51 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
} }
used.push({ x, y }); used.push({ x, y });
const pad = 8; const pad = 10;
const staleColor = '#f59e0b'; 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 ( return (
<g> <g>
{labels.map((label, i) => { <rect
const yy = y + i * (LABEL_H + LABEL_GAP) + LABEL_H / 2; x={x - w / 2}
const isStale = label.stale === true; y={y - h / 2}
const textWidth = label.text.length * 6; width={w}
const iconWidth = isStale ? 14 : 0; height={h}
const w = Math.max(LABEL_W, Math.min(textWidth + pad * 2 + iconWidth, 180)); rx={7}
const h = LABEL_H + 4; fill={isFocused ? 'rgba(2,6,23,0.94)' : 'rgba(2,6,23,0.85)'}
const textX = x - (iconWidth / 2); stroke={isStale ? staleColor : edgeColor}
return ( strokeWidth={isFocused ? 1.3 : 1}
<g key={i}> strokeDasharray={isStale ? '2,2' : 'none'}
<rect />
x={x - w / 2} {isStale && (
y={yy - h / 2} <circle
width={w} cx={x - w / 2 + pad + staleDotRadius}
height={h} cy={y}
rx={4} r={staleDotRadius}
fill="rgba(15,23,42,0.92)" fill={staleColor}
stroke={isStale ? staleColor : UNIFI_GREEN} opacity={0.95}
strokeWidth={1} />
strokeDasharray={isStale ? '2,2' : 'none'} )}
/> <text
{isStale && ( x={textX}
<circle y={y}
cx={x - w / 2 + 10} textAnchor={textAnchor}
cy={yy} dominantBaseline="middle"
r={5} fill={isStale ? staleColor : edgeColor}
fill={staleColor} fontSize={compactOnly ? 10.2 : 11}
opacity={0.9} fontWeight={600}
/> >
)} {labelText}
<text </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>
);
})}
</g> </g>
); );
})()} })()}
@@ -436,6 +778,7 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
const pos = positions[String(s.ip)]; const pos = positions[String(s.ip)];
if (!pos) return null; if (!pos) return null;
const label = s.dns?.split('.')[0] || s.ip || '?'; const label = s.dns?.split('.')[0] || s.ip || '?';
const typeMeta = getNodeTypeMeta(s.type);
const isDragging = drag?.nodeId === s.ip; const isDragging = drag?.nodeId === s.ip;
return ( return (
<g <g
@@ -452,6 +795,7 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
fill={UNIFI_CARD_BG} fill={UNIFI_CARD_BG}
stroke={UNIFI_CARD_BORDER} stroke={UNIFI_CARD_BORDER}
strokeWidth={1} strokeWidth={1}
filter="url(#nodeShadow)"
/> />
<g transform={`translate(${NODE_WIDTH / 2}, ${NODE_HEIGHT / 2})`}> <g transform={`translate(${NODE_WIDTH / 2}, ${NODE_HEIGHT / 2})`}>
{/* Флаг страны в левом верхнем углу карточки (картинка, т.к. эмодзи в SVG часто отображаются как буквы) */} {/* Флаг страны в левом верхнем углу карточки (картинка, т.к. эмодзи в SVG часто отображаются как буквы) */}
@@ -477,29 +821,50 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
})()} })()}
{/* Иконка сервера в стиле UNIFI: прямоугольник с «портами» */} {/* Иконка сервера в стиле UNIFI: прямоугольник с «портами» */}
<g transform="translate(-10, -16)"> <g transform="translate(-10, -16)">
<rect x={2} y={0} width={16} height={12} rx={2} fill="none" stroke={UNIFI_GREEN} strokeWidth={1.5} /> <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={UNIFI_GREEN} strokeWidth={1} opacity={0.8} /> <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={UNIFI_GREEN} strokeWidth={1} opacity={0.8} /> <line x1={5} y1={8} x2={15} y2={8} stroke={typeMeta.color} strokeWidth={1} opacity={0.8} />
</g> </g>
<text <text
x={0} x={0}
y={8} y={8}
textAnchor="middle" textAnchor="middle"
fill={UNIFI_TEXT} fill={UNIFI_TEXT}
fontSize={12} fontSize={11.5}
fontWeight={600} fontWeight={600}
> >
{label.length > 12 ? label.slice(0, 11) + '…' : label} {label.length > 14 ? `${label.slice(0, 13)}` : label}
</text> </text>
<text <text
x={0} x={0}
y={22} y={22}
textAnchor="middle" textAnchor="middle"
fill={UNIFI_TEXT_MUTED} fill={UNIFI_TEXT_MUTED}
fontSize={10} fontSize={9.5}
> >
{s.ip} {s.ip}
</text> </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>
</g> </g>
); );
@@ -514,6 +879,17 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
const sk = speedKey(selectedEdge.raw?.fromKey, selectedEdge.raw?.toKey); const sk = speedKey(selectedEdge.raw?.fromKey, selectedEdge.raw?.toKey);
const speed = speedMap[sk] ?? selectedEdge.speed; const speed = speedMap[sk] ?? selectedEdge.speed;
const speedStale = speedStaleMap[sk] === true; 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 ( return (
<div <div
className="network-map-edge-modal-backdrop" className="network-map-edge-modal-backdrop"
@@ -635,6 +1011,32 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
</div> </div>
</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> </div>
</div> </div>
@@ -688,5 +1090,118 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
</div> </div>
</div> </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> </tr>
) : ( ) : (
filtered.map((item) => { filtered.map((item) => {
const iconInfo = getIconById(item.icon); const { Icon } = getIconById(item.icon);
return ( return (
<tr key={item.id}> <tr key={item.id}>
<td> <td>
<span className={`avatar avatar-sm bg-${item.color}-lt text-${item.color} rounded d-inline-flex align-items-center justify-content-center`}> <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> </span>
</td> </td>
<td><code className="small">{item.id}</code></td> <td><code className="small">{item.id}</code></td>
+3 -1
View File
@@ -8,6 +8,8 @@ import {
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import PageHeader from './components/PageHeader.jsx'; import PageHeader from './components/PageHeader.jsx';
const REQUEST_TIMEOUT_MS = 15000;
function formatBytes(bytes) { function formatBytes(bytes) {
if (bytes == null || bytes === 0) return '0 B'; if (bytes == null || bytes === 0) return '0 B';
const k = 1024; const k = 1024;
@@ -149,7 +151,7 @@ export default function ResourceStatsPage() {
const fetchStats = useCallback(async (abortSignal) => { const fetchStats = useCallback(async (abortSignal) => {
setLoading(true); setLoading(true);
setError(null); setError(null);
const opts = abortSignal ? { signal: abortSignal } : {}; const opts = { timeout: REQUEST_TIMEOUT_MS, noRetry: true, ...(abortSignal ? { signal: abortSignal } : {}) };
try { try {
const res = await api.get('/resources/stats', opts); const res = await api.get('/resources/stats', opts);
const data = res?.data; 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 api from './lib/api.js';
import ConfirmDialog from './components/ConfirmDialog.jsx'; import ConfirmDialog from './components/ConfirmDialog.jsx';
import ConfirmModal from './components/ConfirmModal.jsx'; import ConfirmModal from './components/ConfirmModal.jsx';
@@ -147,6 +147,7 @@ function ServerManager() {
fetchConnections(signal); fetchConnections(signal);
fetchNetworkConfig(signal); fetchNetworkConfig(signal);
return () => controller.abort(); return () => controller.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps -- init-only, fetch functions defined below
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -303,7 +304,7 @@ function ServerManager() {
useEffect(() => { useEffect(() => {
if (currentPage > totalPages && totalPages >= 1) setCurrentPage(1); if (currentPage > totalPages && totalPages >= 1) setCurrentPage(1);
}, [viewMode, totalPages]); }, [viewMode, totalPages, currentPage]);
// === Handlers === // === Handlers ===
const handleSort = (field) => { const handleSort = (field) => {
@@ -501,7 +502,7 @@ function ServerManager() {
return `${urlSettings.baseUrl}?${params.toString()}`; return `${urlSettings.baseUrl}?${params.toString()}`;
}; };
const handleGenerateLink = (server) => { const _handleGenerateLink = (server) => {
setLinkGeneratorServer(server); setLinkGeneratorServer(server);
setLinkGeneratorOpen(true); setLinkGeneratorOpen(true);
}; };
@@ -511,7 +512,7 @@ function ServerManager() {
setLinkGeneratorServer(null); setLinkGeneratorServer(null);
}; };
const applyPreset = (name) => { const _applyPreset = (name) => {
const p = presets[name]; const p = presets[name];
if (!p) return; if (!p) return;
setUrlSettings(prev => ({ ...prev, ...p })); setUrlSettings(prev => ({ ...prev, ...p }));
@@ -529,7 +530,7 @@ function ServerManager() {
try { try {
document.execCommand('copy'); document.execCommand('copy');
notify.success('Скопировано в буфер обмена!'); notify.success('Скопировано в буфер обмена!');
} catch (err) { } catch (_err) {
notify.error('Не удалось скопировать'); notify.error('Не удалось скопировать');
} }
document.body.removeChild(textArea); document.body.removeChild(textArea);
@@ -541,14 +542,14 @@ function ServerManager() {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
notify.success('Скопировано в буфер обмена!'); notify.success('Скопировано в буфер обмена!');
return; return;
} catch (err) { } catch (_err) {
// fallback // fallback
} }
} }
fallbackCopyToClipboard(text); fallbackCopyToClipboard(text);
}; };
const quickCopyLink = async (server) => { const _quickCopyLink = async (server) => {
const url = generateServerUrl(server); const url = generateServerUrl(server);
await copyToClipboard(url); await copyToClipboard(url);
}; };
@@ -800,8 +801,8 @@ function ServerManager() {
{/* Toolbar */} {/* Toolbar */}
<div className="card mb-3"> <div className="card mb-3">
<div className="card-body py-2"> <div className="card-body py-2">
<div className="d-flex align-items-center justify-content-between"> <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 align-items-center gap-2"> <div className="d-flex flex-wrap align-items-center gap-2">
{/* Открыть фильтры (offcanvas) */} {/* Открыть фильтры (offcanvas) */}
<button <button
type="button" type="button"
@@ -1087,22 +1088,22 @@ function ServerManager() {
<h3 className="card-title mb-0">Добавить связь между серверами</h3> <h3 className="card-title mb-0">Добавить связь между серверами</h3>
</div> </div>
<div className="card-body"> <div className="card-body">
<div className="row g-3"> <div className="row g-2 g-md-3">
<div className="col-md-6"> <div className="col-12 col-md-6">
<label className="form-label">Сервер A</label> <label className="form-label">Сервер A</label>
<select className="form-select" value={newConnection.from} onChange={e => setNewConnection({ ...newConnection, from: e.target.value })}> <select className="form-select" value={newConnection.from} onChange={e => setNewConnection({ ...newConnection, from: e.target.value })}>
<option value="">Выберите сервер</option> <option value="">Выберите сервер</option>
{servers.map(s => <option key={s.ip} value={s.ip}>{s.ip} ({s.dns})</option>)} {servers.map(s => <option key={s.ip} value={s.ip}>{s.ip} ({s.dns})</option>)}
</select> </select>
</div> </div>
<div className="col-md-6"> <div className="col-12 col-md-6">
<label className="form-label">Сервер B</label> <label className="form-label">Сервер B</label>
<select className="form-select" value={newConnection.to} onChange={e => setNewConnection({ ...newConnection, to: e.target.value })}> <select className="form-select" value={newConnection.to} onChange={e => setNewConnection({ ...newConnection, to: e.target.value })}>
<option value="">Выберите сервер</option> <option value="">Выберите сервер</option>
{servers.map(s => <option key={s.ip} value={s.ip}>{s.ip} ({s.dns})</option>)} {servers.map(s => <option key={s.ip} value={s.ip}>{s.ip} ({s.dns})</option>)}
</select> </select>
</div> </div>
<div className="col-md-4"> <div className="col-12 col-md-4">
<label className="form-label">Тип туннеля</label> <label className="form-label">Тип туннеля</label>
<select className="form-select" value={newConnection.tunnelType} onChange={e => setNewConnection({ ...newConnection, tunnelType: e.target.value })}> <select className="form-select" value={newConnection.tunnelType} onChange={e => setNewConnection({ ...newConnection, tunnelType: e.target.value })}>
<option value="GRE">GRE</option> <option value="GRE">GRE</option>
@@ -1111,11 +1112,11 @@ function ServerManager() {
<option value="OpenVPN">OpenVPN</option> <option value="OpenVPN">OpenVPN</option>
</select> </select>
</div> </div>
<div className="col-md-4"> <div className="col-12 col-md-4">
<label className="form-label">IP туннеля A</label> <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" /> <input className="form-control" value={newConnection.ipA} onChange={e => setNewConnection({ ...newConnection, ipA: e.target.value })} placeholder="10.10.100.1" />
</div> </div>
<div className="col-md-4"> <div className="col-12 col-md-4">
<label className="form-label">IP туннеля B</label> <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" /> <input className="form-control" value={newConnection.ipB} onChange={e => setNewConnection({ ...newConnection, ipB: e.target.value })} placeholder="10.10.100.2" />
</div> </div>
@@ -1255,8 +1256,8 @@ function ServerManager() {
<span className="badge bg-blue-lt text-blue">{newConnection.to || 'B'}</span> <span className="badge bg-blue-lt text-blue">{newConnection.to || 'B'}</span>
</div> </div>
</div> </div>
<div className="row g-3"> <div className="row g-2 g-md-3">
<div className="col-md-4"> <div className="col-12 col-md-4">
<FormField <FormField
label="Туннель" label="Туннель"
name="tunnelType" name="tunnelType"
@@ -1272,7 +1273,7 @@ function ServerManager() {
]} ]}
/> />
</div> </div>
<div className="col-md-4"> <div className="col-12 col-md-4">
<FormField <FormField
label="IP сервера A" label="IP сервера A"
name="ipA" name="ipA"
@@ -1282,7 +1283,7 @@ function ServerManager() {
required required
/> />
</div> </div>
<div className="col-md-4"> <div className="col-12 col-md-4">
<FormField <FormField
label="IP сервера B" label="IP сервера B"
name="ipB" 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_SIZE = 200;
const DONUT_OUTER = 88; const DONUT_OUTER = 88;
const DONUT_INNER = 52; const DONUT_INNER = 52;
const REQUEST_TIMEOUT_MS = 15000;
function JumphostCard({ jumphost }) { function JumphostCard({ jumphost }) {
const { name, host, error, interfaces } = jumphost; const { name, host, error, interfaces } = jumphost;
@@ -184,7 +185,7 @@ export default function TrafficDashboard() {
const fetchStats = useCallback(async (abortSignal) => { const fetchStats = useCallback(async (abortSignal) => {
setLoading(true); setLoading(true);
setError(null); setError(null);
const opts = abortSignal ? { signal: abortSignal } : {}; const opts = { timeout: REQUEST_TIMEOUT_MS, ...(abortSignal ? { signal: abortSignal } : {}) };
try { try {
const [statsRes, settingsRes] = await Promise.all([ const [statsRes, settingsRes] = await Promise.all([
api.get('/traffic/interface-stats', opts), api.get('/traffic/interface-stats', opts),
+48 -5
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import api from './lib/api.js'; import api from './lib/api.js';
import { import {
IconClock, IconClock,
@@ -33,14 +34,14 @@ function loadHistoryFromStorage() {
if (!raw) return {}; if (!raw) return {};
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') return parsed; if (parsed && typeof parsed === 'object') return parsed;
} catch (_) {} } catch { /* no-op */ }
return {}; return {};
} }
function saveHistoryToStorage(historyMap) { function saveHistoryToStorage(historyMap) {
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(historyMap)); localStorage.setItem(STORAGE_KEY, JSON.stringify(historyMap));
} catch (_) {} } catch { /* no-op */ }
} }
/** Группировка истории по дням: массив { day, dayLabel, incidents } */ /** Группировка истории по дням: массив { day, dayLabel, incidents } */
@@ -81,6 +82,7 @@ function historyToResponseTimeData(history) {
} }
export default function UptimeMonitorPage() { export default function UptimeMonitorPage() {
const location = useLocation();
const [servers, setServers] = useState([]); const [servers, setServers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false); const [checking, setChecking] = useState(false);
@@ -94,6 +96,8 @@ export default function UptimeMonitorPage() {
const [currentCheckingServerIds, setCurrentCheckingServerIds] = useState([]); const [currentCheckingServerIds, setCurrentCheckingServerIds] = useState([]);
const intervalRef = useRef(null); const intervalRef = useRef(null);
const checkingRef = useRef(false); const checkingRef = useRef(false);
const activeRunIdRef = useRef(0);
const checkControllersRef = useRef(new Set());
const jumphosts = useMemo( const jumphosts = useMemo(
() => () =>
@@ -160,6 +164,7 @@ export default function UptimeMonitorPage() {
const runChecks = useCallback(async () => { const runChecks = useCallback(async () => {
if (jumphosts.length === 0) return; if (jumphosts.length === 0) return;
if (checkingRef.current) return; if (checkingRef.current) return;
const runId = ++activeRunIdRef.current;
checkingRef.current = true; checkingRef.current = true;
setChecking(true); setChecking(true);
setCurrentCheckingServerIds([]); setCurrentCheckingServerIds([]);
@@ -171,18 +176,28 @@ export default function UptimeMonitorPage() {
const checkOne = async () => { const checkOne = async () => {
let server; let server;
while ((server = getNext()) != null) { while ((server = getNext()) != null) {
if (activeRunIdRef.current !== runId) break;
const serverId = server.id || server.dns || server.ip; const serverId = server.id || server.dns || server.ip;
if (!serverId) continue; if (!serverId) continue;
setCurrentCheckingServerIds((prev) => [...prev, serverId]); setCurrentCheckingServerIds((prev) => [...prev, serverId]);
const t0 = Date.now(); const t0 = Date.now();
let ok = false; let ok = false;
let ms = null; let ms = null;
const controller = new AbortController();
checkControllersRef.current.add(controller);
try { 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; ok = checkData?.ok === true;
ms = typeof checkData?.ms === 'number' ? checkData.ms : Date.now() - t0; 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; ms = Date.now() - t0;
} finally {
checkControllersRef.current.delete(controller);
} }
const lastCheckTs = Date.now(); const lastCheckTs = Date.now();
const entry = { ts: lastCheckTs, ok, ms }; const entry = { ts: lastCheckTs, ok, ms };
@@ -196,6 +211,7 @@ export default function UptimeMonitorPage() {
} }
}; };
await Promise.all(Array.from({ length: PARALLEL_CHECKS }, checkOne)); await Promise.all(Array.from({ length: PARALLEL_CHECKS }, checkOne));
if (activeRunIdRef.current !== runId) return;
setCurrentCheckingServerIds([]); setCurrentCheckingServerIds([]);
checkingRef.current = false; checkingRef.current = false;
setChecking(false); setChecking(false);
@@ -217,6 +233,30 @@ export default function UptimeMonitorPage() {
}; };
}, [jumphosts, runChecks, checkIntervalMs]); }, [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 при изменении // Сохраняем историю в localStorage при изменении
useEffect(() => { useEffect(() => {
saveHistoryToStorage(historyMap); saveHistoryToStorage(historyMap);
@@ -225,7 +265,10 @@ export default function UptimeMonitorPage() {
const selectedServer = selectedServerId const selectedServer = selectedServerId
? jumphosts.find((s) => (s.id || s.dns || s.ip) === selectedServerId) ? jumphosts.find((s) => (s.id || s.dns || s.ip) === selectedServerId)
: null; : null;
const selectedHistory = selectedServerId ? historyMap[selectedServerId] || [] : []; const selectedHistory = useMemo(
() => (selectedServerId ? historyMap[selectedServerId] || [] : []),
[selectedServerId, historyMap]
);
const responseTimeData = useMemo( const responseTimeData = useMemo(
() => historyToResponseTimeData(selectedHistory), () => historyToResponseTimeData(selectedHistory),
[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({ function BulkActionsBar({
selectedCount = 0, selectedCount = 0,
totalCount = 0, totalCount: _totalCount = 0,
onSelectAll = () => {}, onSelectAll: _onSelectAll = () => {},
onDeselectAll = () => {}, onDeselectAll = () => {},
onDelete = null, onDelete = null,
onEdit = null, onEdit = null,
onExport = null, onExport = null,
customActions = [], customActions = [],
className = '', className: _className = '',
}) { }) {
const actions = []; const actions = [];
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { IconHash } from '@tabler/icons-react'; import { IconHash } from '@tabler/icons-react';
import { getIconById } from '../lib/brandIcons.js';
function CommunityAutocompleteInput({ function CommunityAutocompleteInput({
value, value,
@@ -105,6 +106,7 @@ function CommunityAutocompleteInput({
const label = s.name ? `${s.value} - ${s.name}` : `${s.value}`; const label = s.name ? `${s.value} - ${s.name}` : `${s.value}`;
const description = s.description || ''; const description = s.description || '';
const tags = Array.isArray(s.tags) ? s.tags : []; const tags = Array.isArray(s.tags) ? s.tags : [];
const iconInfo = s.icon ? getIconById(s.icon) : null;
return ( return (
<button <button
type="button" type="button"
@@ -114,9 +116,15 @@ function CommunityAutocompleteInput({
onMouseEnter={() => setActiveIndex(idx)} onMouseEnter={() => setActiveIndex(idx)}
> >
<div className="d-flex align-items-start"> <div className="d-flex align-items-start">
<span className="avatar me-2 bg-blue-lt text-blue border-0" style={{ width: 22, height: 22 }}> {iconInfo ? (
<IconHash size={14} /> <span className="avatar me-2 bg-blue-lt text-blue border-0" style={{ width: 22, height: 22 }}>
</span> <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="flex-fill text-start">
<div className="fw-medium">{label}</div> <div className="fw-medium">{label}</div>
{(description || tags.length > 0) && ( {(description || tags.length > 0) && (
+7 -1
View File
@@ -1,3 +1,5 @@
import { getIconById } from '../lib/brandIcons.js';
function normalizeTablerColor(input) { function normalizeTablerColor(input) {
const allowed = new Set(['blue','azure','indigo','purple','pink','red','orange','yellow','lime','green','teal','cyan','grape','gray']); 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, ''); 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 color = normalizeTablerColor(meta?.color || 'blue');
const cls = `badge bg-${color}-lt text-${color}`; 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 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 ( 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="row g-2 g-md-3 mb-4">
<div className="col-md-4"> <div className="col-6 col-md-4">
<div className="card card-sm bg-blue-lt"> <div className="card card-sm bg-blue-lt">
<div className="card-body"> <div className="card-body">
<div className="h1 mb-0">{stats.length}</div> <div className="h1 mb-0">{stats.length}</div>
@@ -109,7 +109,7 @@ function CommunityStats({ communities = [] }) {
</div> </div>
</div> </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 card-sm bg-green-lt">
<div className="card-body"> <div className="card-body">
<div className="h1 mb-0">{totalUsage}</div> <div className="h1 mb-0">{totalUsage}</div>
@@ -117,7 +117,7 @@ function CommunityStats({ communities = [] }) {
</div> </div>
</div> </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 card-sm bg-purple-lt">
<div className="card-body"> <div className="card-body">
<div className="h1 mb-0">{Object.keys(statsByCategory).length}</div> <div className="h1 mb-0">{Object.keys(statsByCategory).length}</div>
@@ -198,12 +198,12 @@ function CommunityStats({ communities = [] }) {
{Object.keys(statsByCategory).length > 1 && ( {Object.keys(statsByCategory).length > 1 && (
<div> <div>
<h4 className="mb-3">По категориям</h4> <h4 className="mb-3">По категориям</h4>
<div className="row g-3"> <div className="row g-2 g-md-3">
{Object.entries(statsByCategory).map(([category, items]) => { {Object.entries(statsByCategory).map(([category, items]) => {
const categoryTotal = items.reduce((sum, item) => sum + item.count, 0); const categoryTotal = items.reduce((sum, item) => sum + item.count, 0);
const categoryPercentage = ((categoryTotal / totalUsage) * 100).toFixed(1); const categoryPercentage = ((categoryTotal / totalUsage) * 100).toFixed(1);
return ( 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 card-sm">
<div className="card-body"> <div className="card-body">
<div className="d-flex justify-content-between align-items-start mb-2"> <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 { IconArrowUp, IconArrowDown, IconEdit, IconTrash, IconCopy, IconCheck, IconX } from '@tabler/icons-react';
import TableSkeleton, { TableEmpty } from './TableSkeleton.jsx'; import TableSkeleton, { TableEmpty } from './TableSkeleton.jsx';
import EmptyState from './EmptyState.jsx'; import EmptyState from './EmptyState.jsx';
+2 -2
View File
@@ -16,7 +16,7 @@ class ErrorBoundary extends Component {
}; };
} }
static getDerivedStateFromError(error) { static getDerivedStateFromError(_error) {
return { hasError: true }; return { hasError: true };
} }
@@ -93,7 +93,7 @@ class ErrorBoundary extends Component {
{this.state.error?.message || 'Что-то пошло не так. Попробуйте обновить страницу.'} {this.state.error?.message || 'Что-то пошло не так. Попробуйте обновить страницу.'}
</p> </p>
{process.env.NODE_ENV === 'development' && this.state.errorInfo && ( {import.meta.env.DEV && this.state.errorInfo && (
<div className="card mt-3"> <div className="card mt-3">
<div className="card-body"> <div className="card-body">
<h3 className="card-title">Детали ошибки (только в dev режиме)</h3> <h3 className="card-title">Детали ошибки (только в dev режиме)</h3>
+1 -1
View File
@@ -40,7 +40,7 @@ export default function HistoryModal({ resource, show, onClose, onRolledBack })
notify.success('Откат выполнен успешно'); notify.success('Откат выполнен успешно');
onRolledBack?.(res.data || {}); onRolledBack?.(res.data || {});
onClose?.(); onClose?.();
} catch (e) { } catch (_e) {
notify.error('Не удалось выполнить откат'); notify.error('Не удалось выполнить откат');
} finally { } finally {
setLoading(false); 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 { useState, useMemo, useRef, useEffect } from 'react';
import BRAND_ICONS, { getIconById } from '../lib/brandIcons.js'; 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) при выборе. * 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 [search, setSearch] = useState('');
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const selected = getIconById(value); const selected = allowNone && !value
const filtered = useMemo(() => { ? { id: '', label: 'Без иконки', Icon: IconMinus }
: getIconById(value);
const filteredIcons = useMemo(() => {
const q = (search || '').trim().toLowerCase(); const q = (search || '').trim().toLowerCase();
if (!q) return BRAND_ICONS; if (!q) return BRAND_ICONS;
return BRAND_ICONS.filter( 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) item.label.toLowerCase().includes(q) || item.id.toLowerCase().includes(q)
); );
}, [search]); }, [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) => { const handleSelect = (item) => {
onChange?.(item.id); 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}> <div className="d-flex align-items-center gap-2 flex-wrap" ref={panelRef}>
<button <button
type="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)} onClick={() => setOpen((v) => !v)}
disabled={disabled} disabled={disabled}
aria-expanded={open} aria-expanded={open}
aria-haspopup="listbox" aria-haspopup="listbox"
> >
<span className="avatar avatar-sm bg-primary-lt text-primary rounded d-flex align-items-center justify-content-center"> <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={18} stroke={1.5} /> <selected.Icon size={20} stroke={1.5} />
</span> </span>
<span>{selected.label}</span> <span>{selected.label}</span>
</button> </button>
{open && ( {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"> <div className="input-group input-group-sm mb-2">
<span className="input-group-text"> <span className="input-group-text">
<IconSearch size={14} /> <IconSearch size={14} />
@@ -72,7 +82,7 @@ export default function IconPicker({ value, onChange, disabled = false, label =
</div> </div>
<div <div
className="d-flex flex-wrap gap-1 overflow-auto" className="d-flex flex-wrap gap-1 overflow-auto"
style={{ maxHeight: 200 }} style={{ maxHeight: 320 }}
role="listbox" role="listbox"
> >
{filtered.map((item) => ( {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 ${ className={`btn btn-sm d-flex align-items-center justify-content-center rounded ${
value === item.id ? 'btn-primary' : 'btn-outline-secondary' value === item.id ? 'btn-primary' : 'btn-outline-secondary'
}`} }`}
style={{ width: 40, height: 40 }} style={{ width: 56, height: 56 }}
onClick={() => handleSelect(item)} onClick={() => handleSelect(item)}
title={item.label} title={item.label}
role="option" role="option"
aria-selected={value === item.id} aria-selected={value === item.id}
> >
<item.Icon size={22} stroke={1.5} /> <item.Icon size={26} stroke={1.5} />
</button> </button>
))} ))}
</div> </div>
+2 -2
View File
@@ -178,7 +178,7 @@ function ImportModal({
{/* Stats */} {/* Stats */}
<div className="row g-2"> <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 bg-green-lt">
<div className="card-body py-2"> <div className="card-body py-2">
<div className="d-flex align-items-center"> <div className="d-flex align-items-center">
@@ -191,7 +191,7 @@ function ImportModal({
</div> </div>
</div> </div>
</div> </div>
<div className="col-md-6"> <div className="col-12 col-md-6">
<div className="card bg-red-lt"> <div className="card bg-red-lt">
<div className="card-body py-2"> <div className="card-body py-2">
<div className="d-flex align-items-center"> <div className="d-flex align-items-center">
+2 -2
View File
@@ -19,12 +19,12 @@ function MobileCardView({
// минимальная дистанция свайпа в px // минимальная дистанция свайпа в px
const minSwipeDistance = 50 const minSwipeDistance = 50
const onTouchStart = (e, item) => { const onTouchStart = (e, _item) => {
setTouchEnd(null) setTouchEnd(null)
setTouchStart(e.targetTouches[0].clientX) setTouchStart(e.targetTouches[0].clientX)
} }
const onTouchMove = (e, item) => { const onTouchMove = (e, _item) => {
setTouchEnd(e.targetTouches[0].clientX) 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 { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { IconCheck, IconAlertTriangle, IconInfoCircle, IconX } from '@tabler/icons-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) { if (forceToast && typeof window !== 'undefined' && window.notify?.success) {
window.notify.success(text, details); window.notify.success(text, details);
} }
} catch {} } catch { /* no-op */ }
} }
function colorByType(type) { function colorByType(type) {
+1 -1
View File
@@ -29,7 +29,7 @@ function PageHeader({ title, icon, pretitle, actions, meta }) {
</div> </div>
{actions && ( {actions && (
<div className="row mt-2"> <div className="row mt-2">
<div className="col d-print-none"> <div className="col d-print-none overflow-auto">
{actions} {actions}
</div> </div>
</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'; import { createPortal } from 'react-dom';
function getBody() { function getBody() {
@@ -20,7 +20,7 @@ function PortalDropdown({
const [coords, setCoords] = useState({ top: 0, left: 0, minWidth: 0 }); 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 BASE_Z = 1035; // above navbar (1030), below modal-backdrop (1040) and modal (1050)
const updatePosition = () => { const updatePosition = useCallback(() => {
const btn = buttonRef.current; const btn = buttonRef.current;
if (!btn) return; if (!btn) return;
const rect = btn.getBoundingClientRect(); const rect = btn.getBoundingClientRect();
@@ -32,12 +32,11 @@ function PortalDropdown({
left = Math.max(0, rect.right - (menuWidth || rect.width)); left = Math.max(0, rect.right - (menuWidth || rect.width));
} }
setCoords({ top: rect.bottom + window.scrollY, left: left + window.scrollX, minWidth }); setCoords({ top: rect.bottom + window.scrollY, left: left + window.scrollX, minWidth });
}; }, [align]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (open) updatePosition(); if (open) updatePosition();
// eslint-disable-next-line react-hooks/exhaustive-deps }, [open, updatePosition]);
}, [open]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -60,7 +59,7 @@ function PortalDropdown({
document.removeEventListener('keydown', onKey); document.removeEventListener('keydown', onKey);
document.removeEventListener('mousedown', onClickOutside); document.removeEventListener('mousedown', onClickOutside);
}; };
}, [open]); }, [open, updatePosition]);
const body = getBody(); const body = getBody();
+1 -1
View File
@@ -59,7 +59,7 @@ function QuickAddBar({
value={value} value={value}
onChange={(e) => setValue(e.target.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="col">
<div className="card"><div className="card-body p-2"> <div className="card"><div className="card-body p-2">
<div className="text-muted">Готово</div> <div className="text-muted">Готово</div>
+1 -1
View File
@@ -14,7 +14,7 @@ function SavedFilters({
const [savedFilters, setSavedFilters] = useState([]) const [savedFilters, setSavedFilters] = useState([])
const [showSaveDialog, setShowSaveDialog] = useState(false) const [showSaveDialog, setShowSaveDialog] = useState(false)
const [filterName, setFilterName] = useState('') const [filterName, setFilterName] = useState('')
const [showList, setShowList] = useState(false) const [_showList, setShowList] = useState(false)
const storageKey = `savedFilters_${pageKey}` 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 || ''; const e = settingsRes?.headers?.etag || settingsRes?.headers?.ETag || '';
setEtag(e ? String(e) : ''); setEtag(e ? String(e) : '');
setServersList(Array.isArray(serversRes?.data) ? serversRes.data : []); setServersList(Array.isArray(serversRes?.data) ? serversRes.data : []);
} catch (e) { } catch (_e) {
setError('Не удалось загрузить настройки'); setError('Не удалось загрузить настройки');
} finally { } finally {
setLoading(false); 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 { import {
IconCheck, IconCheck,
IconX, IconX,
@@ -32,17 +33,17 @@ const DEFAULT_TITLES = {
function Toast({ toast, onClose }) { function Toast({ toast, onClose }) {
const [isLeaving, setIsLeaving] = useState(false) const [isLeaving, setIsLeaving] = useState(false)
const handleClose = useCallback(() => {
setIsLeaving(true)
setTimeout(() => onClose(toast.id), 300)
}, [onClose, toast.id])
useEffect(() => { useEffect(() => {
if (toast.duration && toast.duration > 0) { if (toast.duration && toast.duration > 0) {
const timer = setTimeout(() => handleClose(), toast.duration) const timer = setTimeout(() => handleClose(), toast.duration)
return () => clearTimeout(timer) return () => clearTimeout(timer)
} }
}, [toast.duration]) }, [toast.duration, handleClose])
const handleClose = () => {
setIsLeaving(true)
setTimeout(() => onClose(toast.id), 300)
}
const getIcon = () => { const getIcon = () => {
switch (toast.type) { switch (toast.type) {
@@ -121,7 +122,7 @@ function Toast({ toast, onClose }) {
export function ToastContainer({ children }) { export function ToastContainer({ children }) {
const [toasts, setToasts] = useState([]) const [toasts, setToasts] = useState([])
const addToast = (type, message, options = {}) => { const addToast = useCallback((type, message, options = {}) => {
const id = Date.now() const id = Date.now()
const toast = { const toast = {
id, id,
@@ -136,23 +137,23 @@ export function ToastContainer({ children }) {
} }
setToasts(prev => [...prev, toast]) setToasts(prev => [...prev, toast])
return id return id
} }, [])
const removeToast = (id) => { const removeToast = useCallback((id) => {
setToasts(prev => prev.filter(t => t.id !== id)) setToasts(prev => prev.filter(t => t.id !== id))
} }, [])
const contextValue = { const contextValue = useMemo(() => ({
success: (message, options) => addToast('success', message, options), success: (message, options) => addToast('success', message, options),
error: (message, options) => addToast('error', message, options), error: (message, options) => addToast('error', message, options),
warning: (message, options) => addToast('warning', message, options), warning: (message, options) => addToast('warning', message, options),
info: (message, options) => addToast('info', message, options), info: (message, options) => addToast('info', message, options),
} }), [addToast])
// Глобальный доступ через window.toast (для обратной совместимости) // Глобальный доступ через window.toast (для обратной совместимости)
useEffect(() => { useEffect(() => {
window.toast = contextValue window.toast = contextValue
}, []) }, [contextValue])
return ( return (
<ToastContext.Provider value={contextValue}> <ToastContext.Provider value={contextValue}>
+4 -4
View File
@@ -7,7 +7,7 @@ import { IconTrophy, IconMapPin, IconCloud, IconHash, IconChartBar } from '@tabl
function TopNStats({ data, loading }) { function TopNStats({ data, loading }) {
const [topCountries, setTopCountries] = useState([]); const [topCountries, setTopCountries] = useState([]);
const [topProviders, setTopProviders] = useState([]); const [topProviders, setTopProviders] = useState([]);
const [topCommunities, setTopCommunities] = useState([]); const [_topCommunities, _setTopCommunities] = useState([]);
useEffect(() => { useEffect(() => {
if (!data || !Array.isArray(data.servers)) return; if (!data || !Array.isArray(data.servers)) return;
@@ -58,9 +58,9 @@ function TopNStats({ data, loading }) {
}; };
return ( return (
<div className="row g-3"> <div className="row g-2 g-md-3">
{/* Top Страны */} {/* Top Страны */}
<div className="col-md-6"> <div className="col-12 col-md-6">
<div className="card h-100"> <div className="card h-100">
<div className="card-header"> <div className="card-header">
<h3 className="card-title"> <h3 className="card-title">
@@ -107,7 +107,7 @@ function TopNStats({ data, loading }) {
</div> </div>
{/* Top Провайдеры */} {/* Top Провайдеры */}
<div className="col-md-6"> <div className="col-12 col-md-6">
<div className="card h-100"> <div className="card h-100">
<div className="card-header"> <div className="card-header">
<h3 className="card-title"> <h3 className="card-title">
+2 -2
View File
@@ -58,7 +58,7 @@ function ValidatedInput({
message: validationResult.message || '', message: validationResult.message || '',
isValidating: false isValidating: false
}) })
} catch (error) { } catch (_error) {
setValidationState({ setValidationState({
valid: false, valid: false,
message: 'Ошибка валидации', message: 'Ошибка валидации',
@@ -84,7 +84,7 @@ function ValidatedInput({
// Запускаем валидацию сразу при потере фокуса // Запускаем валидацию сразу при потере фокуса
if (validate && !validateOnChange) { if (validate && !validateOnChange) {
const result = validate(value) const result = validate(value)
const validationResult = result instanceof Promise ? result.then(r => { const _validationResult = result instanceof Promise ? result.then(r => {
setValidationState({ setValidationState({
valid: r.valid, valid: r.valid,
message: r.message || '', 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 { useVirtualizer } from '@tanstack/react-virtual';
import { IconArrowUp, IconArrowDown, IconEdit, IconTrash, IconCopy } from '@tabler/icons-react'; import { IconArrowUp, IconArrowDown, IconEdit, IconTrash, IconCopy } from '@tabler/icons-react';
import TableSkeleton from './TableSkeleton.jsx'; import TableSkeleton from './TableSkeleton.jsx';
+3 -3
View File
@@ -64,12 +64,12 @@ function WsUpdateModal({ show, url, onClose }) {
ws.onerror = () => setStatus('error'); ws.onerror = () => setStatus('error');
ws.onclose = () => setStatus('closed'); ws.onclose = () => setStatus('closed');
} catch (e) { } catch (_e) {
setStatus('error'); setStatus('error');
} }
return () => { return () => {
try { wsRef.current?.close(); } catch {} try { wsRef.current?.close(); } catch { /* no-op */ }
wsRef.current = null; wsRef.current = null;
}; };
}, [show, url]); }, [show, url]);
@@ -96,7 +96,7 @@ function WsUpdateModal({ show, url, onClose }) {
const copyLog = async () => { const copyLog = async () => {
try { try {
await navigator.clipboard.writeText(plainLog); await navigator.clipboard.writeText(plainLog);
} catch {} } catch { /* no-op */ }
}; };
const clearLog = () => { const clearLog = () => {
@@ -42,10 +42,6 @@ function AddFilterModal({ show, newFilter, onNewFilterChange, onAddFilter, onClo
onChange={(e) => { onChange={(e) => {
const v = e.target.value; const v = e.target.value;
onNewFilterChange({ ...newFilter, community: v }); 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 required
/> />
@@ -9,19 +9,18 @@ function AddServerModal({ show, newServer, customProvider, onNewServerChange, on
const modalRef = useRef(null); const modalRef = useRef(null);
useEffect(() => { useEffect(() => {
if (window.Tabler && window.Tabler.Modal && modalRef.current) { const modalEl = modalRef.current;
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current); if (window.Tabler && window.Tabler.Modal && modalEl) {
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalEl);
if (show) { if (show) {
modalInstance.show(); modalInstance.show();
} else { } else {
modalInstance.hide(); modalInstance.hide();
} }
const handler = () => onClose && onClose(); const handler = () => onClose && onClose();
modalRef.current.addEventListener('hide.bs.modal', handler); modalEl.addEventListener('hide.bs.modal', handler);
return () => { return () => {
if (modalRef.current) { modalEl.removeEventListener('hide.bs.modal', handler);
modalRef.current.removeEventListener('hide.bs.modal', handler);
}
}; };
} }
}, [show, onClose]); }, [show, onClose]);
@@ -8,19 +8,18 @@ function DeleteServerModal({ show, server, onDelete, onClose }) {
const modalRef = useRef(null); const modalRef = useRef(null);
useEffect(() => { useEffect(() => {
if (window.Tabler && window.Tabler.Modal && modalRef.current) { const modalEl = modalRef.current;
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current); if (window.Tabler && window.Tabler.Modal && modalEl) {
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalEl);
if (show) { if (show) {
modalInstance.show(); modalInstance.show();
} else { } else {
modalInstance.hide(); modalInstance.hide();
} }
const handler = () => onClose && onClose(); const handler = () => onClose && onClose();
modalRef.current.addEventListener('hide.bs.modal', handler); modalEl.addEventListener('hide.bs.modal', handler);
return () => { return () => {
if (modalRef.current) { modalEl.removeEventListener('hide.bs.modal', handler);
modalRef.current.removeEventListener('hide.bs.modal', handler);
}
}; };
} }
}, [show, onClose]); }, [show, onClose]);
@@ -40,19 +40,18 @@ function EditServerModal({ show, server, onChange, onSave, onClose }) {
}, [show, server]); }, [show, server]);
useEffect(() => { useEffect(() => {
if (window.Tabler && window.Tabler.Modal && modalRef.current) { const modalEl = modalRef.current;
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current); if (window.Tabler && window.Tabler.Modal && modalEl) {
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalEl);
if (show) { if (show) {
modalInstance.show(); modalInstance.show();
} else { } else {
modalInstance.hide(); modalInstance.hide();
} }
const handler = () => onClose && onClose(); const handler = () => onClose && onClose();
modalRef.current.addEventListener('hide.bs.modal', handler); modalEl.addEventListener('hide.bs.modal', handler);
return () => { return () => {
if (modalRef.current) { modalEl.removeEventListener('hide.bs.modal', handler);
modalRef.current.removeEventListener('hide.bs.modal', handler);
}
}; };
} }
}, [show, onClose]); }, [show, onClose]);

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