86 Commits
v3 .. v4
Author SHA1 Message Date
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
92 changed files with 7467 additions and 1301 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 }}"
+9 -4
View File
@@ -4,6 +4,10 @@ on:
push:
branches:
- main
- v4
- tabler
- v3
workflow_dispatch:
jobs:
build-and-push:
@@ -29,7 +33,7 @@ jobs:
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
file: ./Dockerfile.fast
push: true
platforms: linux/amd64
cache-from: type=gha
@@ -38,8 +42,8 @@ jobs:
git.shts.su/${{ gitea.repository }}:latest
git.shts.su/${{ gitea.repository }}:${{ gitea.sha }}
labels: |
org.opencontainers.image.title=S3 Lists Manager
org.opencontainers.image.description=S3 Lists Manager for domains and AS management
org.opencontainers.image.title=RouterOS Manager
org.opencontainers.image.description=RouterOS Manager for domains and AS management
org.opencontainers.image.version=latest
org.opencontainers.image.revision=${{ gitea.sha }}
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
@@ -47,5 +51,6 @@ jobs:
BUILDKIT_INLINE_CACHE=1
- name: Trigger container update webhook
continue-on-error: true
run: |
curl -X POST -sS --fail "${{ secrets.portainer_webhook }}"
curl -X POST -sS --fail "${{ secrets.portainer_webhook }}"
+26 -1
View File
@@ -1,6 +1,6 @@
# Docker Images
Этот проект предоставляет Docker образы для S3 Lists Manager с двумя различными версиями интерфейса.
Этот проект предоставляет Docker образы для RouterOS Manager с двумя различными версиями интерфейса.
## 🏷️ Доступные теги
@@ -75,6 +75,9 @@ services:
environment:
- NODE_ENV=production
- ENCRYPTION_KEY=${ENCRYPTION_KEY} # Обязательно! Используйте .env файл или secrets
# Прокси EvoBGP для UI (Communities / Domains / …) — без CORS в браузере:
# - EVOBGP_API_URL=http://evobgp:8080
# - EVOBGP_API_TOKEN=...
s3-lists-manager-tabler:
image: git.shts.su/[repository]:tabler
@@ -104,6 +107,28 @@ docker-compose up -d
| `NODE_ENV` | Окружение Node.js | `production` | Нет |
| `PORT` | Порт для запуска сервера | `3001` | Нет |
| `ENCRYPTION_KEY` | Ключ шифрования для IPSec паролей (64 hex символа) | - | **Да** |
| `EVOBGP_API_URL` | Базовый URL API EvoBGP для серверного прокси `/api/evobgp` (например `http://evobgp:8080` или `http://10.0.0.5:8080`; при необходимости к пути добавляется `/v1`) | - | Да, если используете интеграцию EvoBGP в UI |
| `EVOBGP_API_TOKEN` | Bearer-токен к API EvoBGP (только на сервере, не в браузере) | - | Да, если используете интеграцию EvoBGP в UI |
### EvoBGP и reverse proxy (nginx)
Запросы UI идут на тот же origin: `/api/evobgp/…` обрабатывает **Node** внутри образа. Если перед приложением стоит **nginx**, нужно проксировать префикс `/api` на Node **без потери** сегмента `/api` в URI (иначе в upstream попадёт не тот путь и будет 404).
**Рекомендуемый вариант** (путь `/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;
}
```
**Типичная ошибка**: внутри `location /api` указать `proxy_pass http://127.0.0.1:3001/;` — nginx **срезает** `/api`, в Node приходит `/evobgp/router-lists/catalog` вместо `/api/evobgp/router-lists/catalog`. В коде добавлен запасной маршрут на `/evobgp`, но лучше поправить nginx.
**Проверка**: `GET http://<хост>:<порт>/api/evobgp` (без хвоста) должен вернуть JSON вида `{"ok":true,"proxy":true,"evobgpConfigured":true}` — если вместо этого HTML «404» или страница SPA, запрос **не доходит** до Node (только статика, другой порт или неверный `proxy_pass`).
### ENCRYPTION_KEY
+4
View File
@@ -50,6 +50,10 @@ COPY backend/services ./services
COPY backend/utils ./utils
COPY --from=frontend-builder /app/frontend/dist ./public
# EvoBGP (справочник community / router-lists через прокси /api/evobgp, без CORS в браузере):
# EVOBGP_API_URL — например http://evobgp:8080 или http://192.168.1.10:8080 (к /v1 приведётся автоматически при необходимости)
# EVOBGP_API_TOKEN — Bearer-токен API EvoBGP
#
# ENCRYPTION_KEY должен быть установлен через переменные окружения при запуске контейнера
# Это обязательный параметр для работы с IPSec паролями
# Пример: docker run -e ENCRYPTION_KEY=your-64-hex-character-key ...
+1 -1
View File
@@ -138,7 +138,7 @@ frontend/ # Vite React UI
## Лицензия
MIT
# 📂 S3 Lists Manager
# 📂 RouterOS Manager
Веб-интерфейс для удобного управления файлами в S3-совместимом хранилище Yandex Cloud. Приложение позволяет в реальном времени просматривать, добавлять, редактировать, удалять и массово изменять записи в следующих файлах:
+1 -1
View File
@@ -2,7 +2,7 @@
## Краткое описание
Проведена комплексная модернизация пользовательского интерфейса S3 Lists Manager с внедрением современных UX/UI практик, улучшением доступности и мобильного опыта.
Проведена комплексная модернизация пользовательского интерфейса RouterOS Manager с внедрением современных UX/UI практик, улучшением доступности и мобильного опыта.
---
+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,
};
+3 -3
View File
@@ -23,8 +23,8 @@ async function getCommunities(req, res) {
name: c.name ? String(c.name) : '',
description: c.description ? String(c.description) : '',
tags: Array.isArray(c.tags) ? c.tags.map(String) : [],
gatewayDefault: c.gatewayDefault ? String(c.gatewayDefault) : '',
color: c.color ? String(c.color) : ''
color: c.color ? String(c.color) : '',
icon: c.icon ? String(c.icon) : ''
}));
},
defaultValue: []
@@ -68,8 +68,8 @@ async function postCommunities(req, res) {
name: entry.name ? String(entry.name) : '',
description: entry.description ? String(entry.description) : '',
tags: Array.isArray(entry.tags) ? entry.tags.map(String) : [],
gatewayDefault: entry.gatewayDefault ? String(entry.gatewayDefault) : '',
color: entry.color ? String(entry.color) : '',
icon: entry.icon ? String(entry.icon) : '',
category: entry.category ? String(entry.category) : '',
priority: typeof entry.priority === 'number' ? entry.priority : 0,
enabled: typeof entry.enabled === 'boolean' ? entry.enabled : true,
+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,
};
+396 -40
View File
@@ -15,7 +15,7 @@ const {
buildMikrotikRecursiveRoutes,
getParentGateway,
} = require('../utils/mikrotikInterfaceGenerator');
const { createRosClient, applyBlock, rosPrint, rosAdd, rosRemove } = require('../services/mikrotikApplyService');
const { createRosClient, applyBlock, rosPrint, rosAdd, rosSet, rosRemove } = require('../services/mikrotikApplyService');
const IPSEC_PASSWORDS_KEY = 'network-config/ipsec-passwords.json';
const NETWORK_CONFIG_KEY = 'network-config.json';
@@ -264,32 +264,44 @@ function updateUptimeCache(serverId, entry) {
}
/**
* GET /api/uptime/cache
* Возвращает закешированные результаты проверок, если кеш младше TTL из настроек.
* Получить данные кеша Uptime Monitor (для оповещений и др.).
* Учитывает TTL из ui-settings.uptimeMonitorCacheSeconds.
* @returns {Promise<{ results: Record<string, { ok, lastCheckTs, ms }>, updatedAt: number|null }>}
*/
async function getUptimeCache(req, res) {
async function getUptimeCacheData() {
try {
const uiSettings = await loadUiSettings();
const ttlSec = Math.max(0, parseInt(uiSettings.uptimeMonitorCacheSeconds, 10) || 120);
if (ttlSec === 0) {
return res.json({ results: {}, updatedAt: null });
}
if (ttlSec === 0) return { results: {}, updatedAt: null };
const data = await readS3TextObject(UPTIME_CACHE_KEY).catch(() => null);
if (!data?.body) {
return res.json({ results: {}, updatedAt: null });
}
if (!data?.body) return { results: {}, updatedAt: null };
let cache = { results: {}, updatedAt: null };
try {
const parsed = JSON.parse(data.body);
if (parsed && typeof parsed === 'object') cache = parsed;
} catch (_) {}
const now = Date.now();
if (!cache.updatedAt || now - cache.updatedAt > ttlSec * 1000) {
return res.json({ results: {}, updatedAt: null });
}
return res.json({
if (!cache.updatedAt || now - cache.updatedAt > ttlSec * 1000) return { results: {}, updatedAt: null };
return {
results: cache.results && typeof cache.results === 'object' ? cache.results : {},
updatedAt: cache.updatedAt,
};
} catch (e) {
console.warn('[uptime] getUptimeCacheData', e?.message);
return { results: {}, updatedAt: null };
}
}
/**
* GET /api/uptime/cache
* Возвращает закешированные результаты проверок, если кеш младше TTL из настроек.
*/
async function getUptimeCache(req, res) {
try {
const { results, updatedAt } = await getUptimeCacheData();
return res.json({
results: Object.keys(results).length ? results : {},
updatedAt,
});
} catch (e) {
console.error('[uptime] getUptimeCache', e);
@@ -1462,7 +1474,368 @@ async function getAddressLists(req, res) {
}
}
function normalizeOspfInterfaceName(entry) {
if (!entry || typeof entry !== 'object') return '';
return String(entry.interfaces ?? entry.interface ?? '').trim();
}
function parseOspfCost(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) return null;
return parsed;
}
function sameOspfInterface(a, b) {
return String(a || '').trim().toUpperCase() === String(b || '').trim().toUpperCase();
}
function sameOspfArea(a, b) {
const left = String(a || '').trim().toUpperCase() || 'BACKBONE';
const right = String(b || '').trim().toUpperCase() || 'BACKBONE';
return left === right;
}
/**
* GET /api/mikrotik/ospf-interface-templates?serverId=
* Возвращает текущие OSPF interface-template с HOME роутеров.
*/
async function getOspfInterfaceTemplates(req, res) {
try {
const requestedServerId = String(req.query?.serverId || '').trim();
const servers = await readServersFromS3();
const homeServers = servers.filter((s) => String(s?.type || '').toLowerCase() === 'home');
const targets = requestedServerId
? homeServers.filter((s) => [s.id, s.ip, s.dns].map((x) => String(x || '')).includes(requestedServerId))
: homeServers;
if (requestedServerId && targets.length === 0) {
return sendError(res, 404, 'HOME server not found', 'E_NOT_FOUND');
}
const results = [];
const errors = [];
for (const server of targets) {
const serverId = server.id || server.ip || server.dns;
const creds = getMikrotikCredentials(server);
if (!creds) {
errors.push({
serverId,
serverLabel: server.dns || server.ip || serverId,
error: 'MikroTik credentials not configured for this server',
});
continue;
}
try {
const client = createRosClient(creds);
const raw = await rosPrint(client, 'routing/ospf/interface-template');
const templates = (Array.isArray(raw) ? raw : [])
.map((item) => {
const interfaceName = normalizeOspfInterfaceName(item);
if (!interfaceName) return null;
return {
id: item['.id'] || null,
interfaceName,
cost: parseOspfCost(item.cost),
area: item.area || '',
networks: item.networks || '',
networkType: item['type'] || item['network-type'] || '',
disabled: String(item.disabled || '').toLowerCase() === 'true',
};
})
.filter(Boolean);
results.push({
serverId,
serverLabel: server.dns || server.ip || serverId,
templates,
});
} catch (error) {
errors.push({
serverId,
serverLabel: server.dns || server.ip || serverId,
error: error?.response?.data?.detail || error?.response?.data?.message || error.message || 'Failed to read OSPF templates',
});
}
}
return res.json({
ok: errors.length === 0,
results,
errors,
});
} catch (error) {
return sendError(res, 500, error.message || 'Failed to load OSPF templates', 'E_OSPF_LOAD');
}
}
/**
* POST /api/mikrotik/ospf-interface-templates/apply
* Body: { servers: [{ serverId, templates: [{ rosId?, interfaceName, area?, cost }] }] }
*/
async function applyOspfInterfaceTemplates(req, res) {
try {
const payloadServers = Array.isArray(req.body?.servers) ? req.body.servers : [];
if (payloadServers.length === 0) {
return sendError(res, 400, 'servers array is required', 'E_BAD_REQUEST');
}
const servers = await readServersFromS3();
const results = [];
const errors = [];
const summary = { updated: 0, skipped: 0, missing: 0, invalid: 0 };
for (const payload of payloadServers) {
const serverRef = String(payload?.serverId || '').trim();
const desiredTemplates = Array.isArray(payload?.templates) ? payload.templates : [];
const server = servers.find((s) =>
[s?.id, s?.ip, s?.dns].map((x) => String(x || '')).includes(serverRef)
);
if (!server || String(server.type || '').toLowerCase() !== 'home') {
errors.push({ serverId: serverRef, error: 'HOME server not found' });
continue;
}
const serverId = server.id || server.ip || server.dns;
const creds = getMikrotikCredentials(server);
if (!creds) {
errors.push({ serverId, error: 'MikroTik credentials not configured for this server' });
continue;
}
try {
const client = createRosClient(creds);
const raw = await rosPrint(client, 'routing/ospf/interface-template');
const existing = (Array.isArray(raw) ? raw : []).filter((item) => normalizeOspfInterfaceName(item));
let updated = 0;
let skipped = 0;
let missing = 0;
let invalid = 0;
for (const desired of desiredTemplates) {
const desiredRosId = String(desired?.rosId || '').trim();
const interfaceName = String(desired?.interfaceName || '').trim();
const desiredArea = String(desired?.area || '').trim();
const desiredCost = parseOspfCost(desired?.cost);
if ((!interfaceName && !desiredRosId) || desiredCost == null) {
invalid += 1;
continue;
}
let match = null;
if (desiredRosId) {
match = existing.find((item) => String(item?.['.id'] || '') === desiredRosId);
}
if (!match && interfaceName) {
match = existing.find((item) => {
if (!sameOspfInterface(normalizeOspfInterfaceName(item), interfaceName)) return false;
if (!desiredArea) return true;
return sameOspfArea(item?.area, desiredArea);
});
}
if (!match || !match['.id']) {
missing += 1;
continue;
}
const currentCost = parseOspfCost(match.cost);
if (currentCost === desiredCost) {
skipped += 1;
continue;
}
await rosSet(client, 'routing/ospf/interface-template', match['.id'], { cost: String(desiredCost) });
updated += 1;
}
summary.updated += updated;
summary.skipped += skipped;
summary.missing += missing;
summary.invalid += invalid;
results.push({
serverId,
serverLabel: server.dns || server.ip || serverId,
updated,
skipped,
missing,
invalid,
});
} catch (error) {
errors.push({
serverId,
serverLabel: server.dns || server.ip || serverId,
error: error?.response?.data?.detail || error?.response?.data?.message || error.message || 'Failed to apply OSPF templates',
});
}
}
return res.json({
ok: errors.length === 0,
summary,
results,
errors,
});
} catch (error) {
return sendError(res, 500, error.message || 'Failed to apply OSPF templates', 'E_OSPF_APPLY');
}
}
const ADDRESS_LIST_PATH = 'ip/firewall/address-list';
const ADDRESS_LIST_REMOVE_CHUNK_SIZE = 100;
const ADDRESS_LIST_ADD_CONCURRENCY = 8;
const ADDRESS_LIST_ADD_EXECUTE_CHUNK_SIZE = 80;
function chunkArray(items, size) {
if (!Array.isArray(items) || items.length === 0) return [];
const chunkSize = Math.max(1, Number(size) || 1);
const chunks = [];
for (let i = 0; i < items.length; i += chunkSize) {
chunks.push(items.slice(i, i + chunkSize));
}
return chunks;
}
/**
* Удаление address-list по .id:
* 1) пытаемся батчем через POST .../remove { numbers: "*1,*2" }
* 2) при ошибке откатываемся к поштучному DELETE, чтобы не ломать совместимость.
*/
async function removeAddressListIds(client, ids) {
const normalizedIds = [...new Set(
(Array.isArray(ids) ? ids : [])
.map((id) => String(id || '').trim())
.filter(Boolean)
)];
if (normalizedIds.length === 0) return 0;
let removed = 0;
const chunks = chunkArray(normalizedIds, ADDRESS_LIST_REMOVE_CHUNK_SIZE);
for (const chunk of chunks) {
try {
await client.command(`${ADDRESS_LIST_PATH}/remove`, { numbers: chunk.join(',') });
removed += chunk.length;
continue;
} catch (err) {
console.warn(
`[address-lists] batch remove fallback (${chunk.length} ids):`,
err?.message || String(err)
);
}
for (const id of chunk) {
try {
await rosRemove(client, ADDRESS_LIST_PATH, id);
removed++;
} catch (err) {
console.warn(`[address-lists] remove ${id}:`, err?.message);
}
}
}
return removed;
}
function normalizeAddressListEntries(entries) {
return [...new Map(
(Array.isArray(entries) ? entries : [])
.map((entry) => {
const address = String(entry?.address || '').trim();
const list = String(entry?.list || 'ban').trim();
const comment = entry?.comment != null ? String(entry.comment).trim() : '';
if (!address || !list) return null;
const params = { address, list };
if (comment) params.comment = comment;
const key = `${list}|${address}|${comment}`;
return [key, params];
})
.filter(Boolean)
).values()];
}
function escapeRouterOsString(value) {
return `"${String(value || '')
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')}"`;
}
function buildAddressListAddScript(entriesChunk) {
return entriesChunk
.map((params) => {
const parts = [
'/ip/firewall/address-list/add',
`address=${escapeRouterOsString(params.address)}`,
`list=${escapeRouterOsString(params.list)}`,
];
if (params.comment != null && String(params.comment).trim()) {
parts.push(`comment=${escapeRouterOsString(params.comment)}`);
}
return parts.join(' ');
})
.join(';\n');
}
async function addAddressListEntriesParallel(client, normalized) {
if (!Array.isArray(normalized) || normalized.length === 0) return { added: 0, failed: 0 };
let index = 0;
let added = 0;
let failed = 0;
const workersCount = Math.min(ADDRESS_LIST_ADD_CONCURRENCY, normalized.length);
const workers = Array.from({ length: workersCount }, async () => {
while (true) {
const current = index;
index += 1;
if (current >= normalized.length) break;
const params = normalized[current];
try {
await rosAdd(client, ADDRESS_LIST_PATH, params);
added++;
} catch (err) {
failed++;
console.warn('[address-lists] add:', err?.message);
}
}
});
await Promise.all(workers);
return { added, failed };
}
async function addAddressListEntries(client, entries) {
const normalized = normalizeAddressListEntries(entries);
if (normalized.length === 0) return { added: 0, failed: 0 };
let added = 0;
let failed = 0;
const chunks = chunkArray(normalized, ADDRESS_LIST_ADD_EXECUTE_CHUNK_SIZE);
for (const chunk of chunks) {
const script = buildAddressListAddScript(chunk);
try {
// Один execute на пачку резко снижает HTTP overhead.
await client.command('execute', { script });
added += chunk.length;
continue;
} catch (err) {
console.warn(
`[address-lists] execute add fallback (${chunk.length} entries):`,
err?.message || String(err)
);
}
const fallbackResult = await addAddressListEntriesParallel(client, chunk);
added += fallbackResult.added;
failed += fallbackResult.failed;
}
return { added, failed };
}
/**
* POST /api/mikrotik/address-lists/apply-summary
@@ -1475,7 +1848,9 @@ async function applyAddressListSummary(req, res) {
if (!serverId) {
return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
}
const ids = Array.isArray(removeIds) ? removeIds.filter((id) => id != null && String(id).trim()) : [];
const ids = Array.isArray(removeIds)
? [...new Set(removeIds.map((id) => String(id || '').trim()).filter(Boolean))]
: [];
const entries = Array.isArray(addEntries)
? addEntries.filter((e) => e && (e.address || e.list))
: [];
@@ -1495,32 +1870,10 @@ async function applyAddressListSummary(req, res) {
let removed = 0;
let added = 0;
for (const id of ids) {
try {
const pathId = String(id).trim();
await rosRemove(client, ADDRESS_LIST_PATH, pathId);
removed++;
} catch (err) {
console.warn(`[address-lists] remove ${id}:`, err?.message);
}
}
removed = await removeAddressListIds(client, ids);
for (const entry of entries) {
try {
const params = {
address: String(entry.address || '').trim(),
list: String(entry.list || 'ban').trim(),
};
if (entry.comment != null && String(entry.comment).trim()) {
params.comment = String(entry.comment).trim();
}
if (!params.address || !params.list) continue;
await rosAdd(client, ADDRESS_LIST_PATH, params);
added++;
} catch (err) {
console.warn('[address-lists] add:', err?.message);
}
}
const addResult = await addAddressListEntries(client, entries);
added = addResult.added;
return res.json({ ok: true, removed, added });
} catch (error) {
@@ -1544,7 +1897,10 @@ module.exports = {
speedTestViaTunnel,
loadNetworkConfig,
getAddressLists,
getOspfInterfaceTemplates,
applyOspfInterfaceTemplates,
applyAddressListSummary,
uptimeCheck,
getUptimeCache,
getUptimeCacheData,
};
+2
View File
@@ -776,5 +776,7 @@ module.exports = {
getPingServicesList,
getPingServices,
refreshPingServicesCache,
checkOneServerFast,
loadUiSettingsSync,
};
+16 -7
View File
@@ -74,13 +74,11 @@ function parseResource(raw) {
}
/**
* GET /api/resources/stats
* По каждому jumphost/home с MikroTik — запрос system/resource, ответ с группировкой по роутерам.
* Внутренняя функция: возвращает данные по ресурсам роутеров (без HTTP).
*/
async function getResourceStats(req, res) {
try {
const servers = await readServersFromS3();
const routers = (Array.isArray(servers) ? servers : []).filter(
async function getResourceStatsData() {
const servers = await readServersFromS3();
const routers = (Array.isArray(servers) ? servers : []).filter(
(s) =>
s &&
(String(s.type || '').toLowerCase() === 'jumphost' ||
@@ -132,7 +130,17 @@ async function getResourceStats(req, res) {
})
);
return res.json({ routers: results });
return { routers: results };
}
/**
* GET /api/resources/stats
* По каждому jumphost/home с MikroTik — запрос system/resource, ответ с группировкой по роутерам.
*/
async function getResourceStats(req, res) {
try {
const data = await getResourceStatsData();
return res.json(data);
} catch (error) {
console.error('getResourceStats:', error);
return sendError(
@@ -146,4 +154,5 @@ async function getResourceStats(req, res) {
module.exports = {
getResourceStats,
getResourceStatsData,
};
+859
View File
@@ -0,0 +1,859 @@
/**
* Rule-based "local AI" optimizer for route selection.
* Returns best paths and probability of optimality for all candidates.
*/
const { sendError } = require('../middleware/errorHandler');
const { readS3TextObject, listS3Objects } = require('../services/s3Service');
const { 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 {
const raw = await readS3TextObject('bgp_data/communities.json').catch(() => null);
if (!raw?.body) return new Map();
const arr = JSON.parse(raw.body || '[]');
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,
};
+28
View File
@@ -31,11 +31,14 @@ const mikrotikConfigRoutes = require('./routes/mikrotikConfigRoutes');
const mikrotikBackupRoutes = require('./routes/mikrotikBackupRoutes');
const trafficRoutes = require('./routes/trafficRoutes');
const resourceStatsRoutes = require('./routes/resourceStatsRoutes');
const alertsRoutes = require('./routes/alertsRoutes');
const routeOptimizerRoutes = require('./routes/routeOptimizerRoutes');
const { initMikrotikBackupScheduler } = require('./services/mikrotikBackupScheduler');
const { initNetworkMapScheduler } = require('./services/networkMapScheduler');
const { initPingServicesScheduler } = require('./services/pingServicesScheduler');
const { initUptimeMonitorScheduler } = require('./services/uptimeMonitorScheduler');
const schedulerRoutes = require('./routes/schedulerRoutes');
const { createEvobgpProxy, normalizeEvobgpBaseUrl } = require('./routes/evobgpProxyRoutes');
const app = express();
const port = Number(process.env.PORT) || 3001;
@@ -154,6 +157,23 @@ app.get('/api/version', (req, res) => {
});
});
// === EvoBGP reverse proxy (токен и URL только на сервере; без CORS в браузере) ===
function evobgpProxyHealth(req, res) {
const base = normalizeEvobgpBaseUrl(process.env.EVOBGP_API_URL || '');
const hasToken = Boolean(String(process.env.EVOBGP_API_TOKEN || '').trim());
res.json({
ok: true,
proxy: true,
evobgpConfigured: Boolean(base && hasToken),
});
}
// Точное совпадение до app.use: GET /api/evobgp и GET /evobgp — проверка, что запрос доходит до Node
app.get('/api/evobgp', evobgpProxyHealth);
app.get('/evobgp', evobgpProxyHealth);
app.use('/api/evobgp', createEvobgpProxy({ logger }));
// Если nginx делает proxy_pass http://upstream/ и срезает префикс /api, до приложения приходит /evobgp/…
app.use('/evobgp', createEvobgpProxy({ logger }));
// === Cache TTL ===
const DEFAULT_CACHE_TTL = Math.max(0, Math.min(300, Number(process.env.CACHE_TTL_SECONDS) || 30));
app.use((req, res, next) => {
@@ -471,6 +491,8 @@ app.post('/api/mikrotik/speed-test', writeLimiter, mikrotikConfigRoutes.speedTes
app.post('/api/mikrotik/apply', mikrotikConfigRoutes.applyMikrotikConfig);
app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript);
app.get('/api/mikrotik/address-lists', mikrotikConfigRoutes.getAddressLists);
app.get('/api/mikrotik/ospf-interface-templates', mikrotikConfigRoutes.getOspfInterfaceTemplates);
app.post('/api/mikrotik/ospf-interface-templates/apply', writeLimiter, mikrotikConfigRoutes.applyOspfInterfaceTemplates);
app.post('/api/mikrotik/address-lists/apply-summary', writeLimiter, mikrotikConfigRoutes.applyAddressListSummary);
// === UPTIME MONITOR (проверка доступности: http / internal-ping / external-ping из настроек) ===
@@ -483,6 +505,9 @@ app.get('/api/traffic/interface-stats', trafficRoutes.getInterfaceStats);
// === RESOURCE STATS (RAM/CPU/HDD по роутерам) ===
app.get('/api/resources/stats', resourceStatsRoutes.getResourceStats);
// === ALERTS (агрегация проблем: серверы, ресурсы роутеров) ===
app.get('/api/alerts', alertsRoutes.getAlerts);
// === MIKROTIK BACKUPS (S3) ===
app.post('/api/mikrotik/backups', writeLimiter, mikrotikBackupRoutes.createBackup);
app.get('/api/mikrotik/backups', mikrotikBackupRoutes.listBackups);
@@ -493,6 +518,9 @@ app.post('/api/mikrotik/backups/run', writeLimiter, mikrotikBackupRoutes.runBack
// === NETWORK MAP CACHE (для быстрой загрузки карты сети) ===
app.get('/api/network-map-cache', schedulerRoutes.getNetworkMapCache);
// === LOCAL AI ROUTE OPTIMIZER (rule-based) ===
app.get('/api/route-optimizer', routeOptimizerRoutes.getRouteOptimizer);
// === SCHEDULER (карта сети: пинг и скорость по расписанию) ===
app.get('/api/scheduler/network-map/settings', schedulerRoutes.getNetworkMapSchedulerSettings);
app.patch('/api/scheduler/network-map/settings', writeLimiter, schedulerRoutes.patchNetworkMapSchedulerSettings);
+80 -10
View File
@@ -11,9 +11,11 @@ const { readS3TextObject, writeS3JsonObject } = require('./s3Service');
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json';
const NEXT_RUN_AT_KEY = 'networkMapSchedulerNextRunAt';
const DEFAULT_INTERVAL_MINUTES = 5;
const DEFAULT_LOG_MAX_LINES = 500;
const RESTART_RECOVERY_DELAY_MS = 10 * 60 * 1000;
/** Кольцевой буфер логов */
class LogBuffer {
@@ -329,16 +331,34 @@ async function writeCache(pingMap, speedMap) {
}
let schedulerTimer = null;
let schedulerStartTimeout = null;
/** Время последнего запуска по расписанию (для расчёта следующего) */
let lastScheduledRunAt = null;
/** Реально запланированное время следующего запуска */
let nextScheduledRunAt = null;
/** Интервал активного таймера (мс) */
let activeIntervalMs = null;
async function persistNextRunAt(nextRunAt) {
try {
const ui = await loadUiSettings();
ui[NEXT_RUN_AT_KEY] = typeof nextRunAt === 'number' ? Math.round(nextRunAt) : null;
await writeS3JsonObject(UI_SETTINGS_KEY, ui);
} catch (e) {
logBuffer.push(`Не удалось сохранить nextRunAt: ${e?.message || e}`);
}
}
function getSettingsFromUiSettings(uiSettings) {
const persistedNextRunAtRaw = uiSettings?.[NEXT_RUN_AT_KEY];
const persistedNextRunAt = Number(persistedNextRunAtRaw);
return {
enabled: Boolean(uiSettings.networkMapSchedulerEnabled),
intervalMinutes: Math.max(1, Math.min(1440, parseInt(uiSettings.networkMapSchedulerIntervalMinutes, 10) || DEFAULT_INTERVAL_MINUTES)),
runPing: uiSettings.networkMapSchedulerRunPing !== false,
runSpeedTest: uiSettings.networkMapSchedulerRunSpeedTest !== false,
logMaxLines: Math.max(100, Math.min(2000, parseInt(uiSettings.networkMapSchedulerLogMaxLines, 10) || DEFAULT_LOG_MAX_LINES)),
persistedNextRunAt: Number.isFinite(persistedNextRunAt) ? persistedNextRunAt : null,
};
}
@@ -351,8 +371,14 @@ function initNetworkMapScheduler(logger, port) {
const log = logger || console;
const baseUrl = `http://127.0.0.1:${port}`;
async function tick() {
lastScheduledRunAt = Date.now();
async function tick(source = 'interval') {
if (source === 'interval') {
lastScheduledRunAt = Date.now();
if (activeIntervalMs) {
nextScheduledRunAt = lastScheduledRunAt + activeIntervalMs;
persistNextRunAt(nextScheduledRunAt);
}
}
let uiSettings;
try {
const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
@@ -369,6 +395,7 @@ function initNetworkMapScheduler(logger, port) {
baseUrl,
runPing: settings.runPing,
runSpeedTest: settings.runSpeedTest,
trigger: source === 'startup-delay' ? 'startup-delay' : 'scheduled',
log: (msg) => {
logBuffer.push(msg);
log.info({ component: 'network-map-scheduler' }, msg);
@@ -389,29 +416,62 @@ function initNetworkMapScheduler(logger, port) {
}
const settings = getSettingsFromUiSettings(uiSettings);
if (!settings.enabled) {
activeIntervalMs = null;
nextScheduledRunAt = null;
persistNextRunAt(null);
log.info({ component: 'network-map-scheduler' }, 'Scheduler disabled in settings');
return;
}
logBuffer.setMaxLines(settings.logMaxLines);
const intervalMs = settings.intervalMinutes * 60 * 1000;
activeIntervalMs = intervalMs;
const now = Date.now();
const persisted = settings.persistedNextRunAt;
let firstDelayMs = intervalMs;
if (typeof persisted === 'number' && Number.isFinite(persisted)) {
if (persisted > now) {
// Плановая дата ещё не наступила — сохраняем исходный план.
firstDelayMs = Math.max(1000, persisted - now);
} else {
// Плановая дата уже прошла (контейнер был перезапущен/простаивал) —
// первый запуск через 10 минут от старта контейнера.
firstDelayMs = RESTART_RECOVERY_DELAY_MS;
}
}
nextScheduledRunAt = now + firstDelayMs;
persistNextRunAt(nextScheduledRunAt);
log.info(
{ component: 'network-map-scheduler', intervalMinutes: settings.intervalMinutes },
'Network map scheduler started (first run in 10 s, then every %d min)',
{
component: 'network-map-scheduler',
intervalMinutes: settings.intervalMinutes,
firstDelayMs,
recoveryDelayMs: RESTART_RECOVERY_DELAY_MS,
},
'Network map scheduler started (first run in %d sec, then every %d min)',
Math.round(firstDelayMs / 1000),
settings.intervalMinutes
);
schedulerTimer = setInterval(tick, intervalMs);
// Первый запуск через 10 сек, чтобы кеш появился вскоре после старта сервера
setTimeout(() => tick(), 10 * 1000);
schedulerStartTimeout = setTimeout(() => {
tick('interval');
schedulerTimer = setInterval(() => tick('interval'), intervalMs);
}, firstDelayMs);
}
schedule();
}
function stopNetworkMapScheduler() {
if (schedulerStartTimeout) {
clearTimeout(schedulerStartTimeout);
schedulerStartTimeout = null;
}
if (schedulerTimer) {
clearInterval(schedulerTimer);
schedulerTimer = null;
}
activeIntervalMs = null;
nextScheduledRunAt = null;
persistNextRunAt(null);
}
function getLogs() {
@@ -439,9 +499,19 @@ function pushLog(msg) {
*/
function getNextRunAt(settings) {
if (!settings || !settings.enabled) return null;
const intervalMs = (settings.intervalMinutes || DEFAULT_INTERVAL_MINUTES) * 60 * 1000;
const from = lastScheduledRunAt || Date.now();
return from + intervalMs;
if (typeof nextScheduledRunAt === 'number' && Number.isFinite(nextScheduledRunAt)) {
return nextScheduledRunAt;
}
if (typeof lastScheduledRunAt === 'number' && Number.isFinite(lastScheduledRunAt)) {
const intervalMs = activeIntervalMs || (settings.intervalMinutes || DEFAULT_INTERVAL_MINUTES) * 60 * 1000;
return lastScheduledRunAt + intervalMs;
}
if (typeof settings?.persistedNextRunAt === 'number' && Number.isFinite(settings.persistedNextRunAt)) {
const now = Date.now();
if (settings.persistedNextRunAt > now) return settings.persistedNextRunAt;
return now + RESTART_RECOVERY_DELAY_MS;
}
return null;
}
module.exports = {
+2 -2
View File
@@ -1,4 +1,4 @@
# S3 Lists Manager - Frontend
# RouterOS Manager - Frontend
Современный веб-интерфейс для управления списками доменов и AS с использованием Tabler UI.
@@ -172,4 +172,4 @@ import { IconPlus, IconEdit, IconTrash } from '@tabler/icons-react';
---
**S3 Lists Manager** - Современный интерфейс для управления списками с использованием Tabler UI.
**RouterOS Manager** - Современный интерфейс для управления списками с использованием Tabler UI.
+1 -1
View File
@@ -1,6 +1,6 @@
# 🎨 UX/UI Улучшения - Документация
Этот документ описывает все внедренные UX/UI улучшения в проект S3 Lists Manager.
Этот документ описывает все внедренные UX/UI улучшения в проект RouterOS Manager.
## 📋 Содержание
+3 -2
View File
@@ -15,7 +15,7 @@ export default defineConfig([
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
globals: { ...globals.browser, process: 'readonly' },
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
@@ -23,7 +23,8 @@ export default defineConfig([
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]', argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }],
'no-empty': ['error', { allowEmptyCatch: true }],
},
},
])
+2 -2
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="S3 Lists Manager - Управление списками доменов и AS" />
<meta name="description" content="RouterOS Manager - Управление списками доменов и AS" />
<meta name="theme-color" content="#206bc4" />
<!-- Inter Font -->
@@ -12,7 +12,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<title>S3 Lists Manager</title>
<title>RouterOS Manager</title>
</head>
<body>
<div id="root"></div>
+10
View File
@@ -20,6 +20,7 @@
"diff": "^8.0.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"react-router-dom": "^6.30.1",
"recharts": "^3.7.0"
},
@@ -2798,6 +2799,15 @@
"react": "^19.1.0"
}
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
+1
View File
@@ -24,6 +24,7 @@
"diff": "^8.0.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"react-router-dom": "^6.30.1",
"recharts": "^3.7.0"
},
+50 -73
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import api from './lib/api.js';
import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx';
import CommunityBadge from './components/CommunityBadge.jsx';
@@ -43,21 +43,24 @@ import AddItemModal from './components/AddItemModal.jsx';
import FormModal from './components/FormModal.jsx';
import { getAsnName, getAsnNameSync } from './lib/asn.js';
import LastSaved from './components/LastSaved.jsx';
const API_URL = '/api';
import {
listCommunities,
fetchAsnsData,
saveAsnsData,
} from './lib/evobgpData.js';
function ASNsNewManager() {
const [items, setItems] = useState([]);
const [originalItems, setOriginalItems] = useState([]);
const [etag, setEtag] = useState('');
const [, setEtag] = useState('');
const [lastModified, setLastModified] = useState('');
const [contentLength, setContentLength] = useState(null);
const [, setContentLength] = useState(null);
const [newItem, setNewItem] = useState({ asn: '', community: '' });
const [newInvalid, setNewInvalid] = useState({ asn: false, community: false });
const [, setNewInvalid] = useState({ asn: false, community: false });
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const searchTimer = useRef(null);
const _searchTimer = useRef(null);
const didInit = useRef(false);
const [editingValue, setEditingValue] = useState('');
const [editModalOpen, setEditModalOpen] = useState(false);
@@ -80,9 +83,9 @@ function ASNsNewManager() {
useEffect(() => {
(async () => {
try {
const res = await api.get(`/communities`);
setCommunities(Array.isArray(res.data) ? res.data : []);
} catch (e) {
const list = await listCommunities();
setCommunities(list);
} catch (_e) {
// тихо игнорируем
}
})();
@@ -112,21 +115,17 @@ function ASNsNewManager() {
return () => controller.abort();
}, []);
const fetchItems = async (abortSignal) => {
const fetchItems = useCallback(async (_abortSignal) => {
setLoading(true);
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const response = await api.get(`/asns`, { params: { offset: 0, limit: 0, format: 'std' }, ...opts });
const payload = Array.isArray(response.data?.items) ? response.data.items : [];
const mapped = payload.map(item => ({ asn: String(item.domain), community: String(item.type) }));
const mapped = await fetchAsnsData();
const total = mapped.length;
setItems(mapped);
setOriginalItems(mapped);
setTotalItems(Number.isFinite(total) ? total : 0);
setEtag(response.headers?.etag || '');
setLastModified(response.headers?.['last-modified'] || '');
const lengthHeader = response.headers?.['content-length-source'];
setContentLength(typeof lengthHeader !== 'undefined' ? Number(lengthHeader) : null);
setEtag('');
setLastModified(new Date().toISOString());
setContentLength(null);
setError('');
} catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
@@ -135,7 +134,7 @@ function ASNsNewManager() {
} finally {
setLoading(false);
}
};
}, []);
useEffect(() => {
if (!didInit.current) {
@@ -144,7 +143,7 @@ function ASNsNewManager() {
fetchItems(controller.signal);
return () => controller.abort();
}
}, []);
}, [fetchItems]);
const handleAddItem = (item) => {
const asn = item?.asn || newItem.asn;
@@ -298,7 +297,7 @@ function ASNsNewManager() {
return { added, removed, changed };
};
const [showDiff, setShowDiff] = useState(false);
const [_showDiff, setShowDiff] = useState(false);
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
@@ -312,7 +311,29 @@ function ASNsNewManager() {
setShowDiff(true);
};
const handleSaveChanges = async () => {
const performSave = useCallback(async () => {
setConfirmSaveOpen(false);
setLoading(true);
try {
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
.map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
await saveAsnsData(unique, originalItems);
setEtag('');
await fetchItems();
setSuccess('Изменения успешно сохранены!');
setTimeout(() => setSuccess(''), 3000);
notifyMutationSuccess('Изменения сохранены');
} catch (error) {
console.error('Error saving changes:', error);
setError('Не удалось сохранить изменения.');
} finally {
setLoading(false);
}
}, [items, originalItems, fetchItems]);
const handleSaveChanges = useCallback(async () => {
// подготовим diff и спросим подтверждение только при массовых изменениях (>10)
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
.map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() }));
@@ -330,49 +351,7 @@ function ASNsNewManager() {
// Малое изменение - сохраняем сразу
performSave();
}
};
const performSave = async () => {
setConfirmSaveOpen(false);
setLoading(true);
try {
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
.map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
// Загрузим полный список, совместим и сохраним
const fullRes = await api.get(`/asns`, { params: { offset: 0, limit: 0, format: 'std' } });
const fullPayload = Array.isArray(fullRes.data?.items) ? fullRes.data.items : (Array.isArray(fullRes.data) ? fullRes.data : []);
const full = fullPayload.map(item => ({ asn: String(item.domain).trim(), community: String(item.type || '').trim() }));
const fullMap = new Map(full.map(i => [i.asn, { asn: i.asn, community: i.community }]));
const originalPageMap = new Map(originalItems.map(i => [String(i.asn).trim(), true]));
const uniqueMap = new Map(unique.map(i => [String(i.asn).trim(), { asn: i.asn, community: i.community }]));
for (const key of originalPageMap.keys()) {
if (!uniqueMap.has(key)) fullMap.delete(key);
}
for (const [key, val] of uniqueMap.entries()) fullMap.set(key, val);
const fullToSave = Array.from(fullMap.values()).map(i => ({ domain: i.asn, type: i.community }));
const et = fullRes?.headers?.etag || etag;
const payload = { domains: fullToSave, etag: et };
const response = await api.post(`/asns`, payload, { validateStatus: () => true });
if (response.status === 412) {
setError('Данные изменились в S3 (ETag mismatch). Обновите список и попробуйте снова.');
return;
}
if (response.status >= 400) throw new Error(`Save failed with status ${response.status}`);
setEtag(response.headers?.etag || et);
await fetchItems();
setSuccess('Изменения успешно сохранены!');
setTimeout(() => setSuccess(''), 3000);
notifyMutationSuccess('Изменения сохранены');
} catch (error) {
console.error('Error saving changes:', error);
setError('Не удалось сохранить изменения.');
} finally {
setLoading(false);
}
};
}, [items, originalItems, performSave]);
const handleImport = () => {
setImportOpen(true);
@@ -380,10 +359,7 @@ function ASNsNewManager() {
const handleExport = async () => {
try {
const effectiveQ = searchTerm ? searchTerm : (filterCommunity ? filterCommunity : '');
const response = await api.get(`/asns`, { params: { q: effectiveQ, offset: 0, limit: 0, format: 'std' } });
const payload = Array.isArray(response.data?.items) ? response.data.items : (Array.isArray(response.data) ? response.data : []);
const all = payload.map(item => ({ asn: item.domain, community: item.type }));
const all = filtered;
const header = ['asn', 'community'];
const csv = [header, ...all.map(i => [i.asn, i.community])]
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
@@ -401,7 +377,7 @@ function ASNsNewManager() {
};
// Drag&Drop импорт (быстрый путь)
const onDropImport = async (e) => {
const _onDropImport = async (e) => {
e.preventDefault();
const file = e.dataTransfer?.files?.[0];
if (!file) return;
@@ -469,6 +445,7 @@ function ASNsNewManager() {
});
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const paginatedItems = filtered.slice((currentPage - 1) * pageSize, (currentPage) * pageSize);
const paginatedAsns = paginatedItems.map(i => i?.asn).filter(Boolean).join(',');
// Подгружаем имена ASN из кэша/внешних API (после вычисления paginatedItems)
useEffect(() => {
@@ -482,7 +459,7 @@ function ASNsNewManager() {
const name = await getAsnName(asn);
if (name !== null) setAsnNameMap(prev => ({ ...prev, [asn]: name }));
});
}, [JSON.stringify(paginatedItems)]);
}, [paginatedAsns, paginatedItems]);
// Для фильтра - список всех уникальных community
const allCommunities = Array.from(new Set(items.filter(i => i != null).map(i => String(i.community || '')).filter(c => c !== '')));
@@ -507,7 +484,7 @@ function ASNsNewManager() {
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [loading, items, originalItems, etag, sortField, sortOrder, filterCommunity]);
}, [loading, handleSaveChanges]);
const handleEditKeyDown = (e) => {
if (e.key === 'Enter') {
+216 -7
View File
@@ -1,4 +1,4 @@
/* Custom styles for S3 Lists Manager */
/* Custom styles for RouterOS Manager */
/* Layout: Tabler Fluid Vertical (https://preview.tabler.io/layout-fluid-vertical.html) */
/* --- Fluid Vertical: page structure --- */
@@ -72,6 +72,7 @@
.navbar-vertical .navbar-sidebar-footer {
flex-shrink: 0;
border-top-color: rgba(255, 255, 255, 0.1) !important;
overflow: visible;
}
.navbar-vertical .navbar-sidebar-footer .nav-link.btn-icon {
padding: 0.35rem;
@@ -92,6 +93,23 @@
.navbar-vertical .navbar-sidebar-footer .dropdown-toggle::after {
display: none;
}
/* Все выпадающие меню в футере сайдбара — поверх контента и без обрезки */
.navbar-vertical .navbar-sidebar-footer .dropdown-menu {
z-index: 1050;
}
.navbar-vertical .navbar-sidebar-footer .dropdown-menu-card {
overflow-x: hidden;
box-sizing: border-box;
}
.navbar-vertical .navbar-sidebar-footer .dropdown-menu-card .card-footer {
flex-wrap: nowrap;
min-width: 0;
}
.navbar-vertical .navbar-sidebar-footer .dropdown-menu-card .card-footer .btn {
flex: 1 1 auto;
min-width: 0;
justify-content: center;
}
/* Стрелка только у пунктов с подменю (dropdown-toggle), не у обычных ссылок и не у бренда */
.navbar-vertical .nav-link:not(.dropdown-toggle)::after {
display: none !important;
@@ -115,8 +133,9 @@
width: 1.25rem;
height: 1.25rem;
}
/* Sidebar dropdown: inline submenu (no popup) */
.navbar-vertical .nav-item.dropdown .dropdown-menu {
/* Sidebar dropdown: inline submenu (no popup) — только для основного списка меню,
не затрагиваем иконки в нижнем футере сайдбара (язык, тема, колокол и т.д.) */
.navbar-vertical .navbar-nav > .nav-item.dropdown .dropdown-menu {
position: static;
float: none;
border: none;
@@ -125,12 +144,12 @@
padding: 0 0 0.5rem 0;
margin: 0;
}
.navbar-vertical .nav-item.dropdown .dropdown-item {
.navbar-vertical .navbar-nav > .nav-item.dropdown .dropdown-item {
padding-left: 1rem;
padding-top: 0.35rem;
padding-bottom: 0.35rem;
}
.navbar-vertical .nav-item.dropdown .dropdown-item .nav-link-icon {
.navbar-vertical .navbar-nav > .nav-item.dropdown .dropdown-item .nav-link-icon {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
@@ -138,7 +157,7 @@
align-items: center;
justify-content: center;
}
.navbar-vertical .nav-item.dropdown .dropdown-item .nav-link-icon .icon {
.navbar-vertical .navbar-nav > .nav-item.dropdown .dropdown-item .nav-link-icon .icon {
width: 1rem;
height: 1rem;
}
@@ -347,6 +366,96 @@
padding-left: 0;
}
/* ===== Мобильный сайдбар: верхняя полоса + оверлей меню (Tabler-style) ===== */
.sidebar-backdrop {
position: fixed;
inset: 0;
z-index: 1035;
background: rgba(0, 0, 0, 0.4);
transition: opacity 0.2s ease;
}
body.sidebar-overlay-open {
overflow: hidden;
touch-action: none;
}
@media (max-width: 767.98px) {
/* На мобильных: колоночный layout — сайдбар сверху как полоса, контент на всю ширину снизу */
.page {
display: flex;
flex-direction: column;
}
.page .navbar-vertical {
position: relative;
width: 100% !important;
max-width: 100% !important;
min-height: auto !important;
flex-shrink: 0;
flex-direction: row;
align-items: center;
z-index: 1041; /* выше бэкдропа (1035), чтобы меню и верхняя полоса были кликабельны */
}
.page .navbar-vertical > .container-fluid {
flex-direction: row !important;
flex-wrap: nowrap;
width: 100%;
min-height: 3.5rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.page .navbar-vertical .navbar-brand {
flex-shrink: 0;
margin-right: 0.5rem;
}
.page .navbar-vertical .navbar-brand .fw-bold {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 12rem;
}
.page .navbar-vertical .navbar-nav.flex-row {
margin-left: auto;
}
.page .navbar-vertical .navbar-toggler {
padding: 0.5rem;
}
/* Меню скрыто по умолчанию, при открытии — оверлей слева (z-index внутри контекста навбара) */
.page .navbar-vertical .navbar-collapse {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(100vw, 280px);
max-width: 280px;
z-index: 1042;
background: #1e293b;
margin: 0;
padding: 1rem 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15);
flex-direction: column;
display: none;
}
.page .navbar-vertical .navbar-collapse.show {
display: flex !important;
}
.page-wrapper {
margin-left: 0 !important;
flex: 1;
min-width: 0;
width: 100%;
}
.navbar-vertical .nav-link,
.navbar-vertical .dropdown-item {
min-height: 44px;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
display: flex;
align-items: center;
}
}
/* Responsive navbar */
@media (max-width: 991.98px) {
.navbar-collapse {
@@ -461,7 +570,7 @@
/* Responsive sizing for small screens */
@media (max-width: 992px) {
.header-actions { gap: 0.4rem; }
.header-actions { gap: 0.4rem; flex-wrap: wrap; }
.header-actions .btn {
padding: 0.35rem 0.6rem;
font-size: 0.875rem;
@@ -474,6 +583,30 @@
.header-actions { width: 100%; }
}
/* Мобильные отступы контента */
@media (max-width: 767.98px) {
.page-body .container-fluid,
.page-body .container-xl {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.card .card-body {
padding: 0.75rem 1rem;
}
.offcanvas-start {
width: min(320px, 100vw) !important;
max-width: 100vw !important;
}
.modal-dialog {
margin: 0.5rem;
max-width: calc(100% - 1rem);
}
.modal-fullscreen-sm-down .modal-dialog {
max-width: 100%;
margin: 0;
}
}
/* Custom responsive adjustments */
@media (max-width: 992px) {
.page-wrapper {
@@ -564,6 +697,82 @@
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* Easy Switch: bring visuals in line with Tabler pages */
.easy-switch-page .card-tabs .nav-link {
font-weight: 500;
}
.easy-switch-server-header {
background-color: rgba(32, 107, 196, 0.05);
cursor: pointer;
user-select: none;
}
.easy-switch-flag {
font-size: 1.5rem;
line-height: 1;
}
.easy-switch-gateway-card {
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
border: 1px solid var(--tblr-border-color);
position: relative;
}
.easy-switch-gateway-card:hover {
transform: translateY(-1px);
box-shadow: 0 0.125rem 0.375rem rgba(32, 41, 57, 0.1);
}
.easy-switch-gateway-card-active {
border: 1px solid var(--tblr-primary) !important;
box-shadow: 0 0.25rem 0.75rem rgba(32, 107, 196, 0.25);
}
.easy-switch-active-check {
width: 20px;
height: 20px;
position: absolute;
top: 8px;
right: 8px;
}
.easy-switch-fast-slot {
min-height: 18px;
}
.easy-switch-fast-badge {
font-size: 0.65rem;
padding: 2px 6px;
}
.easy-switch-gateway-flag {
width: 28px;
height: 28px;
font-size: 1.25rem;
line-height: 1;
background-color: rgba(0, 0, 0, 0.06);
}
.easy-switch-gateway-flag-active {
background-color: rgba(255, 255, 255, 0.25);
}
.easy-switch-gateway-title {
font-size: 0.85rem;
line-height: 1.2;
word-break: break-word;
}
.easy-switch-gateway-ip {
font-size: 0.75rem;
}
.easy-switch-gateway-ping {
font-size: 1.1rem;
}
/* Ripple effect для кнопок */
@keyframes ripple {
to {
+99 -26
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, createContext, useContext } from 'react';
import { useState, useEffect, createContext, useContext, useRef } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
BrowserRouter as Router,
@@ -27,7 +27,8 @@ import {
IconChartBar,
IconClock,
IconClockPlay,
IconShield
IconShield,
IconRoute2
} from '@tabler/icons-react';
import ServerManager from './ServerManager';
import FilterManager from './FilterManager';
@@ -51,6 +52,8 @@ import PingServicesManager from './PingServicesManager.jsx';
import FirewallPage from './FirewallPage.jsx';
import SettingsPage from './SettingsPage.jsx';
import ResourceStatsPage from './ResourceStatsPage.jsx';
import RouteOptimizerPage from './RouteOptimizerPage.jsx';
import OspfToolsPage from './OspfToolsPage.jsx';
import './App.css';
import { NotifyProvider } from './components/NotifyProvider.jsx';
import ToastContainer from './components/ToastContainer.jsx';
@@ -58,6 +61,8 @@ import CommandPalette, { KeyboardShortcutsButton } from './components/CommandPal
import ErrorBoundary from './components/ErrorBoundary.jsx';
import NetworkErrorHandler from './components/NetworkErrorHandler.jsx';
import { PingProvider } from './contexts/PingContext.jsx';
import { AlertsProvider } from './contexts/AlertsContext.jsx';
import AlertsBell from './components/AlertsBell.jsx';
// --- Simple i18n (RU/EN) ---
const LanguageContext = createContext({ lang: 'ru', setLang: () => {}, t: (k) => k });
@@ -69,16 +74,18 @@ function LanguageProvider({ children }) {
ru: {
home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты',
dashboard: 'Панель', trafficTraffic: 'Расход трафика', resourceStats: 'Статистика ресурсов', networkMap: 'Карта сети', uptimeMonitor: 'Uptime Monitor', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
routeOptimizer: 'Оптимизация маршрутов ИИ',
communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL',
easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы', pingServices: 'Пинг сервисов', firewall: 'Firewall',
easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы', pingServices: 'Пинг сервисов', firewall: 'Firewall', ospfTools: 'Инструменты OSPF',
light: 'Светлая', dark: 'Тёмная',
layoutSidebar: 'Сайдбар', layoutHorizontal: 'Верхнее меню'
},
en: {
home: 'Home', data: 'Data', management: 'Management', tools: 'Tools',
dashboard: 'Dashboard', trafficTraffic: 'Traffic Usage', resourceStats: 'Resource Stats', networkMap: 'Network Map', uptimeMonitor: 'Uptime Monitor', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs',
routeOptimizer: 'AI Route Optimizer',
communities: 'Communities', servers: 'Servers', filters: 'Filters', billing: 'Billing', autoUrls: 'Auto URLs',
easySwitch: 'Easy Switch', networkConfig: 'Network Config', mikrotikBackups: 'MikroTik Backups', pingServices: 'Ping Services', firewall: 'Firewall',
easySwitch: 'Easy Switch', networkConfig: 'Network Config', mikrotikBackups: 'MikroTik Backups', pingServices: 'Ping Services', firewall: 'Firewall', ospfTools: 'OSPF Tools',
light: 'Light', dark: 'Dark',
layoutSidebar: 'Sidebar', layoutHorizontal: 'Top menu'
}
@@ -124,12 +131,14 @@ function App() {
<LanguageProvider>
<ThemeProvider>
<PingProvider>
<ToastContainer>
<NotifyProvider>
<NetworkErrorHandler />
<MainLayout />
</NotifyProvider>
</ToastContainer>
<AlertsProvider>
<ToastContainer>
<NotifyProvider>
<NetworkErrorHandler />
<MainLayout />
</NotifyProvider>
</ToastContainer>
</AlertsProvider>
</PingProvider>
</ThemeProvider>
</LanguageProvider>
@@ -142,16 +151,58 @@ function App() {
const LAYOUT_FLUID = 'fluid';
const LAYOUT_HORIZONTAL = 'horizontal';
const SIDEBAR_COLLAPSE_ID = 'sidebar-menu';
const MOBILE_BREAKPOINT = 768;
function MainLayout() {
const location = useLocation();
const { lang, setLang, t } = useContext(LanguageContext);
const { theme, setTheme } = useContext(ThemeContext);
const [layout, setLayout] = useState(() => localStorage.getItem('layout') || LAYOUT_FLUID);
const [sidebarBackdrop, setSidebarBackdrop] = useState(false);
const sidebarTogglerRef = useRef(null);
useEffect(() => {
localStorage.setItem('layout', layout);
}, [layout]);
// Мобильный сайдбар: бэкдроп при открытом меню и закрытие при навигации
useEffect(() => {
if (layout !== LAYOUT_FLUID) return;
const collapseEl = document.getElementById(SIDEBAR_COLLAPSE_ID);
const onShow = () => {
if (window.innerWidth < MOBILE_BREAKPOINT) {
setSidebarBackdrop(true);
document.body.classList.add('sidebar-overlay-open');
}
};
const onHide = () => {
setSidebarBackdrop(false);
document.body.classList.remove('sidebar-overlay-open');
};
if (collapseEl) {
collapseEl.addEventListener('show.bs.collapse', onShow);
collapseEl.addEventListener('hide.bs.collapse', onHide);
return () => {
collapseEl.removeEventListener('show.bs.collapse', onShow);
collapseEl.removeEventListener('hide.bs.collapse', onHide);
};
}
}, [layout]);
useEffect(() => {
if (layout !== LAYOUT_FLUID || window.innerWidth >= MOBILE_BREAKPOINT) return;
setSidebarBackdrop(false);
document.body.classList.remove('sidebar-overlay-open');
const collapseEl = document.getElementById(SIDEBAR_COLLAPSE_ID);
if (collapseEl?.classList.contains('show')) {
sidebarTogglerRef.current?.click?.();
}
}, [location.pathname, layout]);
useEffect(() => {
}, [location.pathname, layout]);
// Состояние для управления выпадающими меню
const [dropdownStates, setDropdownStates] = useState({
data: false,
@@ -205,6 +256,7 @@ function MainLayout() {
{ id: 'traffic', title: t('trafficTraffic'), path: '/traffic', icon: IconChartPie },
{ id: 'resource-stats', title: t('resourceStats'), path: '/resource-stats', icon: IconChartBar },
{ id: 'network-map', title: t('networkMap'), path: '/network-map', icon: IconNetwork },
{ id: 'route-optimizer', title: t('routeOptimizer'), path: '/route-optimizer', icon: IconRoute2 },
{ id: 'uptime-monitor', title: t('uptimeMonitor'), path: '/uptime-monitor', icon: IconClock }
]
},
@@ -238,6 +290,7 @@ function MainLayout() {
items: [
{ id: 'auto-urls', title: t('autoUrls'), path: '/auto-urls', icon: IconDownload },
{ id: 'mikrotik-backups', title: t('mikrotikBackups'), path: '/mikrotik-backups', icon: IconDatabase },
{ id: 'ospf-tools', title: t('ospfTools'), path: '/ospf-tools', icon: IconRoute2 },
{ id: 'mikrotik-tools', title: 'MikroTik Инструменты', path: '/mikrotik-tools', icon: IconNetwork },
{ id: 'firewall', title: t('firewall'), path: '/firewall', icon: IconShield },
{ id: 'interface-speed', title: 'Скорость интерфейсов', path: '/interface-speed', icon: IconNetwork },
@@ -284,7 +337,7 @@ function MainLayout() {
<div className="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pe-0 pe-md-3">
<Link to="/" className="navbar-brand d-flex align-items-center">
<IconBrandTabler className="navbar-brand-icon me-2" />
<span className="fw-bold">S3 Lists Manager</span>
<span className="fw-bold">RouterOS Manager</span>
</Link>
</div>
<button
@@ -365,12 +418,12 @@ function MainLayout() {
</div>
</div>
<div className="nav-item dropdown me-1">
<a href="#" className="nav-link px-2 dropdown-toggle" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()}>
<span className="badge">{lang.toUpperCase()}</span>
<a href="#" className="nav-link px-2 dropdown-toggle d-flex align-items-center" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()} title="Язык">
<span className="fs-5" aria-hidden>{lang === 'ru' ? '🇷🇺' : '🇺🇸'}</span>
</a>
<div className="dropdown-menu dropdown-menu-end">
<a href="#" className={`dropdown-item ${lang === 'ru' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('ru'); }}>Русский</a>
<a href="#" className={`dropdown-item ${lang === 'en' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('en'); }}>English</a>
<a href="#" className={`dropdown-item d-flex align-items-center ${lang === 'ru' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('ru'); }}><span className="me-2" aria-hidden>🇷🇺</span>Русский</a>
<a href="#" className={`dropdown-item d-flex align-items-center ${lang === 'en' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('en'); }}><span className="me-2" aria-hidden>🇺🇸</span>English</a>
</div>
</div>
<div className="nav-item dropdown me-1">
@@ -382,6 +435,9 @@ function MainLayout() {
<a href="#" className={`dropdown-item ${theme === 'dark' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setTheme('dark'); }}>🌙 {t('dark')}</a>
</div>
</div>
<div className="nav-item me-1">
<AlertsBell />
</div>
<div className="nav-item me-1">
<KeyboardShortcutsButton className="nav-link px-2" />
</div>
@@ -399,6 +455,7 @@ function MainLayout() {
<Route path="/traffic" element={<TrafficDashboard />} />
<Route path="/resource-stats" element={<ResourceStatsPage />} />
<Route path="/network-map" element={<NetworkMapDashboard />} />
<Route path="/route-optimizer" element={<RouteOptimizerPage />} />
<Route path="/uptime-monitor" element={<UptimeMonitorPage />} />
<Route path="/domains" element={<DomainsNewManager />} />
<Route path="/ip-ranges" element={<IPRangesManager />} />
@@ -411,6 +468,7 @@ function MainLayout() {
<Route path="/network-config" element={<NetworkConfigManager />} />
<Route path="/easy-switch" element={<EasySwitchManager />} />
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
<Route path="/ospf-tools" element={<OspfToolsPage />} />
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
<Route path="/firewall" element={<FirewallPage />} />
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
@@ -441,22 +499,23 @@ function MainLayout() {
<div className="container-fluid">
<Link to="/" className="navbar-brand navbar-brand-autodark">
<IconBrandTabler className="navbar-brand-icon" />
<span className="fw-bold">S3 Lists Manager</span>
<span className="fw-bold">RouterOS Manager</span>
</Link>
<div className="navbar-nav flex-row d-md-none">
<button
ref={sidebarTogglerRef}
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#sidebar-menu"
aria-controls="sidebar-menu"
data-bs-target={`#${SIDEBAR_COLLAPSE_ID}`}
aria-controls={SIDEBAR_COLLAPSE_ID}
aria-expanded="false"
aria-label="Открыть меню"
>
<IconMenu2 />
</button>
</div>
<div className="collapse navbar-collapse" id="sidebar-menu">
<div className="collapse navbar-collapse" id={SIDEBAR_COLLAPSE_ID}>
<ul className="navbar-nav pt-lg-3">
{navCategories.map(category => (
<li key={category.id} className={`nav-item${category.items ? ' dropdown' : ''}${(category.single && activeTab === category.id) || (category.items && category.items.some(i => activeTab === i.id)) ? ' active' : ''}`}>
@@ -509,19 +568,19 @@ function MainLayout() {
<div className="navbar-sidebar-footer border-top mt-auto">
<div className="d-flex align-items-center justify-content-center gap-1 py-2 px-2">
<div className="nav-item dropdown dropup">
<a href="#" className="nav-link btn-icon rounded" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()} title="Язык">
<span className="badge bg-secondary">{lang.toUpperCase()}</span>
<a href="#" className="nav-link btn-icon rounded d-inline-flex align-items-center justify-content-center" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()} title="Язык">
<span className="fs-5" aria-hidden>{lang === 'ru' ? '🇷🇺' : '🇺🇸'}</span>
</a>
<div className="dropdown-menu dropdown-menu-end">
<a href="#" className={`dropdown-item ${lang === 'ru' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('ru'); }}>Русский</a>
<a href="#" className={`dropdown-item ${lang === 'en' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('en'); }}>English</a>
<div className="dropdown-menu dropdown-menu-start">
<a href="#" className={`dropdown-item d-flex align-items-center ${lang === 'ru' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('ru'); }}><span className="me-2" aria-hidden>🇷🇺</span>Русский</a>
<a href="#" className={`dropdown-item d-flex align-items-center ${lang === 'en' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('en'); }}><span className="me-2" aria-hidden>🇺🇸</span>English</a>
</div>
</div>
<div className="nav-item dropdown dropup">
<a href="#" className="nav-link btn-icon rounded" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()} title={t('layoutSidebar')}>
{layout === LAYOUT_FLUID ? <IconLayoutSidebarLeftExpand size={20} /> : <IconLayoutNavbar size={20} />}
</a>
<div className="dropdown-menu dropdown-menu-end">
<div className="dropdown-menu dropdown-menu-start">
<a href="#" className={`dropdown-item d-flex align-items-center ${layout === LAYOUT_FLUID ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLayout(LAYOUT_FLUID); }}>
<IconLayoutSidebarLeftExpand className="me-2" size={18} /> {t('layoutSidebar')}
</a>
@@ -534,11 +593,14 @@ function MainLayout() {
<a href="#" className="nav-link btn-icon rounded" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()} title={t('light') + ' / ' + t('dark')}>
{theme === 'dark' ? '🌙' : '☀️'}
</a>
<div className="dropdown-menu dropdown-menu-end">
<div className="dropdown-menu dropdown-menu-start">
<a href="#" className={`dropdown-item ${theme === 'light' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setTheme('light'); }}> {t('light')}</a>
<a href="#" className={`dropdown-item ${theme === 'dark' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setTheme('dark'); }}>🌙 {t('dark')}</a>
</div>
</div>
<div className="nav-item">
<AlertsBell dropup />
</div>
<div className="nav-item">
<KeyboardShortcutsButton className="nav-link btn-icon rounded" title="Горячие клавиши" />
</div>
@@ -553,6 +615,15 @@ function MainLayout() {
</div>
</header>
{/* Бэкдроп мобильного меню: закрытие по клику */}
{sidebarBackdrop && (
<div
className="sidebar-backdrop d-md-none"
aria-hidden="true"
onClick={() => sidebarTogglerRef.current?.click?.()}
/>
)}
<div className="page-wrapper">
<div className="page-body">
<div className="container-fluid">
@@ -562,6 +633,7 @@ function MainLayout() {
<Route path="/traffic" element={<TrafficDashboard />} />
<Route path="/resource-stats" element={<ResourceStatsPage />} />
<Route path="/network-map" element={<NetworkMapDashboard />} />
<Route path="/route-optimizer" element={<RouteOptimizerPage />} />
<Route path="/uptime-monitor" element={<UptimeMonitorPage />} />
<Route path="/domains" element={<DomainsNewManager />} />
<Route path="/ip-ranges" element={<IPRangesManager />} />
@@ -574,6 +646,7 @@ function MainLayout() {
<Route path="/network-config" element={<NetworkConfigManager />} />
<Route path="/easy-switch" element={<EasySwitchManager />} />
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
<Route path="/ospf-tools" element={<OspfToolsPage />} />
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
<Route path="/firewall" element={<FirewallPage />} />
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
+5 -5
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import api from './lib/api.js';
import {
IconPlus,
@@ -101,7 +101,7 @@ function AutoUrlManager() {
const hasAtLeastOneValidRow = urls.some(u => isValidHttpUrl(u.url) && isValidCommunity(u.community));
const saveUrls = async () => {
const saveUrls = useCallback(async () => {
try {
setSaving(true);
setError('');
@@ -124,7 +124,7 @@ function AutoUrlManager() {
} finally {
setSaving(false);
}
};
}, [urls]);
const processUrls = async () => {
try {
@@ -158,7 +158,7 @@ function AutoUrlManager() {
}
setSuccess('Пример формата скопирован');
setTimeout(() => setSuccess(''), 2000);
} catch (e) {
} catch (_e) {
setError('Не удалось скопировать пример');
setTimeout(() => setError(''), 2000);
}
@@ -619,7 +619,7 @@ function AutoUrlManager() {
</tr>
</thead>
<tbody>
{filteredAndSortedUrls.map((url, displayIndex) => {
{filteredAndSortedUrls.map((url, _displayIndex) => {
const originalIndex = urls.findIndex(u => u === url);
const v = getRowValidity(url);
const isValid = v.url && v.community;
+128 -124
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import api from './lib/api.js';
import axios from 'axios';
import FormModal from './components/FormModal.jsx';
@@ -41,8 +41,43 @@ import {
import BulkActionsBar from './components/BulkActionsBar.jsx';
import { BillingCard } from './components/billing/index.js';
function normalizeBillingItems(rawItems, rates) {
const toRUB = (amt, ccy) => {
if (!amt) return 0;
const rub = Number(rates?.RUB) || 1;
if (!ccy || ccy === 'RUB') return amt;
if (ccy === 'USD') return amt * rub;
const c = Number(rates?.[ccy]);
return c ? (amt / c) * rub : amt * rub;
};
return (rawItems || []).map((item, idx) => {
const payments = Array.isArray(item.payments)
? item.payments
: (item.lastPaymentDate && item.lastPaymentAmount
? [{ date: item.lastPaymentDate, amount: item.lastPaymentAmount, currency: item.lastPaymentCurrency || 'USD', note: 'Импортировано' }]
: []);
const merged = { id: item.id ?? String(idx + 1), payments, ...item };
if (payments.length > 0) {
const last = payments.slice().sort((a, b) => new Date(b.date) - new Date(a.date))[0];
if (last?.date) {
const current = new Date(item.nextPaymentDate || 0);
const now = new Date();
if (current < now || !item.nextPaymentDate) {
const costRUB = toRUB(item.monthlyCost || 0, item.monthlyCostCurrency || 'USD');
const amtRUB = toRUB(last.amount, last.currency || 'USD');
const months = costRUB > 0 ? Math.max(1, Math.floor(amtRUB / costRUB)) : 1;
const d = new Date(last.date);
d.setMonth(d.getMonth() + months);
merged.nextPaymentDate = d.toISOString().slice(0, 10);
}
}
}
return merged;
});
}
function BillingManager() {
const [billingData, setBillingData] = useState([]);
const [rawBillingData, setRawBillingData] = useState([]);
const [servers, setServers] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
@@ -51,7 +86,7 @@ function BillingManager() {
const [currentPage, setCurrentPage] = useState(1);
const [sortField, setSortField] = useState('nextPaymentDate');
const [sortOrder, setSortOrder] = useState('asc');
const [filterStatus, setFilterStatus] = useState('');
const [filterStatus, _setFilterStatus] = useState('');
const [filterUrgency, setFilterUrgency] = useState('');
const [viewMode, setViewMode] = useState('cards'); // 'cards' | 'table'
const [activeTab, setActiveTab] = useState('subscriptions'); // 'subscriptions' | 'payments'
@@ -76,14 +111,14 @@ function BillingManager() {
const [editingPayment, setEditingPayment] = useState(null);
const [showDeletePaymentModal, setShowDeletePaymentModal] = useState(false);
const [paymentToDelete, setPaymentToDelete] = useState(null);
const [futureDateConfirm, setFutureDateConfirm] = useState(false);
const [_futureDateConfirm, _setFutureDateConfirm] = useState(false);
// История платежей
const [paymentSearchTerm, setPaymentSearchTerm] = useState('');
const [paymentSortField, setPaymentSortField] = useState('date');
const [paymentSortOrder, setPaymentSortOrder] = useState('desc');
const [selectedPayments, setSelectedPayments] = useState(new Set());
const [paymentPage, setPaymentPage] = useState(1);
const [paymentSearchTerm, _setPaymentSearchTerm] = useState('');
const [_paymentSortField, _setPaymentSortField] = useState('date');
const [_paymentSortOrder, _setPaymentSortOrder] = useState('desc');
const [_selectedPayments, _setSelectedPayments] = useState(new Set());
const [_paymentPage, _setPaymentPage] = useState(1);
const paymentPageSize = 10;
// Курсы валют
@@ -92,7 +127,7 @@ function BillingManager() {
EUR: 1,
RUB: 1
});
const [ratesLoading, setRatesLoading] = useState(false);
const [_ratesLoading, setRatesLoading] = useState(false);
// Новый элемент
const [newItem, setNewItem] = useState({
@@ -112,6 +147,73 @@ function BillingManager() {
serverId: '',
});
const fetchExchangeRates = useCallback(async (abortSignal) => {
setRatesLoading(true);
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const cbr = await axios.get('https://www.cbr-xml-daily.ru/latest.js', opts);
const rubPerUsd = cbr.data && cbr.data.rates?.USD ? 1 / Number(cbr.data.rates.USD) : null;
const rubPerEur = cbr.data && cbr.data.rates?.EUR ? 1 / Number(cbr.data.rates.EUR) : null;
if (rubPerUsd) {
const eurPerUsd = rubPerEur ? rubPerUsd / rubPerEur : 1;
setExchangeRates({
USD: 1,
EUR: eurPerUsd,
RUB: rubPerUsd
});
return;
}
} catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
console.warn('ЦБ недоступен, пробуем резервный источник:', error?.message);
}
try {
const response = await axios.get('https://open.er-api.com/v6/latest/USD', opts);
const rates = response.data?.rates || {};
setExchangeRates({
USD: 1,
EUR: rates.EUR || 1,
RUB: rates.RUB || 1
});
} catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
console.error('Ошибка при загрузке курсов валют:', error);
setExchangeRates({ USD: 1, EUR: 1.05, RUB: 95 });
} finally {
setRatesLoading(false);
}
}, []);
const fetchServers = useCallback(async (abortSignal) => {
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const res = await api.get(`/servers`, opts);
setServers(Array.isArray(res.data) ? res.data : []);
} catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
console.error('Ошибка при загрузке серверов:', error);
}
}, []);
const fetchBillingData = useCallback(async (abortSignal) => {
setLoading(true);
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const response = await api.get(`/billing`, opts);
setRawBillingData(Array.isArray(response.data) ? response.data : []);
setError('');
} catch (err) {
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
console.error('Ошибка при загрузке данных биллинга:', err);
setError('Ошибка при загрузке данных');
setRawBillingData([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
@@ -119,13 +221,19 @@ function BillingManager() {
fetchExchangeRates(signal);
fetchServers(signal);
return () => controller.abort();
}, []);
}, [fetchBillingData, fetchExchangeRates, fetchServers]);
// Нормализация с учётом курсов валют (без повторных запросов)
const billingData = useMemo(
() => normalizeBillingItems(rawBillingData, exchangeRates),
[rawBillingData, exchangeRates]
);
// Автоматическая синхронизация: добавляем серверы без записей в биллинге
useEffect(() => {
if (servers.length === 0 || loading) return;
setBillingData((prev) => {
setRawBillingData((prev) => {
const existingServerIds = new Set(
prev.map(b => b.serverId).filter(Boolean)
);
@@ -165,118 +273,14 @@ function BillingManager() {
}, [servers, loading]);
const fetchExchangeRates = async (abortSignal) => {
setRatesLoading(true);
const opts = abortSignal ? { signal: abortSignal } : {};
try {
// Основной источник: ЦБ РФ (base = RUB)
const cbr = await axios.get('https://www.cbr-xml-daily.ru/latest.js', opts);
const rubPerUsd = cbr.data && cbr.data.rates?.USD ? 1 / Number(cbr.data.rates.USD) : null;
const rubPerEur = cbr.data && cbr.data.rates?.EUR ? 1 / Number(cbr.data.rates.EUR) : null;
if (rubPerUsd) {
const eurPerUsd = rubPerEur ? rubPerUsd / rubPerEur : 1;
setExchangeRates({
USD: 1,
EUR: eurPerUsd, // сколько EUR за 1 USD
RUB: rubPerUsd // сколько RUB за 1 USD
});
return;
}
} catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
console.warn('ЦБ недоступен, пробуем резервный источник:', error?.message);
}
try {
// Резервный источник: open.er-api
const response = await axios.get('https://open.er-api.com/v6/latest/USD', opts);
const rates = response.data?.rates || {};
setExchangeRates({
USD: 1,
EUR: rates.EUR || 1,
RUB: rates.RUB || 1
});
} catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
console.error('Ошибка при загрузке курсов валют:', error);
// Фолбек на разумные значения
setExchangeRates({ USD: 1, EUR: 1.05, RUB: 95 });
} finally {
setRatesLoading(false);
}
};
const fetchServers = async (abortSignal) => {
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const res = await api.get(`/servers`, opts);
setServers(Array.isArray(res.data) ? res.data : []);
} catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
console.error('Ошибка при загрузке серверов:', error);
}
};
const fetchBillingData = async (abortSignal) => {
setLoading(true);
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const response = await api.get(`/billing`, opts);
const rates = exchangeRates; // из closure, на первой загрузке может быть {1,1,1} для USD достаточно
const toRUB = (amt, ccy) => {
if (!amt) return 0;
const rub = Number(rates.RUB) || 1;
if (!ccy || ccy === 'RUB') return amt;
if (ccy === 'USD') return amt * rub;
const c = Number(rates[ccy]);
return c ? (amt / c) * rub : amt * rub;
};
const normalized = (response.data || []).map((item, idx) => {
const payments = Array.isArray(item.payments)
? item.payments
: (item.lastPaymentDate && item.lastPaymentAmount
? [{ date: item.lastPaymentDate, amount: item.lastPaymentAmount, currency: item.lastPaymentCurrency || 'USD', note: 'Импортировано' }]
: []
);
const merged = { id: item.id ?? String(idx + 1), payments, ...item };
if (payments.length > 0) {
const last = payments.slice().sort((a, b) => new Date(b.date) - new Date(a.date))[0];
if (last?.date) {
const current = new Date(item.nextPaymentDate || 0);
const now = new Date();
if (current < now || !item.nextPaymentDate) {
const costRUB = toRUB(item.monthlyCost || 0, item.monthlyCostCurrency || 'USD');
const amtRUB = toRUB(last.amount, last.currency || 'USD');
const months = costRUB > 0 ? Math.max(1, Math.floor(amtRUB / costRUB)) : 1;
const d = new Date(last.date);
d.setMonth(d.getMonth() + months);
merged.nextPaymentDate = d.toISOString().slice(0, 10);
}
}
}
return merged;
});
setBillingData(normalized);
setError('');
} catch (err) {
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
console.error('Ошибка при загрузке данных биллинга:', err);
setError('Ошибка при загрузке данных');
setBillingData([]);
} finally {
setLoading(false);
}
};
const handleSaveChanges = async () => {
setLoading(true);
try {
// Убираем служебное поле _external перед сохранением
const payload = billingData.map(({ _external, ...rest }) => rest);
const payload = billingData.map(({ _external: _ext, ...rest }) => rest);
await api.post(`/billing`, { domains: payload });
// После сохранения снимаем флаг _external со всех записей
setBillingData(prev => prev.map(item => ({ ...item, _external: false })));
setRawBillingData(prev => prev.map(item => ({ ...item, _external: false })));
setSuccess('Данные успешно сохранены');
setTimeout(() => setSuccess(''), 3000);
} catch (err) {
@@ -466,7 +470,7 @@ function BillingManager() {
const confirmDelete = () => {
if (selectedItem) {
setBillingData(prev => prev.filter(item => item.id !== selectedItem.id));
setRawBillingData(prev => prev.filter(item => item.id !== selectedItem.id));
setShowDeleteModal(false);
setSelectedItem(null);
}
@@ -479,7 +483,7 @@ function BillingManager() {
}
const newId = Math.max(...billingData.map(item => parseInt(item.id) || 0), 0) + 1;
setBillingData(prev => [...prev, { ...newItem, id: newId.toString() }]);
setRawBillingData(prev => [...prev, { ...newItem, id: newId.toString() }]);
setShowAddModal(false);
setSuccess('Элемент добавлен');
setTimeout(() => setSuccess(''), 3000);
@@ -492,7 +496,7 @@ function BillingManager() {
}
// Снимаем флаг _external при редактировании (запись теперь заполнена)
setBillingData(prev => prev.map(item =>
setRawBillingData(prev => prev.map(item =>
item.id === editingItem.id ? { ...editingItem, _external: false } : item
));
setShowEditModal(false);
@@ -504,7 +508,7 @@ function BillingManager() {
const handleDeletePayment = () => {
if (!paymentToDelete) return;
const { serverId, index } = paymentToDelete;
setBillingData(prev => prev.map(s => {
setRawBillingData(prev => prev.map(s => {
if (s.id !== serverId) return s;
const payments = [...(s.payments || [])];
payments.splice(index, 1);
@@ -1151,7 +1155,7 @@ function BillingManager() {
}
if (editingPayment) {
setBillingData(prev => prev.map(s => {
setRawBillingData(prev => prev.map(s => {
if (s.id !== editingPayment.serverId) return s;
const payments = [...(s.payments || [])];
payments[editingPayment.index] = {
@@ -1166,7 +1170,7 @@ function BillingManager() {
}));
setEditingPayment(null);
} else {
setBillingData(prev => prev.map(s => {
setRawBillingData(prev => prev.map(s => {
if (s.id !== paymentDraft.serverId) return s;
const payments = [...(s.payments || []), {
date: paymentDraft.date,
+159 -97
View File
@@ -11,13 +11,13 @@ import {
IconFilter,
IconChartBar,
IconList,
IconLink,
IconRefresh,
IconSortAscending,
IconSortDescending,
IconUpload,
IconDownload,
IconDeviceFloppy
IconDeviceFloppy,
IconPlugConnected
} from '@tabler/icons-react';
import PageHeader from './components/PageHeader.jsx';
import PortalDropdown from './components/PortalDropdown.jsx';
@@ -29,8 +29,9 @@ import ConfirmModal from './components/ConfirmModal.jsx';
import FormField from './components/FormField.jsx';
import ErrorAlert from './components/ErrorAlert.jsx';
import Pagination from './components/Pagination.jsx';
const API_URL = '/api';
import IconPicker from './components/IconPicker.jsx';
import { getIconById } from './lib/brandIcons.js';
import { listCommunities, saveCommunitiesData, normalizeEvobgpApiBaseUrl } from './lib/evobgpData.js';
const normalizeCommunityValue = (value) => {
const trimmed = String(value || '').trim();
@@ -53,14 +54,25 @@ function CommunitiesManager() {
// Состояние для переключения между списком и статистикой
const [activeTab, setActiveTab] = useState('list'); // 'list' | 'stats'
const [newItem, setNewItem] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '', category: '', priority: 0, enabled: true });
const [newItem, setNewItem] = useState({ value: '', name: '', description: '', tags: '', color: '', icon: '', category: '', priority: 0, enabled: true });
const [editingValue, setEditingValue] = useState(null);
const [editingDraft, setEditingDraft] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '', category: '', priority: 0, enabled: true });
const [editingDraft, setEditingDraft] = useState({ value: '', name: '', description: '', tags: '', color: '', icon: '', category: '', priority: 0, enabled: true });
const [filterUsage, setFilterUsage] = useState({});
const [filterUsageLoading, setFilterUsageLoading] = useState(false);
const [filterUsageError, setFilterUsageError] = useState('');
const [showOnlyUsedInFilters, setShowOnlyUsedInFilters] = useState(false);
const [evobgpUrlDraft, setEvobgpUrlDraft] = useState('');
const [evobgpTokenDraft, setEvobgpTokenDraft] = useState('');
const [evobgpSettingsOpen, setEvobgpSettingsOpen] = useState(() => {
if (import.meta.env?.VITE_EVOBGP_API_URL) return false;
try {
return Boolean(window.localStorage?.getItem('evobgp_api_url'));
} catch {
return false;
}
});
// Модальные окна
const [addModalOpen, setAddModalOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
@@ -72,6 +84,15 @@ function CommunitiesManager() {
fetchFilterUsage();
}, []);
useEffect(() => {
try {
setEvobgpUrlDraft(String(window.localStorage?.getItem('evobgp_api_url') || ''));
setEvobgpTokenDraft(String(window.localStorage?.getItem('evobgp_api_token') || ''));
} catch {
/* no-op */
}
}, []);
useEffect(() => {
if (!filterUsage) return;
setItems((prev) => {
@@ -79,7 +100,7 @@ function CommunitiesManager() {
Object.keys(filterUsage).forEach((value) => {
if (!value) return;
if (!map.has(value)) {
map.set(value, { value, name: '', description: '', tags: [], gatewayDefault: '', color: '', _external: true });
map.set(value, { value, name: '', description: '', tags: [], color: '', icon: '', _external: true });
}
});
return Array.from(map.values());
@@ -89,57 +110,12 @@ function CommunitiesManager() {
const fetchItems = async () => {
setLoading(true);
try {
const [dictRes, asnsRes, ipRes, domRes] = await Promise.all([
api.get(`/communities`),
api.get(`/asns`, { params: { offset: 0, limit: 0 } }).catch(() => ({ data: [] })),
api.get(`/ip-ranges`, { params: { offset: 0, limit: 0 } }).catch(() => ({ data: [] })),
api.get(`/domains-new`, { params: { offset: 0, limit: 0 } }).catch(() => ({ data: [] })),
]);
const dict = Array.isArray(dictRes.data) ? dictRes.data : [];
const asnsPayload = Array.isArray(asnsRes.data?.items) ? asnsRes.data.items : (Array.isArray(asnsRes.data) ? asnsRes.data : []);
const ipPayload = Array.isArray(ipRes.data?.items) ? ipRes.data.items : (Array.isArray(ipRes.data) ? ipRes.data : []);
const domPayload = Array.isArray(domRes.data?.items) ? domRes.data.items : (Array.isArray(domRes.data) ? domRes.data : []);
const usedCommunities = new Set();
// ASNs: fields domain/type
for (const it of asnsPayload) {
const v = normalizeCommunityValue(it.type);
if (v) usedCommunities.add(v);
}
// IP ranges: fields ipRange/community
for (const it of ipPayload) {
const v = normalizeCommunityValue(it.community);
if (v) usedCommunities.add(v);
}
// Domains: fields domain/community
for (const it of domPayload) {
const v = normalizeCommunityValue(it.community);
if (v) usedCommunities.add(v);
}
const map = new Map();
// put dictionary items first
for (const d of dict) {
const raw = String(d.value || '').trim();
if (!raw) continue;
const value = normalizeCommunityValue(raw);
map.set(value, {
value,
name: String(d.name || ''),
description: String(d.description || ''),
tags: Array.isArray(d.tags) ? d.tags : [],
gatewayDefault: String(d.gatewayDefault || ''),
color: String(d.color || ''),
_external: false,
});
}
// add unknown ones from usage
for (const v of usedCommunities) {
if (!map.has(v)) {
map.set(v, { value: v, name: '', description: '', tags: [], gatewayDefault: '', color: '', _external: true });
}
}
setItems(Array.from(map.values()));
const rows = await listCommunities();
const normalized = rows.map((item) => ({
...item,
value: normalizeCommunityValue(item.value),
}));
setItems(normalized);
setError('');
} catch (e) {
console.error('Error fetching communities:', e);
@@ -188,9 +164,7 @@ function CommunitiesManager() {
const saveAll = async (data) => {
setLoading(true);
try {
// не отправляем служебное поле _external
const payload = data.map(({ _external, ...rest }) => rest);
await api.post(`/communities`, { communities: payload });
await saveCommunitiesData(data);
setSuccess('Справочник сохранён!');
setTimeout(() => setSuccess(''), 3000);
} catch (e) {
@@ -215,10 +189,10 @@ function CommunitiesManager() {
name: String(newItem.name || ''),
description: String(newItem.description || ''),
tags: toTagsArray(newItem.tags),
gatewayDefault: String(newItem.gatewayDefault || ''),
color: String(newItem.color || ''),
icon: String(newItem.icon || ''),
}]);
setNewItem({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '' });
setNewItem({ value: '', name: '', description: '', tags: '', color: '', icon: '' });
setAddModalOpen(false);
setSuccess('Community успешно добавлен!');
setTimeout(() => setSuccess(''), 3000);
@@ -231,8 +205,8 @@ function CommunitiesManager() {
name: item.name || '',
description: item.description || '',
tags: fromTagsArray(item.tags),
gatewayDefault: item.gatewayDefault || '',
color: item.color || '',
icon: item.icon || '',
});
setEditModalOpen(true);
};
@@ -247,8 +221,8 @@ function CommunitiesManager() {
name: String(editingDraft.name || ''),
description: String(editingDraft.description || ''),
tags: toTagsArray(editingDraft.tags),
gatewayDefault: String(editingDraft.gatewayDefault || ''),
color: String(editingDraft.color || ''),
icon: String(editingDraft.icon || ''),
} : i);
setItems(next);
setEditingValue(null);
@@ -278,7 +252,7 @@ function CommunitiesManager() {
};
const handleImport = () => {
const text = window.prompt('Вставьте JSON-массив community (value, name, description, tags, gatewayDefault, color)');
const text = window.prompt('Вставьте JSON-массив community (value, name, description, tags, color, icon)');
if (!text) return;
try {
const arr = JSON.parse(text);
@@ -295,13 +269,13 @@ function CommunitiesManager() {
name: String(x.name || ''),
description: String(x.description || ''),
tags: Array.isArray(x.tags) ? x.tags : [],
gatewayDefault: String(x.gatewayDefault || ''),
color: String(x.color || ''),
icon: String(x.icon || ''),
});
});
return Array.from(map.values());
});
} catch (e) {
} catch (_e) {
setError('Неверный JSON.');
}
};
@@ -318,6 +292,43 @@ function CommunitiesManager() {
const handleSave = () => saveAll(items);
const saveEvobgpConnection = () => {
const trimmedUrl = String(evobgpUrlDraft || '').trim();
if (!trimmedUrl) {
try {
window.localStorage.removeItem('evobgp_api_url');
window.localStorage.removeItem('evobgp_api_token');
} catch (_e) {
setError('Не удалось очистить настройки в localStorage.');
return;
}
setEvobgpUrlDraft('');
setEvobgpTokenDraft('');
setError('');
setSuccess('Включён серверный прокси /api/evobgp (EVOBGP_API_URL и EVOBGP_API_TOKEN в контейнере).');
fetchItems();
setTimeout(() => setSuccess(''), 5000);
return;
}
const norm = normalizeEvobgpApiBaseUrl(trimmedUrl);
if (!norm) {
setError('Укажите корректный URL EvoBGP (например http://192.168.0.1:8080). Порт — у API EvoBGP, не у этого интерфейса.');
return;
}
try {
window.localStorage.setItem('evobgp_api_url', norm);
window.localStorage.setItem('evobgp_api_token', String(evobgpTokenDraft || '').trim());
} catch (_e) {
setError('Не удалось сохранить настройки в localStorage.');
return;
}
setEvobgpUrlDraft(norm);
setError('');
setSuccess('Прямое подключение к EvoBGP из браузера сохранено. Загружаем данные…');
fetchItems();
setTimeout(() => setSuccess(''), 4000);
};
const sorted = useMemo(() => {
const arr = [...items];
arr.sort((a, b) => {
@@ -359,6 +370,61 @@ function CommunitiesManager() {
icon={<IconFilter size={24} />}
/>
<div className="card mb-3">
<div className="card-header py-2 d-flex align-items-center justify-content-between">
<div className="d-flex align-items-center gap-2">
<IconPlugConnected size={18} />
<span className="fw-semibold">Подключение к EvoBGP API</span>
</div>
<button
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={() => setEvobgpSettingsOpen((o) => !o)}
>
{evobgpSettingsOpen ? 'Скрыть' : 'Настроить'}
</button>
</div>
{evobgpSettingsOpen && (
<div className="card-body">
<p className="text-secondary small mb-3">
<strong>По умолчанию</strong> запросы идут на внутренний прокси <code>/api/evobgp</code>: URL и токен задаются в{' '}
<strong>переменных окружения контейнера</strong> <code>EVOBGP_API_URL</code> и <code>EVOBGP_API_TOKEN</code>{' '}
(без CORS). Ниже только <strong>опциональное</strong> прямое подключение из браузера к EvoBGP; оставьте URL пустым и
нажмите «Сохранить», чтобы снова использовать прокси.
</p>
<div className="row g-2 align-items-end">
<div className="col-12 col-md-6">
<label className="form-label">Прямой Base URL EvoBGP (необязательно)</label>
<input
type="url"
className="form-control"
placeholder="оставьте пустым для прокси"
autoComplete="off"
value={evobgpUrlDraft}
onChange={(e) => setEvobgpUrlDraft(e.target.value)}
/>
</div>
<div className="col-12 col-md-4">
<label className="form-label">Токен (только для прямого режима)</label>
<input
type="password"
className="form-control"
placeholder="Bearer token"
autoComplete="off"
value={evobgpTokenDraft}
onChange={(e) => setEvobgpTokenDraft(e.target.value)}
/>
</div>
<div className="col-12 col-md-2">
<button type="button" className="btn btn-primary w-100" onClick={saveEvobgpConnection}>
Сохранить
</button>
</div>
</div>
</div>
)}
</div>
{/* Уведомления */}
{error && (
<ErrorAlert message={error} onClose={() => setError('')} onRetry={fetchItems} />
@@ -553,6 +619,7 @@ function CommunitiesManager() {
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{ width: '8%' }}>Иконка</th>
<th className="cursor-pointer user-select-none" onClick={()=>changeSort('value')}>
<div className="d-flex align-items-center">
<IconHash size={16} className="me-1 text-muted" />
@@ -577,7 +644,6 @@ function CommunitiesManager() {
</th>
<th>Описание</th>
<th>Теги</th>
<th>Gateway</th>
<th style={{ width: '10%' }}>Исп. в фильтрах</th>
<th className="text-end">Действия</th>
</tr>
@@ -600,8 +666,18 @@ function CommunitiesManager() {
</td></tr>
) : paginated.map((item) => {
const usageCount = filterUsage[item.value] || 0;
const iconInfo = item.icon ? getIconById(item.icon) : null;
return (
<tr key={item.value}>
<td>
{iconInfo ? (
<span className="avatar avatar-sm bg-blue-lt text-blue border">
<iconInfo.Icon size={20} stroke={1.5} />
</span>
) : (
<span className="text-muted"></span>
)}
</td>
<td>
<code className="text-blue">{item.value}</code>
{item._external && (
@@ -617,9 +693,6 @@ function CommunitiesManager() {
<span key={t} className="badge bg-blue-lt text-blue me-1">{t}</span>
))}
</td>
<td>
<span className="text-muted">{item.gatewayDefault || '—'}</span>
</td>
<td>
{usageCount > 0 ? (
<span className="badge bg-blue-lt text-blue">{usageCount} в фильтрах</span>
@@ -629,13 +702,6 @@ function CommunitiesManager() {
</td>
<td className="text-end">
<div className="btn-list">
<a
className="btn btn-outline-primary btn-icon btn-sm"
href={`/filters?community=${encodeURIComponent(item.value)}`}
title="Открыть в /filters с поиском по community"
>
<IconLink size={16} />
</a>
<button
className="btn btn-outline-primary btn-icon btn-sm"
onClick={()=>startEdit(item)}
@@ -681,7 +747,7 @@ function CommunitiesManager() {
show={addModalOpen}
onClose={() => {
setAddModalOpen(false);
setNewItem({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '' });
setNewItem({ value: '', name: '', description: '', tags: '', color: '', icon: '' });
setError('');
}}
onSubmit={addItem}
@@ -742,14 +808,6 @@ function CommunitiesManager() {
helpText="Разделяйте теги запятыми"
/>
<FormField
label="Gateway по умолчанию"
name="gatewayDefault"
value={newItem.gatewayDefault}
onChange={(val) => setNewItem({...newItem, gatewayDefault: val})}
placeholder="SWE-HIPHOST"
/>
<FormField
label="Цвет бейджа (опционально)"
name="color"
@@ -758,6 +816,12 @@ function CommunitiesManager() {
placeholder="blue, green, red ..."
helpText="Цвет для отображения бейджа в интерфейсе"
/>
<IconPicker
value={newItem.icon}
onChange={(id) => setNewItem({...newItem, icon: id})}
label="Иконка бренда (опционально)"
allowNone
/>
</FormModal>
{/* Модальное окно редактирования */}
@@ -824,14 +888,6 @@ function CommunitiesManager() {
helpText="Разделяйте теги запятыми"
/>
<FormField
label="Gateway по умолчанию"
name="gatewayDefault"
value={editingDraft.gatewayDefault}
onChange={(val) => setEditingDraft({...editingDraft, gatewayDefault: val})}
placeholder="SWE-HIPHOST"
/>
<FormField
label="Цвет бейджа (опционально)"
name="color"
@@ -840,6 +896,12 @@ function CommunitiesManager() {
placeholder="blue, green, red ..."
helpText="Цвет для отображения бейджа в интерфейсе"
/>
<IconPicker
value={editingDraft.icon}
onChange={(id) => setEditingDraft({...editingDraft, icon: id})}
label="Иконка бренда (опционально)"
allowNone
/>
</FormModal>
{/* Модальное окно подтверждения удаления */}
+87 -19
View File
@@ -18,6 +18,11 @@ import {
IconSearch,
IconChevronDown,
IconChevronUp,
IconBell,
IconCpu,
IconDeviceDesktop,
IconDatabase,
IconServerOff,
} from '@tabler/icons-react';
import { getIconById } from './lib/brandIcons.js';
import PageHeader from './components/PageHeader.jsx';
@@ -26,13 +31,14 @@ import TrendIndicator from './components/TrendIndicator.jsx';
import LastSaved from './components/LastSaved.jsx';
import Tooltip from './components/Tooltip.jsx';
import Sparkline from './components/Sparkline.jsx';
import { useAlerts } from './contexts/AlertsContext.jsx';
function StatCard({ icon: Icon, color, value, title, subtitle, to, trend, previousValue }) {
function StatCard({ icon: _Icon, color, value, title, subtitle, to, trend, previousValue }) {
return (
<div className="card h-100 position-relative card-hover">
<div className="card-body d-flex align-items-center">
<span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0`}>
<Icon size={32} />
<_Icon size={32} />
</span>
<div className="flex-grow-1">
<div className="d-flex align-items-baseline gap-2 mb-1">
@@ -58,12 +64,12 @@ function StatCard({ icon: Icon, color, value, title, subtitle, to, trend, previo
);
}
function MetricCard({ title, value, icon: Icon, color, description }) {
function MetricCard({ title, value, icon: _Icon, color, description }) {
return (
<div className="card h-100 position-relative">
<div className="card-body d-flex align-items-center">
<span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0`}>
<Icon size={32} />
<_Icon size={32} />
</span>
<div className="flex-grow-1">
<div className="h3 mb-0 fw-bold">
@@ -177,7 +183,16 @@ function PingServiceCard({ config, ms, previousMs, history = [], loading, isExpa
);
}
const ALERT_ICONS = {
server_offline: IconServerOff,
mikrotik_unreachable: IconServerOff,
high_cpu: IconCpu,
high_ram: IconDeviceDesktop,
high_hdd: IconDatabase,
};
function Dashboard() {
const { alerts, loading: alertsLoading, refresh: refreshAlerts } = useAlerts();
// Глобальный поиск удалён по требованию UX
const [stats, setStats] = useState({
domainsCount: null,
@@ -357,12 +372,12 @@ function Dashboard() {
title="Панель"
icon={<IconCloud size={24} />}
actions={(
<div className="d-flex align-items-center gap-3">
<div className="header-actions d-flex align-items-center flex-wrap gap-2 gap-sm-3">
{lastFetchTime && (
<LastSaved timestamp={lastFetchTime} variant="compact" />
)}
<Tooltip content="Обновить данные" shortcut="⌘R">
<button className="btn btn-outline-primary" onClick={() => window.location.reload()}>
<button className="btn btn-outline-primary btn-sm" onClick={() => window.location.reload()}>
<IconRefresh className="me-2" /> Обновить
</button>
</Tooltip>
@@ -371,12 +386,12 @@ function Dashboard() {
/>
{/* Пинг до сервисов (список из настроек / Пинг сервисов) */}
<div className="d-flex align-items-center justify-content-between mb-2">
<div className="d-flex flex-column flex-sm-row align-items-start align-items-sm-center justify-content-between gap-2 mb-2">
<h3 className="mb-0">Пинг сервисов</h3>
<Tooltip content="Принудительно обновить пинг (без учёта кэша), история и тренд накапливаются">
<button
type="button"
className="btn btn-outline-primary btn-sm"
className="btn btn-outline-primary btn-sm w-100 w-sm-auto"
onClick={() => fetchPingServices(true)}
disabled={pingLoading}
>
@@ -389,7 +404,7 @@ function Dashboard() {
{pingServicesConfig.map((config) => (
<div
key={config.id}
className={expandedPingId === config.id ? 'col-12' : 'col-6 col-md-3'}
className={expandedPingId === config.id ? 'col-12' : 'col-12 col-sm-6 col-md-3'}
>
<PingServiceCard
config={config}
@@ -407,9 +422,62 @@ function Dashboard() {
))}
</div>
{/* Активные оповещения */}
<div className="mb-4">
<div className="d-flex flex-column flex-sm-row align-items-start align-items-sm-center justify-content-between gap-2 mb-2">
<h3 className="mb-0 d-flex align-items-center gap-2">
<IconBell size={24} />
Активные оповещения
{alerts.length > 0 && (
<span className="badge bg-danger rounded-pill">{alerts.length}</span>
)}
</h3>
<button
type="button"
className="btn btn-outline-primary btn-sm"
onClick={() => refreshAlerts()}
disabled={alertsLoading}
>
<IconRefresh className={alertsLoading ? 'spin me-1' : 'me-1'} size={16} />
Обновить
</button>
</div>
{alerts.length === 0 && !alertsLoading && (
<div className="card">
<div className="card-body text-center text-muted py-4">
Нет активных оповещений
</div>
</div>
)}
{alerts.length > 0 && (
<div className="card">
<div className="list-group list-group-flush">
{alerts.map((alert) => {
const Icon = ALERT_ICONS[alert.type] || IconAlertTriangle;
return (
<Link
key={alert.id}
to={alert.link || '/resource-stats'}
className="list-group-item list-group-item-action d-flex align-items-center gap-3"
>
<span className={`avatar avatar-sm flex-shrink-0 bg-${alert.severity === 'critical' ? 'danger' : 'warning'}-lt text-${alert.severity === 'critical' ? 'danger' : 'warning'}`}>
<Icon size={20} />
</span>
<div className="min-w-0 flex-grow-1">
<div className="fw-semibold">{alert.title}</div>
<div className="text-muted small">{alert.description}</div>
</div>
</Link>
);
})}
</div>
</div>
)}
</div>
{/* Основные метрики */}
<div className="row g-3 mb-4">
<div className="col-md-3">
<div className="row g-2 g-md-3 mb-4">
<div className="col-6 col-lg-3">
<div className="animate-in">
<StatCard
icon={IconWorld}
@@ -423,7 +491,7 @@ function Dashboard() {
/>
</div>
</div>
<div className="col-md-3">
<div className="col-6 col-lg-3">
<div className="animate-in" style={{animationDelay: '0.1s'}}>
<StatCard
icon={IconNetwork}
@@ -437,7 +505,7 @@ function Dashboard() {
/>
</div>
</div>
<div className="col-md-3">
<div className="col-6 col-lg-3">
<div className="animate-in" style={{animationDelay: '0.2s'}}>
<StatCard
icon={IconNetwork}
@@ -451,7 +519,7 @@ function Dashboard() {
/>
</div>
</div>
<div className="col-md-3">
<div className="col-6 col-lg-3">
<div className="animate-in" style={{animationDelay: '0.3s'}}>
<StatCard
icon={IconServer}
@@ -468,8 +536,8 @@ function Dashboard() {
</div>
{/* Дополнительные метрики */}
<div className="row g-3 mb-4 justify-content-center">
<div className="col-sm-6 col-md-3">
<div className="row g-2 g-md-3 mb-4 justify-content-center">
<div className="col-6 col-md-6 col-lg-3">
<MetricCard
title="Стран"
value={loading ? '...' : stats.countriesCount ?? '—'}
@@ -478,7 +546,7 @@ function Dashboard() {
description="Географическое покрытие"
/>
</div>
<div className="col-sm-6 col-md-3">
<div className="col-6 col-md-6 col-lg-3">
<MetricCard
title="Провайдеров"
value={loading ? '...' : stats.providersCount ?? '—'}
@@ -487,7 +555,7 @@ function Dashboard() {
description="Облачные провайдеры"
/>
</div>
<div className="col-sm-6 col-md-3">
<div className="col-6 col-md-6 col-lg-3">
<MetricCard
title="Онлайн серверов"
value={loading ? '...' : `${stats.onlineServers ?? '—'}/${stats.totalServers ?? '—'}`}
@@ -496,7 +564,7 @@ function Dashboard() {
description="Активные серверы"
/>
</div>
<div className="col-sm-6 col-md-3">
<div className="col-6 col-md-6 col-lg-3">
<MetricCard
title="Последнее обновление"
value={loading ? '...' : (stats.lastModified ? stats.lastModified : '—')}
+12 -12
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import api from './lib/api.js';
import {
IconPlus,
@@ -39,11 +39,7 @@ function DataManager({ entityName, entityKey, placeholder }) {
const pageSize = 10;
const [confirmState, setConfirmState] = useState({ open: false, text: '', onConfirm: null });
useEffect(() => {
fetchItems();
}, [entityKey]);
const fetchItems = async () => {
const fetchItems = useCallback(async () => {
setLoading(true);
try {
const response = await api.get(`/${entityKey}`);
@@ -55,7 +51,11 @@ function DataManager({ entityName, entityKey, placeholder }) {
} finally {
setLoading(false);
}
};
}, [entityKey, entityName]);
useEffect(() => {
fetchItems();
}, [fetchItems]);
const handleAddItem = () => {
if (newItem.domain.trim() === '') {
@@ -212,8 +212,8 @@ function DataManager({ entityName, entityKey, placeholder }) {
</div>
)}
<div className="row">
<div className="col-lg-4">
<div className="row g-2 g-lg-3">
<div className="col-12 col-lg-4">
{/* Add New Item Card */}
<div className="card card-md">
<div className="card-header">
@@ -333,11 +333,11 @@ function DataManager({ entityName, entityKey, placeholder }) {
</div>
</div>
<div className="col-lg-8">
<div className="col-12 col-lg-8">
<div className="card">
<div className="card-header d-flex justify-content-between align-items-center">
<div className="card-header d-flex flex-column flex-md-row justify-content-between align-items-stretch align-items-md-center gap-2">
<h3 className="card-title mb-0">Список {entityName === 'домен' ? 'доменов' : entityName}</h3>
<div className="d-flex gap-2 w-50">
<div className="d-flex flex-wrap gap-2 flex-grow-1 flex-md-grow-0">
{/* Фильтр по шлюзу */}
<select className="form-select w-auto" value={filterType} onChange={e => { setFilterType(e.target.value); setCurrentPage(1); }}>
<option value="">Все шлюзы</option>
+48 -78
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import api from './lib/api.js';
import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx';
import CommunityBadge from './components/CommunityBadge.jsx';
@@ -49,22 +49,25 @@ import AddItemModal from './components/AddItemModal.jsx';
import LastSaved from './components/LastSaved.jsx';
import FormModal from './components/FormModal.jsx';
import { useToast } from './components/ToastContainer.jsx';
const API_URL = '/api';
import {
listCommunities,
fetchDomainsData,
saveDomainsData,
} from './lib/evobgpData.js';
function DomainsNewManager() {
const toast = useToast();
const [items, setItems] = useState([]);
const [originalItems, setOriginalItems] = useState([]);
const [etag, setEtag] = useState('');
const [, setEtag] = useState('');
const [lastModified, setLastModified] = useState('');
const [contentLength, setContentLength] = useState(null);
const [, setContentLength] = useState(null);
const [newItem, setNewItem] = useState({ domain: '', community: '' });
const [newInvalid, setNewInvalid] = useState({ domain: false, community: false });
const [, setNewInvalid] = useState({ domain: false, community: false });
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const searchTimer = useRef(null);
const _searchTimer = useRef(null);
const didInit = useRef(false);
const [editingValue, setEditingValue] = useState('');
const [editModalOpen, setEditModalOpen] = useState(false);
@@ -90,18 +93,18 @@ function DomainsNewManager() {
const signal = controller.signal;
(async () => {
try {
const res = await api.get(`/communities`, { signal });
setCommunities(Array.isArray(res.data) ? res.data : []);
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
const list = await listCommunities();
setCommunities(list);
} catch (err) {
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
}
})();
(async () => {
try {
const r = await api.get('/ws/url', { signal });
setWsUrl(String(r.data?.url || ''));
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
} catch (err) {
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
}
})();
return () => controller.abort();
@@ -121,20 +124,17 @@ function DomainsNewManager() {
const [wsOpen, setWsOpen] = useState(false);
const [wsUrl, setWsUrl] = useState('');
const fetchItems = async (abortSignal) => {
const fetchItems = useCallback(async (_abortSignal) => {
setLoading(true);
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const response = await api.get(`/domains-new`, { params: { offset: 0, limit: 0, format: 'std' }, ...opts });
const payload = Array.isArray(response.data?.items) ? response.data.items : [];
const payload = await fetchDomainsData();
const total = payload.length;
setItems(payload);
setOriginalItems(payload);
setTotalItems(Number.isFinite(total) ? total : 0);
setEtag(response.headers?.etag || '');
setLastModified(response.headers?.['last-modified'] || '');
const lengthHeader = response.headers?.['content-length-source'];
setContentLength(typeof lengthHeader !== 'undefined' ? Number(lengthHeader) : null);
setEtag('');
setLastModified(new Date().toISOString());
setContentLength(null);
setError('');
} catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
@@ -143,7 +143,7 @@ function DomainsNewManager() {
} finally {
setLoading(false);
}
};
}, []);
useEffect(() => {
if (!didInit.current) {
@@ -152,7 +152,7 @@ function DomainsNewManager() {
fetchItems(controller.signal);
return () => controller.abort();
}
}, []);
}, [fetchItems]);
const handleAddItem = (item) => {
// Если item передан (из модалки), используем его, иначе из state (старый способ)
@@ -273,27 +273,7 @@ function DomainsNewManager() {
setShowDiff(true);
};
const handleSaveChanges = async () => {
// подготовим diff и спросим подтверждение только при массовых изменениях (>10)
const valid = items.filter(i => i != null && isValidDomain(i.domain) && isValidCommunity(i.community))
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
const diffData = computeDiff(originalItems, unique);
setDiff(diffData);
// Умное подтверждение: показываем модалку только при массовых изменениях
const totalChanges = (diffData.added?.length || 0) + (diffData.removed?.length || 0) + (diffData.changed?.length || 0);
if (totalChanges > 10) {
// Массовое изменение - требуется подтверждение
setConfirmSaveOpen(true);
} else {
// Малое изменение - сохраняем сразу
performSave();
}
};
const performSave = async () => {
const performSave = useCallback(async () => {
setConfirmSaveOpen(false);
setLoading(true);
try {
@@ -301,30 +281,8 @@ function DomainsNewManager() {
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
// Подгружаем весь список, применяем изменения текущей страницы и сохраняем полные данные
const fullRes = await api.get(`/domains-new`, { params: { offset: 0, limit: 0, format: 'std' } });
const full = Array.isArray(fullRes.data?.items) ? fullRes.data.items : (Array.isArray(fullRes.data) ? fullRes.data : []);
const fullMap = new Map(full.filter(i => i != null).map(i => [String(i.domain || '').trim().toLowerCase(), { domain: String(i.domain || '').trim().toLowerCase(), community: String(i.community || '').trim() }]));
const originalPageMap = new Map(originalItems.map(i => [String(i.domain).trim().toLowerCase(), true]));
const uniqueMap = new Map(unique.map(i => [String(i.domain).trim().toLowerCase(), { domain: String(i.domain).trim().toLowerCase(), community: String(i.community).trim() }]));
// Удаления: всё, что было на странице, но отсутствует в изменённом наборе
for (const key of originalPageMap.keys()) {
if (!uniqueMap.has(key)) fullMap.delete(key);
}
// Добавления/изменения
for (const [key, val] of uniqueMap.entries()) fullMap.set(key, val);
const fullToSave = Array.from(fullMap.values());
const et = fullRes?.headers?.etag || etag;
const response = await api.post(`/domains-new`, { domains: fullToSave, etag: et }, { validateStatus: () => true });
if (response.status === 412) {
setError('Данные изменились в S3 (ETag mismatch). Обновите список и попробуйте снова.');
return;
}
if (response.status >= 400) {
throw new Error(`Save failed with status ${response.status}`);
}
setEtag(response.headers?.etag || et);
await saveDomainsData(unique, originalItems);
setEtag('');
// После сохранения перезагрузим текущую страницу для консистентности
await fetchItems();
setSuccess('Изменения успешно сохранены!');
@@ -336,10 +294,24 @@ function DomainsNewManager() {
} finally {
setLoading(false);
}
};
}, [items, originalItems, fetchItems]);
const handleSaveChanges = useCallback(async () => {
const valid = items.filter(i => i != null && isValidDomain(i.domain) && isValidCommunity(i.community))
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
const diffData = computeDiff(originalItems, unique);
setDiff(diffData);
const totalChanges = (diffData.added?.length || 0) + (diffData.removed?.length || 0) + (diffData.changed?.length || 0);
if (totalChanges > 10) {
setConfirmSaveOpen(true);
} else {
performSave();
}
}, [items, originalItems, performSave]);
// Drag&Drop импорт
const onDropImport = async (e) => {
const _onDropImport = async (e) => {
e.preventDefault();
const file = e.dataTransfer?.files?.[0];
if (!file) return;
@@ -366,9 +338,7 @@ function DomainsNewManager() {
const handleExport = async () => {
try {
const effectiveQ = searchTerm ? searchTerm : (filterCommunity ? filterCommunity : '');
const response = await api.get(`/domains-new`, { params: { q: effectiveQ, offset: 0, limit: 0, format: 'std' } });
const all = Array.isArray(response.data?.items) ? response.data.items : (Array.isArray(response.data) ? response.data : []);
const all = filtered;
const header = ['domain', 'community'];
const csv = [header, ...all.filter(i => i != null).map(i => [i.domain || '', i.community || ''])]
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
@@ -456,7 +426,7 @@ function DomainsNewManager() {
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [loading, items, originalItems, etag, sortField, sortOrder, filterCommunity]);
}, [loading, handleSaveChanges]);
const handleEditKeyDown = (e) => {
if (e.key === 'Enter') {
@@ -950,10 +920,10 @@ function DomainsNewManager() {
<button type="button" className="btn-close" onClick={() => setShowDiff(false)}></button>
<div className="modal-header"><h3 className="modal-title">Изменения</h3></div>
<div className="modal-body">
<div className="row g-3">
<div className="col-md-4"><div className="card"><div className="card-body"><strong>Добавлено</strong><div className="text-muted">{diff.added.length}</div></div></div></div>
<div className="col-md-4"><div className="card"><div className="card-body"><strong>Удалено</strong><div className="text-muted">{diff.removed.length}</div></div></div></div>
<div className="col-md-4"><div className="card"><div className="card-body"><strong>Изменено</strong><div className="text-muted">{diff.changed.length}</div></div></div></div>
<div className="row g-2 g-md-3">
<div className="col-6 col-md-4"><div className="card"><div className="card-body py-2"><strong>Добавлено</strong><div className="text-muted">{diff.added.length}</div></div></div></div>
<div className="col-6 col-md-4"><div className="card"><div className="card-body py-2"><strong>Удалено</strong><div className="text-muted">{diff.removed.length}</div></div></div></div>
<div className="col-6 col-md-4"><div className="card"><div className="card-body py-2"><strong>Изменено</strong><div className="text-muted">{diff.changed.length}</div></div></div></div>
</div>
</div>
<div className="modal-footer">
+66 -171
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import api from './lib/api.js';
import { usePing } from './contexts/PingContext.jsx';
@@ -42,7 +42,7 @@ function EasySwitchManager() {
// Поиск и фильтры
const [searchQuery, setSearchQuery] = useState('');
const [selectedRule, setSelectedRule] = useState('all'); // all | by-community
const [_selectedRule, _setSelectedRule] = useState('all'); // all | by-community
const [activeTab, setActiveTab] = useState('proxies'); // proxies | providers
const [showOnlyConfigured, setShowOnlyConfigured] = useState(true); // Показывать только настроенные
@@ -53,7 +53,7 @@ function EasySwitchManager() {
const [expandedServers, setExpandedServers] = useState(new Set()); // Развернутые серверы
const [groupByTags, setGroupByTags] = useState(true); // Группировка по тегам
// Единый источник пингов (кеш + API) общий с страницей фильтров
const { pingMap, requestPings } = usePing();
const { pingMap, requestPings, cancelScope } = usePing();
const location = useLocation();
useEffect(() => {
@@ -97,7 +97,7 @@ function EasySwitchManager() {
initData();
return () => controller.abort();
}, []);
}, [loadDataWithParams]);
// При переходе на раздел Easy Switch перезапрашиваем пинги (устаревшие по TTL обновятся)
useEffect(() => {
@@ -115,10 +115,21 @@ function EasySwitchManager() {
if (gw.ip) pingTasks.push({ routerId, gatewayIp: gw.ip });
});
});
requestPings(pingTasks);
requestPings(pingTasks, { scope: 'easy-switch' });
}, [location.pathname, servers, inventoryServers, requestPings]);
const loadDataWithParams = async (communities, inventory, ncGatewaysParam, signal) => {
useEffect(() => {
if (location.pathname === '/easy-switch') return;
cancelScope('easy-switch');
}, [location.pathname, cancelScope]);
useEffect(() => {
return () => {
cancelScope('easy-switch');
};
}, [cancelScope]);
const loadDataWithParams = useCallback(async (communities, inventory, ncGatewaysParam, signal) => {
setLoading(true);
setError('');
@@ -258,13 +269,13 @@ function EasySwitchManager() {
if (gw.ip) pingTasks.push({ routerId, gatewayIp: gw.ip });
});
});
requestPings(pingTasks);
requestPings(pingTasks, { scope: 'easy-switch' });
// Отладочная информация
try {
console.log('[EasySwitch] serversWithGateways:', serversWithGateways);
console.log('[EasySwitch] communitiesDirectory:', communities);
} catch (_) {}
} catch { /* no-op */ }
// Автоматически разворачиваем первый сервер
if (serversWithGateways.length > 0 && expandedServers.size === 0) {
@@ -289,7 +300,7 @@ function EasySwitchManager() {
try {
console.log('[EasySwitch] initialActiveGateways:', initialActive);
} catch (_) {}
} catch { /* no-op */ }
} catch (err) {
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
@@ -298,7 +309,8 @@ function EasySwitchManager() {
} finally {
setLoading(false);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- init-only, deps would cause unnecessary re-fetches
}, []);
const handleGatewaySelect = (serverId, community, gateway) => {
const key = `${serverId}:${community}`;
@@ -370,7 +382,7 @@ function EasySwitchManager() {
const currentFilters = Array.isArray(response.data) ? response.data : [];
// Создаем Set существующих communities в фильтрах
const existingCommunitiesSet = new Set(currentFilters.map(f => f.community));
const _existingCommunitiesSet = new Set(currentFilters.map(f => f.community));
// Обновляем существующие фильтры и добавляем новые
const updatedFilters = [];
@@ -505,7 +517,7 @@ function EasySwitchManager() {
if (loading && servers.length === 0) {
return (
<div className="container-xl">
<div className="easy-switch-page">
<PageHeader
title="Easy Switch"
icon={<IconWorld size={24} />}
@@ -525,7 +537,7 @@ function EasySwitchManager() {
}
return (
<div className="container-xl">
<div className="easy-switch-page">
<PageHeader
title="Easy Switch"
icon={<IconWorld size={24} />}
@@ -554,7 +566,7 @@ function EasySwitchManager() {
/>
{/* Вкладки */}
<div className="mb-3">
<div className="card card-tabs mb-3">
<ul className="nav nav-tabs" role="tablist">
<li className="nav-item" role="presentation">
<button
@@ -599,8 +611,9 @@ function EasySwitchManager() {
)}
{/* Панель фильтров */}
<div className="mb-3">
<div className="row g-2 align-items-center">
<div className="card mb-3">
<div className="card-body py-3">
<div className="row g-2 align-items-center">
{/* Переключатель "Только настроенные" */}
<div className="col-auto">
<label className="form-check form-switch mb-0">
@@ -679,17 +692,18 @@ function EasySwitchManager() {
</button>
</div>
<div className="col-auto">
<button className="btn btn-icon" title="Настройки">
<button className="btn btn-outline-secondary btn-icon" title="Настройки">
<IconSettings size={18} />
</button>
</div>
<div className="col-auto">
<button className="btn btn-icon" title="Быстрые действия">
<button className="btn btn-outline-secondary btn-icon" title="Быстрые действия">
<IconFilter size={18} />
</button>
</div>
</div>
</div>
</div>
{/* Список серверов с communities и шлюзами */}
{filteredServers.length === 0 ? (
@@ -726,12 +740,7 @@ function EasySwitchManager() {
<div key={server.id} className="card mb-3">
{/* Заголовок сервера (кликабельный) */}
<div
className="card-header py-2"
style={{
backgroundColor: 'rgba(32, 107, 196, 0.05)',
cursor: 'pointer',
userSelect: 'none'
}}
className="card-header py-2 easy-switch-server-header"
onClick={() => toggleServerExpanded(server.id)}
>
<div className="d-flex align-items-center">
@@ -742,7 +751,7 @@ function EasySwitchManager() {
<IconChevronDown size={20} className="me-2 text-muted" />
)}
{flag && <span className="me-2" style={{ fontSize: '1.5rem' }}>{flag}</span>}
{flag && <span className="me-2 easy-switch-flag">{flag}</span>}
<IconServer size={20} className="me-2 text-primary" />
<h3 className="card-title mb-0 me-2">{server.name}</h3>
{server.description && (
@@ -793,7 +802,7 @@ function EasySwitchManager() {
<div key={tag} className="mb-4">
{/* Заголовок группы тегов */}
<div className="mb-2 pb-2 border-bottom">
<span className="badge bg-purple-lt text-purple me-2" style={{ fontSize: '0.85rem', padding: '6px 10px' }}>
<span className="badge bg-purple-lt text-purple me-2">
{tag}
</span>
<span className="text-muted small">
@@ -813,12 +822,12 @@ function EasySwitchManager() {
<div className="mb-2">
{/* Заголовок community */}
<div className="mb-2">
<span className="badge bg-blue-lt text-blue me-2" style={{ fontSize: '0.8rem' }}>
<span className="badge bg-blue-lt text-blue me-2">
{comm.value}
</span>
<span className="fw-medium">{comm.name || comm.description || ''}</span>
{hasFilterForComm && (
<span className="badge bg-green-lt text-green ms-2" style={{ fontSize: '0.7rem' }}>
<span className="badge bg-green-lt text-green ms-2">
настроен
</span>
)}
@@ -842,50 +851,21 @@ function EasySwitchManager() {
return (
<div key={gw.name} className="col-12 col-sm-4">
<div
className={`card cursor-pointer h-100 ${isActive ? 'bg-primary text-white' : ''}`}
style={{
cursor: 'pointer',
transition: 'all 0.15s ease',
border: isActive ? '2px solid var(--tblr-primary)' : '1px solid rgba(0,0,0,0.1)',
borderRadius: '6px',
boxShadow: isActive ? '0 3px 8px rgba(32, 107, 196, 0.3)' : 'none',
position: 'relative'
}}
className={`card h-100 easy-switch-gateway-card ${isActive ? 'easy-switch-gateway-card-active bg-primary text-white' : ''}`}
onClick={() => handleGatewaySelect(server.id, comm.value, gw.name)}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.boxShadow = '0 2px 6px rgba(0,0,0,0.1)';
e.currentTarget.style.transform = 'translateY(-1px)';
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.transform = 'translateY(0)';
}
}}
>
<div className="card-body p-2">
{/* Галочка (абсолютное позиционирование) */}
{isActive && (
<div
className="bg-white text-primary rounded-circle d-flex align-items-center justify-content-center"
style={{
width: '20px',
height: '20px',
position: 'absolute',
top: '8px',
right: '8px'
}}
>
<div className="bg-white text-primary rounded-circle d-flex align-items-center justify-content-center easy-switch-active-check">
<IconCheck size={12} />
</div>
)}
{/* Метка Fastest */}
<div className="mb-1" style={{ minHeight: '18px' }}>
<div className="mb-1 easy-switch-fast-slot">
{isFastest && !isActive && (
<span className="badge bg-yellow-lt text-yellow d-inline-flex align-items-center" style={{ fontSize: '0.65rem', padding: '2px 6px' }}>
<span className="badge bg-yellow-lt text-yellow d-inline-flex align-items-center easy-switch-fast-badge">
<IconBolt size={10} className="me-1" />
Fast
</span>
@@ -895,22 +875,14 @@ function EasySwitchManager() {
{/* Флаг и название (комментарий) шлюза */}
<div className="d-flex align-items-center gap-2 mb-1">
<span
className="d-flex align-items-center justify-content-center flex-shrink-0 rounded"
style={{
width: '28px',
height: '28px',
fontSize: '1.25rem',
lineHeight: 1,
backgroundColor: isActive ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.06)',
}}
className={`d-flex align-items-center justify-content-center flex-shrink-0 rounded easy-switch-gateway-flag ${isActive ? 'easy-switch-gateway-flag-active' : ''}`}
title={gw.country || country || ''}
>
{countryToFlag(gw.country || country || '') || '🌐'}
</span>
<div className="flex-grow-1 min-w-0">
<div
className={`fw-bold ${isActive ? 'text-white' : ''}`}
style={{ fontSize: '0.85rem', lineHeight: '1.2', wordBreak: 'break-word' }}
className={`fw-bold easy-switch-gateway-title ${isActive ? 'text-white' : ''}`}
>
{gw.comment || gw.name}
</div>
@@ -919,17 +891,16 @@ function EasySwitchManager() {
{/* IP шлюза ниже */}
{gw.ip && (
<div
className={`small text-truncate ${
className={`small text-truncate easy-switch-gateway-ip ${
isActive ? 'text-white-75' : 'text-muted'
}`}
style={{ fontSize: '0.75rem' }}
>
{gw.ip}
</div>
)}
{/* Метрика (пинг) */}
<div className={`fw-bold mt-1 ${isActive ? 'text-white' : 'text-success'}`} style={{ fontSize: '1.1rem' }}>
<div className={`fw-bold mt-1 easy-switch-gateway-ping ${isActive ? 'text-white' : 'text-success'}`}>
{ping != null ? ping : '—'}
</div>
</div>
@@ -952,7 +923,7 @@ function EasySwitchManager() {
<div className="mb-4">
{/* Заголовок группы без тегов */}
<div className="mb-2 pb-2 border-bottom">
<span className="badge bg-gray-lt text-gray me-2" style={{ fontSize: '0.85rem', padding: '6px 10px' }}>
<span className="badge bg-gray-lt text-gray me-2">
Без тегов
</span>
<span className="text-muted small">
@@ -972,12 +943,12 @@ function EasySwitchManager() {
<div className="mb-2">
{/* Заголовок community */}
<div className="mb-2">
<span className="badge bg-blue-lt text-blue me-2" style={{ fontSize: '0.8rem' }}>
<span className="badge bg-blue-lt text-blue me-2">
{comm.value}
</span>
<span className="fw-medium">{comm.name || comm.description || ''}</span>
{hasFilterForComm && (
<span className="badge bg-green-lt text-green ms-2" style={{ fontSize: '0.7rem' }}>
<span className="badge bg-green-lt text-green ms-2">
настроен
</span>
)}
@@ -1001,50 +972,21 @@ function EasySwitchManager() {
return (
<div key={gw.name} className="col-12 col-sm-4">
<div
className={`card cursor-pointer h-100 ${isActive ? 'bg-primary text-white' : ''}`}
style={{
cursor: 'pointer',
transition: 'all 0.15s ease',
border: isActive ? '2px solid var(--tblr-primary)' : '1px solid rgba(0,0,0,0.1)',
borderRadius: '6px',
boxShadow: isActive ? '0 3px 8px rgba(32, 107, 196, 0.3)' : 'none',
position: 'relative'
}}
className={`card h-100 easy-switch-gateway-card ${isActive ? 'easy-switch-gateway-card-active bg-primary text-white' : ''}`}
onClick={() => handleGatewaySelect(server.id, comm.value, gw.name)}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.boxShadow = '0 2px 6px rgba(0,0,0,0.1)';
e.currentTarget.style.transform = 'translateY(-1px)';
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.transform = 'translateY(0)';
}
}}
>
<div className="card-body p-2">
{/* Галочка (абсолютное позиционирование) */}
{isActive && (
<div
className="bg-white text-primary rounded-circle d-flex align-items-center justify-content-center"
style={{
width: '20px',
height: '20px',
position: 'absolute',
top: '8px',
right: '8px'
}}
>
<div className="bg-white text-primary rounded-circle d-flex align-items-center justify-content-center easy-switch-active-check">
<IconCheck size={12} />
</div>
)}
{/* Метка Fastest */}
<div className="mb-1" style={{ minHeight: '18px' }}>
<div className="mb-1 easy-switch-fast-slot">
{isFastest && !isActive && (
<span className="badge bg-yellow-lt text-yellow d-inline-flex align-items-center" style={{ fontSize: '0.65rem', padding: '2px 6px' }}>
<span className="badge bg-yellow-lt text-yellow d-inline-flex align-items-center easy-switch-fast-badge">
<IconBolt size={10} className="me-1" />
Fast
</span>
@@ -1054,22 +996,14 @@ function EasySwitchManager() {
{/* Флаг и название (комментарий) шлюза */}
<div className="d-flex align-items-center gap-2 mb-1">
<span
className="d-flex align-items-center justify-content-center flex-shrink-0 rounded"
style={{
width: '28px',
height: '28px',
fontSize: '1.25rem',
lineHeight: 1,
backgroundColor: isActive ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.06)',
}}
className={`d-flex align-items-center justify-content-center flex-shrink-0 rounded easy-switch-gateway-flag ${isActive ? 'easy-switch-gateway-flag-active' : ''}`}
title={gw.country || country || ''}
>
{countryToFlag(gw.country || country || '') || '🌐'}
</span>
<div className="flex-grow-1 min-w-0">
<div
className={`fw-bold ${isActive ? 'text-white' : ''}`}
style={{ fontSize: '0.85rem', lineHeight: '1.2', wordBreak: 'break-word' }}
className={`fw-bold easy-switch-gateway-title ${isActive ? 'text-white' : ''}`}
>
{gw.comment || gw.name}
</div>
@@ -1078,17 +1012,16 @@ function EasySwitchManager() {
{/* IP шлюза ниже */}
{gw.ip && (
<div
className={`small text-truncate ${
className={`small text-truncate easy-switch-gateway-ip ${
isActive ? 'text-white-75' : 'text-muted'
}`}
style={{ fontSize: '0.75rem' }}
>
{gw.ip}
</div>
)}
{/* Метрика (пинг) */}
<div className={`fw-bold mt-1 ${isActive ? 'text-white' : 'text-success'}`} style={{ fontSize: '1.1rem' }}>
<div className={`fw-bold mt-1 easy-switch-gateway-ping ${isActive ? 'text-white' : 'text-success'}`}>
{ping != null ? ping : '—'}
</div>
</div>
@@ -1120,12 +1053,12 @@ function EasySwitchManager() {
<div className="mb-2">
{/* Заголовок community */}
<div className="mb-2">
<span className="badge bg-blue-lt text-blue me-2" style={{ fontSize: '0.8rem' }}>
<span className="badge bg-blue-lt text-blue me-2">
{comm.value}
</span>
<span className="fw-medium">{comm.name || comm.description || ''}</span>
{hasFilterForComm && (
<span className="badge bg-green-lt text-green ms-2" style={{ fontSize: '0.7rem' }}>
<span className="badge bg-green-lt text-green ms-2">
настроен
</span>
)}
@@ -1149,50 +1082,21 @@ function EasySwitchManager() {
return (
<div key={gw.name} className="col-12 col-sm-4">
<div
className={`card cursor-pointer h-100 ${isActive ? 'bg-primary text-white' : ''}`}
style={{
cursor: 'pointer',
transition: 'all 0.15s ease',
border: isActive ? '2px solid var(--tblr-primary)' : '1px solid rgba(0,0,0,0.1)',
borderRadius: '6px',
boxShadow: isActive ? '0 3px 8px rgba(32, 107, 196, 0.3)' : 'none',
position: 'relative'
}}
className={`card h-100 easy-switch-gateway-card ${isActive ? 'easy-switch-gateway-card-active bg-primary text-white' : ''}`}
onClick={() => handleGatewaySelect(server.id, comm.value, gw.name)}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.boxShadow = '0 2px 6px rgba(0,0,0,0.1)';
e.currentTarget.style.transform = 'translateY(-1px)';
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.transform = 'translateY(0)';
}
}}
>
<div className="card-body p-2">
{/* Галочка (абсолютное позиционирование) */}
{isActive && (
<div
className="bg-white text-primary rounded-circle d-flex align-items-center justify-content-center"
style={{
width: '20px',
height: '20px',
position: 'absolute',
top: '8px',
right: '8px'
}}
>
<div className="bg-white text-primary rounded-circle d-flex align-items-center justify-content-center easy-switch-active-check">
<IconCheck size={12} />
</div>
)}
{/* Метка Fastest */}
<div className="mb-1" style={{ minHeight: '18px' }}>
<div className="mb-1 easy-switch-fast-slot">
{isFastest && !isActive && (
<span className="badge bg-yellow-lt text-yellow d-inline-flex align-items-center" style={{ fontSize: '0.65rem', padding: '2px 6px' }}>
<span className="badge bg-yellow-lt text-yellow d-inline-flex align-items-center easy-switch-fast-badge">
<IconBolt size={10} className="me-1" />
Fast
</span>
@@ -1202,22 +1106,14 @@ function EasySwitchManager() {
{/* Флаг и название (комментарий) шлюза */}
<div className="d-flex align-items-center gap-2 mb-1">
<span
className="d-flex align-items-center justify-content-center flex-shrink-0 rounded"
style={{
width: '28px',
height: '28px',
fontSize: '1.25rem',
lineHeight: 1,
backgroundColor: isActive ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.06)',
}}
className={`d-flex align-items-center justify-content-center flex-shrink-0 rounded easy-switch-gateway-flag ${isActive ? 'easy-switch-gateway-flag-active' : ''}`}
title={gw.country || country || ''}
>
{countryToFlag(gw.country || country || '') || '🌐'}
</span>
<div className="flex-grow-1 min-w-0">
<div
className={`fw-bold ${isActive ? 'text-white' : ''}`}
style={{ fontSize: '0.85rem', lineHeight: '1.2', wordBreak: 'break-word' }}
className={`fw-bold easy-switch-gateway-title ${isActive ? 'text-white' : ''}`}
>
{gw.comment || gw.name}
</div>
@@ -1226,15 +1122,14 @@ function EasySwitchManager() {
{/* IP шлюза ниже */}
{gw.ip && (
<div
className={`small text-truncate mb-1 ${isActive ? 'text-white-75' : 'text-muted'}`}
style={{ fontSize: '0.75rem' }}
className={`small text-truncate mb-1 easy-switch-gateway-ip ${isActive ? 'text-white-75' : 'text-muted'}`}
>
{gw.ip}
</div>
)}
{/* Метрика (пинг) */}
<div className={`fw-bold mt-1 ${isActive ? 'text-white' : 'text-success'}`} style={{ fontSize: '1.1rem' }}>
<div className={`fw-bold mt-1 easy-switch-gateway-ping ${isActive ? 'text-white' : 'text-success'}`}>
{ping != null ? ping : '—'}
</div>
</div>
+22 -22
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { Link, useLocation } from 'react-router-dom';
import api from './lib/api.js';
import ConfirmDialog from './components/ConfirmDialog.jsx';
@@ -47,6 +47,7 @@ import {
import { CSS } from '@dnd-kit/utilities';
import { normalizeGateways, countryToFlag } from './utils/serverUtils.js';
import { usePing } from './contexts/PingContext.jsx';
import { listCommunities } from './lib/evobgpData.js';
// Компонент автокомплита для выбора Community
function CommunityAutocomplete({ label, value, onChange, communities = [], required = false, placeholder = 'Введите или выберите community...', baseAS = '65001' }) {
@@ -79,7 +80,7 @@ function CommunityAutocomplete({ label, value, onChange, communities = [], requi
// 3. Если ввели "65001:120", ищем "120" в справочнике
if (search.includes(':')) {
const searchParts = search.split(':');
const searchAS = searchParts[0];
const _searchAS = searchParts[0];
const searchNum = searchParts[1];
// Точное совпадение числовой части
@@ -246,7 +247,7 @@ function CommunityAutocomplete({ label, value, onChange, communities = [], requi
{Object.entries(groupedCommunities).map(([category, items]) => (
<div key={category}>
<div className="dropdown-header small text-muted bg-light">{category}</div>
{items.map((c, idx) => {
{items.map((c, _idx) => {
const globalIdx = filteredCommunities.indexOf(c);
return (
<div
@@ -773,7 +774,7 @@ function FilterManager() {
const { pingMap, requestPings } = usePing();
// Получить metadata сервера из inventory по имени (dns/hostName/ip или короткое имя типа "ihor")
const getInventoryMetaForServer = (serverName) => {
const getInventoryMetaForServer = useCallback((serverName) => {
if (!serverName) return null;
const name = String(serverName).trim();
return inventoryServers.find((srv) => {
@@ -781,20 +782,18 @@ function FilterManager() {
const hostName = String(srv.hostName || '').trim();
const ip = String(srv.ip || '').trim();
if (dns === name || hostName === name || ip === name) return true;
// На странице Фильтров selectedServer.name короткое имя (первая часть dns), например "ihor"
if (dns && dns.split('.')[0] === name) return true;
return false;
});
};
}, [inventoryServers]);
// Получить routerId для MikroTik (id/dns/ip), как на странице Easy Switch
const getRouterIdForServer = (server) => {
const getRouterIdForServer = useCallback((server) => {
if (!server) return null;
// Сначала ищем по полному имени (fullName/dns), затем по короткому (name)
const meta = getInventoryMetaForServer(server.fullName || server.dns || server.name) || getInventoryMetaForServer(server.name);
if (!meta) return null;
return meta.id || meta.dns || meta.ip || null;
};
}, [getInventoryMetaForServer]);
// Собираем gateways только для выбранного сервера
const selectedServerGateways = (() => {
@@ -893,7 +892,7 @@ function FilterManager() {
.filter((gw) => gw.ip)
.map((gw) => ({ routerId, gatewayIp: gw.ip }));
requestPings(tasks);
}, [location.pathname, selectedServer, selectedServerGateways, requestPings]);
}, [location.pathname, selectedServer, selectedServerGateways, requestPings, getRouterIdForServer]);
useEffect(() => {
const controller = new AbortController();
@@ -913,10 +912,11 @@ function FilterManager() {
(async () => {
try {
const res = await api.get(`/communities`, opts);
setCommunities(Array.isArray(res.data) ? res.data : []);
const rows = await listCommunities();
if (signal.aborted) return;
setCommunities(Array.isArray(rows) ? rows : []);
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
if (signal.aborted) return;
}
})();
@@ -990,7 +990,7 @@ function FilterManager() {
setTimeout(() => setSuccess(''), 3000);
return;
}
} catch (legacyError) {
} catch (_legacyError) {
// Фильтров по legacyId тоже нет - это нормально
console.log('No legacy filters found');
}
@@ -1165,7 +1165,7 @@ function FilterManager() {
}
};
const handleLoadServerConfig = async (serverId, legacyId = null) => {
const _handleLoadServerConfig = async (serverId, legacyId = null) => {
try {
const response = await api.get(`/server-configs/${serverId}`);
const config = response.data?.config;
@@ -1192,7 +1192,7 @@ function FilterManager() {
setTimeout(() => setSuccess(''), 3000);
return;
}
} catch (legacyError) {
} catch (_legacyError) {
console.log('No legacy config found');
}
}
@@ -1299,7 +1299,7 @@ function FilterManager() {
}
};
const handleDeployToAllServers = async () => {
const _handleDeployToAllServers = async () => {
if (!selectedServer) {
setError('Сначала выберите сервер с фильтрами для развертывания.');
return;
@@ -1521,7 +1521,7 @@ function FilterManager() {
setEditServerModalOpen(false);
setSuccess('Сервер успешно обновлён!');
setTimeout(() => setSuccess(''), 3000);
} catch (error) {
} catch (_error) {
setEditServerError('Не удалось сохранить изменения.');
}
};
@@ -1576,7 +1576,7 @@ function FilterManager() {
await navigator.clipboard.writeText(config);
setSuccess('Конфигурация скопирована в буфер обмена!');
setTimeout(() => setSuccess(''), 3000);
} catch (err) {
} catch (_err) {
setError('Не удалось скопировать конфигурацию');
}
} else {
@@ -1590,7 +1590,7 @@ function FilterManager() {
document.execCommand('copy');
setSuccess('Конфигурация скопирована в буфер обмена!');
setTimeout(() => setSuccess(''), 3000);
} catch (err) {
} catch (_err) {
setError('Не удалось скопировать конфигурацию');
}
document.body.removeChild(textArea);
@@ -1598,7 +1598,7 @@ function FilterManager() {
};
// Функция для экспорта конфигурации в S3
const exportConfigToS3 = async () => {
const _exportConfigToS3 = async () => {
setLoading(true);
try {
const response = await api.post(`/filters/export-config`);
@@ -2561,7 +2561,7 @@ function FilterManager() {
try {
await api.post(`/server-configs`, { servers: updatedServers });
setServers(updatedServers);
} catch (error) {
} catch (_error) {
setError('Не удалось обновить статус сервера');
}
}}
+6 -6
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import api from './lib/api.js';
import PageHeader from './components/PageHeader.jsx';
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
@@ -250,7 +250,7 @@ export default function FirewallPage() {
const res = await api.get('/servers');
const list = Array.isArray(res.data) ? res.data : [];
if (!cancelled) setServers(list);
} catch (e) {
} catch (_e) {
if (!cancelled) setError('Не удалось загрузить список серверов.');
} finally {
if (!cancelled) setLoadingServers(false);
@@ -259,7 +259,7 @@ export default function FirewallPage() {
return () => { cancelled = true; };
}, []);
const fetchAddressLists = async () => {
const fetchAddressLists = useCallback(async () => {
if (!serverId) {
setData(null);
return;
@@ -281,12 +281,12 @@ export default function FirewallPage() {
} finally {
setLoading(false);
}
};
}, [serverId]);
useEffect(() => {
if (serverId) fetchAddressLists();
else setData(null);
}, [serverId]);
}, [serverId, fetchAddressLists]);
const runAnalysis = () => {
if (!data?.ban?.length) return;
@@ -319,7 +319,7 @@ export default function FirewallPage() {
[s.subnet]: { org: info.org, country: info.country, city: info.city },
}));
}
} catch (_) {
} catch {
if (!cancelled) setIpInfoMap((prev) => ({ ...prev, [s.subnet]: null }));
}
if (i < slice.length - 1) await new Promise((r) => setTimeout(r, 180));
+6 -7
View File
@@ -5,7 +5,6 @@ import {
MiniMap,
useEdgesState,
useNodesState,
addEdge,
MarkerType,
Handle,
Position,
@@ -224,7 +223,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
});
setSelectedNodeId(nodeId);
setTimeout(() => setSelectedNodeId(null), 2000);
} catch {}
} catch { /* no-op */ }
}, []);
// Сброс раскладки: очищаем localStorage и пересоздаём ноды
@@ -232,7 +231,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
if (typeof window !== 'undefined') {
try {
window.localStorage.removeItem(STORAGE_KEY);
} catch {}
} catch { /* no-op */ }
}
setNodes((current) => {
return current.map((n, i) => ({
@@ -244,7 +243,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
if (instanceRef.current) {
try {
instanceRef.current.fitView({ padding: 0.2 });
} catch {}
} catch { /* no-op */ }
}
}, 100);
}, [setNodes, computeGridPosition]);
@@ -378,7 +377,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
requestAnimationFrame(() => {
try {
inst.fitView({ padding: 0.2, includeHiddenNodes: true, minZoom: 0.2, maxZoom: 2 });
} catch {}
} catch { /* no-op */ }
});
}, []);
@@ -389,7 +388,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
const t = setTimeout(() => {
try {
i.fitView({ padding: 0.2, includeHiddenNodes: true, minZoom: 0.2, maxZoom: 2 });
} catch {}
} catch { /* no-op */ }
}, 50);
return () => clearTimeout(t);
}, [servers, connections]);
@@ -403,7 +402,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
setTimeout(() => {
try {
instanceRef.current.fitView({ padding: 0.2 });
} catch {}
} catch { /* no-op */ }
}, 60);
}
};
+75 -99
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import api from './lib/api.js';
import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx';
import CommunityBadge from './components/CommunityBadge.jsx';
@@ -44,21 +44,41 @@ import Pagination from './components/Pagination.jsx';
import AddItemModal from './components/AddItemModal.jsx';
import LastSaved from './components/LastSaved.jsx';
import FormModal from './components/FormModal.jsx';
import {
listCommunities,
fetchIpRangesData,
saveIpRangesData,
} from './lib/evobgpData.js';
const API_URL = '/api';
const isValidIPv4 = (ip) => {
const octets = ip.split('.');
if (octets.length !== 4) return false;
return octets.every(o => /^\d{1,3}$/.test(o) && Number(o) >= 0 && Number(o) <= 255);
};
const isValidCidr = (value) => {
const v = String(value).trim();
const parts = v.split('/');
if (parts.length !== 2) return false;
const [ip, mask] = parts;
if (!isValidIPv4(ip)) return false;
if (!/^\d{1,2}$/.test(mask)) return false;
const m = Number(mask);
return m >= 0 && m <= 32;
};
function IPRangesManager() {
const [items, setItems] = useState([]);
const [originalItems, setOriginalItems] = useState([]);
const [etag, setEtag] = useState('');
const [, setEtag] = useState('');
const [lastModified, setLastModified] = useState('');
const [contentLength, setContentLength] = useState(null);
const [, setContentLength] = useState(null);
const [newItem, setNewItem] = useState({ ipRange: '', community: '' });
const [newInvalid, setNewInvalid] = useState({ ipRange: false, community: false });
const [, setNewInvalid] = useState({ ipRange: false, community: false });
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const searchTimer = useRef(null);
const _searchTimer = useRef(null);
const didInit = useRef(false);
const [editingValue, setEditingValue] = useState('');
const [editModalOpen, setEditModalOpen] = useState(false);
@@ -80,9 +100,9 @@ function IPRangesManager() {
useEffect(() => {
(async () => {
try {
const res = await api.get(`/communities`);
setCommunities(Array.isArray(res.data) ? res.data : []);
} catch (e) {
const list = await listCommunities();
setCommunities(list);
} catch (_e) {
// тихо игнорируем
}
})();
@@ -90,23 +110,6 @@ function IPRangesManager() {
const renderCommunityBadge = (value) => (<CommunityBadge value={value} communities={communities} />);
const isValidIPv4 = (ip) => {
const octets = ip.split('.');
if (octets.length !== 4) return false;
return octets.every(o => /^\d{1,3}$/.test(o) && Number(o) >= 0 && Number(o) <= 255);
};
const isValidCidr = (value) => {
const v = String(value).trim();
const parts = v.split('/');
if (parts.length !== 2) return false;
const [ip, mask] = parts;
if (!isValidIPv4(ip)) return false;
if (!/^\d{1,2}$/.test(mask)) return false;
const m = Number(mask);
return m >= 0 && m <= 32;
};
// Допускаем как числовые, так и строковые (AS:NNN) community
const isValidCommunity = (value) => /^(\d+|\d+:\d+)$/.test(String(value).trim());
@@ -126,20 +129,17 @@ function IPRangesManager() {
return () => controller.abort();
}, []);
const fetchItems = async (abortSignal) => {
const fetchItems = useCallback(async (_abortSignal) => {
setLoading(true);
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const response = await api.get(`/ip-ranges`, { params: { offset: 0, limit: 0, format: 'std' }, ...opts });
const payload = Array.isArray(response.data?.items) ? response.data.items : [];
const payload = await fetchIpRangesData();
const total = payload.length;
setItems(payload);
setOriginalItems(payload);
setTotalItems(Number.isFinite(total) ? total : 0);
setEtag(response.headers?.etag || '');
setLastModified(response.headers?.['last-modified'] || '');
const lengthHeader = response.headers?.['content-length-source'];
setContentLength(typeof lengthHeader !== 'undefined' ? Number(lengthHeader) : null);
setEtag('');
setLastModified(new Date().toISOString());
setContentLength(null);
setError('');
} catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
@@ -148,7 +148,7 @@ function IPRangesManager() {
} finally {
setLoading(false);
}
};
}, []);
useEffect(() => {
if (!didInit.current) {
@@ -157,7 +157,7 @@ function IPRangesManager() {
fetchItems(controller.signal);
return () => controller.abort();
}
}, []);
}, [fetchItems]);
const handleAddItem = (item) => {
const ipRange = item?.ipRange || newItem.ipRange;
@@ -268,7 +268,7 @@ function IPRangesManager() {
});
};
const duplicateItem = (item) => {
const _duplicateItem = (item) => {
setItems([...items, { ...item, ipRange: `${item.ipRange.split('/')[0]}/24` }]);
window.notify?.success?.('Запись продублирована');
};
@@ -316,19 +316,19 @@ function IPRangesManager() {
return { added, removed, changed };
};
const [showDiff, setShowDiff] = useState(false);
const [_showDiff, setShowDiff] = useState(false);
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [clearCommunitiesOpen, setClearCommunitiesOpen] = useState(false);
// Аналитика
const [analyzeOpen, setAnalyzeOpen] = useState(false);
const [analyzeConfirmOpen, setAnalyzeConfirmOpen] = useState(false);
const [analysisResultsOpen, setAnalysisResultsOpen] = useState(false);
const [_analyzeOpen, _setAnalyzeOpen] = useState(false);
const [_analyzeConfirmOpen, _setAnalyzeConfirmOpen] = useState(false);
const [_analysisResultsOpen, setAnalysisResultsOpen] = useState(false);
const [analysisPreview, setAnalysisPreview] = useState(null);
const [overwriteConfirmOpen, setOverwriteConfirmOpen] = useState(false);
const [analyzeFilters, setAnalyzeFilters] = useState({ community: '', minMask: 0, maxMask: 32, type: 'any', supernet16: false });
const [_overwriteConfirmOpen, setOverwriteConfirmOpen] = useState(false);
const [analyzeFilters, _setAnalyzeFilters] = useState({ community: '', minMask: 0, maxMask: 32, type: 'any', supernet16: false });
const handlePreviewDiff = () => {
const valid = items.filter(i => i != null && isValidCidr(i.ipRange) && isValidCommunity(i.community))
@@ -338,27 +338,7 @@ function IPRangesManager() {
setShowDiff(true);
};
const handleSaveChanges = async () => {
// подготовим diff и спросим подтверждение только при массовых изменениях (>10)
const valid = items.filter(i => i != null && isValidCidr(i.ipRange) && isValidCommunity(i.community))
.map(i => ({ ipRange: i.ipRange.trim(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
const diffData = computeDiff(originalItems, unique);
setDiff(diffData);
// Умное подтверждение: показываем модалку только при массовых изменениях
const totalChanges = (diffData.added?.length || 0) + (diffData.removed?.length || 0) + (diffData.changed?.length || 0);
if (totalChanges > 10) {
// Массовое изменение - требуется подтверждение
setConfirmSaveOpen(true);
} else {
// Малое изменение - сохраняем сразу
performSave();
}
};
const performSave = async () => {
const performSave = useCallback(async () => {
setConfirmSaveOpen(false);
setLoading(true);
try {
@@ -366,28 +346,8 @@ function IPRangesManager() {
.map(i => ({ ipRange: i.ipRange.trim(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
// Загрузим полный список, совместим с изменениями текущей страницы и сохраним
const fullRes = await api.get(`/ip-ranges`, { params: { offset: 0, limit: 0, format: 'std' } });
const full = Array.isArray(fullRes.data?.items) ? fullRes.data.items : (Array.isArray(fullRes.data) ? fullRes.data : []);
const fullMap = new Map(full.map(i => [String(i.ipRange).trim(), { ipRange: String(i.ipRange).trim(), community: String(i.community || '').trim() }]));
const originalPageMap = new Map(originalItems.map(i => [String(i.ipRange).trim(), true]));
const uniqueMap = new Map(unique.map(i => [String(i.ipRange).trim(), { ipRange: String(i.ipRange).trim(), community: String(i.community).trim() }]));
for (const key of originalPageMap.keys()) {
if (!uniqueMap.has(key)) fullMap.delete(key);
}
for (const [key, val] of uniqueMap.entries()) fullMap.set(key, val);
const fullToSave = Array.from(fullMap.values());
const et = fullRes?.headers?.etag || etag;
const response = await api.post(`/ip-ranges`, { ipRanges: fullToSave, etag: et }, { validateStatus: () => true });
if (response.status === 412) {
setError('Данные изменились в S3 (ETag mismatch). Обновите список и попробуйте снова.');
return;
}
if (response.status >= 400) {
throw new Error(`Save failed with status ${response.status}`);
}
setEtag(response.headers?.etag || et);
await saveIpRangesData(unique, originalItems);
setEtag('');
await fetchItems();
setSuccess('Изменения успешно сохранены!');
setTimeout(() => setSuccess(''), 3000);
@@ -398,7 +358,21 @@ function IPRangesManager() {
} finally {
setLoading(false);
}
};
}, [items, originalItems, fetchItems]);
const handleSaveChanges = useCallback(async () => {
const valid = items.filter(i => i != null && isValidCidr(i.ipRange) && isValidCommunity(i.community))
.map(i => ({ ipRange: i.ipRange.trim(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
const diffData = computeDiff(originalItems, unique);
setDiff(diffData);
const totalChanges = (diffData.added?.length || 0) + (diffData.removed?.length || 0) + (diffData.changed?.length || 0);
if (totalChanges > 10) {
setConfirmSaveOpen(true);
} else {
performSave();
}
}, [items, originalItems, performSave]);
const handleImport = () => {
setImportOpen(true);
@@ -406,9 +380,7 @@ function IPRangesManager() {
const handleExport = async () => {
try {
const effectiveQ = searchTerm ? searchTerm : (filterCommunity ? filterCommunity : '');
const response = await api.get(`/ip-ranges`, { params: { q: effectiveQ, offset: 0, limit: 0, format: 'std' } });
const all = Array.isArray(response.data?.items) ? response.data.items : (Array.isArray(response.data) ? response.data : []);
const all = filtered;
const header = ['ipRange', 'community'];
const csv = [header, ...all.map(i => [i.ipRange, i.community])]
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
@@ -426,7 +398,7 @@ function IPRangesManager() {
};
// Drag&Drop импорт (оставим как быстрый путь)
const onDropImport = async (e) => {
const _onDropImport = async (e) => {
e.preventDefault();
const file = e.dataTransfer?.files?.[0];
if (!file) return;
@@ -532,7 +504,7 @@ function IPRangesManager() {
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [loading, items, originalItems, etag, sortField, sortOrder, filterCommunity]);
}, [loading, handleSaveChanges]);
const handleEditKeyDown = (e) => {
if (e.key === 'Enter') {
@@ -582,7 +554,7 @@ function IPRangesManager() {
return 'public';
};
const formatBigInt = (n) => {
const _formatBigInt = (n) => {
try {
const s = (n ?? 0n).toString();
return s.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
@@ -591,7 +563,7 @@ function IPRangesManager() {
}
};
const buildAnalysisConfirmText = () => {
const _buildAnalysisConfirmText = () => {
const parts = [];
parts.push(`Community: ${analyzeFilters.community || 'не выбрано'}`);
parts.push(`Маска: /${analyzeFilters.minMask}..../${analyzeFilters.maxMask}`);
@@ -705,7 +677,7 @@ function IPRangesManager() {
return Array.from(new Set(out));
};
const runAnalysis = () => {
const _runAnalysis = () => {
const community = String(analyzeFilters.community || '').trim();
if (!community) { window.notify?.error?.('Выберите community'); return; }
// исходные для community
@@ -744,7 +716,7 @@ function IPRangesManager() {
setAnalysisResultsOpen(true);
};
const applyAnalysisToItems = () => {
const _applyAnalysisToItems = () => {
if (!analysisPreview) return;
const { community, afterCidrs, unchangedCidrs } = analysisPreview;
const keptOthers = items.filter(i => i != null && i.community !== community);
@@ -770,10 +742,14 @@ function IPRangesManager() {
window.notify?.success?.('Диапазоны перезаписаны по результатам анализа. Не забудьте сохранить изменения.');
};
const exportAnalysisCsv = () => {
const _exportAnalysisCsv = () => {
if (!analysisPreview) return;
const { community, afterCidrs } = analysisPreview;
const totalAddr = afterCidrs?.reduce((s, c) => s + countAddresses(parseMask(c)), 0n) ?? 0n;
const header = ['community', 'ranges', 'totalAddresses'];
const lines = [header, ...analysisResult.map(r => [r.community, r.ranges, r.totalAddresses.toString()])]
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
const rows = [[community, String(afterCidrs?.length ?? 0), totalAddr.toString()]];
const lines = [header, ...rows]
.map(r => (Array.isArray(r) ? r : []).map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
+1 -1
View File
@@ -83,7 +83,7 @@ export default function InterfaceSpeedTest() {
return () => controller.abort();
}, [notify]);
const gateways = useMemo(
const _gateways = useMemo(
() =>
networkConfig?.gateways && Array.isArray(networkConfig.gateways)
? networkConfig.gateways
+60 -60
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import * as Diff from 'diff';
import api from './lib/api.js';
import { useNotify } from './components/NotifyProvider.jsx';
@@ -156,13 +156,70 @@ function MikrotikBackupsManager() {
const [backupPage, setBackupPage] = useState(1);
const backupPageSize = 10;
const makeServerId = (s) => (s.id || s.dns || s.ip || '').toString();
const fetchServers = useCallback(async (abortSignal) => {
const opts = abortSignal ? { signal: abortSignal } : {};
try {
setLoadingServers(true);
const res = await api.get('/servers', opts);
const list = Array.isArray(res.data) ? res.data : [];
setServers(list);
const firstJumphost = list.find((s) => String(s.type || '').toLowerCase() === 'jumphost');
if (firstJumphost && !selectedServerId) {
setSelectedServerId(makeServerId(firstJumphost));
}
} catch (err) {
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
console.error('Error fetching servers for backups:', err);
notify.error('Не удалось загрузить список серверов для бэкапов');
} finally {
setLoadingServers(false);
}
}, [selectedServerId, notify]);
const fetchUiSettings = useCallback(async (abortSignal) => {
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const res = await api.get('/ui-settings', opts);
const data = res?.data || {};
setUiSettings(data);
const list = Array.isArray(data.mikrotikBackupServers) ? data.mikrotikBackupServers : [];
setBackupServerIds(new Set(list.map((v) => String(v))));
const e = res?.headers?.etag || res?.headers?.ETag || '';
setUiSettingsEtag(e ? String(e) : '');
} catch (err) {
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
console.error('Error fetching UI settings for backups:', err);
}
}, []);
const fetchBackups = useCallback(async (serverId, abortSignal) => {
if (!serverId) return;
const opts = abortSignal ? { signal: abortSignal } : {};
try {
setLoadingBackups(true);
setSelectedKeys([]);
setDiffResult(null);
const res = await api.get('/mikrotik/backups', { params: { serverId }, ...opts });
const items = res.data?.items || [];
setBackups(items);
} catch (err) {
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
console.error('Error fetching backups:', err);
notify.error('Не удалось загрузить список бэкапов MikroTik');
} finally {
setLoadingBackups(false);
}
}, [notify]);
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
fetchServers(signal);
fetchUiSettings(signal);
return () => controller.abort();
}, []);
}, [fetchServers, fetchUiSettings]);
useEffect(() => {
if (selectedServerId) {
@@ -173,15 +230,13 @@ function MikrotikBackupsManager() {
setBackups([]);
setSelectedKeys([]);
}
}, [selectedServerId]);
}, [selectedServerId, fetchBackups]);
const jumphostServers = useMemo(
() => (servers || []).filter((s) => String(s.type || '').toLowerCase() === 'jumphost'),
[servers],
);
const makeServerId = (s) => (s.id || s.dns || s.ip || '').toString();
const jumphostInputServers = useMemo(
() => jumphostServers.map((s) => ({ ...s, id: makeServerId(s) })),
[jumphostServers],
@@ -319,61 +374,6 @@ function MikrotikBackupsManager() {
setBackupServerIds(new Set());
};
const fetchServers = async (abortSignal) => {
const opts = abortSignal ? { signal: abortSignal } : {};
try {
setLoadingServers(true);
const res = await api.get('/servers', opts);
const list = Array.isArray(res.data) ? res.data : [];
setServers(list);
const firstJumphost = list.find((s) => String(s.type || '').toLowerCase() === 'jumphost');
if (firstJumphost && !selectedServerId) {
setSelectedServerId(makeServerId(firstJumphost));
}
} catch (err) {
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
console.error('Error fetching servers for backups:', err);
notify.error('Не удалось загрузить список серверов для бэкапов');
} finally {
setLoadingServers(false);
}
};
const fetchUiSettings = async (abortSignal) => {
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const res = await api.get('/ui-settings', opts);
const data = res?.data || {};
setUiSettings(data);
const list = Array.isArray(data.mikrotikBackupServers) ? data.mikrotikBackupServers : [];
setBackupServerIds(new Set(list.map((v) => String(v))));
const e = res?.headers?.etag || res?.headers?.ETag || '';
setUiSettingsEtag(e ? String(e) : '');
} catch (err) {
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
console.error('Error fetching UI settings for backups:', err);
}
};
const fetchBackups = async (serverId, abortSignal) => {
if (!serverId) return;
const opts = abortSignal ? { signal: abortSignal } : {};
try {
setLoadingBackups(true);
setSelectedKeys([]);
setDiffResult(null);
const res = await api.get('/mikrotik/backups', { params: { serverId }, ...opts });
const items = res.data?.items || [];
setBackups(items);
} catch (err) {
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
console.error('Error fetching backups:', err);
notify.error('Не удалось загрузить список бэкапов MikroTik');
} finally {
setLoadingBackups(false);
}
};
const handleSelectKey = (key) => {
setSelectedKeys((prev) => {
if (prev.includes(key)) {
+4 -4
View File
@@ -82,7 +82,7 @@ function MikrotikTools() {
[networkConfig]
);
const handleSelectGatewayMeta = (meta) => {
const _handleSelectGatewayMeta = (meta) => {
setGatewayMeta(meta);
// Если цель не задана подставляем IP gateway как target
if (!target && meta && meta.ip) {
@@ -214,7 +214,7 @@ function MikrotikTools() {
[jumphostServers, serverId]
);
const interfacesForServer = useMemo(() => {
const _interfacesForServer = useMemo(() => {
if (!serverId || !interfaces.length) return [];
const ids = new Set(
[serverId, currentServer?.id, currentServer?.ip, currentServer?.dns]
@@ -391,7 +391,7 @@ function MikrotikTools() {
{/* Цели — карточки в стиле сервера/gateway */}
<div className="row row-cards g-1 mt-2">
{POPULAR_TARGETS.map(({ id: tid, name, target: tgt, Icon, color }) => {
{POPULAR_TARGETS.map(({ id: tid, name, target: tgt, Icon: _Icon, color }) => {
const isSelected = target === tgt;
const bgClass = color === 'red' ? 'bg-red-lt' : color === 'blue' ? 'bg-blue-lt' : 'bg-orange-lt';
const textClass = color === 'red' ? 'text-red' : color === 'blue' ? 'text-blue' : 'text-orange';
@@ -404,7 +404,7 @@ function MikrotikTools() {
>
<div className="card-body py-2 px-2 d-flex align-items-center gap-2">
<span className={`avatar avatar-sm ${bgClass} ${textClass}`}>
<Icon size={20} stroke={1.5} />
<_Icon size={20} stroke={1.5} />
</span>
<div className="flex-grow-1 min-w-0">
<div className="fw-semibold text-truncate small">{name}</div>
+35 -16
View File
@@ -249,6 +249,7 @@ function NetworkConfigManager() {
fetchServers();
fetchIpsecPasswords();
fetchUiSettings();
// eslint-disable-next-line react-hooks/exhaustive-deps -- init-only, fetch functions defined below
}, []);
const fetchUiSettings = async () => {
@@ -393,6 +394,7 @@ function NetworkConfigManager() {
label: getServerLabel(id),
server: getServerInfo(id),
}));
// eslint-disable-next-line react-hooks/exhaustive-deps -- getServerInfo/getServerLabel are stable helpers
}, [config.gateways, config.tunnelInterfaces, config.ipPools, servers]);
// === Фильтрация ===
@@ -431,6 +433,7 @@ function NetworkConfigManager() {
}
return result;
// eslint-disable-next-line react-hooks/exhaustive-deps -- getServerInfo/getServerLabel/gatewayTypeFilter are stable
}, [config.gateways, providerFilter, serverFilter, typeFilter, searchTerm, servers]);
const filteredInterfaces = useMemo(() => {
@@ -456,6 +459,7 @@ function NetworkConfigManager() {
}
return result;
// eslint-disable-next-line react-hooks/exhaustive-deps -- getServerLabel is stable helper
}, [config.tunnelInterfaces, typeFilter, serverFilter, searchTerm, servers]);
const filteredPools = useMemo(() => {
@@ -468,7 +472,7 @@ function NetworkConfigManager() {
if (searchTerm) {
const term = searchTerm.toLowerCase();
result = result.filter(p => {
const server = getServerInfo(p.serverId);
const _server = getServerInfo(p.serverId);
return (
p.name?.toLowerCase().includes(term) ||
p.cidr?.toLowerCase().includes(term) ||
@@ -479,6 +483,7 @@ function NetworkConfigManager() {
}
return result;
// eslint-disable-next-line react-hooks/exhaustive-deps -- getServerInfo/getServerLabel are stable helpers
}, [config.ipPools, serverFilter, searchTerm, servers]);
// === Группировка по серверам ===
@@ -528,6 +533,7 @@ function NetworkConfigManager() {
});
return grouped;
// eslint-disable-next-line react-hooks/exhaustive-deps -- getServerCategory is stable helper
}, [gatewaysByServer, servers]);
const interfacesByCategory = useMemo(() => {
@@ -547,6 +553,7 @@ function NetworkConfigManager() {
});
return grouped;
// eslint-disable-next-line react-hooks/exhaustive-deps -- getServerCategory is stable helper
}, [interfacesByServer, servers]);
// === Сброс фильтров ===
@@ -913,7 +920,7 @@ function NetworkConfigManager() {
return;
}
const invalidPairs = templateServerPairs.filter((pair, index) => {
const invalidPairs = templateServerPairs.filter((pair, _index) => {
if (!pair.server1 || !pair.server2) {
return true;
}
@@ -1274,7 +1281,7 @@ function NetworkConfigManager() {
};
// === Генерация IP из пула ===
const generateIpFromPool = (cidr, usedIps, excludeIp = null, forRemote = false, pairedLocalIp = null) => {
const generateIpFromPool = (cidr, usedIps, _excludeIp = null, forRemote = false, pairedLocalIp = null) => {
// Парсим CIDR (например, "10.10.0.0/24")
const [network, prefixLength] = cidr.split('/');
if (!network || !prefixLength) {
@@ -1590,6 +1597,7 @@ function NetworkConfigManager() {
}
return a.type.localeCompare(b.type);
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- generatePtrZone/getServerInfo are stable helpers
}, [config.tunnelInterfaces, servers, globalPtrZoneReplaceFrom, globalPtrZoneReplaceTo]);
// === Фильтрация реестра IP ===
@@ -1650,7 +1658,7 @@ function NetworkConfigManager() {
};
// === CRUD для IPSec Passwords ===
const handleAddIpsecPassword = () => {
const _handleAddIpsecPassword = () => {
setEditingIpsecPassword({ name: '', password: '', description: '' });
setIpsecPasswordModalMode('add');
setIpsecPasswordModalOpen(true);
@@ -2021,7 +2029,7 @@ function NetworkConfigManager() {
};
// === Генерация кода MikroTik для рекурсивных маршрутов ===
const generateMikrotikRecursiveRoutes = async () => {
const _generateMikrotikRecursiveRoutes = async () => {
const recursiveGateways = (config.gateways || []).filter(gw => gw.type === 'recursive');
const blocks = [];
@@ -2073,7 +2081,7 @@ function NetworkConfigManager() {
}
// Обрабатываем каждый родительский gateway
parentGatewaysList.forEach((parentRef, parentIndex) => {
parentGatewaysList.forEach((parentRef, _parentIndex) => {
const parent = getParentGateway(parentRef.id);
if (!parent) {
@@ -2264,7 +2272,7 @@ function NetworkConfigManager() {
if (obj.t === 'log') console.log('[MikroTik apply]', obj.msg, obj);
else if (obj.t === 'result') finalData = obj;
else if (obj.t === 'error') finalData = { ok: false, error: obj.error, mikrotikRequests: obj.mikrotikRequests };
} catch (_) {}
} catch { /* no-op */ }
}
}
if (buffer.trim()) {
@@ -2272,7 +2280,7 @@ function NetworkConfigManager() {
const obj = JSON.parse(buffer);
if (obj.t === 'result') finalData = obj;
else if (obj.t === 'error') finalData = { ok: false, error: obj.error, mikrotikRequests: obj.mikrotikRequests };
} catch (_) {}
} catch { /* no-op */ }
}
data = finalData || {};
} else {
@@ -2403,7 +2411,7 @@ function NetworkConfigManager() {
let validRoutesCount = 0;
parentGatewaysList.forEach((parentRef, parentIndex) => {
parentGatewaysList.forEach((parentRef, _parentIndex) => {
const parent = getParentGateway(parentRef.id);
if (!parent) {
@@ -2542,7 +2550,7 @@ function NetworkConfigManager() {
try {
document.execCommand('copy');
notify.success('Код скопирован в буфер обмена!');
} catch (err) {
} catch (_err) {
notify.error('Не удалось скопировать код');
}
document.body.removeChild(textArea);
@@ -2589,7 +2597,7 @@ function NetworkConfigManager() {
}, [generatedMikrotikCode]);
// === Получение названия типа блока ===
const getBlockTypeLabel = (type) => {
const _getBlockTypeLabel = (type) => {
switch (type) {
case 'recursive-routes':
return 'Рекурсивные маршруты';
@@ -2967,11 +2975,11 @@ function NetworkConfigManager() {
// === Рендер карточки Gateway ===
const renderGatewayCard = (gateway) => {
const server = getServerInfo(gateway.serverId);
const provider = server?.provider || '';
const _provider = server?.provider || '';
const serverCountry = server?.country || '';
const gatewayCountry = gateway.country || '';
const displayCountry = gatewayCountry || serverCountry;
const gatewayType = GATEWAY_TYPES.find(t => t.value === gateway.type) || GATEWAY_TYPES[0];
const _gatewayType = GATEWAY_TYPES.find(t => t.value === gateway.type) || GATEWAY_TYPES[0];
// Поддержка старого формата (parentGatewayId) и нового (parentGateways)
const parentGatewaysList = gateway.type === 'recursive'
@@ -3135,7 +3143,7 @@ function NetworkConfigManager() {
const interfaceTypeColor = getInterfaceTypeColor(iface.type);
// Определяем отображаемый IP для зеленого блока (remote IP другого сервера)
const displayIp = displayRemoteIp !== '—' ? displayRemoteIp : displayLocalIp;
const _displayIp = displayRemoteIp !== '—' ? displayRemoteIp : displayLocalIp;
return (
<div className="card" style={{ borderRadius: '12px', maxWidth: '100%' }}>
@@ -3665,6 +3673,17 @@ function NetworkConfigManager() {
{/* Interfaces Tab */}
{activeTab === 'interfaces' && (
<>
<div className="card mb-3">
<div className="card-body d-flex flex-wrap align-items-center justify-content-between gap-2">
<div className="text-muted small">
Настройки OSPF вынесены в отдельный раздел проекта.
</div>
<a className="btn btn-outline-primary btn-sm" href="/ospf-tools">
Открыть Инструменты OSPF
</a>
</div>
</div>
{filteredInterfaces.length === 0 ? (
<div className="card">
<div className="card-body text-center py-5">
@@ -5520,7 +5539,7 @@ function NetworkConfigManager() {
<div className="row g-2">
{GATEWAY_TEMPLATES.map(template => {
// Генерируем предпросмотр IP адресов если есть базовый IP и сервер
const previewIps = template.gateways.map((gw, idx) => {
const previewIps = template.gateways.map((gw, _idx) => {
// Для прямых gateways показываем IP сервера, если выбран
if (gw.type === 'direct' && templateServerId) {
const server = getServerInfo(templateServerId);
@@ -5698,7 +5717,7 @@ function NetworkConfigManager() {
<label className="form-label small">Родительские gateway/интерфейсы</label>
<div className="d-flex flex-column gap-2">
{(gw.parentGateways || []).map((parent, parentIndex) => {
const parentInfo = getParentGateway(parent.id, templateGateways);
const _parentInfo = getParentGateway(parent.id, templateGateways);
return (
<div key={parentIndex} className="d-flex align-items-center gap-2">
<div className="flex-grow-1">
+49 -7
View File
@@ -76,6 +76,7 @@ export default function NetworkMapDashboard() {
const lastSuccessfulPingRef = useRef({});
const lastSuccessfulSpeedRef = useRef({});
const [pingCacheSeconds, setPingCacheSeconds] = useState(0);
const [alertSettings, setAlertSettings] = useState({});
const [loading, setLoading] = useState(true);
const [pingLoading, setPingLoading] = useState(false);
const [speedLoading, setSpeedLoading] = useState(false);
@@ -102,12 +103,17 @@ export default function NetworkMapDashboard() {
const config = configRes?.data || {};
const tunnelInterfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : [];
const uiSettings = uiSettingsRes?.data || {};
const uiAlertSettings =
uiSettings.alertSettings && typeof uiSettings.alertSettings === 'object'
? uiSettings.alertSettings
: {};
const cache = cacheRes?.data || {};
const ttlSeconds = Math.max(
0,
parseInt(uiSettings.networkMapPingCacheSeconds, 10) || 0
);
setPingCacheSeconds(ttlSeconds);
setAlertSettings(uiAlertSettings);
const getServer = (serverId) =>
serversList.find((s) => s.id === serverId || s.ip === serverId || s.dns === serverId);
@@ -195,14 +201,21 @@ export default function NetworkMapDashboard() {
/** Один ключ пинга на ребро (маршруты статичны — одна сторона) */
const edgePingKey = (a, b) => [String(a), String(b)].sort().join(':');
const requestPings = useCallback(async () => {
if (connections.length === 0) return;
/**
* Запросить пинг для всех соединений или для конкретного подмножества.
* Если передать force=true, игнорирует TTL кеша по пингу.
*/
const requestPings = useCallback(async (options = {}) => {
const { connections: targetConnections, force = false } = options;
const sourceConnections =
Array.isArray(targetConnections) && targetConnections.length > 0 ? targetConnections : connections;
if (sourceConnections.length === 0) return;
pingAbortRef.current = false;
setPingLoading(true);
const ttlMs = Math.max(0, (pingCacheSeconds || 0) * 1000);
const tasks = [];
connections.forEach((c) => {
sourceConnections.forEach((c) => {
const from = String(c.from);
const to = String(c.to);
if (!from || !to || from === to) return;
@@ -214,7 +227,7 @@ export default function NetworkMapDashboard() {
srcTo && ['jumphost', 'home'].includes(String(srcTo.type || '').toLowerCase()) && c.internalToFrom;
const ekey = edgePingKey(from, to);
if (ttlMs > 0 && pingTimestampsRef.current[ekey] && Date.now() - pingTimestampsRef.current[ekey] < ttlMs) {
if (!force && ttlMs > 0 && pingTimestampsRef.current[ekey] && Date.now() - pingTimestampsRef.current[ekey] < ttlMs) {
return;
}
@@ -291,8 +304,15 @@ export default function NetworkMapDashboard() {
const speedKey = (key1, key2) =>
[String(key1), String(key2)].sort().join(':');
const requestSpeeds = useCallback(async () => {
const withSpeed = connections.filter(
/**
* Запросить скорость для всех соединений или подмножества.
* Если передать force=true, принудительно выполняет новый speed-test (игнорируя кеш на бэкенде).
*/
const requestSpeeds = useCallback(async (options = {}) => {
const { connections: targetConnections, force: _force = false } = options;
const sourceConnections =
Array.isArray(targetConnections) && targetConnections.length > 0 ? targetConnections : connections;
const withSpeed = sourceConnections.filter(
(c) => c.speedTestServerId && c.interfaceName
);
if (withSpeed.length === 0) return;
@@ -307,6 +327,7 @@ export default function NetworkMapDashboard() {
{
serverId: c.speedTestServerId,
interfaceName: c.interfaceName,
forceRefresh: true, // ручной запуск со страницы карты сети всегда должен делать новый замер
},
{ timeout: 120000 }
);
@@ -343,6 +364,22 @@ export default function NetworkMapDashboard() {
setSpeedLoading(false);
}, [connections]);
const handleRefreshPingForConnection = useCallback(
(connection) => {
if (!connection) return;
requestPings({ connections: [connection], force: true });
},
[requestPings]
);
const handleRefreshSpeedForConnection = useCallback(
(connection) => {
if (!connection) return;
requestSpeeds({ connections: [connection], force: true });
},
[requestSpeeds]
);
useEffect(() => {
const controller = new AbortController();
fetchData(controller.signal);
@@ -357,7 +394,7 @@ export default function NetworkMapDashboard() {
};
const handleRefreshSpeeds = () => {
requestSpeeds();
requestSpeeds({ force: true });
};
if (loading) {
@@ -427,6 +464,11 @@ export default function NetworkMapDashboard() {
speedMap={speedMap}
pingStaleMap={pingStaleMap}
speedStaleMap={speedStaleMap}
onRefreshPingForConnection={handleRefreshPingForConnection}
onRefreshSpeedForConnection={handleRefreshSpeedForConnection}
pingLoading={pingLoading}
speedLoading={speedLoading}
alertSettings={alertSettings}
/>
)}
</div>
+643 -128
View File
@@ -4,14 +4,18 @@ import { IconRefresh, IconMaximize, IconMinimize, IconClock } from '@tabler/icon
const FLAG_CDN = 'https://flagcdn.com';
const UNIFI_BG = '#0f172a';
const UNIFI_BG_DEEP = '#0b1220';
const UNIFI_CARD_BG = '#1e293b';
const UNIFI_CARD_BORDER = '#334155';
const UNIFI_GREEN = '#22c55e';
const UNIFI_GREEN_SOFT = '#34d399';
const UNIFI_BLUE_SOFT = '#38bdf8';
const UNIFI_WARNING = '#f59e0b';
const UNIFI_TEXT = '#f1f5f9';
const UNIFI_TEXT_MUTED = '#94a3b8';
const NODE_WIDTH = 88;
const NODE_HEIGHT = 52;
const STORAGE_KEY = 'network-map-unifi-positions';
const NODE_WIDTH = 124;
const NODE_HEIGHT = 64;
const STORAGE_KEY = 'network-map-unifi-positions';
function computeInitialLayout(servers, size) {
const positions = {};
@@ -34,7 +38,7 @@ function computeInitialLayout(servers, size) {
const baseX = paddingX;
columns.forEach((col, colIdx) => {
// В пределах колонки сортируем по dns/ip для стабильности
// В пределах колонки сортируем по dns/ip для стабильности/
const sorted = [...col].sort((a, b) => {
const la = (a.dns || a.ip || '').toString();
const lb = (b.dns || b.ip || '').toString();
@@ -86,7 +90,62 @@ function speedKey(key1, key2) {
return [String(key1), String(key2)].sort().join(':');
}
export default function NetworkMapUnifi({ servers = [], connections = [], pingMap = {}, speedMap = {}, pingStaleMap = {}, speedStaleMap = {} }) {
function getNodeTypeMeta(type) {
const t = String(type || '').toLowerCase();
if (t === 'home') return { label: 'HOME', color: '#60a5fa' };
if (t === 'jumphost') return { label: 'JUMP', color: '#34d399' };
if (t === 'exit') return { label: 'EXIT', color: '#f59e0b' };
return { label: 'NODE', color: '#94a3b8' };
}
function getTunnelColor(tunnelType) {
const t = String(tunnelType || '').toLowerCase();
if (t.includes('wireguard') || t === 'wg') return UNIFI_BLUE_SOFT;
if (t.includes('ipsec')) return UNIFI_GREEN;
return UNIFI_GREEN_SOFT;
}
function sideVector(side) {
if (side === 'right') return { x: 1, y: 0 };
if (side === 'left') return { x: -1, y: 0 };
if (side === 'bottom') return { x: 0, y: 1 };
return { x: 0, y: -1 };
}
function anchorPoint(pos, side, slotOffset = 0) {
if (side === 'right') return { x: pos.x + NODE_WIDTH / 2, y: pos.y + slotOffset };
if (side === 'left') return { x: pos.x - NODE_WIDTH / 2, y: pos.y + slotOffset };
if (side === 'bottom') return { x: pos.x + slotOffset, y: pos.y + NODE_HEIGHT / 2 };
return { x: pos.x + slotOffset, y: pos.y - NODE_HEIGHT / 2 };
}
function clamp(val, min, max) {
return Math.max(min, Math.min(max, val));
}
function hash01(str) {
let h = 2166136261;
const s = String(str || '');
for (let i = 0; i < s.length; i += 1) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return (h >>> 0) / 4294967295;
}
export default function NetworkMapUnifi({
servers = [],
connections = [],
pingMap = {},
speedMap = {},
pingStaleMap = {},
speedStaleMap = {},
onRefreshPingForConnection,
onRefreshSpeedForConnection,
pingLoading = false,
speedLoading = false,
alertSettings = {},
}) {
const containerRef = useRef(null);
const svgRef = useRef(null);
const [size, setSize] = useState({ w: 800, h: 520 });
@@ -96,6 +155,33 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
const [hoveredEdgeKey, setHoveredEdgeKey] = useState(null);
const [selectedEdge, setSelectedEdge] = useState(null);
const mapSpeedThresholds = useMemo(() => {
const raw =
alertSettings?.networkMapNodeThresholds &&
typeof alertSettings.networkMapNodeThresholds === 'object'
? alertSettings.networkMapNodeThresholds
: {};
const low = Math.max(1, Number(raw.lowSpeedMbps) || 40);
const belowNorm = Math.max(low, Number(raw.belowNormSpeedMbps) || 80);
const normal = Math.max(belowNorm, Number(raw.normalSpeedMbps) || 120);
return { low, belowNorm, normal };
}, [alertSettings]);
const nodeMetaByIp = useMemo(() => {
const byIp = new Map();
servers.forEach((s) => {
const ip = String(s.ip || '');
if (!ip) return;
byIp.set(ip, {
ip,
dns: s.dns || '',
type: String(s.type || '').toLowerCase(),
nodeLabel: s.dns ? String(s.dns).split('.')[0] : ip,
});
});
return byIp;
}, [servers]);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
@@ -127,10 +213,10 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw) saved = JSON.parse(raw) || {};
} catch {}
} catch { /* no-op */ }
const initial = computeInitialLayout(servers, size);
const merged = {};
servers.forEach((s, i) => {
servers.forEach((s, _i) => {
const ip = String(s.ip);
if (saved[ip] && typeof saved[ip].x === 'number' && typeof saved[ip].y === 'number') {
merged[ip] = { x: saved[ip].x, y: saved[ip].y };
@@ -139,13 +225,13 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
}
});
setPositions(merged);
}, [servers.length, size.w, size.h]);
}, [servers, size]);
const savePositions = useCallback((next) => {
const _savePositions = useCallback((next) => {
setPositions(next);
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {}
} catch { /* no-op */ }
}, []);
const getSvgCoords = useCallback((clientX, clientY) => {
@@ -169,7 +255,7 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
const next = { ...prev, [drag.nodeId]: { x: drag.startX + (p.x - drag.mouseX), y: drag.startY + (p.y - drag.mouseY) } };
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {}
} catch { /* no-op */ }
return next;
});
};
@@ -187,7 +273,7 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
setPositions(initial);
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(initial));
} catch {}
} catch { /* no-op */ }
}, [servers, size]);
const onNodeMouseDown = useCallback(
@@ -220,16 +306,23 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
speed && (speed.tcpDownloadBps != null || speed.tcpUploadBps != null)
? formatSpeedShort(speed.tcpDownloadBps, speed.tcpUploadBps)
: null;
const compactEdgeLabel = pingLabel || speedLabel || null;
const edgeLabel =
pingLabel && speedLabel ? `${pingLabel}${speedLabel}` : (pingLabel || speedLabel);
return {
edgeKey: `${from}-${to}-${c.tunnelType || ''}`,
from,
to,
p1,
p2,
pingMs: typeof ms === 'number' ? ms : null,
pingLabel,
speedLabel,
compactEdgeLabel,
edgeLabel,
pingStale,
speedStale,
stale: pingStale || speedStale,
speed,
tunnelType: c.tunnelType,
raw: c,
@@ -237,77 +330,308 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
}).filter((e) => e.p1 && e.p2);
}, [connections, positions, pingMap, speedMap, pingStaleMap, speedStaleMap]);
// Вычисляем старт/финиш и одну контрольную точку:
// рёбра выходят из карточек под 90°, дальше сразу плавная дуга без прямых сегментов.
// Роутинг рёбер: раскладываем рёбра по "портам" на сторонах узла (эффект трезубца),
// а также даём параллельным связям разный изгиб.
const routedEdges = useMemo(() => {
const nodePortBuckets = new Map();
const edgeRoutes = new Map();
const pairBuckets = new Map();
const slotStep = 9;
const getEdgeSides = (e) => {
const dx = e.p2.x - e.p1.x;
const dy = e.p2.y - e.p1.y;
if (Math.abs(dx) >= Math.abs(dy)) {
return dx >= 0
? { fromSide: 'right', toSide: 'left' }
: { fromSide: 'left', toSide: 'right' };
}
return dy >= 0
? { fromSide: 'bottom', toSide: 'top' }
: { fromSide: 'top', toSide: 'bottom' };
};
const getSortValue = (side, targetPos) =>
side === 'left' || side === 'right' ? targetPos.y : targetPos.x;
edgesWithPing.forEach((e, idx) => {
const { fromSide, toSide } = getEdgeSides(e);
if (!nodePortBuckets.has(e.from)) nodePortBuckets.set(e.from, {});
if (!nodePortBuckets.has(e.to)) nodePortBuckets.set(e.to, {});
const fromBuckets = nodePortBuckets.get(e.from);
const toBuckets = nodePortBuckets.get(e.to);
fromBuckets[fromSide] = fromBuckets[fromSide] || [];
toBuckets[toSide] = toBuckets[toSide] || [];
fromBuckets[fromSide].push({
idx,
role: 'from',
sortValue: getSortValue(fromSide, e.p2),
});
toBuckets[toSide].push({
idx,
role: 'to',
sortValue: getSortValue(toSide, e.p1),
});
const pairKey = [e.from, e.to].sort().join(':');
if (!pairBuckets.has(pairKey)) pairBuckets.set(pairKey, []);
pairBuckets.get(pairKey).push(idx);
edgeRoutes.set(idx, {
fromSide,
toSide,
fromSlot: 0,
toSlot: 0,
bundleOffset: 0,
});
});
nodePortBuckets.forEach((sides) => {
Object.keys(sides).forEach((side) => {
const list = sides[side];
list.sort((a, b) => a.sortValue - b.sortValue);
const center = (list.length - 1) / 2;
list.forEach((item, order) => {
const slotOffset = (order - center) * slotStep;
const route = edgeRoutes.get(item.idx);
if (!route) return;
if (item.role === 'from') route.fromSlot = slotOffset;
else route.toSlot = slotOffset;
});
});
});
pairBuckets.forEach((idxList) => {
idxList.sort((a, b) => {
const ea = edgesWithPing[a];
const eb = edgesWithPing[b];
return String(ea.tunnelType || '').localeCompare(String(eb.tunnelType || '')) || ea.edgeKey.localeCompare(eb.edgeKey);
});
const center = (idxList.length - 1) / 2;
idxList.forEach((edgeIdx, order) => {
const route = edgeRoutes.get(edgeIdx);
if (!route) return;
route.bundleOffset = (order - center) * 14;
});
});
return edgesWithPing.map((e, idx) => ({
...e,
route: edgeRoutes.get(idx),
}));
}, [edgesWithPing]);
// Вычисляем геометрию ребра: выход из "порта" узла + короткий stem (трезубец) + плавная дуга.
const computeCurvePoints = useCallback((e) => {
const { p1, p2 } = e;
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const adx = Math.abs(dx);
const ady = Math.abs(dy);
const route = e.route || { fromSide: 'right', toSide: 'left', fromSlot: 0, toSlot: 0, bundleOffset: 0 };
const stem = 14;
let sx = p1.x;
let sy = p1.y;
let ex = p2.x;
let ey = p2.y;
const startAnchor = anchorPoint(p1, route.fromSide, route.fromSlot);
const endAnchor = anchorPoint(p2, route.toSide, route.toSlot);
const fromVec = sideVector(route.fromSide);
const toVec = sideVector(route.toSide);
if (adx >= ady) {
const sign = dx >= 0 ? 1 : -1;
sx = p1.x + sign * (NODE_WIDTH / 2);
sy = p1.y;
ex = p2.x - sign * (NODE_WIDTH / 2);
ey = p2.y;
} else {
const sign = dy >= 0 ? 1 : -1;
sx = p1.x;
sy = p1.y + sign * (NODE_HEIGHT / 2);
ex = p2.x;
ey = p2.y - sign * (NODE_HEIGHT / 2);
}
const sx = startAnchor.x;
const sy = startAnchor.y;
const ex = endAnchor.x;
const ey = endAnchor.y;
const sOutX = sx + fromVec.x * stem;
const sOutY = sy + fromVec.y * stem;
const eOutX = ex + toVec.x * stem;
const eOutY = ey + toVec.y * stem;
const ddx = ex - sx;
const ddy = ey - sy;
const ddx = eOutX - sOutX;
const ddy = eOutY - sOutY;
const len = Math.hypot(ddx, ddy) || 1;
const mx = (sx + ex) / 2;
const my = (sy + ey) / 2;
const baseOffset = Math.min(40, len / 2);
const perpX = (-ddy / len) * baseOffset;
const perpY = (ddx / len) * baseOffset;
const cpx = mx + perpX;
const cpy = my + perpY;
const perpLen = Math.hypot(perpX, perpY) || 1;
const ux = perpX / perpLen;
const uy = perpY / perpLen;
const baseOffset = Math.min(44, len / 2);
const bendOffset = baseOffset + (route.bundleOffset || 0);
const bendX = (-ddy / len) * bendOffset;
const bendY = (ddx / len) * bendOffset;
return { sx, sy, ex, ey, cpx, cpy, mx, my, perpX: ux, perpY: uy };
const cpx = (sOutX + eOutX) / 2 + bendX;
const cpy = (sOutY + eOutY) / 2 + bendY;
// Середина квадратичной кривой при t=0.5
const mx = 0.25 * sOutX + 0.5 * cpx + 0.25 * eOutX;
const my = 0.25 * sOutY + 0.5 * cpy + 0.25 * eOutY;
const perpLen = Math.hypot(-ddy, ddx) || 1;
const ux = (-ddy) / perpLen;
const uy = (ddx) / perpLen;
return {
sx,
sy,
ex,
ey,
sOutX,
sOutY,
eOutX,
eOutY,
cpx,
cpy,
mx,
my,
perpX: ux,
perpY: uy,
};
}, []);
const pathD = useCallback((e) => {
const { sx, sy, ex, ey, cpx, cpy } = computeCurvePoints(e);
return `M ${sx} ${sy} Q ${cpx} ${cpy} ${ex} ${ey}`;
const { sx, sy, ex, ey, sOutX, sOutY, eOutX, eOutY, cpx, cpy } = computeCurvePoints(e);
return `M ${sx} ${sy} L ${sOutX} ${sOutY} Q ${cpx} ${cpy} ${eOutX} ${eOutY} L ${ex} ${ey}`;
}, [computeCurvePoints]);
/** Точка для подписи: середина ребра + смещение по перпендикуляру (чтобы подпись была у ребра, а не уезжала) */
/** Точка подписи на кривой с небольшим jitter и ограничением в пределах карты */
const labelPlacement = useCallback((e, level = 0) => {
const { mx, my, perpX, perpY } = computeCurvePoints(e);
const baseOffset = 18;
const stepOffset = 26;
const x = mx + perpX * (baseOffset + level * stepOffset);
const y = my + perpY * (baseOffset + level * stepOffset);
return { x, y };
}, [computeCurvePoints]);
const geom = computeCurvePoints(e);
const jitter = (hash01(e.edgeKey) - 0.5) * 0.24;
const t = clamp(0.42 + jitter, 0.26, 0.74);
const omt = 1 - t;
const xCurve = omt * omt * geom.sOutX + 2 * omt * t * geom.cpx + t * t * geom.eOutX;
const yCurve = omt * omt * geom.sOutY + 2 * omt * t * geom.cpy + t * t * geom.eOutY;
const baseOffset = 16;
const stepOffset = 18;
const x = xCurve + geom.perpX * (baseOffset + level * stepOffset);
const y = yCurve + geom.perpY * (baseOffset + level * stepOffset);
const margin = 22;
return {
x: clamp(x, margin, Math.max(margin, size.w - margin)),
y: clamp(y, margin, Math.max(margin, size.h - margin)),
};
}, [computeCurvePoints, size.w, size.h]);
const problematicNodes = useMemo(() => {
const byIp = new Map();
servers.forEach((s) => {
byIp.set(String(s.ip), {
ip: String(s.ip),
dns: s.dns || '',
type: String(s.type || '').toLowerCase(),
linkCount: 0,
staleCount: 0,
maxPingMs: null,
maxPingPeerLabel: null,
minSpeedMbps: null,
minSpeedPeerLabel: null,
});
});
(connections || []).forEach((c) => {
const from = byIp.get(String(c.from));
const to = byIp.get(String(c.to));
if (from) from.linkCount += 1;
if (to) to.linkCount += 1;
});
routedEdges.forEach((e) => {
[e.from, e.to].forEach((nodeIp) => {
const node = byIp.get(String(nodeIp));
if (!node) return;
const peerIp = String(nodeIp) === String(e.from) ? String(e.to) : String(e.from);
const peerMeta = nodeMetaByIp.get(peerIp);
const peerLabel = peerMeta?.nodeLabel || peerIp;
if (e.stale) node.staleCount += 1;
if (typeof e.pingMs === 'number') {
if (node.maxPingMs == null || e.pingMs > node.maxPingMs) {
node.maxPingMs = e.pingMs;
node.maxPingPeerLabel = peerLabel;
}
}
const down = e.speed?.tcpDownloadBps != null ? e.speed.tcpDownloadBps / 1_000_000 : null;
const up = e.speed?.tcpUploadBps != null ? e.speed.tcpUploadBps / 1_000_000 : null;
const edgeMin = down != null && up != null ? Math.min(down, up) : (down ?? up);
if (edgeMin != null && Number.isFinite(edgeMin)) {
if (node.minSpeedMbps == null || edgeMin < node.minSpeedMbps) {
node.minSpeedMbps = edgeMin;
node.minSpeedPeerLabel = peerLabel;
}
}
});
});
return Array.from(byIp.values())
.map((n) => {
const issues = [];
let riskScore = 0;
if (n.maxPingMs != null) {
if (n.maxPingMs >= 120) {
issues.push(`Очень высокий ping${n.maxPingPeerLabel ? ` (с ${n.maxPingPeerLabel})` : ''}`);
riskScore += 3;
} else if (n.maxPingMs >= 80) {
issues.push(`Повышенный ping${n.maxPingPeerLabel ? ` (с ${n.maxPingPeerLabel})` : ''}`);
riskScore += 2;
}
}
if (n.minSpeedMbps != null) {
if (n.minSpeedMbps < mapSpeedThresholds.low) {
issues.push(`Низкая скорость${n.minSpeedPeerLabel ? ` (с ${n.minSpeedPeerLabel})` : ''}`);
riskScore += 3;
} else if (n.minSpeedMbps < mapSpeedThresholds.belowNorm) {
issues.push(`Скорость ниже нормы${n.minSpeedPeerLabel ? ` (с ${n.minSpeedPeerLabel})` : ''}`);
riskScore += 2;
} else if (n.minSpeedMbps < mapSpeedThresholds.normal) {
issues.push(`Норма скорости не достигнута${n.minSpeedPeerLabel ? ` (с ${n.minSpeedPeerLabel})` : ''}`);
riskScore += 1;
}
}
if (n.staleCount > 0) {
issues.push('Есть устаревшие метрики');
riskScore += n.staleCount >= 2 ? 2 : 1;
}
if (n.linkCount >= 7) {
issues.push('Высокая связанность');
riskScore += 1;
}
return {
...n,
issues,
riskScore,
nodeLabel: n.dns ? String(n.dns).split('.')[0] : n.ip,
affectedLink:
(n.maxPingPeerLabel && `${n.dns ? String(n.dns).split('.')[0] : n.ip}${n.maxPingPeerLabel}`) ||
(n.minSpeedPeerLabel && `${n.dns ? String(n.dns).split('.')[0] : n.ip}${n.minSpeedPeerLabel}`) ||
'—',
};
})
.filter((n) => n.issues.length > 0)
.sort((a, b) =>
b.riskScore - a.riskScore ||
(b.maxPingMs ?? -1) - (a.maxPingMs ?? -1) ||
(a.minSpeedMbps ?? Infinity) - (b.minSpeedMbps ?? Infinity)
);
}, [servers, connections, routedEdges, mapSpeedThresholds, nodeMetaByIp]);
const criticalNodes = useMemo(
() => problematicNodes.filter((n) => n.riskScore >= 4).slice(0, 8),
[problematicNodes]
);
const warningNodes = useMemo(
() => problematicNodes.filter((n) => n.riskScore >= 1 && n.riskScore < 4).slice(0, 12),
[problematicNodes]
);
if (servers.length === 0) return null;
return (
<>
<div
ref={containerRef}
className="network-map-unifi"
style={{
width: '100%',
height: 520,
minHeight: 400,
height: isFullscreen ? '100%' : '68vh',
minHeight: isFullscreen ? 400 : 620,
background: UNIFI_BG,
borderRadius: 12,
overflow: 'hidden',
@@ -323,17 +647,38 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
preserveAspectRatio="xMidYMid meet"
style={{ display: 'block' }}
>
{/* Рёбра: зелёные кривые (стиль UNIFI — проводные соединения) */}
<defs>
<linearGradient id="unifiBg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={UNIFI_BG} />
<stop offset="100%" stopColor={UNIFI_BG_DEEP} />
</linearGradient>
<filter id="edgeGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2.4" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="nodeShadow" x="-40%" y="-40%" width="180%" height="180%">
<feDropShadow dx="0" dy="1" stdDeviation="2" floodColor="#020617" floodOpacity="0.55" />
</filter>
</defs>
<rect x="0" y="0" width={size.w} height={size.h} fill="url(#unifiBg)" />
{/* Рёбра: сглаженные линии с цветовой дифференциацией туннеля */}
<g>
{(() => {
const used = [];
const LABEL_W = 100;
const LABEL_H = 18;
const LABEL_GAP = 6;
return edgesWithPing.map((e, idx) => {
const LABEL_W = 118;
const LABEL_H = 22;
const denseMode = routedEdges.length > 14;
return routedEdges.map((e, idx) => {
const dimFactor =
hoveredEdgeKey && hoveredEdgeKey !== e.edgeKey ? 0.25 : 1;
const strokeWidth = hoveredEdgeKey === e.edgeKey ? 3.2 : 2.5;
hoveredEdgeKey && hoveredEdgeKey !== e.edgeKey ? 0.16 : 1;
const strokeWidth = hoveredEdgeKey === e.edgeKey ? 3 : 1.8;
const edgeColor = getTunnelColor(e.tunnelType);
const isFocused =
hoveredEdgeKey === e.edgeKey || selectedEdge?.edgeKey === e.edgeKey;
const baseOpacity = denseMode ? 0.62 : 0.82;
return (
<g
key={e.edgeKey || `${e.from}-${e.to}-${idx}`}
@@ -347,19 +692,19 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
<path
d={pathD(e)}
fill="none"
stroke={UNIFI_GREEN}
stroke={edgeColor}
strokeWidth={strokeWidth}
strokeLinecap="round"
opacity={dimFactor}
opacity={baseOpacity * dimFactor}
filter="url(#edgeGlow)"
/>
{(() => {
const labels = [
e.pingLabel ? { text: e.pingLabel, stale: e.pingStale } : null,
e.speedLabel ? { text: e.speedLabel, stale: e.speedStale } : null,
].filter(Boolean);
if (labels.length === 0) return null;
const totalH = labels.length * LABEL_H + (labels.length - 1) * LABEL_GAP;
const minDist = 70;
const compactOnly = denseMode && !isFocused && !e.stale;
const labelText = compactOnly
? (e.compactEdgeLabel || e.edgeLabel)
: e.edgeLabel;
if (!labelText) return null;
const minDist = denseMode ? 52 : 70;
let level = 0;
let x; let y;
while (level < 10) {
@@ -374,54 +719,51 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
}
used.push({ x, y });
const pad = 8;
const staleColor = '#f59e0b';
const pad = 10;
const isStale = e.stale === true;
const staleColor = UNIFI_WARNING;
const textWidth = labelText.length * (compactOnly ? 5.9 : 6.2);
const staleDotRadius = 4.2;
const staleDotSpace = isStale ? 18 : 0;
const minW = compactOnly ? 76 : LABEL_W;
const maxW = compactOnly ? 160 : 210;
const w = Math.max(minW, Math.min(textWidth + pad * 2 + staleDotSpace, maxW));
const h = isStale ? LABEL_H + 2 : LABEL_H;
const textAnchor = isStale ? 'start' : 'middle';
const textX = isStale ? (x - w / 2 + pad + staleDotSpace) : x;
return (
<g>
{labels.map((label, i) => {
const yy = y + i * (LABEL_H + LABEL_GAP) + LABEL_H / 2;
const isStale = label.stale === true;
const textWidth = label.text.length * 6;
const iconWidth = isStale ? 14 : 0;
const w = Math.max(LABEL_W, Math.min(textWidth + pad * 2 + iconWidth, 180));
const h = LABEL_H + 4;
const textX = x - (iconWidth / 2);
return (
<g key={i}>
<rect
x={x - w / 2}
y={yy - h / 2}
width={w}
height={h}
rx={4}
fill="rgba(15,23,42,0.92)"
stroke={isStale ? staleColor : UNIFI_GREEN}
strokeWidth={1}
strokeDasharray={isStale ? '2,2' : 'none'}
/>
{isStale && (
<circle
cx={x - w / 2 + 10}
cy={yy}
r={5}
fill={staleColor}
opacity={0.9}
/>
)}
<text
x={textX}
y={yy}
textAnchor="middle"
dominantBaseline="middle"
fill={isStale ? staleColor : UNIFI_GREEN}
fontSize={i === 0 ? 12 : 11}
fontWeight={600}
>
{label.text}
</text>
</g>
);
})}
<rect
x={x - w / 2}
y={y - h / 2}
width={w}
height={h}
rx={7}
fill={isFocused ? 'rgba(2,6,23,0.94)' : 'rgba(2,6,23,0.85)'}
stroke={isStale ? staleColor : edgeColor}
strokeWidth={isFocused ? 1.3 : 1}
strokeDasharray={isStale ? '2,2' : 'none'}
/>
{isStale && (
<circle
cx={x - w / 2 + pad + staleDotRadius}
cy={y}
r={staleDotRadius}
fill={staleColor}
opacity={0.95}
/>
)}
<text
x={textX}
y={y}
textAnchor={textAnchor}
dominantBaseline="middle"
fill={isStale ? staleColor : edgeColor}
fontSize={compactOnly ? 10.2 : 11}
fontWeight={600}
>
{labelText}
</text>
</g>
);
})()}
@@ -436,6 +778,7 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
const pos = positions[String(s.ip)];
if (!pos) return null;
const label = s.dns?.split('.')[0] || s.ip || '?';
const typeMeta = getNodeTypeMeta(s.type);
const isDragging = drag?.nodeId === s.ip;
return (
<g
@@ -452,6 +795,7 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
fill={UNIFI_CARD_BG}
stroke={UNIFI_CARD_BORDER}
strokeWidth={1}
filter="url(#nodeShadow)"
/>
<g transform={`translate(${NODE_WIDTH / 2}, ${NODE_HEIGHT / 2})`}>
{/* Флаг страны в левом верхнем углу карточки (картинка, т.к. эмодзи в SVG часто отображаются как буквы) */}
@@ -477,29 +821,50 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
})()}
{/* Иконка сервера в стиле UNIFI: прямоугольник с «портами» */}
<g transform="translate(-10, -16)">
<rect x={2} y={0} width={16} height={12} rx={2} fill="none" stroke={UNIFI_GREEN} strokeWidth={1.5} />
<line x1={5} y1={4} x2={15} y2={4} stroke={UNIFI_GREEN} strokeWidth={1} opacity={0.8} />
<line x1={5} y1={8} x2={15} y2={8} stroke={UNIFI_GREEN} strokeWidth={1} opacity={0.8} />
<rect x={2} y={0} width={16} height={12} rx={2} fill="none" stroke={typeMeta.color} strokeWidth={1.5} />
<line x1={5} y1={4} x2={15} y2={4} stroke={typeMeta.color} strokeWidth={1} opacity={0.8} />
<line x1={5} y1={8} x2={15} y2={8} stroke={typeMeta.color} strokeWidth={1} opacity={0.8} />
</g>
<text
x={0}
y={8}
textAnchor="middle"
fill={UNIFI_TEXT}
fontSize={12}
fontSize={11.5}
fontWeight={600}
>
{label.length > 12 ? label.slice(0, 11) + '…' : label}
{label.length > 14 ? `${label.slice(0, 13)}` : label}
</text>
<text
x={0}
y={22}
textAnchor="middle"
fill={UNIFI_TEXT_MUTED}
fontSize={10}
fontSize={9.5}
>
{s.ip}
</text>
<rect
x={NODE_WIDTH / 2 - 42}
y={-NODE_HEIGHT / 2 + 5}
width={36}
height={14}
rx={7}
fill="rgba(15,23,42,0.9)"
stroke={typeMeta.color}
strokeWidth={1}
/>
<text
x={NODE_WIDTH / 2 - 24}
y={-NODE_HEIGHT / 2 + 15}
textAnchor="middle"
fill={typeMeta.color}
fontSize={8}
fontWeight={700}
letterSpacing={0.5}
>
{typeMeta.label}
</text>
</g>
</g>
);
@@ -514,6 +879,17 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
const sk = speedKey(selectedEdge.raw?.fromKey, selectedEdge.raw?.toKey);
const speed = speedMap[sk] ?? selectedEdge.speed;
const speedStale = speedStaleMap[sk] === true;
const canPingFrom =
fromServer &&
['jumphost', 'home'].includes(String(fromServer.type || '').toLowerCase()) &&
selectedEdge.raw?.internalFromTo;
const canPingTo =
toServer &&
['jumphost', 'home'].includes(String(toServer.type || '').toLowerCase()) &&
selectedEdge.raw?.internalToFrom;
const canPing = Boolean(canPingFrom || canPingTo);
const canSpeed =
Boolean(selectedEdge.raw?.speedTestServerId && selectedEdge.raw?.interfaceName);
return (
<div
className="network-map-edge-modal-backdrop"
@@ -635,6 +1011,32 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
</div>
</div>
)}
<div className="mt-3 d-flex flex-wrap gap-2 justify-content-end">
<button
type="button"
className="btn btn-outline-primary btn-sm"
disabled={!canPing || pingLoading || !onRefreshPingForConnection}
onClick={() => onRefreshPingForConnection && onRefreshPingForConnection(selectedEdge.raw)}
>
<IconRefresh
size={14}
className={pingLoading ? 'spin me-1' : 'me-1'}
/>
{pingLoading ? 'Пинг…' : 'Проверить пинг'}
</button>
<button
type="button"
className="btn btn-outline-primary btn-sm"
disabled={!canSpeed || speedLoading || !onRefreshSpeedForConnection}
onClick={() => onRefreshSpeedForConnection && onRefreshSpeedForConnection(selectedEdge.raw)}
>
<IconRefresh
size={14}
className={speedLoading ? 'spin me-1' : 'me-1'}
/>
{speedLoading ? 'Скорость…' : 'Проверить скорость'}
</button>
</div>
</div>
</div>
</div>
@@ -688,5 +1090,118 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
</div>
</div>
</div>
<div className="row g-3 mt-2">
<div className="col-12 col-xl-6">
<div className="card">
<div className="card-header d-flex align-items-center">
<h3 className="card-title mb-0">Критичные узлы</h3>
<div className="card-actions ms-auto">
<span className="badge bg-red-lt text-red">{criticalNodes.length}</span>
</div>
</div>
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0 table-sm table-striped">
<thead>
<tr>
<th scope="col">Узел</th>
<th scope="col">Тип</th>
<th scope="col">Ping max</th>
<th scope="col">Speed min</th>
<th scope="col">Связи</th>
<th scope="col">Проблемная связь</th>
<th scope="col">Проблемы</th>
</tr>
</thead>
<tbody>
{criticalNodes.length === 0 ? (
<tr>
<td colSpan={7} className="text-muted">Критичных узлов не найдено</td>
</tr>
) : (
criticalNodes.map((n) => (
<tr key={`critical-${n.ip}`}>
<td>
<div className="fw-semibold">{n.nodeLabel}</div>
<div className="text-muted small">{n.ip}</div>
</td>
<td>
<span className="badge bg-secondary-lt text-secondary">
{String(n.type || 'node').toUpperCase()}
</span>
</td>
<td>{n.maxPingMs != null ? `${n.maxPingMs} ms` : '—'}</td>
<td>{n.minSpeedMbps != null ? `${n.minSpeedMbps.toFixed(1)} Мбит/с` : '—'}</td>
<td>{n.linkCount}</td>
<td>
<span className="text-muted">{n.affectedLink}</span>
</td>
<td>
<span className="badge bg-orange-lt text-orange">
{n.issues.slice(0, 2).join(', ')}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
<div className="col-12 col-xl-6">
<div className="card">
<div className="card-header d-flex align-items-center">
<h3 className="card-title mb-0">Требуют внимания</h3>
<div className="card-actions ms-auto">
<span className="badge bg-yellow-lt text-yellow">{warningNodes.length}</span>
</div>
</div>
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0 table-sm table-striped">
<thead>
<tr>
<th scope="col">Узел</th>
<th scope="col">Ping max</th>
<th scope="col">Speed min</th>
<th scope="col">Stale</th>
<th scope="col">Проблемная связь</th>
<th scope="col">Проблемы</th>
</tr>
</thead>
<tbody>
{warningNodes.length === 0 ? (
<tr>
<td colSpan={6} className="text-muted">Узлов с предупреждениями не найдено</td>
</tr>
) : (
warningNodes.map((n) => (
<tr key={`warn-${n.ip}`}>
<td>
<div className="fw-semibold">{n.nodeLabel}</div>
<div className="text-muted small">{n.ip}</div>
</td>
<td>{n.maxPingMs != null ? `${n.maxPingMs} ms` : '—'}</td>
<td>{n.minSpeedMbps != null ? `${n.minSpeedMbps.toFixed(1)} Мбит/с` : '—'}</td>
<td>
<span className={n.staleCount > 0 ? 'badge bg-yellow-lt text-yellow' : 'text-muted'}>
{n.staleCount}
</span>
</td>
<td>
<span className="text-muted">{n.affectedLink}</span>
</td>
<td>
<span className="text-muted">{n.issues.slice(0, 2).join(', ')}</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
</>
);
}
+500
View File
@@ -0,0 +1,500 @@
import { useEffect, useMemo, useState, useCallback } from 'react';
import { IconDatabase, IconRefresh, IconRoute2, IconWand } from '@tabler/icons-react';
import PageHeader from './components/PageHeader.jsx';
import api from './lib/api.js';
import { useNotify } from './components/NotifyProvider.jsx';
const OSPF_COST_STEP = 10;
function moveInArray(arr, fromIndex, toIndex) {
if (fromIndex < 0 || toIndex < 0 || fromIndex >= arr.length || toIndex >= arr.length) return arr;
const copy = [...arr];
const [moved] = copy.splice(fromIndex, 1);
copy.splice(toIndex, 0, moved);
return copy;
}
function isUnknownOspfInterfaceName(name) {
const value = String(name || '').trim();
if (!value) return true;
if (value.startsWith('*')) return true;
if (value.toLowerCase() === 'unknown') return true;
return false;
}
function getOspfRouterIdentity(server) {
if (!server || typeof server !== 'object') {
return { key: 'unknown', label: 'Unknown router' };
}
const host = String(server.mikrotikHost || server.ip || server.dns || server.id || '').trim();
const port = String(server.mikrotikPort || 80).trim();
const user = String(server.mikrotikUser || 'admin').trim();
const key = `${host}::${port}::${user}`;
const label = host || String(server.dns || server.ip || server.id || 'Unknown router');
return { key, label };
}
function buildOspfTemplatesFromRouterResults(routerResults = [], servers = []) {
const serverById = new Map((servers || []).map((s) => [String(s.id || s.ip || s.dns || ''), s]));
const byComposite = new Map();
(routerResults || []).forEach((result) => {
const serverId = String(result?.serverId || '').trim();
if (!serverId) return;
const server = serverById.get(serverId) || {
id: serverId,
ip: String(result?.serverLabel || serverId),
dns: String(result?.serverLabel || ''),
};
const identity = getOspfRouterIdentity(server);
const templates = Array.isArray(result?.templates) ? result.templates : [];
templates.forEach((tpl) => {
const interfaceName = String(tpl?.interfaceName || '').trim();
if (isUnknownOspfInterfaceName(interfaceName)) return;
const area = String(tpl?.area || '').trim() || 'backbone';
const numericCost = Number(tpl?.cost);
const cost = Number.isFinite(numericCost) && numericCost >= 0 ? numericCost : OSPF_COST_STEP;
const compositeKey = `${identity.key}::${area.toLowerCase()}::${interfaceName.toUpperCase()}`;
if (byComposite.has(compositeKey)) return;
byComposite.set(compositeKey, {
key: compositeKey,
rosId: tpl?.id || null,
serverId,
routerKey: identity.key,
routerLabel: identity.label,
interfaceName,
area,
cost,
active: !tpl?.disabled,
});
});
});
return Array.from(byComposite.values());
}
function buildOptimizerProbabilityMap(routeOptimizerData, servers = []) {
const homes = Array.isArray(routeOptimizerData?.homes) ? routeOptimizerData.homes : [];
const byRef = new Map();
(servers || []).forEach((server) => {
[server?.id, server?.ip, server?.dns]
.filter(Boolean)
.forEach((ref) => byRef.set(String(ref), server));
});
const probabilities = {};
homes.forEach((entry) => {
const homeRefCandidates = [
entry?.home?.id,
entry?.home?.ip,
entry?.home?.dns,
].filter(Boolean).map(String);
const homeServer = homeRefCandidates.map((ref) => byRef.get(ref)).find(Boolean);
if (!homeServer) return;
const identity = getOspfRouterIdentity(homeServer);
const candidates = Array.isArray(entry?.homeToJumphostCandidates)
? [...entry.homeToJumphostCandidates]
: [];
candidates.sort((a, b) =>
Number(b?.probabilityOptimal || 0) - Number(a?.probabilityOptimal || 0) ||
Number(b?.score || 0) - Number(a?.score || 0)
);
const uniqueIface = [];
const seen = new Set();
candidates.forEach((candidate) => {
const iface = String(candidate?.interfaceName || '').trim();
if (!iface) return;
const key = iface.toUpperCase();
if (seen.has(key)) return;
seen.add(key);
uniqueIface.push(candidate);
});
uniqueIface.forEach((candidate, _idx) => {
const iface = String(candidate?.interfaceName || '').trim();
const hintKey = `${identity.key}::${iface.toUpperCase()}`;
const probabilityOptimal = Number(candidate?.probabilityOptimal || 0);
const prev = probabilities[hintKey];
if (prev == null || probabilityOptimal > prev) {
probabilities[hintKey] = probabilityOptimal;
}
});
});
return probabilities;
}
export default function OspfToolsPage() {
const notify = useNotify();
const [servers, setServers] = useState([]);
const [templates, setTemplates] = useState([]);
const [loading, setLoading] = useState(false);
const [optimizerLoading, setOptimizerLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [dragging, setDragging] = useState(null);
const [optimizerProbabilities, setOptimizerProbabilities] = useState({});
const groupedByRouter = useMemo(() => {
const grouped = {};
(templates || []).forEach((item) => {
const sid = String(item?.serverId || '');
const server = servers.find((s) => String(s.id || s.ip || s.dns || '') === sid);
const identity = getOspfRouterIdentity(server || { id: sid, ip: sid });
if (!grouped[identity.key]) {
grouped[identity.key] = {
routerKey: identity.key,
routerLabel: identity.label,
representativeServerId: sid,
areas: {},
};
}
if (!grouped[identity.key].areas[item.area]) grouped[identity.key].areas[item.area] = [];
grouped[identity.key].areas[item.area].push(item);
});
return Object.values(grouped).map((group) => ({
...group,
areas: Object.entries(group.areas)
.map(([area, areaItems]) => ({
area,
items: [...areaItems].sort((a, b) => Number(a?.cost || 0) - Number(b?.cost || 0)),
}))
.sort((a, b) => a.area.localeCompare(b.area)),
}));
}, [templates, servers]);
const optimalHints = useMemo(() => {
const hints = {};
groupedByRouter.forEach((routerGroup) => {
(routerGroup.areas || []).forEach((areaGroup) => {
const ranked = [...(areaGroup.items || [])]
.map((item) => {
const ifaceKey = `${routerGroup.routerKey}::${String(item.interfaceName || '').toUpperCase()}`;
const probabilityOptimal = Number(optimizerProbabilities[ifaceKey] || 0);
return { item, probabilityOptimal };
})
.sort((a, b) =>
Number(b.probabilityOptimal || 0) - Number(a.probabilityOptimal || 0) ||
String(a.item.interfaceName || '').localeCompare(String(b.item.interfaceName || ''))
);
ranked.forEach(({ item, probabilityOptimal }, idx) => {
const hintKey = `${routerGroup.routerKey}::${areaGroup.area}::${String(item.interfaceName || '').toUpperCase()}`;
hints[hintKey] = {
optimalCost: (idx + 1) * OSPF_COST_STEP,
probabilityOptimal,
};
});
});
});
return hints;
}, [groupedByRouter, optimizerProbabilities]);
const routerColumnClass = groupedByRouter.length > 1 ? 'col-12 col-xxl-6' : 'col-12';
const loadServers = async () => {
try {
const response = await api.get('/servers');
setServers(Array.isArray(response.data) ? response.data : []);
} catch (error) {
console.error('Failed to load servers:', error);
setServers([]);
}
};
const loadOspf = useCallback(async ({ silent = false } = {}) => {
setLoading(true);
try {
const response = await api.get('/mikrotik/ospf-interface-templates');
const results = Array.isArray(response.data?.results) ? response.data.results : [];
const errors = Array.isArray(response.data?.errors) ? response.data.errors : [];
setTemplates(buildOspfTemplatesFromRouterResults(results, servers));
if (!silent) {
if (results.length > 0) notify.success(`OSPF загружен: ${results.length} роутер(ов)`);
else notify.warning('OSPF Interface Template не найдены');
if (errors.length > 0) notify.warning(`Ошибки чтения OSPF: ${errors.length}`);
}
} catch (error) {
console.error('Failed to load OSPF:', error);
if (!silent) notify.error('Не удалось загрузить OSPF с MikroTik');
} finally {
setLoading(false);
}
}, [servers, notify]);
const loadOptimizerHints = useCallback(async ({ silent = false } = {}) => {
setOptimizerLoading(true);
try {
const response = await api.get('/route-optimizer', { timeout: 30000 });
const probabilities = buildOptimizerProbabilityMap(response?.data, servers);
setOptimizerProbabilities(probabilities);
return probabilities;
} catch (error) {
console.error('Failed to load route optimizer hints:', error);
setOptimizerProbabilities({});
if (!silent) notify.warning('Не удалось загрузить рекомендации из оптимизатора маршрутов');
return {};
} finally {
setOptimizerLoading(false);
}
}, [servers, notify]);
useEffect(() => {
loadServers();
}, []);
useEffect(() => {
if (servers.length === 0) return;
loadOspf({ silent: true });
loadOptimizerHints({ silent: true });
}, [servers.length, loadOspf, loadOptimizerHints]);
const onDrop = (routerKey, area, targetKey) => {
if (!dragging || dragging.routerKey !== routerKey || dragging.area !== area) return;
if (dragging.key === targetKey) {
setDragging(null);
return;
}
setTemplates((prev) => {
const current = prev
.filter((item) => item.routerKey === routerKey && String(item.area) === String(area))
.sort((a, b) => Number(a?.cost || 0) - Number(b?.cost || 0));
const fromIndex = current.findIndex((item) => item.key === dragging.key);
const toIndex = current.findIndex((item) => item.key === targetKey);
if (fromIndex < 0 || toIndex < 0) return prev;
const reordered = moveInArray(current, fromIndex, toIndex).map((item, idx) => ({
...item,
cost: (idx + 1) * OSPF_COST_STEP,
}));
const other = prev.filter(
(item) => !(item.routerKey === routerKey && String(item.area) === String(area))
);
return [...other, ...reordered];
});
setDragging(null);
};
const optimizeCosts = async () => {
let hints = optimalHints;
if (!hints || Object.keys(hints).length === 0) {
const probabilities = await loadOptimizerHints({ silent: true });
const refreshedHints = {};
groupedByRouter.forEach((routerGroup) => {
(routerGroup.areas || []).forEach((areaGroup) => {
const ranked = [...(areaGroup.items || [])]
.map((item) => {
const ifaceKey = `${routerGroup.routerKey}::${String(item.interfaceName || '').toUpperCase()}`;
const probabilityOptimal = Number((probabilities || {})[ifaceKey] || 0);
return { item, probabilityOptimal };
})
.sort((a, b) =>
Number(b.probabilityOptimal || 0) - Number(a.probabilityOptimal || 0) ||
String(a.item.interfaceName || '').localeCompare(String(b.item.interfaceName || ''))
);
ranked.forEach(({ item, probabilityOptimal }, idx) => {
const hintKey = `${routerGroup.routerKey}::${areaGroup.area}::${String(item.interfaceName || '').toUpperCase()}`;
refreshedHints[hintKey] = {
optimalCost: (idx + 1) * OSPF_COST_STEP,
probabilityOptimal,
};
});
});
});
hints = refreshedHints;
}
if (!hints || Object.keys(hints).length === 0) {
notify.warning('Нет рекомендаций оптимизатора для расчета cost');
return;
}
let changedCount = 0;
let hintedCount = 0;
const nextTemplates = (templates || []).map((item) => {
const hintKey = `${item.routerKey}::${item.area}::${String(item.interfaceName || '').toUpperCase()}`;
const hint = hints[hintKey];
if (!hint) return item;
hintedCount += 1;
const nextCost = Number(hint.optimalCost);
if (!Number.isFinite(nextCost) || nextCost < 0 || Number(item.cost) === nextCost) return item;
changedCount += 1;
return { ...item, cost: nextCost };
});
setTemplates(nextTemplates);
if (hintedCount === 0) {
notify.warning('Для интерфейсов на странице нет рекомендаций оптимизатора');
return;
}
if (changedCount === 0) {
notify.success('Текущие cost уже соответствуют оптимальным рекомендациям');
return;
}
notify.success(`Оптимизация применена: обновлено cost у ${changedCount} интерфейсов`);
};
const saveOspf = async () => {
if (groupedByRouter.length === 0) {
notify.warning('Нет OSPF данных для сохранения');
return;
}
setSaving(true);
try {
const payload = {
servers: groupedByRouter.map((group) => ({
serverId: group.representativeServerId,
templates: group.areas.flatMap((areaGroup) =>
areaGroup.items.map((item) => ({
rosId: item.rosId || null,
interfaceName: item.interfaceName,
area: item.area,
cost: item.cost,
}))
),
})),
};
const response = await api.post('/mikrotik/ospf-interface-templates/apply', payload);
const summary = response.data?.summary || {};
notify.success(`OSPF сохранен: обновлено ${summary.updated || 0}, пропущено ${summary.skipped || 0}`);
} catch (error) {
console.error('Failed to save OSPF:', error);
notify.error('Не удалось сохранить OSPF в MikroTik');
} finally {
setSaving(false);
}
};
return (
<div className="ospf-tools-page">
<PageHeader
title="Инструменты OSPF"
icon={<IconRoute2 size={24} />}
actions={(
<div className="btn-list">
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => {
loadOspf();
loadOptimizerHints({ silent: true });
}}
disabled={loading || optimizerLoading}
>
<IconRefresh size={16} className={`me-1 ${(loading || optimizerLoading) ? 'spin' : ''}`} />
Загрузить из MikroTik
</button>
<button
type="button"
className="btn btn-outline-primary"
onClick={optimizeCosts}
disabled={loading || optimizerLoading || groupedByRouter.length === 0}
title="Проставить интерфейсам потенциально оптимальный cost"
>
<IconWand size={16} className="me-1" />
Оптимизировать
</button>
<button
type="button"
className="btn btn-primary"
onClick={saveOspf}
disabled={saving || groupedByRouter.length === 0}
>
<IconDatabase size={16} className={`me-1 ${saving ? 'spin' : ''}`} />
Сохранить OSPF
</button>
</div>
)}
/>
<div className="card">
<div className="card-header">
<div className="text-muted small">
Данные OSPF берутся напрямую с MikroTik. Группировка: роутер Area интерфейсы.
Чем меньше cost, тем выше приоритет. Рядом отображается потенциально оптимальный cost из оптимизатора маршрутов.
</div>
</div>
<div className="card-body">
{groupedByRouter.length === 0 ? (
<div className="text-muted">OSPF Interface Template не найдены.</div>
) : (
<div className="row g-3">
{groupedByRouter.map((routerGroup) => (
<div key={routerGroup.routerKey} className={routerColumnClass}>
<div className="card card-sm border">
<div className="card-header fw-semibold">{routerGroup.routerLabel}</div>
<div className="card-body p-0">
{routerGroup.areas.map((areaGroup) => (
<div key={`${routerGroup.routerKey}-${areaGroup.area}`} className="border-top">
<div className="px-3 py-2 bg-light text-muted small fw-semibold">
Area: {areaGroup.area}
</div>
<div className="list-group list-group-flush">
{areaGroup.items.map((item) => (
(() => {
const hint = optimalHints[`${routerGroup.routerKey}::${areaGroup.area}::${String(item.interfaceName || '').toUpperCase()}`];
const hintMatches = hint && Number(hint.optimalCost) === Number(item.cost);
return (
<div
key={item.key}
className="list-group-item"
draggable
onDragStart={() => setDragging({
routerKey: routerGroup.routerKey,
area: areaGroup.area,
key: item.key,
})}
onDragOver={(e) => e.preventDefault()}
onDrop={() => onDrop(routerGroup.routerKey, areaGroup.area, item.key)}
onDragEnd={() => setDragging(null)}
style={{ cursor: 'grab' }}
>
<div className="d-flex align-items-center justify-content-between gap-2 flex-wrap">
<div className="d-flex align-items-center gap-2 flex-wrap">
<span className="text-muted small">::</span>
<code>{item.interfaceName}</code>
<span className={`badge ${item.active ? 'bg-green-lt text-green' : 'bg-secondary-lt text-secondary'}`}>
{item.active ? 'Активен' : 'Неактивен'}
</span>
</div>
<div className="d-flex align-items-center gap-2 ms-auto">
{hint && (
<span
className={`badge ${hintMatches ? 'bg-green-lt text-green' : 'bg-yellow-lt text-yellow'}`}
title={`Вероятность оптимальности: ${hint.probabilityOptimal}%`}
>
opt cost {hint.optimalCost}
</span>
)}
<span className="badge bg-primary-lt text-primary">cost {item.cost}</span>
</div>
</div>
</div>
);
})()
))}
</div>
</div>
))}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
+2 -2
View File
@@ -308,12 +308,12 @@ export default function PingServicesManager() {
</tr>
) : (
filtered.map((item) => {
const iconInfo = getIconById(item.icon);
const { Icon } = getIconById(item.icon);
return (
<tr key={item.id}>
<td>
<span className={`avatar avatar-sm bg-${item.color}-lt text-${item.color} rounded d-inline-flex align-items-center justify-content-center`}>
<iconInfo.Icon size={18} stroke={1.5} />
<Icon size={18} stroke={1.5} />
</span>
</td>
<td><code className="small">{item.id}</code></td>
+3 -1
View File
@@ -8,6 +8,8 @@ import {
} from '@tabler/icons-react';
import PageHeader from './components/PageHeader.jsx';
const REQUEST_TIMEOUT_MS = 15000;
function formatBytes(bytes) {
if (bytes == null || bytes === 0) return '0 B';
const k = 1024;
@@ -149,7 +151,7 @@ export default function ResourceStatsPage() {
const fetchStats = useCallback(async (abortSignal) => {
setLoading(true);
setError(null);
const opts = abortSignal ? { signal: abortSignal } : {};
const opts = { timeout: REQUEST_TIMEOUT_MS, noRetry: true, ...(abortSignal ? { signal: abortSignal } : {}) };
try {
const res = await api.get('/resources/stats', opts);
const data = res?.data;
+531
View File
@@ -0,0 +1,531 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
IconRoute2,
IconRefresh,
IconBrain,
IconAlertCircle,
} from '@tabler/icons-react';
import api from './lib/api.js';
import PageHeader from './components/PageHeader.jsx';
import { countryToFlag } from './utils/serverUtils.js';
function MetricBadge({ label, value, tone = 'secondary' }) {
return (
<span className={`badge bg-${tone}-lt text-${tone}`}>
{label}: {value}
</span>
);
}
function fmtProb(v) {
return typeof v === 'number' ? `${v}%` : '—';
}
function fmtSpeed(dl, ul, fallback) {
if (typeof dl === 'number' || typeof ul === 'number') {
return `DL: ${dl ?? '—'} Mbps, UL: ${ul ?? '—'} Mbps`;
}
return `speed: ${fallback ?? '—'} Mbps`;
}
function routeNodeKey(node) {
return String(node?.id || node?.dns || node?.ip || node?.label || '').trim();
}
const OPT_TABLE_STYLE = { tableLayout: 'fixed', width: '100%' };
const OPT_COLS = ['24%', '20%', '22%', '18%', '16%'];
function OptimizerColgroup() {
return (
<colgroup>
{OPT_COLS.map((w) => (
<col key={w} style={{ width: w }} />
))}
</colgroup>
);
}
export default function RouteOptimizerPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [data, setData] = useState(null);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const res = await api.get('/route-optimizer', { timeout: 30000 });
setData(res?.data || null);
} catch (e) {
console.error('[RouteOptimizerPage] load failed:', e);
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить маршруты');
setData(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
const id = setInterval(load, 30000);
return () => clearInterval(id);
}, [load]);
const homes = Array.isArray(data?.homes) ? data.homes : [];
const communityOptimization = Array.isArray(data?.communityOptimizationByJumphost)
? data.communityOptimizationByJumphost
: [];
const communityFullRoutes = (() => {
const out = [];
const homesSafe = Array.isArray(homes) ? homes : [];
const byJh = new Map();
homesSafe.forEach((h) => {
const home = h?.home || null;
const routes = Array.isArray(h?.fullRouteCandidates) ? h.fullRouteCandidates : [];
routes.forEach((r) => {
const jhKey = routeNodeKey(r?.jumphost);
const exKey = routeNodeKey(r?.exit);
if (!jhKey || !exKey) return;
const mapKey = `${jhKey}:::${exKey}`;
const prev = byJh.get(mapKey);
if (!prev || Number(r?.score || 0) > Number(prev?.route?.score || 0)) {
byJh.set(mapKey, { home, route: r });
}
});
});
communityOptimization.forEach((item) => {
const jh = item?.jumphost || null;
const jhKey = routeNodeKey(jh);
const rows = Array.isArray(item?.recommendations) ? item.recommendations : [];
rows.forEach((r) => {
const rec = r?.recommended || null;
const exKey = routeNodeKey(rec?.exit);
if (!rec || !jhKey || !exKey) return;
const bestFull = byJh.get(`${jhKey}:::${exKey}`);
if (!bestFull?.route) return;
out.push({
community: r.community,
communityInfo: r.communityInfo,
home: bestFull.home || bestFull.route.home,
jumphost: bestFull.route.jumphost || jh,
exit: bestFull.route.exit || rec.exit,
fullRouteScore: bestFull.route.score,
fullRouteProbability: bestFull.route.probabilityOptimal,
fullRouteConfidence: bestFull.route.confidence,
homeToJumphost: bestFull.route.homeToJumphost || null,
jumphostToExit: bestFull.route.jumphostToExit || null,
recommendedGateway: rec.gateway || null,
tunnelGateway: rec.tunnelGateway || null,
pinnedBySettings: Boolean(r?.pinnedBySettings),
shouldSwitch: Boolean(r?.shouldSwitch),
});
});
});
return out.sort((a, b) => Number(b.fullRouteScore || 0) - Number(a.fullRouteScore || 0));
})();
return (
<div>
<PageHeader
title="Локальный ИИ: оптимизатор маршрутов"
icon={<IconBrain size={24} />}
meta="Rule-based выбор маршрутов: Home → Jumphost → Exit"
actions={(
<div className="btn-list">
<Link to="/settings#route-ai" className="btn btn-outline-secondary">
Настройки AI
</Link>
<button type="button" className="btn btn-outline-primary" onClick={load} disabled={loading}>
<IconRefresh className={loading ? 'spin me-2' : 'me-2'} size={18} />
{loading ? 'Обновление...' : 'Обновить'}
</button>
</div>
)}
/>
{error && (
<div className="alert alert-danger d-flex align-items-center">
<IconAlertCircle className="me-2" size={18} />
{error}
</div>
)}
{loading && !data && (
<div className="text-muted py-4">Расчёт маршрутов...</div>
)}
{!loading && !error && homes.length === 0 && (
<div className="alert alert-info">
Нет доступных маршрутов. Проверьте типы серверов (`home`, `jumphost`, `exit`) и `network-config.tunnelInterfaces`.
</div>
)}
<div className="row g-3">
{homes.map((entry) => {
const best = entry.bestFullRoute;
const bestHomeToJh = entry.bestHomeToJumphost || null;
const home = entry.home || {};
const hjCandidates = Array.isArray(entry.homeToJumphostCandidates)
? entry.homeToJumphostCandidates
: [];
const routes = Array.isArray(entry.fullRouteCandidates) ? entry.fullRouteCandidates : [];
return (
<div className="col-12" key={home.id || home.ip || home.dns || home.label || 'home'}>
<div className="card">
<div className="card-header d-flex flex-wrap gap-2 align-items-center justify-content-between">
<h3 className="card-title mb-0 d-flex align-items-center">
<IconRoute2 className="me-2" size={18} />
{home.label || home.dns || home.ip || 'Home'}
</h3>
{best ? (
<MetricBadge
label="Вероятность оптимальности лучшего маршрута"
value={`${best.probabilityOptimal}%`}
tone="green"
/>
) : (
<MetricBadge label="Статус" value="Нет полного маршрута до Exit" tone="orange" />
)}
</div>
<div className="card-body">
<div className="mb-4">
<div className="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
<div className="fw-semibold">Оптимальный маршрут Home Jumphost</div>
{bestHomeToJh ? (
<MetricBadge
label="Вероятность оптимальности"
value={`${bestHomeToJh.probabilityOptimal}%`}
tone="green"
/>
) : (
<MetricBadge label="Статус" value="Нет кандидатов Home→Jumphost" tone="orange" />
)}
</div>
<div className="table-responsive">
<table className="table table-vcenter card-table align-middle" style={OPT_TABLE_STYLE}>
<OptimizerColgroup />
<thead>
<tr>
<th>Jumphost</th>
<th>Интерфейс</th>
<th>Ping / Speed</th>
<th>Score / Confidence</th>
<th>Вероятность</th>
</tr>
</thead>
<tbody>
{hjCandidates.length === 0 ? (
<tr>
<td colSpan={5} className="text-muted">
Кандидаты HomeJumphost не найдены.
</td>
</tr>
) : hjCandidates.map((c) => (
<tr key={c.id}>
<td className="fw-semibold text-break">{c.jumphost?.label || c.jumphost?.dns || c.jumphost?.ip || 'jumphost'}</td>
<td className="small text-muted text-break">{c.interfaceName || '—'}</td>
<td className="small text-muted text-break">
ping: {c.pingMs ?? '—'} ms, {fmtSpeed(c.speedDownloadMbps, c.speedUploadMbps, c.speedMbps)}
</td>
<td className="small">
{c.score} / {c.confidence}
</td>
<td>
<span className={`badge ${bestHomeToJh?.id === c.id ? 'bg-green-lt text-green' : 'bg-secondary-lt text-secondary'}`}>
{fmtProb(c.probabilityOptimal)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{best ? (
<div className="mb-3">
<div className="fw-semibold mb-2">Оптимальный маршрут</div>
<div className="d-flex flex-wrap gap-2">
<span className="badge bg-blue-lt text-blue">{best.home?.label || 'home'}</span>
<span className="text-muted"></span>
<span className="badge bg-indigo-lt text-indigo">{best.jumphost?.label || 'jumphost'}</span>
<span className="text-muted"></span>
<span className="badge bg-orange-lt text-orange">{best.exit?.label || 'exit'}</span>
<MetricBadge label="Score" value={best.score} tone="primary" />
<MetricBadge label="Confidence" value={best.confidence} tone="secondary" />
</div>
</div>
) : null}
<div className="fw-semibold mb-2">Полные маршруты Home Jumphost Exit</div>
<div className="table-responsive">
<table className="table table-vcenter card-table align-middle" style={OPT_TABLE_STYLE}>
<OptimizerColgroup />
<thead>
<tr>
<th>Маршрут</th>
<th>HomeJumphost</th>
<th>JumphostExit</th>
<th>Score / Confidence</th>
<th>Вероятность</th>
</tr>
</thead>
<tbody>
{routes.length === 0 ? (
<tr>
<td colSpan={5} className="text-muted">
Полные маршруты до exit-ноды не найдены.
</td>
</tr>
) : routes.map((r) => (
<tr key={r.id}>
<td className="text-break">
<span className="fw-semibold">{r.home?.label}</span>
<span className="text-muted"> </span>
<span className="fw-semibold">{r.jumphost?.label}</span>
<span className="text-muted"> </span>
<span className="fw-semibold">{r.exit?.label}</span>
</td>
<td className="small text-muted text-break">
ping: {r.homeToJumphost?.pingMs ?? '—'} ms, {fmtSpeed(
r.homeToJumphost?.speedDownloadMbps,
r.homeToJumphost?.speedUploadMbps,
r.homeToJumphost?.speedMbps
)}
</td>
<td className="small text-muted text-break">
ping: {r.jumphostToExit?.pingMs ?? '—'} ms, {fmtSpeed(
r.jumphostToExit?.speedDownloadMbps,
r.jumphostToExit?.speedUploadMbps,
r.jumphostToExit?.speedMbps
)}
</td>
<td className="small">{r.score} / {r.confidence ?? '—'}</td>
<td>
<span className="badge bg-green-lt text-green">{r.probabilityOptimal}%</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
})}
</div>
<div className="mt-4">
<div className="card">
<div className="card-header">
<h3 className="card-title mb-0">Оптимизированные маршруты Home -&gt; JumpHost -&gt; Exit по BGP community</h3>
</div>
<div className="card-body">
<div className="text-muted small mb-3">
Таблица показывает лучший полный маршрут для каждой community с учетом рекомендованного
gateway на Jumphost.
</div>
<div className="table-responsive mb-0">
<table className="table table-vcenter card-table align-middle" style={OPT_TABLE_STYLE}>
<OptimizerColgroup />
<thead>
<tr>
<th>Community</th>
<th>Маршрут (Home -&gt; JumpHost -&gt; Exit)</th>
<th>Сегменты</th>
<th>Gateway / Действие</th>
<th>Вероятность</th>
</tr>
</thead>
<tbody>
{communityFullRoutes.length === 0 ? (
<tr>
<td colSpan={5} className="text-muted">Недостаточно данных для построения полных маршрутов по community.</td>
</tr>
) : communityFullRoutes.map((r, idx) => (
<tr key={`${r.community}-${routeNodeKey(r.home)}-${routeNodeKey(r.jumphost)}-${routeNodeKey(r.exit)}-${idx}`}>
<td className="text-break">
<div className="fw-semibold">{r.community}</div>
{(r.communityInfo?.name || r.communityInfo?.description) && (
<div className="small text-muted">{r.communityInfo?.name || r.communityInfo?.description}</div>
)}
</td>
<td className="small text-break">
<span className="fw-semibold">{r.home?.label || r.home?.dns || r.home?.ip || 'home'}</span>
<span className="text-muted"> </span>
<span className="fw-semibold">{r.jumphost?.label || r.jumphost?.dns || r.jumphost?.ip || 'jumphost'}</span>
<span className="text-muted"> </span>
<span className="fw-semibold">{r.exit?.label || r.exit?.dns || r.exit?.ip || 'exit'}</span>
</td>
<td className="small text-break">
<div>
HJ: ping {r.homeToJumphost?.pingMs ?? '—'} ms, {fmtSpeed(
r.homeToJumphost?.speedDownloadMbps,
r.homeToJumphost?.speedUploadMbps,
r.homeToJumphost?.speedMbps
)}
</div>
<div className="text-muted">
JE: ping {r.jumphostToExit?.pingMs ?? '—'} ms, {fmtSpeed(
r.jumphostToExit?.speedDownloadMbps,
r.jumphostToExit?.speedUploadMbps,
r.jumphostToExit?.speedMbps
)}
</div>
</td>
<td className="small text-break">
<div>{r.recommendedGateway || '—'}</div>
{r.tunnelGateway && <div className="text-muted">{'->'} {r.tunnelGateway}</div>}
<div className="mt-1">
{r.pinnedBySettings ? (
<span className="badge bg-blue-lt text-blue">pin</span>
) : r.shouldSwitch ? (
<span className="badge bg-orange-lt text-orange">switch</span>
) : (
<span className="badge bg-green-lt text-green">ok</span>
)}
</div>
</td>
<td className="small">
<div>{fmtProb(r.fullRouteProbability)}</div>
<div className="text-muted">score {r.fullRouteScore} / conf {r.fullRouteConfidence ?? '—'}</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div className="mt-4">
<h3 className="mb-3">Оптимизация по community и filters</h3>
<div className="text-muted small mb-3">
Рекомендации строятся на основе ваших <code>server-filters</code> (связки
<code> community -&gt; gateway</code>) и текущих метрик канала
<code> jumphost -&gt; exit</code>.
</div>
{communityOptimization.length === 0 ? (
<div className="alert alert-info">Нет данных по <code>server-filters</code> для jumphost.</div>
) : (
<div className="row g-3">
{communityOptimization.map((item) => {
const jh = item.jumphost || {};
const rows = Array.isArray(item.recommendations) ? item.recommendations : [];
return (
<div className="col-12" key={jh.id || jh.ip || jh.dns || jh.label || 'jumphost'}>
<div className="card">
<div className="card-header d-flex flex-wrap align-items-center justify-content-between gap-2">
<h3 className="card-title mb-0">
Jumphost: {jh.label || jh.dns || jh.ip || 'unknown'}
</h3>
<MetricBadge label="Communities" value={rows.length} tone="blue" />
</div>
<div className="card-body">
<div className="table-responsive">
<table className="table table-vcenter card-table align-middle" style={OPT_TABLE_STYLE}>
<OptimizerColgroup />
<thead>
<tr>
<th>Community</th>
<th>Текущий (рекурсивный -&gt; туннель)</th>
<th>Рекомендуемый (рекурсивный -&gt; туннель)</th>
<th>Действие</th>
<th>Вероятность (тек/реком)</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={5} className="text-muted">
Для этого jumphost нет фильтров.
</td>
</tr>
) : rows.map((r) => (
<tr key={`${jh.label}-${r.community}`}>
<td className="text-break">
<div className="fw-semibold">{r.community}</div>
{(r.communityInfo?.name || r.communityInfo?.description) && (
<div className="small text-muted">
{r.communityInfo?.name || r.communityInfo?.description}
</div>
)}
</td>
<td className="small text-break">
<div>{r.current?.gateway || r.currentGateway || '—'}</div>
{r.current?.tunnelGateway && (
<div className="text-muted">
{'->'} {r.current.tunnelGateway}
{r.current.distance != null ? ` (dist ${r.current.distance})` : ''}
</div>
)}
{(r.current?.interfaceName || r.current?.exit?.country) && (
<div className="text-muted">
{r.current?.exit?.country ? `${countryToFlag(r.current.exit.country)} ` : ''}
{r.current?.interfaceName ? `iface: ${r.current.interfaceName}` : ''}
</div>
)}
</td>
<td className="small text-break">
<div>{r.recommended?.gateway || '—'}</div>
{r.pinnedBySettings && (
<div className="text-muted">
pin: {r.pinnedGateway || '—'}
</div>
)}
{r.recommended?.tunnelGateway && (
<div className="text-muted">
{'->'} {r.recommended.tunnelGateway}
{r.recommended.distance != null ? ` (dist ${r.recommended.distance})` : ''}
</div>
)}
{(r.recommended?.interfaceName || r.recommended?.exit?.country) && (
<div className="text-muted">
{r.recommended?.exit?.country ? `${countryToFlag(r.recommended.exit.country)} ` : ''}
{r.recommended?.interfaceName ? `iface: ${r.recommended.interfaceName}` : ''}
</div>
)}
</td>
<td className="text-break">
{r.current == null && r.recommended == null ? (
<span className="badge bg-secondary-lt text-secondary">Недостаточно данных</span>
) : r.pinnedBySettings && !r.pinnedGatewayResolved ? (
<span className="badge bg-yellow-lt text-yellow">Pin задан, но gateway не найден</span>
) : r.pinnedBySettings && r.shouldSwitch ? (
<span className="badge bg-orange-lt text-orange">Принудительный switch (pin)</span>
) : r.pinnedBySettings ? (
<span className="badge bg-blue-lt text-blue">Зафиксировано (pin)</span>
) : r.current == null ? (
<span className="badge bg-orange-lt text-orange">Текущий gateway не сопоставлен</span>
) : r.shouldSwitch ? (
<span className="badge bg-orange-lt text-orange">Рекомендовано переключить</span>
) : (
<span className="badge bg-green-lt text-green">Оставить текущий</span>
)}
</td>
<td className="small">
{fmtProb(r.current?.probabilityOptimal)} / {fmtProb(r.recommended?.probabilityOptimal)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
+20 -19
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { useState, useEffect, useMemo } from 'react';
import api from './lib/api.js';
import ConfirmDialog from './components/ConfirmDialog.jsx';
import ConfirmModal from './components/ConfirmModal.jsx';
@@ -147,6 +147,7 @@ function ServerManager() {
fetchConnections(signal);
fetchNetworkConfig(signal);
return () => controller.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps -- init-only, fetch functions defined below
}, []);
useEffect(() => {
@@ -303,7 +304,7 @@ function ServerManager() {
useEffect(() => {
if (currentPage > totalPages && totalPages >= 1) setCurrentPage(1);
}, [viewMode, totalPages]);
}, [viewMode, totalPages, currentPage]);
// === Handlers ===
const handleSort = (field) => {
@@ -501,7 +502,7 @@ function ServerManager() {
return `${urlSettings.baseUrl}?${params.toString()}`;
};
const handleGenerateLink = (server) => {
const _handleGenerateLink = (server) => {
setLinkGeneratorServer(server);
setLinkGeneratorOpen(true);
};
@@ -511,7 +512,7 @@ function ServerManager() {
setLinkGeneratorServer(null);
};
const applyPreset = (name) => {
const _applyPreset = (name) => {
const p = presets[name];
if (!p) return;
setUrlSettings(prev => ({ ...prev, ...p }));
@@ -529,7 +530,7 @@ function ServerManager() {
try {
document.execCommand('copy');
notify.success('Скопировано в буфер обмена!');
} catch (err) {
} catch (_err) {
notify.error('Не удалось скопировать');
}
document.body.removeChild(textArea);
@@ -541,14 +542,14 @@ function ServerManager() {
await navigator.clipboard.writeText(text);
notify.success('Скопировано в буфер обмена!');
return;
} catch (err) {
} catch (_err) {
// fallback
}
}
fallbackCopyToClipboard(text);
};
const quickCopyLink = async (server) => {
const _quickCopyLink = async (server) => {
const url = generateServerUrl(server);
await copyToClipboard(url);
};
@@ -800,8 +801,8 @@ function ServerManager() {
{/* Toolbar */}
<div className="card mb-3">
<div className="card-body py-2">
<div className="d-flex align-items-center justify-content-between">
<div className="d-flex align-items-center gap-2">
<div className="d-flex flex-column flex-sm-row flex-wrap align-items-stretch align-items-sm-center justify-content-between gap-2">
<div className="d-flex flex-wrap align-items-center gap-2">
{/* Открыть фильтры (offcanvas) */}
<button
type="button"
@@ -1087,22 +1088,22 @@ function ServerManager() {
<h3 className="card-title mb-0">Добавить связь между серверами</h3>
</div>
<div className="card-body">
<div className="row g-3">
<div className="col-md-6">
<div className="row g-2 g-md-3">
<div className="col-12 col-md-6">
<label className="form-label">Сервер A</label>
<select className="form-select" value={newConnection.from} onChange={e => setNewConnection({ ...newConnection, from: e.target.value })}>
<option value="">Выберите сервер</option>
{servers.map(s => <option key={s.ip} value={s.ip}>{s.ip} ({s.dns})</option>)}
</select>
</div>
<div className="col-md-6">
<div className="col-12 col-md-6">
<label className="form-label">Сервер B</label>
<select className="form-select" value={newConnection.to} onChange={e => setNewConnection({ ...newConnection, to: e.target.value })}>
<option value="">Выберите сервер</option>
{servers.map(s => <option key={s.ip} value={s.ip}>{s.ip} ({s.dns})</option>)}
</select>
</div>
<div className="col-md-4">
<div className="col-12 col-md-4">
<label className="form-label">Тип туннеля</label>
<select className="form-select" value={newConnection.tunnelType} onChange={e => setNewConnection({ ...newConnection, tunnelType: e.target.value })}>
<option value="GRE">GRE</option>
@@ -1111,11 +1112,11 @@ function ServerManager() {
<option value="OpenVPN">OpenVPN</option>
</select>
</div>
<div className="col-md-4">
<div className="col-12 col-md-4">
<label className="form-label">IP туннеля A</label>
<input className="form-control" value={newConnection.ipA} onChange={e => setNewConnection({ ...newConnection, ipA: e.target.value })} placeholder="10.10.100.1" />
</div>
<div className="col-md-4">
<div className="col-12 col-md-4">
<label className="form-label">IP туннеля B</label>
<input className="form-control" value={newConnection.ipB} onChange={e => setNewConnection({ ...newConnection, ipB: e.target.value })} placeholder="10.10.100.2" />
</div>
@@ -1255,8 +1256,8 @@ function ServerManager() {
<span className="badge bg-blue-lt text-blue">{newConnection.to || 'B'}</span>
</div>
</div>
<div className="row g-3">
<div className="col-md-4">
<div className="row g-2 g-md-3">
<div className="col-12 col-md-4">
<FormField
label="Туннель"
name="tunnelType"
@@ -1272,7 +1273,7 @@ function ServerManager() {
]}
/>
</div>
<div className="col-md-4">
<div className="col-12 col-md-4">
<FormField
label="IP сервера A"
name="ipA"
@@ -1282,7 +1283,7 @@ function ServerManager() {
required
/>
</div>
<div className="col-md-4">
<div className="col-12 col-md-4">
<FormField
label="IP сервера B"
name="ipB"
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -33,6 +33,7 @@ function formatBytes(bytes) {
const DONUT_SIZE = 200;
const DONUT_OUTER = 88;
const DONUT_INNER = 52;
const REQUEST_TIMEOUT_MS = 15000;
function JumphostCard({ jumphost }) {
const { name, host, error, interfaces } = jumphost;
@@ -184,7 +185,7 @@ export default function TrafficDashboard() {
const fetchStats = useCallback(async (abortSignal) => {
setLoading(true);
setError(null);
const opts = abortSignal ? { signal: abortSignal } : {};
const opts = { timeout: REQUEST_TIMEOUT_MS, ...(abortSignal ? { signal: abortSignal } : {}) };
try {
const [statsRes, settingsRes] = await Promise.all([
api.get('/traffic/interface-stats', opts),
+48 -5
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import api from './lib/api.js';
import {
IconClock,
@@ -33,14 +34,14 @@ function loadHistoryFromStorage() {
if (!raw) return {};
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') return parsed;
} catch (_) {}
} catch { /* no-op */ }
return {};
}
function saveHistoryToStorage(historyMap) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(historyMap));
} catch (_) {}
} catch { /* no-op */ }
}
/** Группировка истории по дням: массив { day, dayLabel, incidents } */
@@ -81,6 +82,7 @@ function historyToResponseTimeData(history) {
}
export default function UptimeMonitorPage() {
const location = useLocation();
const [servers, setServers] = useState([]);
const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false);
@@ -94,6 +96,8 @@ export default function UptimeMonitorPage() {
const [currentCheckingServerIds, setCurrentCheckingServerIds] = useState([]);
const intervalRef = useRef(null);
const checkingRef = useRef(false);
const activeRunIdRef = useRef(0);
const checkControllersRef = useRef(new Set());
const jumphosts = useMemo(
() =>
@@ -160,6 +164,7 @@ export default function UptimeMonitorPage() {
const runChecks = useCallback(async () => {
if (jumphosts.length === 0) return;
if (checkingRef.current) return;
const runId = ++activeRunIdRef.current;
checkingRef.current = true;
setChecking(true);
setCurrentCheckingServerIds([]);
@@ -171,18 +176,28 @@ export default function UptimeMonitorPage() {
const checkOne = async () => {
let server;
while ((server = getNext()) != null) {
if (activeRunIdRef.current !== runId) break;
const serverId = server.id || server.dns || server.ip;
if (!serverId) continue;
setCurrentCheckingServerIds((prev) => [...prev, serverId]);
const t0 = Date.now();
let ok = false;
let ms = null;
const controller = new AbortController();
checkControllersRef.current.add(controller);
try {
const { data: checkData } = await api.post('/uptime/check', { serverId });
const { data: checkData } = await api.post('/uptime/check', { serverId }, { signal: controller.signal });
ok = checkData?.ok === true;
ms = typeof checkData?.ms === 'number' ? checkData.ms : Date.now() - t0;
} catch (_) {
} catch (e) {
if (e?.code === 'ERR_CANCELED' || e?.name === 'CanceledError' || e?.name === 'AbortError') {
setCurrentCheckingServerIds((prev) => prev.filter((id) => id !== serverId));
checkControllersRef.current.delete(controller);
continue;
}
ms = Date.now() - t0;
} finally {
checkControllersRef.current.delete(controller);
}
const lastCheckTs = Date.now();
const entry = { ts: lastCheckTs, ok, ms };
@@ -196,6 +211,7 @@ export default function UptimeMonitorPage() {
}
};
await Promise.all(Array.from({ length: PARALLEL_CHECKS }, checkOne));
if (activeRunIdRef.current !== runId) return;
setCurrentCheckingServerIds([]);
checkingRef.current = false;
setChecking(false);
@@ -217,6 +233,30 @@ export default function UptimeMonitorPage() {
};
}, [jumphosts, runChecks, checkIntervalMs]);
// Остановка фоновых uptime-check при уходе со страницы
useEffect(() => {
if (location.pathname === '/uptime-monitor') return;
activeRunIdRef.current += 1;
checkingRef.current = false;
if (intervalRef.current) clearInterval(intervalRef.current);
const controllers = checkControllersRef.current;
controllers.forEach((controller) => controller.abort());
controllers.clear();
setCurrentCheckingServerIds([]);
setChecking(false);
}, [location.pathname]);
useEffect(() => {
const controllers = checkControllersRef.current;
return () => {
activeRunIdRef.current += 1;
checkingRef.current = false;
if (intervalRef.current) clearInterval(intervalRef.current);
controllers.forEach((controller) => controller.abort());
controllers.clear();
};
}, []);
// Сохраняем историю в localStorage при изменении
useEffect(() => {
saveHistoryToStorage(historyMap);
@@ -225,7 +265,10 @@ export default function UptimeMonitorPage() {
const selectedServer = selectedServerId
? jumphosts.find((s) => (s.id || s.dns || s.ip) === selectedServerId)
: null;
const selectedHistory = selectedServerId ? historyMap[selectedServerId] || [] : [];
const selectedHistory = useMemo(
() => (selectedServerId ? historyMap[selectedServerId] || [] : []),
[selectedServerId, historyMap]
);
const responseTimeData = useMemo(
() => historyToResponseTimeData(selectedHistory),
[selectedHistory]
+224
View File
@@ -0,0 +1,224 @@
import { useState, useRef, useEffect, useLayoutEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Link } from 'react-router-dom';
import { IconBell, IconAlertTriangle, IconServerOff, IconCpu, IconDeviceDesktop, IconDatabase } from '@tabler/icons-react';
import { useAlerts } from '../contexts/AlertsContext.jsx';
const SEVERITY_ICONS = {
critical: IconAlertTriangle,
warning: IconAlertTriangle,
};
const TYPE_ICONS = {
server_offline: IconServerOff,
mikrotik_unreachable: IconServerOff,
high_cpu: IconCpu,
high_ram: IconDeviceDesktop,
high_hdd: IconDatabase,
};
function AlertIcon({ type, severity }) {
const Icon = TYPE_ICONS[type] || SEVERITY_ICONS[severity] || IconAlertTriangle;
const colorClass = severity === 'critical' ? 'text-danger' : 'text-warning';
return <Icon size={18} className={colorClass} />;
}
const PANEL_WIDTH = 320;
const PANEL_MAX_HEIGHT = 320;
const Z_INDEX = 1050;
function AlertsPanelContent({ loading, refresh, alerts, onClose }) {
return (
<>
<div className="card-header d-flex align-items-center justify-content-between py-2 px-3">
<span className="fw-semibold">Оповещения</span>
<button
type="button"
className="btn btn-ghost-secondary btn-sm"
onClick={(e) => { e.preventDefault(); refresh(); }}
disabled={loading}
title="Обновить"
>
{loading ? '…' : '↻'}
</button>
</div>
<div className="list-group list-group-flush list-group-hoverable" style={{ maxHeight: `${PANEL_MAX_HEIGHT}px`, overflowY: 'auto' }}>
{alerts.length === 0 && !loading && (
<div className="list-group-item text-muted small text-center py-3">
Нет активных оповещений
</div>
)}
{alerts.slice(0, 20).map((alert) => (
<Link
key={alert.id}
to={alert.link || '/dashboard'}
className="list-group-item list-group-item-action py-2"
onClick={onClose}
>
<div className="d-flex align-items-start gap-2">
<span className="flex-shrink-0 mt-1">
<AlertIcon type={alert.type} severity={alert.severity} />
</span>
<div className="min-w-0 flex-grow-1">
<div className="fw-medium small">{alert.title}</div>
<div className="text-muted small text-truncate" title={alert.description}>
{alert.entity}
</div>
</div>
</div>
</Link>
))}
{alerts.length > 20 && (
<div className="list-group-item text-muted small text-center py-2">
и ещё {alerts.length - 20}
</div>
)}
</div>
<div className="card-footer py-2 px-3 d-flex align-items-center gap-2 flex-nowrap">
<Link to="/settings#alerts" className="btn btn-sm btn-ghost-secondary text-nowrap" onClick={onClose}>
Настройки
</Link>
<Link to="/dashboard" className="btn btn-sm btn-outline-primary text-nowrap" onClick={onClose}>
Панель
</Link>
</div>
</>
);
}
export default function AlertsBell({ dropup = false }) {
const { alerts, total, loading, refresh } = useAlerts();
const [open, setOpen] = useState(false);
const triggerRef = useRef(null);
const panelRef = useRef(null);
const [position, setPosition] = useState({ top: 0, left: 0 });
const updatePosition = useCallback(() => {
const el = triggerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const padding = 4;
const maxLeft = window.innerWidth - PANEL_WIDTH - padding;
if (dropup) {
setPosition({
left: Math.max(padding, Math.min(rect.left, maxLeft)),
bottom: window.innerHeight - rect.top + padding,
});
} else {
setPosition({
top: rect.bottom + padding,
left: Math.max(padding, Math.min(rect.left, maxLeft)),
});
}
}, [dropup]);
useLayoutEffect(() => {
if (open) updatePosition();
}, [open, dropup, updatePosition]);
useEffect(() => {
if (!open) return;
const onScroll = () => updatePosition();
const onResize = () => updatePosition();
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
const onClickOutside = (e) => {
const panel = panelRef.current;
const trigger = triggerRef.current;
if (!panel || !trigger) return;
if (!panel.contains(e.target) && !trigger.contains(e.target)) setOpen(false);
};
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onResize);
document.addEventListener('keydown', onKey);
document.addEventListener('mousedown', onClickOutside);
return () => {
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onResize);
document.removeEventListener('keydown', onKey);
document.removeEventListener('mousedown', onClickOutside);
};
}, [open, updatePosition]);
const handleTriggerClick = (e) => {
e.preventDefault();
if (dropup) {
setOpen((v) => !v);
}
};
if (dropup) {
return (
<>
<div className="nav-item">
<a
ref={triggerRef}
href="#"
className="nav-link px-2 d-inline-flex align-items-center justify-content-center"
onClick={handleTriggerClick}
aria-expanded={open}
aria-haspopup="true"
aria-label={`Оповещения: ${total} активных`}
title="Оповещения о проблемах"
>
<span className="position-relative d-inline-flex">
<IconBell size={20} />
{total > 0 && (
<span className="badge badge-sm bg-danger position-absolute rounded-pill" style={{ top: '-2px', right: '-2px' }}>
{total > 99 ? '99+' : total}
</span>
)}
</span>
</a>
</div>
{open && typeof document !== 'undefined' && createPortal(
<div
ref={panelRef}
className="dropdown-menu dropdown-menu-card show overflow-hidden shadow-lg border"
data-bs-theme="light"
style={{
position: 'fixed',
left: position.left,
...(position.bottom !== undefined ? { bottom: position.bottom } : { top: position.top }),
width: `${PANEL_WIDTH}px`,
maxWidth: 'calc(100vw - 1rem)',
zIndex: Z_INDEX,
}}
>
<AlertsPanelContent
loading={loading}
refresh={refresh}
alerts={alerts}
onClose={() => setOpen(false)}
/>
</div>,
document.body
)}
</>
);
}
return (
<div className="nav-item dropdown">
<a
href="#"
className="nav-link px-2 dropdown-toggle position-relative d-inline-flex align-items-center justify-content-center"
data-bs-toggle="dropdown"
onClick={(e) => e.preventDefault()}
aria-label={`Оповещения: ${total} активных`}
title="Оповещения о проблемах"
>
<span className="position-relative d-inline-flex">
<IconBell size={20} />
{total > 0 && (
<span className="badge badge-sm bg-danger position-absolute top-0 end-0 translate-middle rounded-pill" style={{ top: '-2px', right: '-2px' }}>
{total > 99 ? '99+' : total}
</span>
)}
</span>
</a>
<div className="dropdown-menu dropdown-menu-end dropdown-menu-card overflow-hidden" style={{ width: `${PANEL_WIDTH}px`, maxWidth: '95vw' }}>
<AlertsPanelContent loading={loading} refresh={refresh} alerts={alerts} onClose={() => {}} />
</div>
</div>
);
}
+3 -3
View File
@@ -8,14 +8,14 @@ import FloatingBulkActionsBar from './FloatingBulkActionsBar.jsx';
*/
function BulkActionsBar({
selectedCount = 0,
totalCount = 0,
onSelectAll = () => {},
totalCount: _totalCount = 0,
onSelectAll: _onSelectAll = () => {},
onDeselectAll = () => {},
onDelete = null,
onEdit = null,
onExport = null,
customActions = [],
className = '',
className: _className = '',
}) {
const actions = [];
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { IconHash } from '@tabler/icons-react';
import { getIconById } from '../lib/brandIcons.js';
function CommunityAutocompleteInput({
value,
@@ -105,6 +106,7 @@ function CommunityAutocompleteInput({
const label = s.name ? `${s.value} - ${s.name}` : `${s.value}`;
const description = s.description || '';
const tags = Array.isArray(s.tags) ? s.tags : [];
const iconInfo = s.icon ? getIconById(s.icon) : null;
return (
<button
type="button"
@@ -114,9 +116,15 @@ function CommunityAutocompleteInput({
onMouseEnter={() => setActiveIndex(idx)}
>
<div className="d-flex align-items-start">
<span className="avatar me-2 bg-blue-lt text-blue border-0" style={{ width: 22, height: 22 }}>
<IconHash size={14} />
</span>
{iconInfo ? (
<span className="avatar me-2 bg-blue-lt text-blue border-0" style={{ width: 22, height: 22 }}>
<iconInfo.Icon size={14} stroke={1.5} />
</span>
) : (
<span className="avatar me-2 bg-blue-lt text-blue border-0" style={{ width: 22, height: 22 }}>
<IconHash size={14} />
</span>
)}
<div className="flex-fill text-start">
<div className="fw-medium">{label}</div>
{(description || tags.length > 0) && (
+7 -1
View File
@@ -1,3 +1,5 @@
import { getIconById } from '../lib/brandIcons.js';
function normalizeTablerColor(input) {
const allowed = new Set(['blue','azure','indigo','purple','pink','red','orange','yellow','lime','green','teal','cyan','grape','gray']);
const c = String(input || 'blue').toLowerCase().replace(/[^a-z-]/g, '');
@@ -11,8 +13,12 @@ function CommunityBadge({ value, communities = [] }) {
const color = normalizeTablerColor(meta?.color || 'blue');
const cls = `badge bg-${color}-lt text-${color}`;
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${Array.isArray(meta.tags) && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : '';
const iconInfo = meta?.icon ? getIconById(meta.icon) : null;
return (
<span className={cls} title={title}>{v}</span>
<span className={cls} title={title}>
{iconInfo ? <iconInfo.Icon size={12} stroke={1.8} className="me-1" /> : null}
{v}
</span>
);
}
+6 -6
View File
@@ -100,8 +100,8 @@ function CommunityStats({ communities = [] }) {
) : (
<>
{/* Общая статистика */}
<div className="row mb-4">
<div className="col-md-4">
<div className="row g-2 g-md-3 mb-4">
<div className="col-6 col-md-4">
<div className="card card-sm bg-blue-lt">
<div className="card-body">
<div className="h1 mb-0">{stats.length}</div>
@@ -109,7 +109,7 @@ function CommunityStats({ communities = [] }) {
</div>
</div>
</div>
<div className="col-md-4">
<div className="col-6 col-md-4">
<div className="card card-sm bg-green-lt">
<div className="card-body">
<div className="h1 mb-0">{totalUsage}</div>
@@ -117,7 +117,7 @@ function CommunityStats({ communities = [] }) {
</div>
</div>
</div>
<div className="col-md-4">
<div className="col-6 col-md-4">
<div className="card card-sm bg-purple-lt">
<div className="card-body">
<div className="h1 mb-0">{Object.keys(statsByCategory).length}</div>
@@ -198,12 +198,12 @@ function CommunityStats({ communities = [] }) {
{Object.keys(statsByCategory).length > 1 && (
<div>
<h4 className="mb-3">По категориям</h4>
<div className="row g-3">
<div className="row g-2 g-md-3">
{Object.entries(statsByCategory).map(([category, items]) => {
const categoryTotal = items.reduce((sum, item) => sum + item.count, 0);
const categoryPercentage = ((categoryTotal / totalUsage) * 100).toFixed(1);
return (
<div key={category} className="col-md-6">
<div key={category} className="col-12 col-md-6">
<div className="card card-sm">
<div className="card-body">
<div className="d-flex justify-content-between align-items-start mb-2">
+1 -1
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import React from 'react';
import { IconArrowUp, IconArrowDown, IconEdit, IconTrash, IconCopy, IconCheck, IconX } from '@tabler/icons-react';
import TableSkeleton, { TableEmpty } from './TableSkeleton.jsx';
import EmptyState from './EmptyState.jsx';
+3 -3
View File
@@ -16,14 +16,14 @@ class ErrorBoundary extends Component {
};
}
static getDerivedStateFromError(error) {
static getDerivedStateFromError(_error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// Логируем ошибку
console.error('ErrorBoundary caught an error:', error, errorInfo);
// Отправляем в мониторинг (если настроен)
this.logErrorToService(error, errorInfo);
@@ -93,7 +93,7 @@ class ErrorBoundary extends Component {
{this.state.error?.message || 'Что-то пошло не так. Попробуйте обновить страницу.'}
</p>
{process.env.NODE_ENV === 'development' && this.state.errorInfo && (
{import.meta.env.DEV && this.state.errorInfo && (
<div className="card mt-3">
<div className="card-body">
<h3 className="card-title">Детали ошибки (только в dev режиме)</h3>
+1 -1
View File
@@ -40,7 +40,7 @@ export default function HistoryModal({ resource, show, onClose, onRolledBack })
notify.success('Откат выполнен успешно');
onRolledBack?.(res.data || {});
onClose?.();
} catch (e) {
} catch (_e) {
notify.error('Не удалось выполнить откат');
} finally {
setLoading(false);
+21 -11
View File
@@ -1,17 +1,20 @@
/* eslint-disable react-refresh/only-export-components */
import { useState, useMemo, useRef, useEffect } from 'react';
import BRAND_ICONS, { getIconById } from '../lib/brandIcons.js';
import { IconSearch } from '@tabler/icons-react';
import { IconSearch, IconMinus } from '@tabler/icons-react';
/**
* Выбор иконки бренда с поиском и предпросмотром.
* value id иконки (например IconBrandGoogle), onChange(id) при выборе.
*/
export default function IconPicker({ value, onChange, disabled = false, label = 'Иконка бренда' }) {
export default function IconPicker({ value, onChange, disabled = false, label = 'Иконка бренда', allowNone = false }) {
const [search, setSearch] = useState('');
const [open, setOpen] = useState(false);
const selected = getIconById(value);
const filtered = useMemo(() => {
const selected = allowNone && !value
? { id: '', label: 'Без иконки', Icon: IconMinus }
: getIconById(value);
const filteredIcons = useMemo(() => {
const q = (search || '').trim().toLowerCase();
if (!q) return BRAND_ICONS;
return BRAND_ICONS.filter(
@@ -19,6 +22,13 @@ export default function IconPicker({ value, onChange, disabled = false, label =
item.label.toLowerCase().includes(q) || item.id.toLowerCase().includes(q)
);
}, [search]);
const filtered = useMemo(() => {
if (!allowNone) return filteredIcons;
const q = (search || '').trim().toLowerCase();
const noneMatches = q.length === 0 || 'без иконки'.includes(q) || 'none'.includes(q);
if (!noneMatches) return filteredIcons;
return [{ id: '', label: 'Без иконки', Icon: IconMinus }, ...filteredIcons];
}, [allowNone, filteredIcons, search]);
const handleSelect = (item) => {
onChange?.(item.id);
@@ -44,19 +54,19 @@ export default function IconPicker({ value, onChange, disabled = false, label =
<div className="d-flex align-items-center gap-2 flex-wrap" ref={panelRef}>
<button
type="button"
className="btn btn-outline-secondary btn-sm d-flex align-items-center gap-2"
className="btn btn-outline-secondary d-flex align-items-center gap-2"
onClick={() => setOpen((v) => !v)}
disabled={disabled}
aria-expanded={open}
aria-haspopup="listbox"
>
<span className="avatar avatar-sm bg-primary-lt text-primary rounded d-flex align-items-center justify-content-center">
<selected.Icon size={18} stroke={1.5} />
<span className="avatar bg-primary-lt text-primary rounded d-flex align-items-center justify-content-center" style={{ width: 34, height: 34 }}>
<selected.Icon size={20} stroke={1.5} />
</span>
<span>{selected.label}</span>
</button>
{open && (
<div className="border rounded p-2 shadow-sm bg-body" style={{ minWidth: 280, maxWidth: 360 }}>
<div className="border rounded p-3 shadow-sm bg-body" style={{ minWidth: 560, maxWidth: 640 }}>
<div className="input-group input-group-sm mb-2">
<span className="input-group-text">
<IconSearch size={14} />
@@ -72,7 +82,7 @@ export default function IconPicker({ value, onChange, disabled = false, label =
</div>
<div
className="d-flex flex-wrap gap-1 overflow-auto"
style={{ maxHeight: 200 }}
style={{ maxHeight: 320 }}
role="listbox"
>
{filtered.map((item) => (
@@ -82,13 +92,13 @@ export default function IconPicker({ value, onChange, disabled = false, label =
className={`btn btn-sm d-flex align-items-center justify-content-center rounded ${
value === item.id ? 'btn-primary' : 'btn-outline-secondary'
}`}
style={{ width: 40, height: 40 }}
style={{ width: 56, height: 56 }}
onClick={() => handleSelect(item)}
title={item.label}
role="option"
aria-selected={value === item.id}
>
<item.Icon size={22} stroke={1.5} />
<item.Icon size={26} stroke={1.5} />
</button>
))}
</div>
+2 -2
View File
@@ -178,7 +178,7 @@ function ImportModal({
{/* Stats */}
<div className="row g-2">
<div className="col-md-6">
<div className="col-12 col-md-6">
<div className="card bg-green-lt">
<div className="card-body py-2">
<div className="d-flex align-items-center">
@@ -191,7 +191,7 @@ function ImportModal({
</div>
</div>
</div>
<div className="col-md-6">
<div className="col-12 col-md-6">
<div className="card bg-red-lt">
<div className="card-body py-2">
<div className="d-flex align-items-center">
+2 -2
View File
@@ -19,12 +19,12 @@ function MobileCardView({
// минимальная дистанция свайпа в px
const minSwipeDistance = 50
const onTouchStart = (e, item) => {
const onTouchStart = (e, _item) => {
setTouchEnd(null)
setTouchStart(e.targetTouches[0].clientX)
}
const onTouchMove = (e, item) => {
const onTouchMove = (e, _item) => {
setTouchEnd(e.targetTouches[0].clientX)
}
+2 -1
View File
@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { IconCheck, IconAlertTriangle, IconInfoCircle, IconX } from '@tabler/icons-react';
@@ -98,7 +99,7 @@ export function notifyMutationSuccess(message, details) {
if (forceToast && typeof window !== 'undefined' && window.notify?.success) {
window.notify.success(text, details);
}
} catch {}
} catch { /* no-op */ }
}
function colorByType(type) {
+1 -1
View File
@@ -29,7 +29,7 @@ function PageHeader({ title, icon, pretitle, actions, meta }) {
</div>
{actions && (
<div className="row mt-2">
<div className="col d-print-none">
<div className="col d-print-none overflow-auto">
{actions}
</div>
</div>
+5 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
function getBody() {
@@ -20,7 +20,7 @@ function PortalDropdown({
const [coords, setCoords] = useState({ top: 0, left: 0, minWidth: 0 });
const BASE_Z = 1035; // above navbar (1030), below modal-backdrop (1040) and modal (1050)
const updatePosition = () => {
const updatePosition = useCallback(() => {
const btn = buttonRef.current;
if (!btn) return;
const rect = btn.getBoundingClientRect();
@@ -32,12 +32,11 @@ function PortalDropdown({
left = Math.max(0, rect.right - (menuWidth || rect.width));
}
setCoords({ top: rect.bottom + window.scrollY, left: left + window.scrollX, minWidth });
};
}, [align]);
useLayoutEffect(() => {
if (open) updatePosition();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
}, [open, updatePosition]);
useEffect(() => {
if (!open) return;
@@ -60,7 +59,7 @@ function PortalDropdown({
document.removeEventListener('keydown', onKey);
document.removeEventListener('mousedown', onClickOutside);
};
}, [open]);
}, [open, updatePosition]);
const body = getBody();
+1 -1
View File
@@ -59,7 +59,7 @@ function QuickAddBar({
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<div className="row g-3 mt-2">
<div className="row g-2 g-md-3 mt-2">
<div className="col">
<div className="card"><div className="card-body p-2">
<div className="text-muted">Готово</div>
+1 -1
View File
@@ -14,7 +14,7 @@ function SavedFilters({
const [savedFilters, setSavedFilters] = useState([])
const [showSaveDialog, setShowSaveDialog] = useState(false)
const [filterName, setFilterName] = useState('')
const [showList, setShowList] = useState(false)
const [_showList, setShowList] = useState(false)
const storageKey = `savedFilters_${pageKey}`
+1 -1
View File
@@ -96,7 +96,7 @@ export default function SettingsModal({ open, onClose }) {
const e = settingsRes?.headers?.etag || settingsRes?.headers?.ETag || '';
setEtag(e ? String(e) : '');
setServersList(Array.isArray(serversRes?.data) ? serversRes.data : []);
} catch (e) {
} catch (_e) {
setError('Не удалось загрузить настройки');
} finally {
setLoading(false);
+15 -14
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, createContext, useContext } from 'react'
/* eslint-disable react-refresh/only-export-components */
import { useState, useEffect, createContext, useContext, useCallback, useMemo } from 'react'
import {
IconCheck,
IconX,
@@ -32,17 +33,17 @@ const DEFAULT_TITLES = {
function Toast({ toast, onClose }) {
const [isLeaving, setIsLeaving] = useState(false)
const handleClose = useCallback(() => {
setIsLeaving(true)
setTimeout(() => onClose(toast.id), 300)
}, [onClose, toast.id])
useEffect(() => {
if (toast.duration && toast.duration > 0) {
const timer = setTimeout(() => handleClose(), toast.duration)
return () => clearTimeout(timer)
}
}, [toast.duration])
const handleClose = () => {
setIsLeaving(true)
setTimeout(() => onClose(toast.id), 300)
}
}, [toast.duration, handleClose])
const getIcon = () => {
switch (toast.type) {
@@ -121,7 +122,7 @@ function Toast({ toast, onClose }) {
export function ToastContainer({ children }) {
const [toasts, setToasts] = useState([])
const addToast = (type, message, options = {}) => {
const addToast = useCallback((type, message, options = {}) => {
const id = Date.now()
const toast = {
id,
@@ -136,23 +137,23 @@ export function ToastContainer({ children }) {
}
setToasts(prev => [...prev, toast])
return id
}
}, [])
const removeToast = (id) => {
const removeToast = useCallback((id) => {
setToasts(prev => prev.filter(t => t.id !== id))
}
}, [])
const contextValue = {
const contextValue = useMemo(() => ({
success: (message, options) => addToast('success', message, options),
error: (message, options) => addToast('error', message, options),
warning: (message, options) => addToast('warning', message, options),
info: (message, options) => addToast('info', message, options),
}
}), [addToast])
// Глобальный доступ через window.toast (для обратной совместимости)
useEffect(() => {
window.toast = contextValue
}, [])
}, [contextValue])
return (
<ToastContext.Provider value={contextValue}>
+4 -4
View File
@@ -7,7 +7,7 @@ import { IconTrophy, IconMapPin, IconCloud, IconHash, IconChartBar } from '@tabl
function TopNStats({ data, loading }) {
const [topCountries, setTopCountries] = useState([]);
const [topProviders, setTopProviders] = useState([]);
const [topCommunities, setTopCommunities] = useState([]);
const [_topCommunities, _setTopCommunities] = useState([]);
useEffect(() => {
if (!data || !Array.isArray(data.servers)) return;
@@ -58,9 +58,9 @@ function TopNStats({ data, loading }) {
};
return (
<div className="row g-3">
<div className="row g-2 g-md-3">
{/* Top Страны */}
<div className="col-md-6">
<div className="col-12 col-md-6">
<div className="card h-100">
<div className="card-header">
<h3 className="card-title">
@@ -107,7 +107,7 @@ function TopNStats({ data, loading }) {
</div>
{/* Top Провайдеры */}
<div className="col-md-6">
<div className="col-12 col-md-6">
<div className="card h-100">
<div className="card-header">
<h3 className="card-title">
+2 -2
View File
@@ -58,7 +58,7 @@ function ValidatedInput({
message: validationResult.message || '',
isValidating: false
})
} catch (error) {
} catch (_error) {
setValidationState({
valid: false,
message: 'Ошибка валидации',
@@ -84,7 +84,7 @@ function ValidatedInput({
// Запускаем валидацию сразу при потере фокуса
if (validate && !validateOnChange) {
const result = validate(value)
const validationResult = result instanceof Promise ? result.then(r => {
const _validationResult = result instanceof Promise ? result.then(r => {
setValidationState({
valid: r.valid,
message: r.message || '',
+1 -1
View File
@@ -1,4 +1,4 @@
import { useRef, useMemo } from 'react';
import { useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { IconArrowUp, IconArrowDown, IconEdit, IconTrash, IconCopy } from '@tabler/icons-react';
import TableSkeleton from './TableSkeleton.jsx';
+3 -3
View File
@@ -64,12 +64,12 @@ function WsUpdateModal({ show, url, onClose }) {
ws.onerror = () => setStatus('error');
ws.onclose = () => setStatus('closed');
} catch (e) {
} catch (_e) {
setStatus('error');
}
return () => {
try { wsRef.current?.close(); } catch {}
try { wsRef.current?.close(); } catch { /* no-op */ }
wsRef.current = null;
};
}, [show, url]);
@@ -96,7 +96,7 @@ function WsUpdateModal({ show, url, onClose }) {
const copyLog = async () => {
try {
await navigator.clipboard.writeText(plainLog);
} catch {}
} catch { /* no-op */ }
};
const clearLog = () => {
@@ -42,10 +42,6 @@ function AddFilterModal({ show, newFilter, onNewFilterChange, onAddFilter, onClo
onChange={(e) => {
const v = e.target.value;
onNewFilterChange({ ...newFilter, community: v });
const found = communities.find(c => c.value === v);
if (found && found.gatewayDefault && !newFilter.gateway) {
onNewFilterChange({ ...newFilter, community: v, gateway: found.gatewayDefault });
}
}}
required
/>
@@ -9,19 +9,18 @@ function AddServerModal({ show, newServer, customProvider, onNewServerChange, on
const modalRef = useRef(null);
useEffect(() => {
if (window.Tabler && window.Tabler.Modal && modalRef.current) {
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current);
const modalEl = modalRef.current;
if (window.Tabler && window.Tabler.Modal && modalEl) {
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalEl);
if (show) {
modalInstance.show();
} else {
modalInstance.hide();
}
const handler = () => onClose && onClose();
modalRef.current.addEventListener('hide.bs.modal', handler);
modalEl.addEventListener('hide.bs.modal', handler);
return () => {
if (modalRef.current) {
modalRef.current.removeEventListener('hide.bs.modal', handler);
}
modalEl.removeEventListener('hide.bs.modal', handler);
};
}
}, [show, onClose]);
@@ -8,19 +8,18 @@ function DeleteServerModal({ show, server, onDelete, onClose }) {
const modalRef = useRef(null);
useEffect(() => {
if (window.Tabler && window.Tabler.Modal && modalRef.current) {
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current);
const modalEl = modalRef.current;
if (window.Tabler && window.Tabler.Modal && modalEl) {
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalEl);
if (show) {
modalInstance.show();
} else {
modalInstance.hide();
}
const handler = () => onClose && onClose();
modalRef.current.addEventListener('hide.bs.modal', handler);
modalEl.addEventListener('hide.bs.modal', handler);
return () => {
if (modalRef.current) {
modalRef.current.removeEventListener('hide.bs.modal', handler);
}
modalEl.removeEventListener('hide.bs.modal', handler);
};
}
}, [show, onClose]);
@@ -40,19 +40,18 @@ function EditServerModal({ show, server, onChange, onSave, onClose }) {
}, [show, server]);
useEffect(() => {
if (window.Tabler && window.Tabler.Modal && modalRef.current) {
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current);
const modalEl = modalRef.current;
if (window.Tabler && window.Tabler.Modal && modalEl) {
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalEl);
if (show) {
modalInstance.show();
} else {
modalInstance.hide();
}
const handler = () => onClose && onClose();
modalRef.current.addEventListener('hide.bs.modal', handler);
modalEl.addEventListener('hide.bs.modal', handler);
return () => {
if (modalRef.current) {
modalRef.current.removeEventListener('hide.bs.modal', handler);
}
modalEl.removeEventListener('hide.bs.modal', handler);
};
}
}, [show, onClose]);
@@ -13,19 +13,18 @@ function LinkGeneratorModal({ show, server, urlSettings, onUrlSettingsChange, on
}, [urlSettings]);
useEffect(() => {
if (window.Tabler && window.Tabler.Modal && modalRef.current) {
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current);
const modalEl = modalRef.current;
if (window.Tabler && window.Tabler.Modal && modalEl) {
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalEl);
if (show) {
modalInstance.show();
} else {
modalInstance.hide();
}
const handler = () => onClose && onClose();
modalRef.current.addEventListener('hide.bs.modal', handler);
modalEl.addEventListener('hide.bs.modal', handler);
return () => {
if (modalRef.current) {
modalRef.current.removeEventListener('hide.bs.modal', handler);
}
modalEl.removeEventListener('hide.bs.modal', handler);
};
}
}, [show, onClose]);
@@ -55,8 +54,8 @@ function LinkGeneratorModal({ show, server, urlSettings, onUrlSettingsChange, on
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div className="modal-body">
<div className="row">
<div className="col-md-6">
<div className="row g-2">
<div className="col-12 col-md-6">
<h6 className="mb-3">Настройки URL</h6>
<div className="mb-3">
<label className="form-label">Базовый URL</label>
@@ -99,7 +98,7 @@ function LinkGeneratorModal({ show, server, urlSettings, onUrlSettingsChange, on
/>
</div>
</div>
<div className="col-md-6">
<div className="col-12 col-md-6">
<div className="mb-3">
<label className="form-label">Telegram Gateway</label>
<input
@@ -118,19 +118,18 @@ function ServerModal({ show, mode = 'add', server, onSave, onClose, error: exter
* Управление Bootstrap modal
*/
useEffect(() => {
if (window.Tabler && window.Tabler.Modal && modalRef.current) {
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current);
const modalEl = modalRef.current;
if (window.Tabler && window.Tabler.Modal && modalEl) {
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalEl);
if (show) {
modalInstance.show();
} else {
modalInstance.hide();
}
const handler = () => onClose && onClose();
modalRef.current.addEventListener('hide.bs.modal', handler);
modalEl.addEventListener('hide.bs.modal', handler);
return () => {
if (modalRef.current) {
modalRef.current.removeEventListener('hide.bs.modal', handler);
}
modalEl.removeEventListener('hide.bs.modal', handler);
};
}
}, [show, onClose]);
@@ -27,7 +27,7 @@ function countryToFlag(isoCode) {
return code.replace(/./g, char => String.fromCodePoint(127397 + char.charCodeAt()));
}
function FilterSection({ title, icon: Icon, iconColor, expanded, onToggle, children }) {
function FilterSection({ title, icon: _Icon, iconColor, expanded, onToggle, children }) {
return (
<div className="filter-section border-bottom">
<button
@@ -37,7 +37,7 @@ function FilterSection({ title, icon: Icon, iconColor, expanded, onToggle, child
aria-expanded={expanded}
>
<span className="d-flex align-items-center">
<Icon size={16} className={`me-2 ${iconColor || 'text-muted'}`} />
<_Icon size={16} className={`me-2 ${iconColor || 'text-muted'}`} />
<span>{title}</span>
</span>
{expanded ? <IconChevronDown size={16} className="text-muted" /> : <IconChevronRight size={16} className="text-muted" />}
+58
View File
@@ -0,0 +1,58 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
import api from '../lib/api.js';
const AlertsContext = createContext(null);
const REFRESH_INTERVAL_MS = 60 * 1000; // 1 минута
export function AlertsProvider({ children }) {
const [alerts, setAlerts] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const intervalRef = useRef(null);
const fetchAlerts = useCallback(async () => {
setLoading(true);
setError(null);
try {
const { data } = await api.get('/alerts');
setAlerts(Array.isArray(data?.alerts) ? data.alerts : []);
} catch (e) {
if (e?.name !== 'CanceledError' && e?.code !== 'ERR_CANCELED') {
setError(e?.message || 'Не удалось загрузить оповещения');
setAlerts([]);
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchAlerts();
intervalRef.current = setInterval(fetchAlerts, REFRESH_INTERVAL_MS);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [fetchAlerts]);
const value = {
alerts,
total: alerts.length,
loading,
error,
refresh: fetchAlerts,
};
return (
<AlertsContext.Provider value={value}>
{children}
</AlertsContext.Provider>
);
}
export function useAlerts() {
const ctx = useContext(AlertsContext);
if (!ctx) throw new Error('useAlerts must be used within AlertsProvider');
return ctx;
}
+29 -3
View File
@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useState, useCallback, useRef } from 'react';
import api from '../lib/api.js';
@@ -16,6 +17,8 @@ const PING_CACHE_TTL_MS = 2 * 60 * 1000; // 2 минуты
export function PingProvider({ children }) {
const [pingMap, setPingMap] = useState({});
const inFlightRef = useRef(new Set());
const controllersRef = useRef(new Map()); // key -> AbortController
const keyScopesRef = useRef(new Map()); // key -> scope
const pingMapRef = useRef(pingMap);
const timestampsRef = useRef({}); // key Date.now() когда запись установлена
pingMapRef.current = pingMap;
@@ -44,8 +47,10 @@ export function PingProvider({ children }) {
* Пропускаются ключи с валидным кешем (не истёк TTL) и уже запрашиваемые.
* @param tasks Array<{ routerId, gatewayIp }>
*/
const requestPings = useCallback((tasks) => {
const requestPings = useCallback((tasks, options = {}) => {
if (!Array.isArray(tasks) || tasks.length === 0) return;
const scope = String(options?.scope || 'default');
const force = Boolean(options?.force);
const toRequest = [];
tasks.forEach(({ routerId, gatewayIp }) => {
@@ -53,40 +58,61 @@ export function PingProvider({ children }) {
if (!routerId || !ip) return;
const key = getKey(routerId, ip);
if (!key) return;
if (isCacheValid(key)) return;
if (!force && isCacheValid(key)) return;
if (inFlightRef.current.has(key)) return;
inFlightRef.current.add(key);
toRequest.push({ routerId, ip, key });
});
toRequest.forEach(({ routerId, ip, key }) => {
const controller = new AbortController();
controllersRef.current.set(key, controller);
keyScopesRef.current.set(key, scope);
api
.post('/mikrotik/ping', {
serverId: routerId,
gatewayIp: ip,
count: PING_COUNT,
})
}, { signal: controller.signal })
.then(({ data }) => {
const value = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null;
timestampsRef.current[key] = Date.now();
setPingMap((prev) => ({ ...prev, [key]: value }));
})
.catch((e) => {
if (e?.code === 'ERR_CANCELED' || e?.name === 'CanceledError' || e?.name === 'AbortError') {
return;
}
console.warn('PingContext: failed to fetch ping for', routerId, ip, e?.message || e);
timestampsRef.current[key] = Date.now();
setPingMap((prev) => ({ ...prev, [key]: null }));
})
.finally(() => {
inFlightRef.current.delete(key);
controllersRef.current.delete(key);
keyScopesRef.current.delete(key);
});
});
}, [isCacheValid]);
const cancelScope = useCallback((scope) => {
const scopeName = String(scope || '');
if (!scopeName) return;
let _canceled = 0;
controllersRef.current.forEach((controller, key) => {
if (keyScopesRef.current.get(key) === scopeName) {
controller.abort();
_canceled += 1;
}
});
}, []);
const value = {
pingMap,
getPing,
requestPings,
getKey,
cancelScope,
};
return (
@@ -81,7 +81,7 @@ function SimpleErrorHandlingExample() {
*/
function RetryButtonExample() {
const [attempts, setAttempts] = useState(0);
const { handleError, handleSuccess } = useErrorHandler();
const { handleError: _handleError, handleSuccess } = useErrorHandler();
const unreliableOperation = async () => {
setAttempts(prev => prev + 1);
+4 -4
View File
@@ -3,7 +3,7 @@
* Устраняет дублирование кода в DomainsNewManager, ASNsNewManager, IPRangesManager
*/
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import api from '../lib/api.js';
/**
@@ -46,7 +46,7 @@ export function useDataManager(config) {
const pageSize = 10;
// Загрузка данных
const fetchItems = async () => {
const fetchItems = useCallback(async () => {
setLoading(true);
try {
const response = await api.get(apiEndpoint, { params: { offset: 0, limit: 0, format: 'std' } });
@@ -69,14 +69,14 @@ export function useDataManager(config) {
} finally {
setLoading(false);
}
};
}, [apiEndpoint, transformResponse]);
useEffect(() => {
if (!didInit.current) {
didInit.current = true;
fetchItems();
}
}, []);
}, [fetchItems]);
// Добавление элемента
const addItem = (newItem) => {
+50 -5
View File
@@ -166,20 +166,29 @@ button:focus-visible,
transform: scale(1.05);
}
/* Minimum touch target size for mobile */
/* Minimum touch target size for mobile (Tabler / a11y) */
@media (max-width: 768px) {
.btn, .page-link, .dropdown-item {
.btn:not(.btn-icon), .page-link, .dropdown-item {
min-height: 44px;
min-width: 44px;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-icon {
min-width: 44px;
min-height: 44px;
width: 44px;
height: 44px;
}
.navbar-toggler {
min-width: 44px;
min-height: 44px;
padding: 0.5rem;
}
}
/* Enhanced table row interactions */
@@ -429,21 +438,57 @@ button:focus-visible,
}
/* ===== Responsive Improvements ===== */
@media (min-width: 576px) {
.w-sm-auto { width: auto !important; }
}
@media (max-width: 768px) {
.card-sm .avatar {
.card-sm .avatar,
.card .avatar.avatar-lg {
width: 2.5rem;
height: 2.5rem;
}
.card-sm .h2 {
.card-sm .h2,
.card .h3.mb-0 {
font-size: 1.25rem;
}
.table-responsive {
font-size: 0.875rem;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.table-responsive .table {
margin-bottom: 0;
}
.table-responsive .table th,
.table-responsive .table td {
white-space: nowrap;
}
.btn-group {
flex-wrap: wrap;
}
.page-title {
font-size: 1.35rem;
}
.page-header .header-actions {
width: 100%;
}
/* Карточки и ряды: компактные отступы на мобильных */
.row.g-3 { --bs-gutter-x: 0.5rem; --bs-gutter-y: 0.5rem; }
.card-title { font-size: 1rem; }
.card-header { padding: 0.5rem 0.75rem; }
}
/* Универсальные мобильные колонки: явный full-width ниже md */
@media (max-width: 767.98px) {
.row .col-md-4:not([class*="col-12"]):not([class*="col-sm"]),
.row .col-md-6:not([class*="col-12"]):not([class*="col-sm"]) {
flex: 0 0 100%;
max-width: 100%;
}
}
+16 -2
View File
@@ -57,12 +57,22 @@ api.interceptors.response.use(
responseCache.set(key, { etag, data: response.data, headers: response.headers });
}
}
} catch {}
} catch { /* no-op */ }
return response;
},
async (error) => {
const isCanceled = error?.code === 'ERR_CANCELED' || error?.name === 'CanceledError' || error?.name === 'AbortError';
if (isCanceled) {
return Promise.reject(error);
}
const config = error?.config || {};
const method = String(config.method || 'get').toUpperCase();
const noRetry = config?.noRetry === true || config?.__noRetry === true;
if (noRetry) {
return Promise.reject(error);
}
// Инициализируем счетчик попыток
config.__retryCount = config.__retryCount || 0;
@@ -104,6 +114,10 @@ api.interceptors.response.use(
api.interceptors.response.use(
(res) => res,
(err) => {
const isCanceled = err?.code === 'ERR_CANCELED' || err?.name === 'CanceledError' || err?.name === 'AbortError';
if (isCanceled) {
return Promise.reject(err);
}
try {
// Не показываем уведомления если это повторная попытка
const isRetrying = err?.config?.__retryCount > 0;
@@ -160,7 +174,7 @@ api.interceptors.request.use((config) => {
config.headers = config.headers || {};
config.headers['If-Match'] = String(config.data.etag);
}
} catch {}
} catch { /* no-op */ }
return config;
});
+1 -1
View File
@@ -17,7 +17,7 @@ function readCache() {
function writeCache(cache) {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
} catch {}
} catch { /* no-op */ }
}
function getCached(asn) {
+125 -36
View File
@@ -1,6 +1,6 @@
/**
* Реестр брендовых иконок (Tabler Icons) для выбора в настройках пинг-сервисов.
* id имя компонента (например IconBrandGoogle), label для поиска и отображения.
* Реестр брендовых иконок для выбора в UI.
* Используем Tabler + Font Awesome + Bootstrap Icons через react-icons.
*/
import {
IconBrandGoogle,
@@ -37,43 +37,132 @@ import {
IconBrandVisa,
IconBrandMastercard,
IconWorld,
IconCloud,
} from '@tabler/icons-react';
import {
FaSteam,
FaSteamSymbol,
FaCloudflare,
FaDiscord,
FaGithub,
FaTelegram,
FaVk,
FaYoutube,
FaGoogle,
FaWhatsapp,
FaApple,
FaAmazon,
FaFacebook,
FaXTwitter,
FaTiktok,
FaLinkedin,
FaReddit,
FaPinterest,
FaChrome,
FaFirefox,
FaEdge,
FaSafari,
FaDocker,
FaSlack,
FaCcVisa,
FaCcMastercard,
FaCcPaypal,
FaStripe,
FaYandex,
} from 'react-icons/fa6';
import {
BsTelegram,
BsDiscord,
BsSteam,
BsCloud,
BsGlobe,
BsGithub,
BsGoogle,
BsYoutube,
BsTwitterX,
BsWhatsapp,
BsApple,
BsTiktok,
} from 'react-icons/bs';
const BRAND_ICONS = [
{ id: 'IconBrandGoogle', label: 'Google', Icon: IconBrandGoogle },
{ id: 'IconBrandCloudflare', label: 'Cloudflare', Icon: IconBrandCloudflare },
{ id: 'IconBrandYandex', label: 'Yandex', Icon: IconBrandYandex },
{ id: 'IconBrandInstagram', label: 'Instagram', Icon: IconBrandInstagram },
{ id: 'IconBrandGithub', label: 'GitHub', Icon: IconBrandGithub },
{ id: 'IconBrandTelegram', label: 'Telegram', Icon: IconBrandTelegram },
{ id: 'IconBrandWhatsapp', label: 'WhatsApp', Icon: IconBrandWhatsapp },
{ id: 'IconBrandFacebook', label: 'Facebook', Icon: IconBrandFacebook },
{ id: 'IconBrandX', label: 'X (Twitter)', Icon: IconBrandX },
{ id: 'IconBrandYoutube', label: 'YouTube', Icon: IconBrandYoutube },
{ id: 'IconBrandTiktok', label: 'TikTok', Icon: IconBrandTiktok },
{ id: 'IconBrandAmazon', label: 'Amazon', Icon: IconBrandAmazon },
{ id: 'IconBrandApple', label: 'Apple', Icon: IconBrandApple },
{ id: 'IconBrandDiscord', label: 'Discord', Icon: IconBrandDiscord },
{ id: 'IconBrandSpotify', label: 'Spotify', Icon: IconBrandSpotify },
{ id: 'IconBrandVk', label: 'VK', Icon: IconBrandVk },
{ id: 'IconBrandReddit', label: 'Reddit', Icon: IconBrandReddit },
{ id: 'IconBrandLinkedin', label: 'LinkedIn', Icon: IconBrandLinkedin },
{ id: 'IconBrandPinterest', label: 'Pinterest', Icon: IconBrandPinterest },
{ id: 'IconBrandTwitch', label: 'Twitch', Icon: IconBrandTwitch },
{ id: 'IconBrandSteam', label: 'Steam', Icon: IconBrandSteam },
{ id: 'IconBrandDocker', label: 'Docker', Icon: IconBrandDocker },
{ id: 'IconBrandChrome', label: 'Chrome', Icon: IconBrandChrome },
{ id: 'IconBrandFirefox', label: 'Firefox', Icon: IconBrandFirefox },
{ id: 'IconBrandSafari', label: 'Safari', Icon: IconBrandSafari },
{ id: 'IconBrandEdge', label: 'Edge', Icon: IconBrandEdge },
{ id: 'IconBrandSlack', label: 'Slack', Icon: IconBrandSlack },
{ id: 'IconBrandZoom', label: 'Zoom', Icon: IconBrandZoom },
{ id: 'IconBrandSkype', label: 'Skype', Icon: IconBrandSkype },
{ id: 'IconBrandPaypal', label: 'PayPal', Icon: IconBrandPaypal },
{ id: 'IconBrandStripe', label: 'Stripe', Icon: IconBrandStripe },
{ id: 'IconBrandVisa', label: 'Visa', Icon: IconBrandVisa },
{ id: 'IconBrandMastercard', label: 'Mastercard', Icon: IconBrandMastercard },
{ id: 'IconWorld', label: 'Сеть (World)', Icon: IconWorld },
{ id: 'IconBrandGoogle', label: 'Google (Tabler)', Icon: IconBrandGoogle },
{ id: 'IconBrandCloudflare', label: 'Cloudflare (Tabler)', Icon: IconBrandCloudflare },
{ id: 'IconCdnBunny', label: 'Bunny CDN', Icon: IconCloud },
{ id: 'IconCdnFastly', label: 'Fastly CDN', Icon: IconCloud },
{ id: 'IconBrandYandex', label: 'Yandex (Tabler)', Icon: IconBrandYandex },
{ id: 'IconBrandInstagram', label: 'Instagram (Tabler)', Icon: IconBrandInstagram },
{ id: 'IconBrandGithub', label: 'GitHub (Tabler)', Icon: IconBrandGithub },
{ id: 'IconBrandTelegram', label: 'Telegram (Tabler)', Icon: IconBrandTelegram },
{ id: 'IconBrandWhatsapp', label: 'WhatsApp (Tabler)', Icon: IconBrandWhatsapp },
{ id: 'IconBrandFacebook', label: 'Facebook (Tabler)', Icon: IconBrandFacebook },
{ id: 'IconBrandX', label: 'X / Twitter (Tabler)', Icon: IconBrandX },
{ id: 'IconBrandYoutube', label: 'YouTube (Tabler)', Icon: IconBrandYoutube },
{ id: 'IconBrandTiktok', label: 'TikTok (Tabler)', Icon: IconBrandTiktok },
{ id: 'IconBrandAmazon', label: 'Amazon (Tabler)', Icon: IconBrandAmazon },
{ id: 'IconBrandApple', label: 'Apple (Tabler)', Icon: IconBrandApple },
{ id: 'IconBrandDiscord', label: 'Discord (Tabler)', Icon: IconBrandDiscord },
{ id: 'IconBrandSpotify', label: 'Spotify (Tabler)', Icon: IconBrandSpotify },
{ id: 'IconBrandVk', label: 'VK (Tabler)', Icon: IconBrandVk },
{ id: 'IconBrandReddit', label: 'Reddit (Tabler)', Icon: IconBrandReddit },
{ id: 'IconBrandLinkedin', label: 'LinkedIn (Tabler)', Icon: IconBrandLinkedin },
{ id: 'IconBrandPinterest', label: 'Pinterest (Tabler)', Icon: IconBrandPinterest },
{ id: 'IconBrandTwitch', label: 'Twitch (Tabler)', Icon: IconBrandTwitch },
{ id: 'IconBrandSteam', label: 'Steam (Tabler)', Icon: IconBrandSteam },
{ id: 'IconBrandDocker', label: 'Docker (Tabler)', Icon: IconBrandDocker },
{ id: 'IconBrandChrome', label: 'Chrome (Tabler)', Icon: IconBrandChrome },
{ id: 'IconBrandFirefox', label: 'Firefox (Tabler)', Icon: IconBrandFirefox },
{ id: 'IconBrandSafari', label: 'Safari (Tabler)', Icon: IconBrandSafari },
{ id: 'IconBrandEdge', label: 'Edge (Tabler)', Icon: IconBrandEdge },
{ id: 'IconBrandSlack', label: 'Slack (Tabler)', Icon: IconBrandSlack },
{ id: 'IconBrandZoom', label: 'Zoom (Tabler)', Icon: IconBrandZoom },
{ id: 'IconBrandSkype', label: 'Skype (Tabler)', Icon: IconBrandSkype },
{ id: 'IconBrandPaypal', label: 'PayPal (Tabler)', Icon: IconBrandPaypal },
{ id: 'IconBrandStripe', label: 'Stripe (Tabler)', Icon: IconBrandStripe },
{ id: 'IconBrandVisa', label: 'Visa (Tabler)', Icon: IconBrandVisa },
{ id: 'IconBrandMastercard', label: 'Mastercard (Tabler)', Icon: IconBrandMastercard },
{ id: 'IconWorld', label: 'Сеть (Tabler)', Icon: IconWorld },
{ id: 'FaSteam', label: 'Steam (Font Awesome)', Icon: FaSteam },
{ id: 'FaSteamSymbol', label: 'Valve / Steam (Font Awesome)', Icon: FaSteamSymbol },
{ id: 'FaCloudflare', label: 'Cloudflare (Font Awesome)', Icon: FaCloudflare },
{ id: 'FaDiscord', label: 'Discord (Font Awesome)', Icon: FaDiscord },
{ id: 'FaGithub', label: 'GitHub (Font Awesome)', Icon: FaGithub },
{ id: 'FaTelegram', label: 'Telegram (Font Awesome)', Icon: FaTelegram },
{ id: 'FaVk', label: 'VK (Font Awesome)', Icon: FaVk },
{ id: 'FaYoutube', label: 'YouTube (Font Awesome)', Icon: FaYoutube },
{ id: 'FaGoogle', label: 'Google (Font Awesome)', Icon: FaGoogle },
{ id: 'FaWhatsapp', label: 'WhatsApp (Font Awesome)', Icon: FaWhatsapp },
{ id: 'FaApple', label: 'Apple (Font Awesome)', Icon: FaApple },
{ id: 'FaAmazon', label: 'Amazon (Font Awesome)', Icon: FaAmazon },
{ id: 'FaFacebook', label: 'Facebook (Font Awesome)', Icon: FaFacebook },
{ id: 'FaXTwitter', label: 'X / Twitter (Font Awesome)', Icon: FaXTwitter },
{ id: 'FaTiktok', label: 'TikTok (Font Awesome)', Icon: FaTiktok },
{ id: 'FaLinkedin', label: 'LinkedIn (Font Awesome)', Icon: FaLinkedin },
{ id: 'FaReddit', label: 'Reddit (Font Awesome)', Icon: FaReddit },
{ id: 'FaPinterest', label: 'Pinterest (Font Awesome)', Icon: FaPinterest },
{ id: 'FaChrome', label: 'Chrome (Font Awesome)', Icon: FaChrome },
{ id: 'FaFirefox', label: 'Firefox (Font Awesome)', Icon: FaFirefox },
{ id: 'FaEdge', label: 'Edge (Font Awesome)', Icon: FaEdge },
{ id: 'FaSafari', label: 'Safari (Font Awesome)', Icon: FaSafari },
{ id: 'FaDocker', label: 'Docker (Font Awesome)', Icon: FaDocker },
{ id: 'FaSlack', label: 'Slack (Font Awesome)', Icon: FaSlack },
{ id: 'FaCcVisa', label: 'Visa (Font Awesome)', Icon: FaCcVisa },
{ id: 'FaCcMastercard', label: 'Mastercard (Font Awesome)', Icon: FaCcMastercard },
{ id: 'FaCcPaypal', label: 'PayPal (Font Awesome)', Icon: FaCcPaypal },
{ id: 'FaStripe', label: 'Stripe (Font Awesome)', Icon: FaStripe },
{ id: 'FaYandex', label: 'Yandex (Font Awesome)', Icon: FaYandex },
{ id: 'BsTelegram', label: 'Telegram (Bootstrap Icons)', Icon: BsTelegram },
{ id: 'BsDiscord', label: 'Discord (Bootstrap Icons)', Icon: BsDiscord },
{ id: 'BsSteam', label: 'Steam (Bootstrap Icons)', Icon: BsSteam },
{ id: 'BsCloud', label: 'Cloud (Bootstrap Icons)', Icon: BsCloud },
{ id: 'BsGlobe', label: 'Globe (Bootstrap Icons)', Icon: BsGlobe },
{ id: 'BsGithub', label: 'GitHub (Bootstrap Icons)', Icon: BsGithub },
{ id: 'BsGoogle', label: 'Google (Bootstrap Icons)', Icon: BsGoogle },
{ id: 'BsYoutube', label: 'YouTube (Bootstrap Icons)', Icon: BsYoutube },
{ id: 'BsTwitterX', label: 'X / Twitter (Bootstrap Icons)', Icon: BsTwitterX },
{ id: 'BsWhatsapp', label: 'WhatsApp (Bootstrap Icons)', Icon: BsWhatsapp },
{ id: 'BsApple', label: 'Apple (Bootstrap Icons)', Icon: BsApple },
{ id: 'BsTiktok', label: 'TikTok (Bootstrap Icons)', Icon: BsTiktok },
];
const ICON_BY_ID = Object.fromEntries(BRAND_ICONS.map((item) => [item.id, item]));
+365
View File
@@ -0,0 +1,365 @@
import axios from 'axios';
const DEFAULT_TIMEOUT_MS = 30000;
const PAGE_LIMIT = 500;
const LS_KEY_URL = 'evobgp_api_url';
const LS_KEY_TOKEN = 'evobgp_api_token';
/**
* Приводит введённый URL к базе API EvoBGP (/v1), без завершающего слэша.
* Примеры: http://host:8080 → http://host:8080/v1
*/
export function normalizeEvobgpApiBaseUrl(input) {
const raw = String(input || '').trim();
if (!raw) return '';
try {
const u = new URL(raw);
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 '';
}
}
function isProxyBase(base) {
return base === '/api/evobgp' || (typeof base === 'string' && base.startsWith('/api/evobgp'));
}
/**
* База EvoBGP API:
* 1) VITE_EVOBGP_API_URL прямой URL (обход прокси, для dev/особых случаев)
* 2) localStorage evobgp_api_url прямой URL из браузера
* 3) /api/evobgp прокси router-lists-ui EvoBGP (токен и URL только на сервере, без CORS)
*/
export function getEvobgpApiBaseUrl() {
const envUrl = import.meta?.env?.VITE_EVOBGP_API_URL;
if (envUrl && String(envUrl).trim()) {
const n = normalizeEvobgpApiBaseUrl(String(envUrl).trim());
if (n) return n;
}
if (typeof window !== 'undefined') {
const stored = window.localStorage?.getItem(LS_KEY_URL);
if (stored && String(stored).trim()) {
const n = normalizeEvobgpApiBaseUrl(String(stored).trim());
if (n) return n;
}
}
return '/api/evobgp';
}
function getAuthToken() {
const envToken = import.meta?.env?.VITE_EVOBGP_API_TOKEN;
if (envToken) return String(envToken);
if (typeof window === 'undefined') return '';
return String(window.localStorage?.getItem(LS_KEY_TOKEN) || '');
}
export const evobgpApi = axios.create({
timeout: DEFAULT_TIMEOUT_MS,
});
evobgpApi.interceptors.request.use((config) => {
const base = getEvobgpApiBaseUrl();
if (!base) {
return Promise.reject(
new Error(
'EvoBGP API URL не задан или некорректен. Проверьте VITE_EVOBGP_API_URL или URL в настройках Communities.',
),
);
}
config.baseURL = base;
if (!isProxyBase(base)) {
const token = getAuthToken();
if (token) {
config.headers = config.headers || {};
if (!config.headers.Authorization) {
config.headers.Authorization = `Bearer ${token}`;
}
}
}
return config;
});
async function paginate(path, params = {}) {
const items = [];
let cursor = null;
while (true) {
const res = await evobgpApi.get(path, {
params: { ...params, limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) },
});
const chunk = Array.isArray(res.data?.items) ? res.data.items : [];
items.push(...chunk);
if (!res.data?.has_more || !res.data?.next_cursor) break;
cursor = res.data.next_cursor;
}
return items;
}
async function fetchRouterListsCatalog() {
const res = await evobgpApi.get('/router-lists/catalog');
return res.data || {};
}
function getCatalogItems(section) {
if (!section || typeof section !== 'object') return [];
return Array.isArray(section.items) ? section.items : [];
}
export 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 evobgpApi.post('/modules', {
type: moduleType,
name: defaultName,
enabled: true,
});
return created.data;
}
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 evobgpApi.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 evobgpApi.post(`/modules/${moduleId}/${listPath}`, createBody(item, communityId));
continue;
}
const currentCommunity = mapCommunityToValue(existingRow.community_id, byId);
if (currentCommunity !== rawCommunity) {
await evobgpApi.patch(
`/modules/${moduleId}/${listPath}/${existingRow.id}`,
patchBody(item, communityId),
);
}
}
}
export 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),
}));
}
}
export 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 }),
});
}
export 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),
}));
}
}
export 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 }),
});
}
export 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),
}));
}
}
export 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 }),
});
}
export 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 evobgpApi.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 evobgpApi.post('/communities', { community: value, title });
continue;
}
if (String(row.title || '') !== title) {
await evobgpApi.patch(`/communities/${row.id}`, { community: value, title });
}
}
}
+1 -1
View File
@@ -57,7 +57,7 @@ export interface Community {
value: string;
name?: string;
description?: string;
gatewayDefault?: string;
icon?: string;
category?: string;
}
+8
View File
@@ -4,8 +4,16 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
build: {
chunkSizeWarningLimit: 2000,
},
server: {
proxy: {
'/v1': {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
},
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
+3 -1
View File
@@ -5,7 +5,9 @@
"main": "index.js",
"scripts": {
"dev": "concurrently \"cd backend && npm run dev\" \"cd frontend && npm run dev\"",
"test": "echo \"Error: no test specified\" && exit 1"
"lint": "cd frontend && npm run lint",
"test": "cd frontend && npm run build",
"test:backend": "cd backend && npm run test"
},
"keywords": [],
"author": "",