291 Commits
Author SHA1 Message Date
denozord 0b5ec5bd85 refactor(ResourceStatsPage, TrafficDashboard): update button click handlers to use arrow functions for consistency and improve error handling by retaining previous data on fetch failure
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m11s
2026-02-23 01:52:48 +07:00
denozord a14f10d048 refactor(api): implement abort signal handling in API requests across multiple components to improve cancellation of ongoing requests and prevent memory leaks
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m12s
2026-02-22 23:53:15 +07:00
denozord 3fda43cf2c docs(README): update locks API documentation to clarify optional soft-locks for editing and S3 access; enhance speed test route comments to explain non-blocking behavior
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m49s
2026-02-22 23:36:31 +07:00
denozord 353d14d64e feat(Validators): add server ID validation function to prevent path traversal and unsafe characters; update routes to utilize new validation logic
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m55s
2026-02-22 23:28:19 +07:00
denozord c3fba50de8 refactor(ResourceStatsPage): update RouterResourceCard layout to include an icon and improve styling
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m25s
2026-02-22 22:17:16 +07:00
denozord 9798e7fe95 feat(ResourceStats): add resource statistics API endpoint and integrate into frontend with routing and localization updates
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m35s
2026-02-22 22:05:47 +07:00
denozord 484dd56312 feat(Dashboard): display average ping response time in PingServiceCard based on historical data
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m7s
2026-02-22 21:50:53 +07:00
denozord 586f8246fb feat(PingServices): implement history tracking for ping results and update API responses to include historical data
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m46s
2026-02-22 21:40:55 +07:00
denozord 800f94354b feat(PingServices): add cache refresh option to fetchPingServices function and update tooltip for manual refresh
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 4m3s
2026-02-22 21:31:07 +07:00
denozord fbd740aef7 feat(Dashboard): enhance PingServiceCard with expandable view and sparkline for historical ping data
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m57s
2026-02-22 21:24:48 +07:00
denozord 7d3804202f feat(UptimeMonitor): add uptime monitor scheduler settings and endpoints for improved monitoring functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m47s
2026-02-22 21:10:50 +07:00
denozord 1548de7064 feat(Settings): add ping services scheduler settings for cache refresh interval and enable/disable functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m31s
2026-02-22 20:50:46 +07:00
denozord 431314b973 feat(PingServicesScheduler): implement ping services scheduler endpoints and cache refresh logic for improved service monitoring
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m29s
2026-02-22 20:40:57 +07:00
denozord 4c7af43be7 refactor(UptimeMonitorPage): update background check logic to utilize cached data on page load
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m10s
2026-02-22 19:18:37 +07:00
denozord 1be7e3bb22 feat(UptimeMonitor): implement uptime cache retrieval and update logic for improved monitoring performance
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m36s
2026-02-22 18:36:02 +07:00
denozord 64d9178784 feat(UptimeMonitorPage): add current checking server ID state to improve UI feedback during uptime checks
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m56s
2026-02-22 18:20:21 +07:00
denozord 8c13ae9df4 feat(MikrotikConfigRoutes): enhance gateway and interface matching logic to support additional server IDs and improve route extraction
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m24s
2026-02-22 18:03:58 +07:00
denozord f80e962767 feat(UptimeMonitor): add uptime check endpoint and frontend settings for monitoring server availability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m34s
2026-02-22 12:57:53 +07:00
denozord afa093781e feat(NetworkMap): adjust network map scheduler to run initial tick after 10 seconds and enhance cache handling in dashboard for better data display
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m54s
2026-02-22 01:30:21 +07:00
denozord f0cb694fa6 feat(UptimeMonitorPage): implement history tracking and visualization for server uptime checks
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m19s
2026-02-21 22:46:36 +07:00
denozord 28a6978243 refactor(UptimeMonitorPage): optimize uptime checks with memoization and update check interval to 2 minutes
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m9s
2026-02-21 22:31:20 +07:00
denozord 236f9f9d2e feat(App, CommandPalette): add Uptime Monitor page and integrate into navigation and command palette
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m19s
2026-02-21 22:08:46 +07:00
denozord e72b3f539d fix(api): extend request timeout to 3 minutes for long-running operations
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m13s
2026-02-21 21:18:38 +07:00
denozord 56f1baa7ba feat(FirewallPage): enhance apply summary functionality with confirmation for individual and bulk actions
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m8s
2026-02-21 19:32:17 +07:00
denozord ef7a4817c9 feat(FirewallPage): implement IP info loading from ipinfo.io for subnet analysis suggestions
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m6s
2026-02-21 19:09:11 +07:00
denozord 8e8ce7cec0 feat(MikrotikConfigRoutes, FirewallPage): add apply-summary endpoint and frontend analysis feature for address lists
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m47s
2026-02-21 19:01:28 +07:00
denozord 4ad5821b5e fix(MikrotikConfigRoutes): update response format for address lists to use res.json for consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m47s
2026-02-21 18:52:30 +07:00
denozord dfbd38d9a3 feat(MikrotikConfigRoutes): add logging for address list requests and responses to improve debugging
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s
2026-02-21 18:47:21 +07:00
denozord c3c30b1c75 refactor(MikrotikConfigRoutes): improve address list retrieval by filtering after fetching all entries
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m54s
2026-02-21 18:40:42 +07:00
denozord f9ac5ac3a3 feat(MikrotikConfigRoutes, App): add endpoint for retrieving address lists and integrate firewall page in frontend
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m58s
2026-02-21 18:29:38 +07:00
denozord 357503ee2f fix(NetworkMapScheduler): increase speed test request timeout to 5 minutes and improve error logging for aborted requests
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m44s
2026-02-18 16:01:21 +07:00
denozord e269b14f5e refactor(NetworkMapScheduler, NetworkMapDashboard): update speed test logic to ensure single test per server to avoid bandwidth contention
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s
2026-02-18 15:30:47 +07:00
denozord 9a8b72de82 feat(NetworkMapDashboard, NetworkMapUnifi): add stale data handling for ping and speed metrics, enhancing UI feedback with visual indicators
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m59s
2026-02-18 15:13:23 +07:00
denozord 8cc3575902 feat(NetworkMapScheduler): add next scheduled run time to settings response and update frontend to display next run time
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m36s
2026-02-18 14:11:27 +07:00
denozord 1fda3a5f4e feat(NetworkMapUnifi): enhance label placement logic and update country flag rendering with CDN support
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m57s
2026-02-18 14:05:20 +07:00
denozord fdc39060a1 feat(NetworkMapUnifi): add short speed formatting and country flag display to network edges
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2026-02-18 13:36:26 +07:00
denozord 283ab53b80 feat(NetworkMapScheduler): improve logging for speed test results by including server labels
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m39s
2026-02-18 13:29:26 +07:00
denozord 89d71ff060 feat(NetworkMapScheduler): enhance job execution logging and add force refresh option for ping and speed tests
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m44s
2026-02-18 13:23:03 +07:00
denozord b1834e988b feat(NetworkMapDashboard): implement cache handling for ping and speed requests to optimize data fetching
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m0s
2026-02-18 13:06:00 +07:00
denozord 6f13f39685 fix(schedulerRoutes): standardize comment formatting for API route documentation
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m35s
2026-02-18 12:45:52 +07:00
denozord dbca38d31a feat(NetworkMapScheduler): integrate network map scheduler functionality with API routes and frontend components for enhanced network monitoring
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m53s
2026-02-18 12:41:20 +07:00
denozord fbe9a07727 feat(InterfaceSpeedTest, MikrotikConfigRoutes): enhance speed test functionality with cache handling and force refresh options
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m53s
2026-02-18 12:24:51 +07:00
denozord 8b362f0f55 feat(MikrotikConfigRoutes): add function to retrieve remote MikroTik credentials for speed tests, enhancing server configuration flexibility
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m55s
2026-02-18 12:16:58 +07:00
denozord 9f85351b6b refactor(NetworkMap): unify ping handling by implementing edge-based keying for ping timestamps in NetworkMapDashboard and NetworkMapUnifi components
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m59s
2026-02-18 12:02:29 +07:00
denozord b5eb47e690 feat(NetworkMapDashboard): implement runWithOnePerKey function to ensure single task execution per node during speed tests
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m0s
2026-02-18 11:55:36 +07:00
denozord 034e0d1eee feat(NetworkMap): add speed test functionality to Network Map Dashboard and Unifi components, including UI updates for speed display and handling
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m9s
2026-02-18 11:43:33 +07:00
denozord 29161ae0cb refactor(InterfaceSpeedTest): simplify TCP speed display by separating download and upload metrics
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m23s
2026-02-18 00:43:14 +07:00
denozord 89c75f8e2e fix(InterfaceSpeedTest): increase timeout for speed test API request to accommodate longer test durations
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2026-02-17 23:35:28 +07:00
denozord 270cee077f refactor(MikrotikConfigRoutes): update speed test configuration to include 'proplist' for TCP metrics and remove deprecated 'protocol' parameter
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m44s
2026-02-17 23:29:36 +07:00
denozord 1a283f64f6 refactor(TrafficRoutes): remove deprecated interface speed test endpoint to streamline API
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m33s
2026-02-17 23:25:48 +07:00
denozord 99b346fd94 refactor(TrafficRoutes): remove deprecated interface statistics and speed test functions; streamline MikroTik credentials retrieval
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m27s
2026-02-17 23:22:38 +07:00
denozord 5c03ec7bd0 feat(TrafficRoutes, InterfaceSpeedTest, Settings): implement MikroTik interface statistics route; update speed test duration settings to default to 5 seconds across UI components
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
2026-02-17 23:18:21 +07:00
denozord 31ed777334 fix(MikrotikSpeedTest): remove 'direction' parameter from speed test configuration to streamline request handling
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
2026-02-17 23:06:05 +07:00
denozord 9dc931a10b feat(MikrotikSpeedTest): add speed test API route and frontend integration; implement caching and error handling for speed test functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s
2026-02-17 23:00:49 +07:00
denozord d29f1aef51 feat(TrafficRoutes, App): implement interface speed test route and UI integration; update error handling for interface measurement
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m37s
2026-02-17 22:41:02 +07:00
denozord ab4ba37d53 refactor(MikrotikTools): streamline interface filtering logic by moving it to a new useMemo hook for improved performance and readability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m3s
2026-02-17 22:32:28 +07:00
denozord d9667a06e6 feat(TrafficStats, Settings): add interface speed test functionality with configurable settings for protocol, duration, and caching; enhance UI for speed test integration
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m39s
2026-02-17 22:25:01 +07:00
denozord baa2fc8044 feat(NetworkMapUnifi): implement edge hover and selection features with detailed connection modal for enhanced user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s
2026-02-17 22:02:42 +07:00
denozord 98b39c5703 feat(NetworkMapUnifi): add fullscreen toggle functionality and improve button layout for better user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s
2026-02-17 21:52:32 +07:00
denozord c9babc1634 refactor(NetworkMapUnifi): simplify curve point calculations and improve label positioning to enhance edge rendering and avoid collisions
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s
2026-02-17 17:35:03 +07:00
denozord fe67f1c10b refactor(NetworkMapUnifi): adjust stub length and base offset calculations for smoother edge rendering
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2026-02-17 17:27:15 +07:00
denozord 08693affc3 refactor(NetworkMapUnifi): enhance curve point calculations for improved edge rendering and layout aesthetics
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m59s
2026-02-17 17:19:43 +07:00
denozord c475762fde refactor(NetworkMapUnifi): improve layout computation by grouping server types and enhancing curve point calculations for better visualization
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m5s
2026-02-17 17:13:56 +07:00
denozord 965eb7467b feat(NetworkMapDashboard, SettingsPage): implement ping cache duration settings for network map and enhance ping request handling
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m5s
2026-02-17 17:08:57 +07:00
denozord 48ee2a962e feat(NetworkMapDashboard): update ping requests to include gateway IP for accurate tunnel interface identification
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m2s
2026-02-17 16:59:58 +07:00
denozord 7be656c3f2 feat(NetworkMapDashboard): improve ping functionality by utilizing internal tunnel IPs for enhanced connectivity checks
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m3s
2026-02-17 16:35:42 +07:00
denozord 683f837b36 feat(NetworkMapDashboard): add ping functionality for jumphost and home servers to enhance network connectivity checks
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m4s
2026-02-17 16:27:47 +07:00
denozord cfd7b53a54 feat(NetworkMapDashboard, NetworkMapUnifi): enhance network visualization with tunnel interface connections and draggable node layout functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m7s
2026-02-17 16:21:37 +07:00
denozord d3b21d3dc0 refactor(SettingsPage, TrafficDashboard): rename traffic interface state variables and enhance interface selection logic for improved clarity and functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m1s
2026-02-17 16:17:02 +07:00
denozord f8a50581e4 refactor(NetworkMapDashboard): replace GraphView with NetworkMapUnifi for improved network visualization
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m7s
2026-02-17 16:12:46 +07:00
denozord 5ede9e079d feat(App, GraphView, SettingsPage, CommandPalette): add Network Map dashboard, enhance GraphView with ping data, and update SettingsPage for jumphost management
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m0s
2026-02-17 16:06:16 +07:00
denozord d6fc877e90 feat(SettingsPage): implement traffic interface selection with loading state and error handling for enhanced user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m4s
2026-02-17 16:00:10 +07:00
denozord e0c7ec32d6 feat(SettingsPage, TrafficDashboard): add traffic interface settings and filtering for enhanced analytics functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2026-02-17 15:56:17 +07:00
denozord e071d5060f refactor(NotifyProvider, ToastContainer): update toast notifications to Tabler style with improved layout, animations, and accessibility features
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m2s
2026-02-17 15:49:47 +07:00
denozord c3d41a32d8 refactor(SettingsPage): enhance sidebar structure and layout for improved navigation and visual consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2026-02-17 15:41:26 +07:00
denozord d3ebe5b2b1 refactor(SettingsPage): adjust spacing in section layouts for improved visual consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m57s
2026-02-17 15:35:25 +07:00
denozord 17cd5a8f43 refactor(SettingsPage): streamline loading and success/error message display for improved clarity and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2026-02-17 15:29:48 +07:00
denozord 7b92d1eb70 refactor(SettingsPage): replace SectionCard with SectionHeading for simplified section layout
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s
2026-02-17 15:24:11 +07:00
denozord 94337c8980 refactor(SettingsPage): update sidebar layout to Tabler style with improved navigation and search functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s
2026-02-17 15:19:32 +07:00
denozord 820b73e622 feat(SettingsPage): enhance sidebar layout and content responsiveness for improved user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m4s
2026-02-17 15:08:50 +07:00
denozord 48f47d66ca feat(SettingsPage): restructure sidebar navigation into groups for improved organization and usability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2026-02-17 15:02:10 +07:00
denozord f38f682a6f refactor(SettingsPage): change container class from 'container-xl' to 'container-fluid' for improved responsiveness
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m1s
2026-02-17 14:58:19 +07:00
denozord 18926a7274 feat(SettingsPage): implement sidebar navigation with search functionality for improved user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s
2026-02-17 14:54:18 +07:00
denozord f67a8f888c refactor(SettingsPage): replace PTR zone icon with IconWorld for improved clarity
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m7s
2026-02-17 14:46:22 +07:00
denozord 73f03c21cd fix(App): correct navigation link for settings page by replacing anchor tag with Link component
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m1s
2026-02-17 14:33:20 +07:00
denozord 3514276855 feat(App): add settings page and integrate into navigation; update command palette with settings option
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 57s
2026-02-17 14:14:09 +07:00
denozord ba0557e877 refactor(ServerCard): update layout and styling of expanded information section to use Tabler datagrid format
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m4s
2026-02-17 13:58:58 +07:00
denozord 1959b1d1d0 feat(TrafficDashboard): add summary statistics and visual representation for traffic data across all systems
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2026-02-17 13:42:30 +07:00
denozord 8278017459 feat(TrafficDashboard): enhance traffic visualization with donut chart and updated color palette
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2026-02-17 13:25:50 +07:00
denozord 2b957c6d9a feat(TrafficDashboard): add traffic statistics route and integrate TrafficDashboard component into the application
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s
2026-02-17 13:19:16 +07:00
denozord 630ceb30b7 refactor(BillingCard): remove button size modifier for consistent button group styling
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m51s
2026-02-17 13:11:20 +07:00
denozord 4c5aaf60bd refactor(BillingCard): update button styles for consistent sizing and improved layout
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m59s
2026-02-17 13:00:21 +07:00
denozord 60f55e447f feat(ServerManager): fetch and integrate network configuration gateways for improved server gateway handling
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s
2026-02-17 12:53:54 +07:00
denozord a0b5eb910f refactor(ServerModal): simplify country selection and remove unused gateway handling logic
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s
2026-02-17 12:45:16 +07:00
denozord 06799406a3 refactor(ServerGroupCard): update button styles and event handling for improved user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m2s
2026-02-17 12:34:10 +07:00
denozord 49b62fbfb5 refactor(BillingCard): wrap action buttons in a button group for improved layout and accessibility
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m55s
2026-02-17 12:14:06 +07:00
denozord 7cd33384b7 feat(ServerManager): add group selection functionality for servers in ServerManager and ServerGroupCard components
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m51s
2026-02-17 12:10:49 +07:00
denozord e09976b64e feat(ServerGroupCard): implement group selection functionality and improve selection indicator logic
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m53s
2026-02-17 11:17:04 +07:00
denozord 804f132d42 refactor(ServerGroupCard): update layout and improve server information display in expanded view
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m53s
2026-02-17 10:57:46 +07:00
denozord 8f3f1bd8a4 feat(ServerGroupCard): enhance server group card layout and functionality with improved selection and expanded view
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2026-02-17 10:48:38 +07:00
denozord 9482ab6d70 feat(BulkActions): integrate FloatingBulkActionsBar for improved bulk action handling in ASNs and IPRanges managers
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m55s
2026-02-17 10:45:34 +07:00
denozord d799c38edf feat(ServerManager): implement server grouping in cards view and enhance server modal for groupKey input
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s
2026-02-17 10:37:23 +07:00
denozord 29fcee8ab1 fix(PingServicesManager): improve error handling during save operation and streamline etag management for UI settings
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m59s
2026-02-17 00:27:58 +07:00
denozord 4c05913694 refactor(PingServicesManager): replace PageHeaderActions with a custom button list for improved UI structure
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m59s
2026-02-17 00:22:24 +07:00
denozord 3f42c6226a refactor(brandIcons): remove unused Microsoft and Nginx icons from brand icons list for cleaner code
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m50s
2026-02-17 00:18:54 +07:00
denozord 89a86f7c72 feat(PingServices): add endpoint for retrieving ping services list and update dashboard to display dynamic service configurations
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m12s
2026-02-17 00:15:32 +07:00
denozord 17ab2a30ec feat(PingServices): add caching functionality for ping service results and enhance settings modal for cache duration configuration
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m41s
2026-02-17 00:05:30 +07:00
denozord 21bd829979 refactor(PingServices): refine interface selection logic for ping operations, accommodating home routers without gateways
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
2026-02-16 23:53:54 +07:00
denozord c0d35c2e51 refactor(PingServices): update server selection logic to include home routers and improve error handling for ping services
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m39s
2026-02-16 23:47:24 +07:00
denozord ee357ed59a feat(SettingsModal): enhance server selection for ping services with autocomplete input and load server list from API
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s
2026-02-16 23:40:33 +07:00
denozord 838ca6b220 feat(PingServices): implement UI settings for ping services configuration and enhance ping functionality through RouterOS
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m34s
2026-02-16 23:35:25 +07:00
denozord dfcbdb35be feat(PingServices): add endpoint and dashboard component for measuring RTT to key services (Google, Cloudflare, Yandex, Instagram)
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m39s
2026-02-16 23:24:51 +07:00
denozord a3023daac9 refactor(MikrotikConfig): extend MikroTik credentials handling to support home routers and update related error messages for improved clarity
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s
2026-02-16 23:04:43 +07:00
denozord 7af6719f4b refactor(App): adjust dropdown z-index to ensure proper layering above sidebar and below modals for improved user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s
2026-02-16 23:02:41 +07:00
denozord dd94f9f887 refactor(PageHeader): update actions positioning to display below the title for improved layout and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m55s
2026-02-16 22:51:35 +07:00
denozord 1f49b02ba0 refactor(PageHeaderActions): restructure actions layout and enhance button visibility for improved user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s
2026-02-16 22:45:06 +07:00
denozord 0935eacd3f refactor(ServerManager): enhance actions layout and button styling for improved user experience and interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s
2026-02-16 22:28:47 +07:00
denozord f2c3562000 refactor(PageHeader): update actions positioning and layout for improved visibility and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m55s
2026-02-16 22:23:27 +07:00
denozord 6fad114b22 refactor(CommunitiesManager): enhance button actions and layout for improved user interaction and functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m57s
2026-02-16 22:18:04 +07:00
denozord f380be5d21 refactor(CommunitiesManager): update tab navigation style to improve layout and user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m51s
2026-02-16 22:12:23 +07:00
denozord 87f8283191 refactor(CommunitiesManager): simplify layout structure and enhance notification handling for improved user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m49s
2026-02-16 22:08:02 +07:00
denozord f13b532f1a refactor(BillingManager): update button groups and styling for improved layout and user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m53s
2026-02-16 21:55:43 +07:00
denozord 915304f800 refactor(BillingManager): improve layout and styling of subscription section for better user interaction and visual consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m59s
2026-02-16 21:43:48 +07:00
denozord 3dbdb6f794 fix(BillingManager): close conditional rendering for modal windows to ensure proper display
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m51s
2026-02-16 21:30:55 +07:00
denozord 08a64b5be4 refactor(BillingManager): add tab navigation for subscriptions and payments, enhancing user experience and data visibility
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 58s
2026-02-16 21:28:58 +07:00
denozord 5af5abb02d refactor(FilterManager): simplify notification handling and improve layout structure for enhanced user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m0s
2026-02-16 21:19:05 +07:00
denozord 8ad4f23a49 refactor(FilterManager): streamline server selection UI and enhance button actions for improved user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m55s
2026-02-16 21:07:48 +07:00
denozord 5c57e160fd refactor(ServerManager): adjust offcanvas width and maxWidth for improved layout and responsiveness
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m0s
2026-02-16 19:48:59 +07:00
denozord 46c9515ed1 refactor(CommunitiesManager): integrate PageHeader component for enhanced layout and consistency in community management
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2026-02-16 19:46:56 +07:00
denozord a6cf9f7130 refactor(ServerManager): integrate PageHeaderActions for enhanced action handling and UI consistency in server management
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m57s
2026-02-16 19:39:28 +07:00
denozord cf6d8b61a3 refactor(PageHeader): standardize header component across various managers by adding icons for improved visual consistency and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m53s
2026-02-16 19:26:56 +07:00
denozord 9784ec581e refactor(ServerSidebar): enhance text handling in filters for improved readability and layout consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m50s
2026-02-16 19:21:40 +07:00
denozord 75d2366462 refactor(ServerManager): implement offcanvas for filters and update ServerSidebar for embedded usage to enhance UI flexibility and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m53s
2026-02-16 19:17:13 +07:00
denozord 700e6674e5 refactor(ASNsNewManager): enhance JSX formatting and structure for improved readability and consistency in component layout
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m50s
2026-02-16 19:02:37 +07:00
denozord 4ce3bf4a9f refactor(IPRangesManager): improve code readability and structure by standardizing JSX formatting and enhancing layout consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m4s
2026-02-16 18:52:37 +07:00
denozord f8713e0bf9 refactor(ASNsNewManager): replace fragment with div for improved structure and consistency in component rendering
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m0s
2026-02-16 18:22:50 +07:00
denozord 23c863714c refactor(IPRangesManager): replace fragment with div for improved structure and consistency in component rendering
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m0s
2026-02-16 18:19:46 +07:00
denozord 3fade5f1c3 refactor(ASNsNewManager): replace div with fragment for improved rendering efficiency and component structure
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 57s
2026-02-16 18:15:37 +07:00
denozord 7509d1c781 refactor(ASNsNewManager): replace fragment with div for improved structure and consistency in component rendering
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 54s
2026-02-16 18:11:46 +07:00
denozord 1c953ed9d8 refactor(Managers): replace modal implementation with FormModal for community editing in ASNs, Domains, and IPRanges managers to enhance code maintainability and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 54s
2026-02-16 18:07:48 +07:00
denozord 59082aa0d0 refactor(Managers): update card header layout in ASNs, Domains, and IPRanges managers for improved visual consistency and enhanced user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 55s
2026-02-16 17:58:47 +07:00
denozord 6efe497e18 refactor(Managers): implement modal editing for community fields in ASNs, Domains, and IPRanges managers to enhance user experience and streamline editing process
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m55s
2026-02-16 17:55:32 +07:00
denozord 57db189375 refactor(Managers): enhance button group styling in tables for improved visual consistency and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m53s
2026-02-16 17:50:48 +07:00
denozord 38ece61b31 refactor(Managers): standardize button group styling and layout across ASNs, Domains, and IPRanges managers for improved UI consistency and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m50s
2026-02-16 17:42:57 +07:00
denozord a80740b802 refactor(Managers): remove S3MetaBar component and standardize pagination placement in ASNs, Domains, and IPRanges managers for improved UI consistency and readability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m51s
2026-02-16 17:37:16 +07:00
denozord dbc94d71a0 refactor(Managers): apply flat card styling across ASNs, Domains, and IPRanges managers for enhanced visual consistency and improved user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m1s
2026-02-16 17:28:51 +07:00
denozord eca744999c refactor(Managers): integrate LastSaved component and optimize button layout for enhanced UI consistency and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m2s
2026-02-16 17:22:48 +07:00
denozord cfcb44d5f0 refactor(DomainsNewManager): integrate LastSaved component and streamline button placement for improved UI consistency and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m57s
2026-02-16 17:18:24 +07:00
denozord 571ec06522 refactor(App): remove mobile navbar for cleaner layout and improved readability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m3s
2026-02-16 17:06:35 +07:00
denozord 698d42ce31 refactor(Managers): remove Breadcrumbs component from various managers for cleaner UI and improved readability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m57s
2026-02-16 16:58:24 +07:00
denozord ebec5c613f refactor(App): update vertical sidebar footer layout and styling for improved accessibility and user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m55s
2026-02-16 16:44:16 +07:00
denozord 0e0f4380d7 refactor(App): enhance vertical sidebar layout and styling for improved usability and consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m19s
2026-02-16 16:38:26 +07:00
denozord 62387aa6d5 refactor(DomainsNewManager): remove unnecessary closing div for cleaner markup and improved readability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m55s
2026-02-16 16:25:00 +07:00
denozord a322de4129 refactor(Tables): implement flat table styling across ASNs, Domains, and IPRanges managers for improved visual consistency and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 58s
2026-02-16 16:20:01 +07:00
denozord 94efee905a refactor(Managers): integrate AddItemModal into ASNs, Domains, and IPRanges managers for improved item addition functionality and UI consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m57s
2026-02-16 16:09:05 +07:00
denozord 3a7578ce26 refactor(ServerSidebar): implement new filter section and sidebar styling for improved user interaction and organization
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2026-02-16 16:03:28 +07:00
denozord af8af84ab7 refactor(DataTable): enhance table styling and functionality with fixed action column, improved striped rows, and new props for UX customization
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s
2026-02-16 15:56:28 +07:00
denozord 1fe9cf4794 refactor(App): update vertical sidebar styling to a consistent dark theme, enhancing visual coherence and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m1s
2026-02-16 15:48:33 +07:00
denozord 01a15d97c6 refactor(App): improve vertical sidebar styling and dropdown behavior for enhanced user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m53s
2026-02-16 15:41:13 +07:00
denozord 336a5b42a6 refactor(workflows): replace hardcoded webhook URL with secret for improved security in Docker publish workflows
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m50s
2026-02-16 15:31:47 +07:00
denozord 5cc19b8c3e refactor(App): enhance vertical sidebar navigation with icon integration and improved dropdown item styling for better user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m41s
2026-02-16 15:21:46 +07:00
denozord b87af004ee refactor(App): update layout and styling for vertical sidebar navigation and page header, enhancing user experience and accessibility
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m32s
2026-02-16 15:16:13 +07:00
denozord 8a8a55e7b6 feat(MikrotikTools): add traceroute and ping mode selection with reset and run buttons for enhanced user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m53s
2026-02-13 00:17:55 +07:00
denozord 79ca6e2d05 refactor(MikrotikTools): replace gateway input with card-based target selection for improved UI and user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m30s
2026-02-13 00:09:50 +07:00
denozord 34a52ed826 refactor(MikrotikTools): enhance UI layout and styling for server and gateway selection, improving readability and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m38s
2026-02-13 00:01:44 +07:00
denozord c7f55dfee9 feat(MikrotikTools): add popular targets for ping/traceroute with icons for enhanced user selection
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m17s
2026-02-12 23:52:42 +07:00
denozord caa566aa8f feat(MikrotikTools): implement gateway selection feature with enhanced UI for improved user experience and server-specific filtering
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m24s
2026-02-12 23:43:44 +07:00
denozord 87064cb89d Revert "refactor(MikrotikTools): streamline parameters section layout and button actions for traceroute and ping, enhancing user interaction"
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m4s
This reverts commit 9f058e5811.
2026-02-12 23:34:36 +07:00
denozord 9f058e5811 refactor(MikrotikTools): streamline parameters section layout and button actions for traceroute and ping, enhancing user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m2s
2026-02-12 23:28:55 +07:00
denozord e7650b961b refactor(MikrotikTools): redesign header and action buttons for traceroute and ping modes, improving layout and user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m8s
2026-02-12 23:19:35 +07:00
denozord 0387491ada feat(MikrotikTools): add traceroute tab state management for improved user navigation between results and analysis
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m35s
2026-02-12 23:09:57 +07:00
denozord d79f831032 refactor(MikrotikTools): improve server search input layout and styling for better usability
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m11s
2026-02-12 23:01:24 +07:00
denozord a42e4189c3 refactor(MikrotikTools): update UI text and layout for server check functionality, enhancing user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m6s
2026-02-12 22:51:39 +07:00
denozord 73bc623fdf feat(MikrotikTools): add ping functionality alongside traceroute with mode selection and result handling
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m54s
2026-02-12 22:33:43 +07:00
denozord dd089453bc refactor(MikrotikTools): improve table layout and enhance row display with severity indicators for traceroute results
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m18s
2026-02-12 22:22:53 +07:00
denozord 2d6c90ab31 fix(MikrotikTools): update gateway card display to show country and provider in subtitle, adjust layout for improved readability
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m5s
2026-02-12 17:33:05 +07:00
denozord d76355b6ef feat(MikrotikTools): enhance gateway selection UI with card display and add address format toggle for traceroute results
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m56s
2026-02-12 17:06:38 +07:00
denozord 9fbda4f4e5 feat(MikrotikTools): implement server search functionality and enhance UI for jumphost selection in traceroute
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m5s
2026-02-12 16:48:26 +07:00
denozord 337a4ce944 feat(MikrotikTools): add configurable DNS usage option in traceroute for flexible user preferences
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m23s
2026-02-12 16:40:13 +07:00
denozord 56b264a724 feat(MikrotikTools): add toggle for DNS/IP display in traceroute results for enhanced user control
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m39s
2026-02-12 16:29:37 +07:00
denozord 635b791118 feat(MikrotikTools): enhance traceroute output with reverse DNS support and improved IP handling
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m28s
2026-02-12 16:14:06 +07:00
denozord 1e28fa33f5 feat(MikrotikTools): deduplicate traceroute results and handle empty rows for improved accuracy
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m40s
2026-02-12 16:06:22 +07:00
denozord 3b60a5885b feat(MikrotikTools): limit packet count per hop in traceroute functionality for improved control
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m31s
2026-02-12 15:56:00 +07:00
denozord ba006edec0 feat(MikrotikTools): enhance traceroute functionality to dynamically determine interface based on gateway IP
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m45s
2026-02-12 15:39:34 +07:00
denozord 6077d3bc5b feat(MikrotikTools): add traceroute functionality via MikroTik API and integrate new tools route in frontend
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m36s
2026-02-12 15:24:53 +07:00
denozord c75f5f6b82 feat(EasySwitchManager, FilterManager): implement location-based ping requests to refresh data on route changes
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m56s
2026-02-12 13:34:03 +07:00
denozord 219ca7450c feat(FilterManager): enhance server metadata retrieval to support short names for improved matching
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m24s
2026-02-12 12:09:58 +07:00
denozord c792645a55 feat(PingContext): integrate PingProvider for centralized ping management across components
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m5s
2026-02-12 11:57:10 +07:00
denozord 793b8acaea feat(FilterManager): enhance GatewayAutocomplete with ping functionality and prefetching for selected server
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m5s
2026-02-12 11:46:50 +07:00
denozord 6b8e060d54 feat(MikrotikBackupsManager): add large modal for displaying backup content with improved UI
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 56s
2026-02-11 16:00:48 +07:00
denozord bdc7dd2bb3 refactor(MikrotikBackupsManager): optimize diff computation and synchronize scrolling between panels
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m25s
2026-02-11 15:51:05 +07:00
denozord 562f09753b feat(MikrotikBackupsManager): implement diff modal for enhanced configuration comparison
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m48s
2026-02-11 15:42:05 +07:00
denozord 91fd3baccc refactor(MikrotikBackupsManager): enhance diff panel styles for better contrast and visibility
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m41s
2026-02-11 14:29:30 +07:00
denozord 9a51265a4f refactor(MikrotikBackupsManager): update diff panel styles for improved readability and visual clarity
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m45s
2026-02-11 14:18:03 +07:00
denozord a2910832be feat(MikrotikBackupsManager): add side-by-side diff functionality for configuration comparison
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m47s
2026-02-11 14:12:07 +07:00
denozord ba6c7b9618 refactor(mikrotikBackupScheduler): improve logging for backup scheduler and remove initial backup on container restart
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m27s
2026-02-11 13:20:24 +07:00
denozord 6a90f4079d feat(ping): add ping caching functionality with configurable duration in UI settings
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m42s
2026-02-11 11:48:32 +07:00
denozord 1651700609 feat(ping): implement dynamic ping target selection from UI settings or default value 2026-02-11 11:45:37 +07:00
denozord 896f1ee3a7 refactor(EasySwitchManager): enhance text display by removing truncation and adding word break for better readability
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m23s
2026-02-11 01:21:26 +07:00
denozord 497940b29f refactor(EasySwitchManager): update gateway display to include comments and improve layout consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m59s
2026-02-11 01:13:59 +07:00
denozord f6d32b3efa refactor(EasySwitchManager): simplify gateway information display by consolidating layout and enhancing clarity of comments
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m33s
2026-02-11 01:03:14 +07:00
denozord 583f9b255e refactor(EasySwitchManager): enhance gateway display with improved layout and additional information including IP and provider details
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m25s
2026-02-11 00:53:48 +07:00
denozord 343626c350 refactor(EasySwitchManager): optimize ping requests by using parallel execution and immediate UI updates for improved responsiveness
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m42s
2026-02-11 00:22:13 +07:00
denozord a0bf4a7a9f feat(ping): refine interface selection logic to prioritize 'ether1' and 'ether2' before falling back to other 'ether' interfaces
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m49s
2026-02-10 23:38:15 +07:00
denozord fc66c7db0b feat(ping): implement additional fallback for interface resolution by selecting the first 'ether' interface if none is specified
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m9s
2026-02-10 23:15:21 +07:00
denozord e914b0003c feat(ping): add fallback mechanism for interface resolution by querying MikroTik routes based on gateway IP
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m8s
2026-02-10 23:03:55 +07:00
denozord de6fd2dddb feat(ping): enhance interface resolution logic by integrating gateway checks for improved tunnel interface matching
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m54s
2026-02-10 22:54:50 +07:00
denozord 18e488a8e8 feat(ping): add logging for ping requests and CLI command for debugging purposes
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m22s
2026-02-10 22:39:38 +07:00
denozord b6df4af920 refactor(ping): remove srcAddress parameter from ping functions to simplify configuration
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m39s
2026-02-10 22:17:38 +07:00
denozord 78bf17920b fix(ping): return ping data directly in JSON format for frontend compatibility
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m6s
2026-02-10 22:00:05 +07:00
denozord b3739388e5 feat(EasySwitchManager): integrate network-config gateways into server data loading and filtering logic for improved accuracy
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m4s
2026-02-10 21:49:55 +07:00
denozord ecee4ab601 feat(Mikrotik): add ping endpoint and implement prefetching of ping data for gateways in EasySwitchManager
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m18s
2026-02-10 21:34:52 +07:00
denozord 8ae99a0d69 refactor(GatewayAutocomplete): remove selected gateway info display to streamline component and reduce clutter
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 4m0s
2026-02-10 20:54:28 +07:00
denozord 5bfd7ff5de fix(FilterManager): update serverCountry and serverProvider assignments to fallback on selectedServer properties for improved accuracy
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m28s
2026-02-10 19:11:32 +07:00
denozord 09c343a272 refactor(FilterManager): rename country and provider variables to serverCountry and serverProvider for clarity and consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m3s
2026-02-10 19:02:24 +07:00
denozord 4155963d97 feat(GatewayAutocomplete): simplify country flag rendering and display provider information for selected gateways
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m36s
2026-02-10 18:53:39 +07:00
denozord e2b03c7689 feat(GatewayAutocomplete): adjust layout for gateway cards and display additional gateway information to enhance user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 4m38s
2026-02-10 18:44:52 +07:00
denozord 56c181f697 feat(GatewayAutocomplete): add selected gateway info and free gateway warning to enhance user feedback
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m32s
2026-02-10 18:33:53 +07:00
denozord 4e2f4ed7a5 refactor(GatewayAutocomplete): remove commented code to streamline FilterManager component
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m42s
2026-02-10 18:28:04 +07:00
denozord 5dfe76f1df feat(GatewayAutocomplete): enhance UI layout and improve gateway card display for better user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m19s
2026-02-10 18:22:34 +07:00
denozord 5e0397f667 feat(GatewayAutocomplete): improve gateway filtering logic to show full list when a gateway is selected, enhancing user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m36s
2026-02-10 18:15:38 +07:00
denozord b2b3933d2d refactor(GatewayAutocomplete): remove unnecessary div to clean up layout and improve code readability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m40s
2026-02-10 17:54:40 +07:00
denozord 5ec477b23a feat(GatewayAutocomplete): replace dropdown with card-based selection for gateways, enhancing user experience and accessibility
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m6s
2026-02-10 17:38:35 +07:00
denozord 4afef9abf8 feat(MikrotikBackupsManager): enhance backup management UI with detailed backup table and improved server selection settings
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m27s
2026-02-10 17:27:47 +07:00
denozord 7b4b65053b feat(workflows): add container update webhook trigger to Docker publish workflows
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m5s
2026-02-10 16:01:48 +07:00
denozord ce13ca7845 feat(MikrotikBackupsManager): add server search functionality to enhance server selection UI
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 4m6s
2026-02-10 15:52:05 +07:00
denozord 7bfa7be02d feat(MikrotikBackupsManager): implement server selection UI with search, sorting, and pagination features
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m43s
2026-02-10 15:38:44 +07:00
denozord 0063e79a73 feat(mikrotikBackupRoutes): add encryption and decryption for backup content to enhance security
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m42s
2026-02-10 01:54:38 +07:00
denozord 77ba6c1fa3 refactor(mikrotikBackupRoutes, mikrotikApplyService): improve error handling and logging for backup and file retrieval processes
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m2s
2026-02-10 01:48:07 +07:00
denozord db31690df5 refactor(mikrotikApplyService): enhance file retrieval logic with improved error handling and support for large files
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m53s
2026-02-10 01:38:19 +07:00
denozord 0c40d2726b refactor(mikrotikBackupRoutes, mikrotikBackupScheduler, mikrotikApplyService): replace direct export command with file-based export method for improved reliability and error handling
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m59s
2026-02-10 01:29:39 +07:00
denozord f2372ef4c9 refactor(MikrotikBackupsManager): update condition to check for selectedServerId instead of selectedServer for improved clarity
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m10s
2026-02-10 01:21:44 +07:00
denozord 3d3afa4d3e refactor(MikrotikBackupsManager): rename selectedServer state to selectedServerId and update related logic for improved clarity and consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m23s
2026-02-10 01:05:07 +07:00
denozord 7f2c4c3c8f refactor(mikrotikBackupRoutes, mikrotikBackupScheduler): update backup export command to use /rest/execute with compact terse script for improved reliability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m19s
2026-02-09 20:51:43 +07:00
denozord 89d35cd7ef feat(mikrotikBackup): add endpoint and UI for manual backup execution
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 4m39s
2026-02-09 19:50:29 +07:00
denozord 5eecb8d070 feat(mikrotikBackupScheduler, MikrotikBackupsManager): enhance backup server management by integrating UI settings and adding server selection functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m58s
2026-02-09 17:06:49 +07:00
denozord 0d9542d7e9 refactor(App, MikrotikBackupsManager): remove unused IconDatabaseBackup and update icon reference for Mikrotik backups
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m28s
2026-02-09 16:51:21 +07:00
denozord bcbbdc19e4 fix(MikrotikBackupsManager): correct backup server description formatting for consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m10s
2026-02-09 16:38:59 +07:00
denozord 275f2f6929 feat(mikrotikBackup): add MikroTik backup functionality with S3 integration and implement backup scheduler
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m2s
2026-02-09 15:13:56 +07:00
denozord 3d9394e27d fix(mikrotikApplyService, ServerModal): update default port for MikroTik connections to 80 and ensure proper number conversion for port input
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m41s
2026-02-04 15:07:13 +07:00
denozord 21d2707a1b refactor(BillingManager): streamline next payment date calculation by integrating it into data normalization process
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m21s
2026-02-04 12:33:48 +07:00
denozord d8457b1497 feat(BillingManager): implement next payment date calculation based on payment history and exchange rates
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m50s
2026-02-04 11:29:34 +07:00
denozord e5b72ca56f feat(BillingManager): add functionality to calculate next payment date by adding one month to the last payment date
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 4m46s
2026-02-04 10:26:19 +07:00
denozord 61c87e69ef fix(mikrotikConfigRoutes): enhance script execution logic to find script ID before running
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m9s
2026-02-03 23:47:39 +07:00
denozord d4a1643a25 fix(mikrotikConfigRoutes): correct script execution command to use '.id' for script name in API call
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m54s
2026-02-03 22:17:49 +07:00
denozord 7815c5354f fix(mikrotikConfigRoutes): update script execution command to use the correct REST API endpoint for running scripts
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m3s
2026-02-03 22:11:28 +07:00
denozord 335af01116 feat(mikrotikConfigRoutes, FilterManager): add run-script endpoint and integrate BGP update functionality in FilterManager
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m7s
2026-02-03 21:56:30 +07:00
denozord 99c027f96f fix(mikrotikApplyService): filter routes by comment prefix when removing to ensure accurate route deletion
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m55s
2026-02-03 21:43:08 +07:00
denozord 6e4a0aae27 refactor(mikrotikInterfaceGenerator, NetworkConfigManager): improve comments for route removal and enhance request logging format in API responses
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m0s
2026-02-03 21:35:04 +07:00
denozord 1dfdfcfeec fix(mikrotikConfigRoutes, mikrotikApplyService): update default port for MikroTik API connections from 443 to 80 to align with REST API standards
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m59s
2026-02-03 21:26:47 +07:00
denozord 999bd14590 refactor(mikrotikApplyService): transition from node-routeros to REST API client for MikroTik configuration; enhance request handling and error management
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m10s
2026-02-03 21:19:01 +07:00
denozord e7c527f787 chore(mikrotikConfigRoutes): remove deprecated MikroTik configuration routes file
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m49s
2026-02-03 21:02:19 +07:00
denozord 76294ab2c9 refactor(NetworkConfigManager, mikrotikConfigRoutes): streamline logging and error handling in MikroTik configuration application; remove redundant debug log setup
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m8s
2026-02-03 20:50:31 +07:00
denozord ce107db7ea feat(MikrotikConfig, NetworkConfigManager): implement streaming logs for MikroTik configuration application and enhance error handling in response processing
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m2s
2026-02-03 20:40:32 +07:00
denozord b374faa593 feat(MikrotikConfig): add ping endpoint and enhance applyMikrotikConfig error handling with safe response method
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m57s
2026-02-03 20:22:19 +07:00
denozord b581c47c28 feat(MikrotikConfig): enhance applyMikrotikConfig and NetworkConfigManager with detailed request logging and error handling improvements
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m57s
2026-02-03 20:13:08 +07:00
denozord e5f093b65f refactor(MikrotikConfig): implement timeout handling and improve error management in applyMikrotikConfig function
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m51s
2026-02-03 20:05:05 +07:00
denozord f56150694b fix(NetworkConfigManager): update button click handler to use arrow function for improved readability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m15s
2026-02-03 19:55:40 +07:00
denozord 3319f9c329 refactor(NetworkConfigManager): update API call for applying MikroTik configuration to use fetch; improve error handling and response processing
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m55s
2026-02-03 19:48:05 +07:00
denozord 94314c383b feat(MikrotikConfig): add API endpoint for applying MikroTik configuration; update frontend to support configuration application with dry-run option and modal interface
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
2026-02-03 19:26:49 +07:00
denozord 1a27217676 feat(MikrotikConnection): add MikroTik connection testing functionality in ServerModal; update API routes and README for new encryption key and connection endpoint
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m13s
2026-02-03 19:12:32 +07:00
denozord 0ea0f376a6 feat(MikrotikConfig): enhance MikroTik configuration generation with support for 'text' and 'json' formats; add new API endpoints for interface and recursive route generation
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m37s
2026-02-03 17:46:30 +07:00
denozord 5b96325184 feat(NetworkConfigManager): implement recursive gateway code generation for enhanced routing configuration, including error handling for missing gateways and parent references
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m45s
2026-01-25 21:43:53 +07:00
denozord ac34faaf48 feat(NetworkConfigManager, SettingsModal): add global PTR zone settings management, allowing users to define DNS replacements in both global and interface-specific contexts for enhanced configuration flexibility
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m47s
2026-01-25 21:35:33 +07:00
denozord f4f79a8f01 fix(NetworkConfigManager): refine IP conflict checking logic to ensure uniqueness of gateways on the same server, improving conflict notification accuracy
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m44s
2026-01-25 21:32:51 +07:00
denozord 49ee872e4a fix(NetworkConfigManager): correct comment formatting for IP conflict checking logic to enhance code clarity
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m54s
2026-01-25 21:24:43 +07:00
denozord e0163fa968 fix(NetworkConfigManager): update IP conflict checking logic to differentiate between gateway and interface IPs, ensuring accurate conflict notifications during gateway creation
Publish Fast Tabler Docker image / build-and-push-fast (push) Has been cancelled
2026-01-25 21:23:03 +07:00
denozord 2ee43d0538 feat(NetworkConfigManager): implement PTR zone replacement functionality, allowing dynamic DNS adjustments for improved server configuration and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Has been cancelled
2026-01-25 21:21:39 +07:00
denozord c7ebe0943b feat(NetworkConfigManager, GatewayAutocompleteInput): add serverId handling for gateway filtering, enhancing configuration accuracy and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m2s
2026-01-25 21:06:15 +07:00
denozord d583757882 feat(NetworkConfigManager): add IP normalization function to enhance gateway IP generation, ensuring correct handling of full and partial IP inputs
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m44s
2026-01-25 19:25:52 +07:00
denozord 80c91d9c54 feat(NetworkConfigManager): enhance gateway IP generation logic to support direct and recursive gateways, improving template-based configuration and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m43s
2026-01-25 19:17:20 +07:00
denozord 604d45ed92 feat(NetworkConfigManager): add gateway templates feature for streamlined gateway creation and configuration, enhancing user experience and efficiency
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m44s
2026-01-25 14:20:56 +07:00
denozord 335f7c9b58 refactor(NetworkConfigManager): replace action buttons with compact icon buttons wrapped in tooltips for improved UI clarity and user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s
2026-01-25 14:15:39 +07:00
denozord 4feb84338a refactor(NetworkConfigManager): improve layout and styling of server cards for better user experience and readability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m47s
2026-01-25 14:08:30 +07:00
denozord 3b858441a2 refactor(NetworkConfigManager): enhance UI structure and styling for server cards, improving readability and user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m42s
2026-01-25 14:00:54 +07:00
denozord cc77a21ba1 feat(NetworkConfigManager): add server-wide MikroTik code generation for interfaces, improving bulk configuration capabilities and user feedback
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s
2026-01-25 12:26:26 +07:00
denozord 6f1d307ee0 feat(NetworkConfigManager): implement category-based accordion view for gateways and interfaces, improving organization and user navigation
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m51s
2026-01-25 03:46:35 +07:00
denozord 0310147044 feat(NetworkConfigManager): add GRE interface list support for home routers, enhancing tunnel management and configuration clarity
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m45s
2026-01-25 03:16:02 +07:00
denozord 2bdad8445e feat(NetworkConfigManager): add support for local-address parameter in GRE tunnel configuration for home routers, improving network setup clarity
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m44s
2026-01-24 18:21:28 +07:00
denozord cd4b95bb8f feat(NetworkConfigManager): implement MikroTik code generation for individual gateways, enhancing user experience with detailed route generation and error handling
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m41s
2026-01-24 13:59:17 +07:00
denozord 6ced966274 fix(NetworkConfigManager): enhance validation for /30 networks by ensuring generated local and remote IPs are always checked against network and broadcast address conflicts, improving IP generation reliability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m43s
2026-01-24 01:18:05 +07:00
denozord 32255c6a3a fix(NetworkConfigManager): enhance validation for /30 networks by ensuring local and remote IPs do not conflict with network and broadcast addresses, improving IP generation accuracy
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m41s
2026-01-24 00:52:35 +07:00
denozord 4ff9ae0f46 fix(NetworkConfigManager): refine validation logic for /30 networks to ensure local IPs are correctly derived and within valid ranges, enhancing overall IP generation accuracy
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m48s
2026-01-24 00:32:51 +07:00
denozord 4c9cb7eab6 fix(NetworkConfigManager): improve validation for /30 networks by ensuring local and remote IPs do not conflict with network and broadcast addresses
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
2026-01-23 19:18:06 +07:00
denozord f60611e689 feat(NetworkConfigManager): implement MikroTik code generation for individual and selected interfaces, enhancing user interaction and flexibility in network configuration
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m37s
2026-01-23 15:59:11 +07:00
denozord b7a55e4241 fix(NetworkConfigManager): enhance IP generation logic for /30 networks to ensure valid address ranges and prevent conflicts with reserved IPs
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m39s
2026-01-23 14:24:36 +07:00
denozord cef571cc40 feat(NetworkConfigManager): add MTU field to interface configuration, allowing for better network performance tuning and flexibility in settings
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m19s
2026-01-23 00:15:02 +07:00
denozord 9b74b77391 refactor(NetworkConfigManager): remove unused MTU settings from interface creation logic to streamline code and improve clarity
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m24s
2026-01-23 00:01:25 +07:00
denozord 2157c6a3c7 feat(NetworkConfigManager): add MTU configuration support for interfaces, enhancing flexibility in tunnel settings and improving user input handling
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m9s
2026-01-22 23:59:01 +07:00
denozord 26f8a97c21 fix(NetworkConfigManager): update GRE tunnel configuration to use DNS domain for remote address if available, improving flexibility in server address handling
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m33s
2026-01-22 23:34:09 +07:00
denozord 3ab4c4095a feat(ServerManagement): add 'home' server type to server configurations and update related components to handle billing and filtering exclusions for home routers 2026-01-22 23:30:04 +07:00
80 changed files with 20142 additions and 3969 deletions
+5 -1
View File
@@ -52,4 +52,8 @@ jobs:
- 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."
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 }}"
+5 -1
View File
@@ -44,4 +44,8 @@ jobs:
org.opencontainers.image.revision=${{ gitea.sha }}
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
build-args: |
BUILDKIT_INLINE_CACHE=1
BUILDKIT_INLINE_CACHE=1
- name: Trigger container update webhook
run: |
curl -X POST -sS --fail "${{ secrets.portainer_webhook }}"
+9 -3
View File
@@ -36,6 +36,7 @@ Frontend доступен на `http://localhost:5173` (по умолчанию)
### Важные переменные окружения
- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, `S3_BUCKET_NAME` — доступ к Object Storage
- `ENCRYPTION_KEY` — ключ шифрования (64 hex символа) для IPSec и MikroTik паролей
- `CORS_ORIGINS` — список разрешённых Origin через запятую (если пусто — разрешены все)
- `LOG_LEVEL` — уровень логов pino (`info` по умолчанию)
- `PORT` — порт backend (по умолчанию 3001)
@@ -83,7 +84,7 @@ BGP_BACKGROUND_URL=http://77.232.38.173:8080/api/update_bgp/background?api_key=d
### Фильтры и конфигурации
- GET `/api/filters` / POST `/api/filters` — фильтры для всех серверов.
- GET `/api/filters/generate-config` — сгенерировать конфиг MikroTik из `filters.json`.
- GET `/api/filters/generate-config?format=text|json` — сгенерировать конфиг MikroTik из `filters.json`.
- POST `/api/filters/export-config` — сохранить сгенерированный конфиг в S3 (`mikrotik-frouting-config.txt`).
- GET `/api/server-configs` / POST `/api/server-configs` — список серверов (id, name, ...).
@@ -93,7 +94,12 @@ BGP_BACKGROUND_URL=http://77.232.38.173:8080/api/update_bgp/background?api_key=d
- GET `/api/server-filters/:serverId` — фильтры сервера.
- POST `/api/server-filters/:serverId` — сохранить фильтры сервера.
- POST `/api/server-filters/generate-config` — сгенерировать конфиг MikroTik на лету из переданных `{ filters }`.
- POST `/api/server-filters/generate-config` — сгенерировать конфиг MikroTik на лету из переданных `{ filters, format?: 'text'|'json' }`.
- POST `/api/mikrotik/generate` — сгенерировать конфиг интерфейсов и маршрутов. Body: `{ format?: 'text'|'json', type?: 'interfaces'|'recursive'|'all', serverId?: string, config?, servers? }`. Возвращает `{ blocks }` — массив блоков с полем `code` (text) или `operations` (json).
- GET `/api/mikrotik/generate-interfaces?format=text|json&serverId=` — только интерфейсы.
- GET `/api/mikrotik/generate-recursive-routes?format=text|json&serverId=` — только рекурсивные маршруты.
- POST `/api/mikrotik/test-connection` — проверить соединение с MikroTik. Body: `{ serverId }` или `{ host, port?, user?, password }`.
- POST `/api/mikrotik/apply` — применить конфигурацию на MikroTik по API. Body: `{ serverId, type?: 'interfaces'|'recursive'|'all', dryRun?: boolean }`. Только для jumphost.
### Прочее
- GET `/api/servers` / POST `/api/servers` — список серверов.
@@ -103,7 +109,7 @@ BGP_BACKGROUND_URL=http://77.232.38.173:8080/api/update_bgp/background?api_key=d
- GET `/api/servers/availability?ttlSeconds=60` — быстрый TCP‑чек доступности нод.
- GET `/api/s3/last-modified` — метаданные S3 (etag/lastModified/contentLength) по ключевым файлам.
- POST `/api/update-bgp/background` — прокси к фоновой задаче обновления BGP. Требует `BGP_BACKGROUND_URL` в `.env`.
- Locks: GET `/api/locks/:resource`, POST `/api/locks/:resource`, DELETE `/api/locks/:resource`.
- Locks: GET `/api/locks/:resource`, POST `/api/locks/:resource`, DELETE `/api/locks/:resource` (опциональные soft-locks для редактирования; доступ к S3 и долгие операции вроде speed-test блокировок не используют).
- History: GET `/api/history/:resource`, POST `/api/history/:resource/rollback`.
## Frontend
+17
View File
@@ -195,6 +195,22 @@ function sanitizeString(str) {
return cleaned.trim();
}
/**
* Валидация идентификатора сервера для использования в S3-ключах и путях.
* Запрещает path traversal (.., /, \) и небезопасные символы.
* @param {string} serverId - идентификатор сервера
* @param {object} opts - { maxLength: number } (по умолчанию 128)
* @returns {boolean}
*/
function isValidServerId(serverId, opts = {}) {
if (!serverId || typeof serverId !== 'string') return false;
const maxLen = opts.maxLength != null ? opts.maxLength : 128;
const trimmed = serverId.trim();
if (trimmed.length === 0 || trimmed.length > maxLen) return false;
if (trimmed.includes('..') || trimmed.includes('/') || trimmed.includes('\\')) return false;
return /^[a-zA-Z0-9_.-]+$/.test(trimmed);
}
/**
* Проверка на SQL injection паттерны
* @param {string} str - строка для проверки
@@ -328,6 +344,7 @@ module.exports = {
isValidASN,
isValidCommunity,
isValidGateway,
isValidServerId,
sanitizeString,
isSafeSQLString,
isSafeXSSString,
+5 -5
View File
@@ -16,17 +16,17 @@
"license": "ISC",
"description": "",
"dependencies": {
"@aws-sdk/client-s3": "^3.687.0",
"ajv": "^8.17.1",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^17.0.1",
"express": "^4.19.2",
"compression": "^1.7.4",
"ajv": "^8.17.1",
"helmet": "^7.1.0",
"express-rate-limit": "^6.11.2",
"helmet": "^7.1.0",
"pino": "^9.4.0",
"pino-http": "^10.3.0",
"prom-client": "^15.1.3",
"@aws-sdk/client-s3": "^3.687.0"
"prom-client": "^15.1.3"
},
"devDependencies": {
"nodemon": "^3.1.10"
+18 -6
View File
@@ -9,13 +9,17 @@ const { streamToString } = require('../services/s3Service');
const { generateMikrotikConfig } = require('../utils/mikrotikConfig');
const { getS3JsonWithHeaders } = require('../utils/s3Helpers');
// GET /api/filters/generate-config
// GET /api/filters/generate-config?format=text|json
async function generateConfig(req, res) {
try {
const format = (req.query.format || 'text').toLowerCase();
if (!['text', 'json'].includes(format)) {
return sendError(res, 400, 'format must be "text" or "json"', 'E_BAD_REQUEST');
}
await getS3JsonWithHeaders('filters.json', req, res, {
transform: (filters) => {
const config = generateMikrotikConfig(filters);
return { config };
const result = generateMikrotikConfig(filters, { format });
return format === 'json' ? result : { config: result };
},
defaultValue: [],
});
@@ -54,10 +58,18 @@ async function exportConfig(req, res) {
}
// POST /api/server-filters/generate-config
// Body: { filters, format?: 'text'|'json' }
async function generateServerFilterConfig(req, res) {
const { filters } = req.body;
const config = generateMikrotikConfig(filters);
res.json({ config });
const { filters, format = 'text' } = req.body || {};
const fmt = String(format || 'text').toLowerCase();
if (!['text', 'json'].includes(fmt)) {
return sendError(res, 400, 'format must be "text" or "json"', 'E_BAD_REQUEST');
}
const result = generateMikrotikConfig(Array.isArray(filters) ? filters : [], { format: fmt });
if (fmt === 'json') {
return res.json(result);
}
res.json({ config: result });
}
module.exports = {
+330
View File
@@ -0,0 +1,330 @@
/**
* Роуты для бэкапов конфигурации MikroTik
* Хранят сырый текст конфигурации (например, вывод /export) в S3.
*
* Ключи в S3: backups/mikrotik/{serverId}/{timestamp}-{rand}.rsc
*
* Задачи:
* - создать бэкап (ручной или из планировщика)
* - получить список бэкапов для сервера
* - прочитать конкретный бэкап
* - (опционально) отдать две версии для сравнения и отката
*/
const crypto = require('crypto');
const { sendError, sendOk } = require('../middleware/errorHandler');
const { writeS3TextObject, readS3TextObject, listS3Objects } = require('../services/s3Service');
const { readServersFromS3 } = require('./serversRoutes');
const { createRosClient, fetchExportViaFile } = require('../services/mikrotikApplyService');
const { encrypt, decrypt } = require('../utils/encryption');
const BACKUP_PREFIX = 'backups/mikrotik';
const ENCRYPTED_PREFIX = 'ENC:';
/** Расшифровать содержимое бэкапа (поддерживает старые незашифрованные) */
function decryptBackupContent(raw) {
if (!raw || typeof raw !== 'string') return '';
if (raw.startsWith(ENCRYPTED_PREFIX)) {
try {
return decrypt(raw.slice(ENCRYPTED_PREFIX.length));
} catch (e) {
console.error('Backup decryption failed:', e?.message);
throw new Error(`Failed to decrypt backup: ${e?.message || 'decryption error'}`);
}
}
return raw;
}
function buildBackupKey(serverId, createdAt, suffix) {
const safeId = String(serverId || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_');
const ts = createdAt.toISOString().replace(/[:-]/g, '').replace(/\.\d+Z$/, 'Z');
const rand = suffix || crypto.randomBytes(3).toString('hex');
return `${BACKUP_PREFIX}/${safeId}/${ts}-${rand}.rsc`;
}
/**
* Внутренняя утилита: сохранить бэкап в S3.
* Используется как HTTP-роутом, так и планировщиком.
*/
async function saveBackupForServer(serverId, config, { source, comment } = {}) {
const now = new Date();
const key = buildBackupKey(serverId, now);
const headerLines = [
'# MikroTik configuration backup',
`# Server: ${serverId}`,
`# CreatedAt: ${now.toISOString()}`,
source ? `# Source: ${String(source)}` : null,
comment ? `# Comment: ${String(comment).replace(/\r?\n/g, ' ')}` : null,
'',
].filter(Boolean);
let content = `${headerLines.join('\n')}\n${String(config || '').replace(/^\uFEFF/, '')}`;
// Шифруем содержимое на S3, если задан ENCRYPTION_KEY
if (process.env.ENCRYPTION_KEY) {
try {
content = ENCRYPTED_PREFIX + encrypt(content);
} catch (e) {
console.error('Backup encryption failed:', e?.message);
throw new Error(`Failed to encrypt backup: ${e?.message || 'encryption error'}`);
}
}
const meta = await writeS3TextObject(key, content, 'text/plain');
return { key, createdAt: now.toISOString(), s3: meta };
}
/**
* POST /api/mikrotik/backups
* Body: { serverId: string, config: string, source?: string, comment?: string }
*
* Сохраняет текст конфигурации (обычно вывод /export) в S3.
* Автоматический бэкап можно организовать внешним планировщиком, дергающим этот эндпоинт.
*/
async function createBackup(req, res) {
try {
const { serverId, config, source, comment } = req.body || {};
if (!serverId || typeof serverId !== 'string') {
return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
}
if (!config || typeof config !== 'string') {
return sendError(res, 400, 'config (MikroTik export text) is required', 'E_BAD_REQUEST');
}
const { key, createdAt, s3 } = await saveBackupForServer(serverId, config, { source, comment });
return res.json({
ok: true,
key,
serverId,
createdAt,
s3,
});
} catch (error) {
console.error('createBackup error:', error);
return sendError(res, 500, error.message || 'Error creating backup', 'E_BACKUP_CREATE');
}
}
/**
* GET /api/mikrotik/backups?serverId=...
*
* Возвращает список бэкапов для сервера (без содержимого).
*/
async function listBackups(req, res) {
try {
const serverId = req.query.serverId;
if (!serverId || typeof serverId !== 'string') {
return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
}
const prefix = `${BACKUP_PREFIX}/${String(serverId).replace(/[^a-zA-Z0-9._-]/g, '_')}/`;
const objects = await listS3Objects(prefix, { maxKeys: 200 });
const items = objects
.filter(o => o.key && o.key.startsWith(prefix))
.map(o => ({
key: o.key,
serverId,
createdAt: o.lastModified,
size: o.size,
etag: o.etag,
}))
.sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
return res.json({ ok: true, items });
} catch (error) {
console.error('listBackups error:', error);
return sendError(res, 500, error.message || 'Error listing backups', 'E_BACKUP_LIST');
}
}
/**
* GET /api/mikrotik/backups/item?key=...
*
* Возвращает содержимое конкретного бэкапа.
*/
async function getBackup(req, res) {
try {
const key = req.query.key;
if (!key || typeof key !== 'string') {
return sendError(res, 400, 'key is required', 'E_BAD_REQUEST');
}
if (!key.startsWith(`${BACKUP_PREFIX}/`)) {
return sendError(res, 400, 'Invalid backup key', 'E_BAD_REQUEST');
}
const data = await readS3TextObject(key);
const config = decryptBackupContent(data.body || '');
return res.json({
ok: true,
key,
config,
etag: data.etag,
lastModified: data.lastModified,
contentLength: data.contentLength,
});
} catch (error) {
console.error('getBackup error:', error);
return sendError(res, 500, error.message || 'Error reading backup', 'E_BACKUP_READ');
}
}
/**
* POST /api/mikrotik/backups/diff
* Body: { keyA: string, keyB: string }
*
* Простой текстовый diff: возвращает оба варианта и флаг same.
* Визуальное сравнение (подсветка отличий) можно сделать на frontend.
*/
async function diffBackups(req, res) {
try {
const { keyA, keyB } = req.body || {};
if (!keyA || !keyB) {
return sendError(res, 400, 'keyA and keyB are required', 'E_BAD_REQUEST');
}
if (!String(keyA).startsWith(`${BACKUP_PREFIX}/`) || !String(keyB).startsWith(`${BACKUP_PREFIX}/`)) {
return sendError(res, 400, 'Invalid backup keys', 'E_BAD_REQUEST');
}
const [a, b] = await Promise.all([
readS3TextObject(keyA),
readS3TextObject(keyB),
]);
const bodyA = decryptBackupContent(a.body || '');
const bodyB = decryptBackupContent(b.body || '');
return res.json({
ok: true,
same: bodyA === bodyB,
a: {
key: keyA,
etag: a.etag,
lastModified: a.lastModified,
contentLength: a.contentLength,
},
b: {
key: keyB,
etag: b.etag,
lastModified: b.lastModified,
contentLength: b.contentLength,
},
// Сырые тексты отдаем целиком — UI сам покажет diff
configA: bodyA,
configB: bodyB,
});
} catch (error) {
console.error('diffBackups error:', error);
return sendError(res, 500, error.message || 'Error diffing backups', 'E_BACKUP_DIFF');
}
}
/**
* Вспомогательная функция: получить MikroTik REST-клиент по serverId
*/
async function getMikrotikClientForServerId(serverId) {
const servers = await readServersFromS3();
const server = servers.find((s) => (s.id || s.dns || s.ip) === serverId);
if (!server || server.type !== 'jumphost') {
const err = new Error('Jumphost server not found');
err.code = 'E_NOT_FOUND';
throw err;
}
const host = server.mikrotikHost || server.ip || server.dns;
if (!host) {
const err = new Error('MikroTik host is not configured for this server');
err.code = 'E_HOST';
throw err;
}
let password = '';
if (server.encryptedMikrotikPassword) {
try {
password = decrypt(server.encryptedMikrotikPassword);
} catch (e) {
const err = new Error('Failed to decrypt MikroTik password');
err.code = 'E_CREDENTIALS';
throw err;
}
} else {
const err = new Error('MikroTik credentials not configured for this server');
err.code = 'E_CREDENTIALS';
throw err;
}
const port = server.mikrotikPort;
const restPort = port === 80 || port === 443 || port === 8443 ? port : 80;
const user = server.mikrotikUser || 'admin';
const creds = { host, port: restPort, user, password, secure: false };
return createRosClient(creds);
}
/**
* POST /api/mikrotik/backups/run
* Body: { serverId: string }
*
* Немедленно делает /rest/export compact на указанном MikroTik и сохраняет результат в S3.
*/
async function runBackupNow(req, res) {
try {
const { serverId } = req.body || {};
if (!serverId || typeof serverId !== 'string') {
return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
}
let client;
try {
client = await getMikrotikClientForServerId(serverId);
} catch (e) {
if (e.code === 'E_NOT_FOUND') {
return sendError(res, 400, e.message, e.code);
}
if (e.code === 'E_HOST' || e.code === 'E_CREDENTIALS') {
return sendError(res, 400, e.message, e.code);
}
throw e;
}
// API не возвращает текст /export напрямую — экспортируем в файл, читаем, удаляем
const config = await fetchExportViaFile(client);
if (!config || !config.trim()) {
return sendError(res, 502, 'Empty export result from MikroTik', 'E_EMPTY_EXPORT');
}
const { key, createdAt, s3 } = await saveBackupForServer(serverId, config, {
source: 'manual',
comment: 'Manual backup from UI (/api/mikrotik/backups/run)',
});
return res.json({
ok: true,
key,
serverId,
createdAt,
s3,
});
} catch (error) {
console.error('runBackupNow error:', error?.message, error?.stack, error?.response?.data);
let msg = error?.message || 'Error running backup now';
const detail = error?.response?.data;
if (detail && typeof detail === 'object') {
const d = detail.detail || detail.message;
if (d) msg += ` (${d})`;
} else if (typeof detail === 'string') {
msg += ` (${detail})`;
}
return sendError(res, 500, msg, 'E_BACKUP_RUN', process.env.NODE_ENV !== 'production' ? { stack: error?.stack } : undefined);
}
}
module.exports = {
createBackup,
listBackups,
getBackup,
diffBackups,
saveBackupForServer,
runBackupNow,
};
File diff suppressed because it is too large Load Diff
+253 -1
View File
@@ -2,7 +2,7 @@
* Разные утилитарные роуты (auto-urls, history, s3 meta, availability, etc.)
*/
const { s3, BUCKET_NAME, writeS3TextObject, headMeta, streamToString } = require('../services/s3Service');
const { s3, BUCKET_NAME, writeS3TextObject, writeS3JsonObject, readS3TextObject, headMeta, streamToString } = require('../services/s3Service');
const { sendError, sendOk } = require('../middleware/errorHandler');
const { resourceToKey, toIso } = require('../utils/helpers');
const { GetObjectCommand, PutObjectCommand, ListObjectVersionsCommand, CopyObjectCommand } = require('@aws-sdk/client-s3');
@@ -309,6 +309,255 @@ function tcpCheck(host, port, timeoutMs) {
});
}
/** Измерить RTT (мс) до host:port по TCP. Возвращает число мс или null при ошибке/таймауте. */
function measureTcpRtt(host, port = 443, timeoutMs = 6000) {
return new Promise((resolve) => {
const start = Date.now();
const socket = new net.Socket();
let settled = false;
const settle = (ms) => {
if (!settled) {
settled = true;
try { socket.destroy(); } catch (_) {}
resolve(ms);
}
};
socket.setTimeout(timeoutMs, () => settle(null));
socket.once('error', () => settle(null));
socket.connect(port, host, () => {
const ms = Math.round(Date.now() - start);
settle(ms);
});
});
}
/** Список целей для пинга по умолчанию (id, name, host, port, icon, color) */
const PING_SERVICES_DEFAULT = [
{ id: 'google', name: 'Google', host: '8.8.8.8', port: 443, icon: 'IconBrandGoogle', color: 'blue' },
{ id: 'cloudflare', name: 'Cloudflare', host: '1.1.1.1', port: 443, icon: 'IconBrandCloudflare', color: 'orange' },
{ id: 'yandex', name: 'Yandex', host: 'ya.ru', port: 443, icon: 'IconBrandYandex', color: 'red' },
{ id: 'instagram', name: 'Instagram', host: 'instagram.com', port: 443, icon: 'IconBrandInstagram', color: 'pink' },
];
function getPingServicesListFromSettings(uiSettings) {
const raw = uiSettings.pingServicesList;
if (Array.isArray(raw) && raw.length > 0) {
return raw.map((s) => ({
id: String(s?.id ?? '').trim() || `svc-${Math.random().toString(36).slice(2, 9)}`,
name: String(s?.name ?? '').trim() || 'Сервис',
host: String(s?.host ?? '').trim() || '0.0.0.0',
port: Math.max(1, parseInt(s?.port, 10) || 443),
icon: typeof s?.icon === 'string' ? s.icon : 'IconWorld',
color: typeof s?.color === 'string' ? s.color : 'primary',
})).filter((s) => s.host !== '0.0.0.0');
}
return PING_SERVICES_DEFAULT;
}
/** Загрузить UI-настройки из S3 (для pingServicesSource и др.) */
async function loadUiSettingsSync() {
try {
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/rt_ui_settings.json' }));
const jsonText = await streamToString(data.Body);
const parsed = JSON.parse(jsonText || '{}');
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
const PING_SERVICES_CACHE_KEY_PREFIX = 'ping-services/cache_';
const PING_SERVICES_HISTORY_KEY_PREFIX = 'ping-services/history_';
const PING_HISTORY_MAX = 30;
function pingServicesStorageSuffix(viaRouter, serverId, gatewayIp) {
return (viaRouter ? 'router' : 'web') + '_' + String(serverId || '').replace(/[^a-zA-Z0-9.-]/g, '_') + '_' + String(gatewayIp || '').replace(/[^a-zA-Z0-9.]/g, '_');
}
/**
* Прочитать историю замеров пингов из S3. Формат: { byId: { [serviceId]: number[] } }.
*/
async function readPingServicesHistory(suffix) {
const key = PING_SERVICES_HISTORY_KEY_PREFIX + suffix;
try {
const raw = await readS3TextObject(key).catch(() => null);
if (!raw?.body) return {};
const parsed = JSON.parse(raw.body);
return parsed?.byId && typeof parsed.byId === 'object' ? parsed.byId : {};
} catch {
return {};
}
}
/**
* Записать историю замеров пингов в S3.
*/
async function writePingServicesHistory(suffix, byId) {
const key = PING_SERVICES_HISTORY_KEY_PREFIX + suffix;
await writeS3JsonObject(key, { byId }).catch((err) => console.warn('[ping-services] history write failed:', err?.message));
}
/**
* Обновить кеш пинг-сервисов: выполнить пинги по списку из настроек и записать результат в S3.
* Используется планировщиком и при промахе кеша в getPingServices.
* @returns {Promise<Record<string, { id: string, name: string, host: string, ms: number|null }>>} byId
*/
async function refreshPingServicesCache() {
const uiSettings = await loadUiSettingsSync();
const servicesList = getPingServicesListFromSettings(uiSettings);
const fallbackPayload = {};
servicesList.forEach((s) => { fallbackPayload[s.id] = { id: s.id, name: s.name, host: s.host, ms: null }; });
const viaRouter = String(uiSettings.pingServicesSource || 'web').toLowerCase() === 'router';
const cacheSeconds = Math.max(0, parseInt(uiSettings.pingServicesCacheSeconds, 10) || 0);
let serverId = null;
let gatewayIp = null;
if (viaRouter) {
const { readServersFromS3 } = require('./serversRoutes');
const servers = await readServersFromS3();
const routerServers = servers.filter(
(s) => s && (String(s.type || '').toLowerCase() === 'jumphost' || String(s.type || '').toLowerCase() === 'home')
);
serverId = (uiSettings.pingServicesServerId && String(uiSettings.pingServicesServerId).trim()) || (routerServers[0] && (routerServers[0].id || routerServers[0].dns || routerServers[0].ip));
gatewayIp = (uiSettings.pingServicesGatewayIp && String(uiSettings.pingServicesGatewayIp).trim()) || null;
}
const suffix = pingServicesStorageSuffix(viaRouter, serverId, gatewayIp);
const cacheKey = cacheSeconds > 0 ? PING_SERVICES_CACHE_KEY_PREFIX + suffix : null;
if (viaRouter) {
const { runPingViaRouter } = require('./mikrotikConfigRoutes');
const { readServersFromS3 } = require('./serversRoutes');
const servers = await readServersFromS3();
const routerServers = servers.filter(
(s) => s && (String(s.type || '').toLowerCase() === 'jumphost' || String(s.type || '').toLowerCase() === 'home')
);
let gatewayIpResolved = gatewayIp;
if (!gatewayIpResolved && serverId && routerServers.length > 0) {
const server = routerServers.find((s) => (s.id || s.dns || s.ip) === serverId) || routerServers[0];
const gateways = Array.isArray(server.gateways) ? server.gateways : [];
const primary = gateways.find((g) => g && g.primary) || gateways[0];
gatewayIpResolved = primary && (primary.ip || primary.remoteIp) ? (primary.ip || primary.remoteIp) : null;
}
if (!serverId) {
console.warn('[ping-services] router mode: no serverId (no jumphost/home in settings or in servers list)');
return fallbackPayload;
}
const results = await Promise.all(
servicesList.map(async (svc) => {
try {
const result = await runPingViaRouter(serverId, gatewayIpResolved || null, svc.host, 3);
const ms = typeof result.avgMs === 'number' ? Math.round(result.avgMs) : null;
return { id: svc.id, name: svc.name, host: svc.host, ms };
} catch (err) {
console.warn('[ping-services] runPingViaRouter failed for', svc.host, err?.message || err);
return { id: svc.id, name: svc.name, host: svc.host, ms: null };
}
})
);
const byId = {};
results.forEach((r) => { byId[r.id] = r; });
if (cacheKey) {
writeS3JsonObject(cacheKey, { byId, cachedAt: Date.now() }).catch((err) => console.warn('[ping-services] cache write failed:', err?.message));
}
const history = await readPingServicesHistory(suffix);
Object.keys(byId).forEach((id) => {
const ms = byId[id]?.ms;
if (typeof ms !== 'number') return;
const list = Array.isArray(history[id]) ? history[id] : [];
history[id] = [...list, ms].slice(-PING_HISTORY_MAX);
});
await writePingServicesHistory(suffix, history);
return byId;
}
const results = await Promise.all(
servicesList.map(async (svc) => {
const ms = await measureTcpRtt(svc.host, svc.port, 6000);
return { id: svc.id, name: svc.name, host: svc.host, ms };
})
);
const byId = {};
results.forEach((r) => { byId[r.id] = r; });
if (cacheKey) {
writeS3JsonObject(cacheKey, { byId, cachedAt: Date.now() }).catch((err) => console.warn('[ping-services] cache write failed:', err?.message));
}
const history = await readPingServicesHistory(suffix);
Object.keys(byId).forEach((id) => {
const ms = byId[id]?.ms;
if (typeof ms !== 'number') return;
const list = Array.isArray(history[id]) ? history[id] : [];
history[id] = [...list, ms].slice(-PING_HISTORY_MAX);
});
await writePingServicesHistory(suffix, history);
return byId;
}
// GET /api/ping-services-list — список сервисов для пинга из настроек (для дашборда и редактора)
async function getPingServicesList(req, res) {
try {
const uiSettings = await loadUiSettingsSync();
const list = getPingServicesListFromSettings(uiSettings);
return res.json({ list });
} catch (e) {
console.error('ping-services-list error', e);
return res.json({ list: PING_SERVICES_DEFAULT });
}
}
// GET /api/ping-services — RTT до сервисов из списка (веб или через RouterOS по настройке)
async function getPingServices(req, res) {
try {
const uiSettings = await loadUiSettingsSync();
const servicesList = getPingServicesListFromSettings(uiSettings);
const fallbackPayload = {};
servicesList.forEach((s) => { fallbackPayload[s.id] = { id: s.id, name: s.name, host: s.host, ms: null }; });
const viaRouter = String(uiSettings.pingServicesSource || 'web').toLowerCase() === 'router';
const cacheSeconds = Math.max(0, parseInt(uiSettings.pingServicesCacheSeconds, 10) || 0);
let serverId = null;
let gatewayIp = null;
if (viaRouter) {
const { readServersFromS3 } = require('./serversRoutes');
const servers = await readServersFromS3();
const routerServers = servers.filter(
(s) => s && (String(s.type || '').toLowerCase() === 'jumphost' || String(s.type || '').toLowerCase() === 'home')
);
serverId = (uiSettings.pingServicesServerId && String(uiSettings.pingServicesServerId).trim()) || (routerServers[0] && (routerServers[0].id || routerServers[0].dns || routerServers[0].ip));
gatewayIp = (uiSettings.pingServicesGatewayIp && String(uiSettings.pingServicesGatewayIp).trim()) || null;
}
const suffix = pingServicesStorageSuffix(viaRouter, serverId, gatewayIp);
const cacheKey = cacheSeconds > 0 ? PING_SERVICES_CACHE_KEY_PREFIX + suffix : null;
const skipCache = ['1', 'true', 'yes'].includes(String(req.query?.refresh || req.query?.nocache || '').toLowerCase());
if (cacheKey && !skipCache) {
try {
const raw = await readS3TextObject(cacheKey).catch(() => null);
if (raw?.body) {
const cached = JSON.parse(raw.body);
const cachedAt = typeof cached.cachedAt === 'number' ? cached.cachedAt : 0;
const ttlMs = cacheSeconds * 1000;
if (cachedAt && Date.now() - cachedAt < ttlMs && cached.byId && typeof cached.byId === 'object') {
const history = await readPingServicesHistory(suffix);
return res.json({ byId: cached.byId, history });
}
}
} catch (_) {}
}
const byId = await refreshPingServicesCache();
const history = await readPingServicesHistory(suffix);
return res.json({ byId, history });
} catch (e) {
console.error('ping-services error', e);
const fallback = {};
PING_SERVICES_DEFAULT.forEach((s) => { fallback[s.id] = { id: s.id, name: s.name, host: s.host, ms: null }; });
res.status(500).json({ byId: fallback, history: {} });
}
}
function anyTrue(promises) {
return new Promise((resolve) => {
if (!Array.isArray(promises) || promises.length === 0) return resolve(false);
@@ -524,5 +773,8 @@ module.exports = {
getWsUrl,
getUiSettings,
postUiSettings,
getPingServicesList,
getPingServices,
refreshPingServicesCache,
};
+149
View File
@@ -0,0 +1,149 @@
/**
* Роуты для сбора статистики ресурсов (RAM, CPU, HDD) с MikroTik RouterOS.
* GET /api/resources/stats — system/resource по каждому jumphost/home с учётами MikroTik.
*/
const { sendError } = require('../middleware/errorHandler');
const { decrypt } = require('../utils/encryption');
const { readServersFromS3 } = require('./serversRoutes');
const { createRosClient } = require('../services/mikrotikApplyService');
function getMikrotikCredentials(server) {
if (!server || (server.type !== 'jumphost' && server.type !== 'home')) return null;
const host = server.mikrotikHost || server.ip || server.dns;
if (!host) return null;
const port = server.mikrotikPort;
const restPort = (port === 80 || port === 443 || port === 8443) ? port : 80;
const user = server.mikrotikUser || 'admin';
let password = '';
if (server.encryptedMikrotikPassword) {
try {
password = decrypt(server.encryptedMikrotikPassword);
} catch (_) {
return null;
}
}
return { host, port: restPort, user, password, secure: false };
}
/**
* Парсинг system/resource в нормализованный объект для UI.
* MikroTik REST возвращает: total-memory, free-memory (bytes), cpu-load (%),
* total-hdd-space, free-hdd-space (bytes), uptime, version, board-name и др.
*/
function parseResource(raw) {
if (!raw || typeof raw !== 'object') return null;
const num = (v) => (v != null && v !== '' ? parseInt(String(v), 10) : null);
const totalMemory = num(raw['total-memory']);
const freeMemory = num(raw['free-memory']);
const usedMemory = totalMemory != null && freeMemory != null ? totalMemory - freeMemory : null;
const totalHdd = num(raw['total-hdd-space']);
const freeHdd = num(raw['free-hdd-space']);
const usedHdd = totalHdd != null && freeHdd != null ? totalHdd - freeHdd : null;
const cpuLoad = raw['cpu-load'] != null ? parseFloat(String(raw['cpu-load'])) : null;
return {
// RAM (bytes и проценты)
totalMemory: totalMemory,
freeMemory: freeMemory,
usedMemory: usedMemory,
memoryUsagePercent:
totalMemory != null && totalMemory > 0 && usedMemory != null
? Math.round((usedMemory / totalMemory) * 100)
: null,
// CPU (%)
cpuLoad: cpuLoad != null ? Math.round(cpuLoad) : null,
cpuCount: num(raw['cpu-count']) ?? null,
cpuFrequency: raw['cpu-frequency'] != null ? String(raw['cpu-frequency']) : null,
cpu: raw.cpu != null ? String(raw.cpu) : null,
// HDD (bytes и проценты)
totalHddSpace: totalHdd,
freeHddSpace: freeHdd,
usedHddSpace: usedHdd,
hddUsagePercent:
totalHdd != null && totalHdd > 0 && usedHdd != null
? Math.round((usedHdd / totalHdd) * 100)
: null,
// прочее
uptime: raw.uptime != null ? String(raw.uptime) : null,
version: raw.version != null ? String(raw.version) : null,
boardName: raw['board-name'] != null ? String(raw['board-name']) : null,
architectureName: raw['architecture-name'] != null ? String(raw['architecture-name']) : null,
platform: raw.platform != null ? String(raw.platform) : null,
};
}
/**
* GET /api/resources/stats
* По каждому jumphost/home с MikroTik — запрос system/resource, ответ с группировкой по роутерам.
*/
async function getResourceStats(req, res) {
try {
const servers = await readServersFromS3();
const routers = (Array.isArray(servers) ? servers : []).filter(
(s) =>
s &&
(String(s.type || '').toLowerCase() === 'jumphost' ||
String(s.type || '').toLowerCase() === 'home')
);
const results = await Promise.all(
routers.map(async (server) => {
const serverId = server.id || server.dns || server.ip;
const name = server.name || server.dns || server.ip || serverId;
const host = server.mikrotikHost || server.ip || server.dns;
const creds = getMikrotikCredentials(server);
if (!creds) {
return {
serverId,
name,
host,
groupKey: server.groupKey || null,
error: 'MikroTik API не настроен или нет пароля',
resource: null,
};
}
try {
const client = createRosClient(creds);
const apiRes = await client.print('system/resource');
const raw = apiRes?.data;
const single = Array.isArray(raw) ? raw[0] : raw;
const resource = parseResource(single);
return {
serverId,
name,
host,
groupKey: server.groupKey || null,
error: null,
resource,
};
} catch (err) {
const msg = err.response?.data?.message || err.message || 'Ошибка запроса к RouterOS';
return {
serverId,
name,
host,
groupKey: server.groupKey || null,
error: msg,
resource: null,
};
}
})
);
return res.json({ routers: results });
} catch (error) {
console.error('getResourceStats:', error);
return sendError(
res,
500,
error.message || 'Ошибка сбора статистики ресурсов',
'E_RESOURCE_STATS'
);
}
}
module.exports = {
getResourceStats,
};
+259
View File
@@ -0,0 +1,259 @@
/**
* Роуты планировщика карты сети: настройки, логи, ручной запуск, кеш.
*/
const { sendError, sendOk } = require('../middleware/errorHandler');
const { readS3TextObject, writeS3JsonObject } = require('../services/s3Service');
const networkMapScheduler = require('../services/networkMapScheduler');
const pingServicesScheduler = require('../services/pingServicesScheduler');
const uptimeMonitorScheduler = require('../services/uptimeMonitorScheduler');
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json';
const SCHEDULER_KEYS = [
'networkMapSchedulerEnabled',
'networkMapSchedulerIntervalMinutes',
'networkMapSchedulerRunPing',
'networkMapSchedulerRunSpeedTest',
'networkMapSchedulerLogMaxLines',
];
const PING_SERVICES_SCHEDULER_KEYS = [
'pingServicesSchedulerEnabled',
'pingServicesSchedulerIntervalMinutes',
];
const UPTIME_MONITOR_SCHEDULER_KEYS = [
'uptimeMonitorSchedulerEnabled',
'uptimeMonitorSchedulerIntervalMinutes',
];
async function readUiSettings() {
try {
const data = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
const parsed = JSON.parse(data?.body || '{}');
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (_) {
return {};
}
}
/** GET /api/scheduler/network-map/settings */
async function getNetworkMapSchedulerSettings(req, res) {
try {
const ui = await readUiSettings();
const settings = networkMapScheduler.getSettingsFromUiSettings(ui);
const nextRunAt = networkMapScheduler.getNextRunAt(settings);
return res.json({ ...settings, nextRunAt: nextRunAt || undefined });
} catch (e) {
console.error('[scheduler] getSettings', e);
return sendError(res, 500, 'Не удалось прочитать настройки', 'E_SCHEDULER');
}
}
/** PATCH /api/scheduler/network-map/settings - обновить только ключи планировщика в ui-settings */
async function patchNetworkMapSchedulerSettings(req, res) {
try {
const body = req.body || {};
const current = await readUiSettings();
for (const key of SCHEDULER_KEYS) {
if (Object.prototype.hasOwnProperty.call(body, key)) {
current[key] = body[key];
}
}
if (Object.prototype.hasOwnProperty.call(body, 'networkMapSchedulerLogMaxLines')) {
networkMapScheduler.setLogMaxLines(body.networkMapSchedulerLogMaxLines);
}
await writeS3JsonObject(UI_SETTINGS_KEY, current);
const settings = networkMapScheduler.getSettingsFromUiSettings(current);
return res.json(settings);
} catch (e) {
console.error('[scheduler] patchSettings', e);
return sendError(res, 500, 'Не удалось сохранить настройки', 'E_SCHEDULER');
}
}
/** GET /api/scheduler/network-map/logs */
async function getNetworkMapSchedulerLogs(req, res) {
try {
const lines = networkMapScheduler.getLogs();
return res.json({ lines });
} catch (e) {
console.error('[scheduler] getLogs', e);
return sendError(res, 500, 'Не удалось прочитать логи', 'E_SCHEDULER');
}
}
/** POST /api/scheduler/network-map/run-now - один запуск по текущим настройкам */
async function runNetworkMapSchedulerNow(req, res) {
try {
const port = Number(process.env.PORT) || 3001;
const baseUrl = `http://127.0.0.1:${port}`;
const ui = await readUiSettings();
const settings = networkMapScheduler.getSettingsFromUiSettings(ui);
networkMapScheduler.runJob({
baseUrl,
runPing: settings.runPing,
runSpeedTest: settings.runSpeedTest,
trigger: 'manual',
}).catch((err) => {
console.error('[scheduler] run-now failed', err);
const msg = err?.message || String(err);
try {
networkMapScheduler.pushLog(`Ошибка выполнения: ${msg}`);
} catch (_) {}
});
return res.json({ ok: true, message: 'Запуск выполняется в фоне' });
} catch (e) {
console.error('[scheduler] run-now', e);
const msg = e?.message || String(e);
try {
networkMapScheduler.pushLog(`Ошибка запуска: ${msg}`);
} catch (_) {}
return sendError(res, 500, 'Не удалось запустить', 'E_SCHEDULER');
}
}
/** GET /api/network-map-cache - кеш пингов и скоростей для карты сети */
async function getNetworkMapCache(req, res) {
try {
const data = await readS3TextObject(NETWORK_MAP_CACHE_KEY).catch(() => null);
if (!data?.body) {
return res.json({ pingMap: {}, speedMap: {}, updatedAt: null });
}
const parsed = JSON.parse(data.body);
return res.json({
pingMap: parsed.pingMap && typeof parsed.pingMap === 'object' ? parsed.pingMap : {},
speedMap: parsed.speedMap && typeof parsed.speedMap === 'object' ? parsed.speedMap : {},
updatedAt: typeof parsed.updatedAt === 'number' ? parsed.updatedAt : null,
});
} catch (e) {
console.error('[network-map-cache] get', e);
return res.json({ pingMap: {}, speedMap: {}, updatedAt: null });
}
}
// === Ping services scheduler ===
/** GET /api/scheduler/ping-services/settings */
async function getPingServicesSchedulerSettings(req, res) {
try {
const ui = await readUiSettings();
const settings = pingServicesScheduler.getSettingsFromUiSettings(ui);
const nextRunAt = pingServicesScheduler.getNextRunAt(settings);
return res.json({ ...settings, nextRunAt: nextRunAt || undefined });
} catch (e) {
console.error('[scheduler] getPingServicesSettings', e);
return sendError(res, 500, 'Не удалось прочитать настройки', 'E_SCHEDULER');
}
}
/** PATCH /api/scheduler/ping-services/settings */
async function patchPingServicesSchedulerSettings(req, res) {
try {
const body = req.body || {};
const current = await readUiSettings();
for (const key of PING_SERVICES_SCHEDULER_KEYS) {
if (Object.prototype.hasOwnProperty.call(body, key)) {
current[key] = body[key];
}
}
await writeS3JsonObject(UI_SETTINGS_KEY, current);
const settings = pingServicesScheduler.getSettingsFromUiSettings(current);
return res.json(settings);
} catch (e) {
console.error('[scheduler] patchPingServicesSettings', e);
return sendError(res, 500, 'Не удалось сохранить настройки', 'E_SCHEDULER');
}
}
/** POST /api/scheduler/ping-services/run-now */
async function runPingServicesSchedulerNow(req, res) {
try {
const { refreshPingServicesCache } = require('./miscRoutes');
refreshPingServicesCache().catch((err) => {
console.error('[scheduler] ping-services run-now failed', err);
});
return res.json({ ok: true, message: 'Обновление кеша пинг-сервисов запущено в фоне' });
} catch (e) {
console.error('[scheduler] ping-services run-now', e);
return sendError(res, 500, 'Не удалось запустить', 'E_SCHEDULER');
}
}
// === Uptime monitor scheduler ===
/** GET /api/scheduler/uptime-monitor/settings */
async function getUptimeMonitorSchedulerSettings(req, res) {
try {
const ui = await readUiSettings();
const settings = uptimeMonitorScheduler.getSettingsFromUiSettings(ui);
const nextRunAt = uptimeMonitorScheduler.getNextRunAt(settings);
return res.json({ ...settings, nextRunAt: nextRunAt || undefined });
} catch (e) {
console.error('[scheduler] getUptimeMonitorSettings', e);
return sendError(res, 500, 'Не удалось прочитать настройки', 'E_SCHEDULER');
}
}
/** PATCH /api/scheduler/uptime-monitor/settings */
async function patchUptimeMonitorSchedulerSettings(req, res) {
try {
const body = req.body || {};
const current = await readUiSettings();
for (const key of UPTIME_MONITOR_SCHEDULER_KEYS) {
if (Object.prototype.hasOwnProperty.call(body, key)) {
current[key] = body[key];
}
}
await writeS3JsonObject(UI_SETTINGS_KEY, current);
const settings = uptimeMonitorScheduler.getSettingsFromUiSettings(current);
return res.json(settings);
} catch (e) {
console.error('[scheduler] patchUptimeMonitorSettings', e);
return sendError(res, 500, 'Не удалось сохранить настройки', 'E_SCHEDULER');
}
}
/** POST /api/scheduler/uptime-monitor/run-now */
async function runUptimeMonitorSchedulerNow(req, res) {
try {
const port = Number(process.env.PORT) || 3001;
const baseUrl = `http://127.0.0.1:${port}`;
const resServers = await fetch(`${baseUrl}/api/servers`).catch(() => null);
const data = resServers?.ok ? await resServers.json() : [];
const servers = Array.isArray(data) ? data : [];
const jumphosts = servers.filter(
(s) => s && ['jumphost', 'home'].includes(String(s.type || '').toLowerCase())
);
const serverIds = jumphosts.map((s) => s.id || s.dns || s.ip).filter(Boolean);
for (const serverId of serverIds) {
fetch(`${baseUrl}/api/uptime/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverId }),
}).catch((err) => console.warn('[scheduler] uptime check', serverId, err?.message));
}
return res.json({ ok: true, message: 'Проверки Uptime Monitor запущены в фоне' });
} catch (e) {
console.error('[scheduler] uptime-monitor run-now', e);
return sendError(res, 500, 'Не удалось запустить', 'E_SCHEDULER');
}
}
module.exports = {
getNetworkMapSchedulerSettings,
patchNetworkMapSchedulerSettings,
getNetworkMapSchedulerLogs,
runNetworkMapSchedulerNow,
getNetworkMapCache,
getPingServicesSchedulerSettings,
patchPingServicesSchedulerSettings,
runPingServicesSchedulerNow,
getUptimeMonitorSchedulerSettings,
patchUptimeMonitorSchedulerSettings,
runUptimeMonitorSchedulerNow,
};
+22 -6
View File
@@ -10,6 +10,7 @@ const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHand
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
const { streamToString } = require('../services/s3Service');
const { getS3TextWithHeaders, getS3JsonWithHeaders } = require('../utils/s3Helpers');
const { isValidServerId } = require('../lib/validators');
const S3_KEY = 'servers.json';
@@ -143,9 +144,19 @@ const serverConfigsListRoutes = {
post: postServerConfigsList
};
function validateServerIdParam(req, res) {
const serverId = req.params?.serverId;
if (!serverId || !isValidServerId(serverId)) {
sendError(res, 400, 'Invalid serverId: only alphanumeric, dot, dash, underscore allowed', 'E_BAD_REQUEST');
return null;
}
return serverId;
}
// GET /api/server-configs/:serverId (конкретная конфигурация)
async function getServerConfig(req, res) {
const { serverId } = req.params;
const serverId = validateServerIdParam(req, res);
if (serverId === null) return;
await getS3TextWithHeaders(
`filter-manager/config-${serverId}.txt`,
req,
@@ -159,7 +170,8 @@ async function getServerConfig(req, res) {
// POST /api/server-configs/:serverId (сохранить конфигурацию)
async function postServerConfig(req, res) {
const { serverId } = req.params;
const serverId = validateServerIdParam(req, res);
if (serverId === null) return;
const { config } = req.body;
if (!config) {
@@ -177,7 +189,8 @@ async function postServerConfig(req, res) {
// DELETE /api/server-configs/:serverId (удалить только конфигурацию)
async function deleteServerConfig(req, res) {
const { serverId } = req.params;
const serverId = validateServerIdParam(req, res);
if (serverId === null) return;
try {
await deleteS3Object(`filter-manager/config-${serverId}.txt`);
@@ -191,7 +204,8 @@ async function deleteServerConfig(req, res) {
// DELETE /api/server-configs/:serverId/complete (удалить конфигурацию и фильтры)
async function deleteServerComplete(req, res) {
const { serverId } = req.params;
const serverId = validateServerIdParam(req, res);
if (serverId === null) return;
try {
await Promise.allSettled([
@@ -208,7 +222,8 @@ async function deleteServerComplete(req, res) {
// GET /api/server-filters/:serverId
async function getServerFilters(req, res) {
const { serverId } = req.params;
const serverId = validateServerIdParam(req, res);
if (serverId === null) return;
await getS3JsonWithHeaders(
`filter-manager/server-filters-${serverId}.json`,
req,
@@ -219,7 +234,8 @@ async function getServerFilters(req, res) {
// POST /api/server-filters/:serverId
async function postServerFilters(req, res) {
const { serverId } = req.params;
const serverId = validateServerIdParam(req, res);
if (serverId === null) return;
const { filters } = req.body;
if (!Array.isArray(filters)) {
+159
View File
@@ -0,0 +1,159 @@
/**
* Кастомные роуты для серверов с поддержкой зашифрованных MikroTik учётных данных
* Для jumphost и home (входной роутер): mikrotikHost, mikrotikPort, mikrotikUser, encryptedMikrotikPassword
*/
const { GetObjectCommand, PutObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
const { s3, BUCKET_NAME, streamToString } = require('../services/s3Service');
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
const { encrypt, decrypt } = require('../utils/encryption');
const S3_KEY = 'servers.json';
const SERVER_TYPES = ['jumphost', 'exit', 'bgp', 'dns', 'home'];
const TYPES_NEED_TUNNEL = ['jumphost', 'exit'];
const TYPES_NEED_GATEWAYS = ['jumphost', 'exit'];
function validateServer(server, i) {
if (!server.ip || !server.dns || !server.country || !server.provider) {
return `Server at index ${i} is missing required fields (ip, dns, country, provider)`;
}
const normalizedType = String(server.type || '').toLowerCase();
if (!SERVER_TYPES.includes(normalizedType)) {
return `Server at index ${i} has invalid type (allowed: ${SERVER_TYPES.join(', ')})`;
}
if (TYPES_NEED_TUNNEL.includes(normalizedType) && !server.tunnel) {
return `Server at index ${i} (${normalizedType}) requires tunnel type`;
}
if (TYPES_NEED_GATEWAYS.includes(normalizedType)) {
if (!Array.isArray(server.gateways) || server.gateways.length === 0) {
return `Server at index ${i} must have gateways for type ${normalizedType}`;
}
const primaries = server.gateways.filter(g => g && g.primary);
if (primaries.length !== 1) {
return `Server at index ${i} must have exactly one primary gateway`;
}
for (let j = 0; j < server.gateways.length; j++) {
const gw = server.gateways[j] || {};
if (!gw.name || String(gw.name).trim().length === 0) {
return `Server at index ${i} gateway at index ${j} is missing name`;
}
}
}
server.type = normalizedType;
return null;
}
async function readServersFromS3() {
try {
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY }));
const body = await streamToString(data.Body);
const parsed = JSON.parse(body || '[]');
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
if (e?.name === 'NoSuchKey' || e?.$metadata?.httpStatusCode === 404) return [];
throw e;
}
}
async function writeServersToS3(servers) {
const body = JSON.stringify(servers, null, 2);
await s3.send(new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: S3_KEY,
Body: body,
ContentType: 'application/json',
}));
}
/**
* GET /api/servers — возвращает серверы, для jumphost заменяет encryptedMikrotikPassword на hasMikrotikPassword
*/
async function getServers(req, res) {
try {
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null);
const etag = head?.ETag || null;
if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
if (checkIfNoneMatch(req, res, etag)) return;
const servers = await readServersFromS3();
const sanitized = servers.map(s => {
const out = { ...s };
const hasMikrotikApi = s.type === 'jumphost' || s.type === 'home';
if (hasMikrotikApi && out.encryptedMikrotikPassword) {
out.hasMikrotikPassword = true;
delete out.encryptedMikrotikPassword;
} else if (hasMikrotikApi) {
out.hasMikrotikPassword = false;
}
return out;
});
res.json(sanitized);
} catch (error) {
if (error?.name === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
return res.json([]);
}
console.error(error);
return sendError(res, 500, 'Error reading servers', 'E_S3');
}
}
/**
* POST /api/servers — сохраняет серверы, шифрует mikrotikPassword при наличии
*/
async function postServers(req, res) {
const items = req.body?.domains ?? req.body?.servers;
if (!Array.isArray(items)) {
return sendError(res, 400, 'Data must be an array (domains or servers)', 'E_BAD_REQUEST');
}
for (let i = 0; i < items.length; i++) {
const err = validateServer(items[i], i);
if (err) return sendError(res, 400, err, 'E_SCHEMA');
}
try {
const currentServers = await readServersFromS3();
const findCurrent = (srv) => currentServers.find(c =>
(srv.id && c.id === srv.id) || (srv.dns && c.dns === srv.dns) || (srv.ip && c.ip === srv.ip)
);
const toSave = items.map((srv) => {
const current = findCurrent(srv);
if (srv.type === 'jumphost' || srv.type === 'home') {
const out = { ...srv };
if (srv.mikrotikPassword !== undefined && srv.mikrotikPassword !== null && String(srv.mikrotikPassword).trim() !== '') {
try {
out.encryptedMikrotikPassword = encrypt(String(srv.mikrotikPassword).trim());
} catch (encErr) {
console.error('MikroTik password encryption failed:', encErr);
}
delete out.mikrotikPassword;
} else if (current?.encryptedMikrotikPassword) {
out.encryptedMikrotikPassword = current.encryptedMikrotikPassword;
}
return out;
}
return srv;
});
await writeServersToS3(toSave);
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null);
return sendOk(res, {
etag: head?.ETag || null,
lastModified: head?.LastModified ? head.LastModified.toISOString() : null,
contentLength: typeof head?.ContentLength === 'number' ? head.ContentLength : null,
});
} catch (error) {
console.error(error);
return sendError(res, 500, error.message || 'Error saving servers', 'E_S3');
}
}
module.exports = {
getServers,
postServers,
readServersFromS3,
};
+128
View File
@@ -0,0 +1,128 @@
/**
* Роуты для сбора статистики трафика с MikroTik RouterOS (интерфейсы по jumphost).
* GET /api/traffic/interface-stats — статистика rx/tx по каждому интерфейсу для всех jumphost.
*/
const { sendError } = require('../middleware/errorHandler');
const { decrypt } = require('../utils/encryption');
const { readServersFromS3 } = require('./serversRoutes');
const { createRosClient } = require('../services/mikrotikApplyService');
/** Получить MikroTik credentials из сервера (jumphost или home) */
function getMikrotikCredentials(server) {
if (!server || (server.type !== 'jumphost' && server.type !== 'home')) return null;
const host = server.mikrotikHost || server.ip || server.dns;
if (!host) return null;
const port = server.mikrotikPort;
const restPort = (port === 80 || port === 443 || port === 8443) ? port : 80;
const user = server.mikrotikUser || 'admin';
let password = '';
if (server.encryptedMikrotikPassword) {
try {
password = decrypt(server.encryptedMikrotikPassword);
} catch (_) {
return null;
}
}
return { host, port: restPort, user, password, secure: false };
}
// UI-настройки (общие с остальным UI)
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
const INTERFACE_SPEED_CACHE_PREFIX = 'interface-speed-cache/';
async function loadUiSettings() {
try {
const data = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
const parsed = JSON.parse(data?.body || '{}');
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (_) {
return {};
}
}
function interfaceSpeedCacheKey(serverId, interfaceName) {
const key = `${serverId || ''}|${interfaceName || ''}`;
// Хэш не нужен: ключи короткие, но на всякий случай экранируем странные символы
return (
INTERFACE_SPEED_CACHE_PREFIX +
String(key)
.trim()
.replace(/[^a-zA-Z0-9_.:-]+/g, '_') +
'.json'
);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* GET /api/traffic/interface-stats
* Возвращает по каждому jumphost список интерфейсов с rx-byte, tx-byte (из RouterOS REST /interface).
*/
async function getInterfaceStats(req, res) {
try {
const servers = await readServersFromS3();
const jumphosts = (Array.isArray(servers) ? servers : []).filter(
(s) => s && (String(s.type || '').toLowerCase() === 'jumphost' || String(s.type || '').toLowerCase() === 'home')
);
const results = await Promise.all(
jumphosts.map(async (server) => {
const creds = getMikrotikCredentials(server);
if (!creds) {
return {
serverId: server.id,
name: server.name || server.dns || server.ip || server.id,
host: server.mikrotikHost || server.ip || server.dns,
error: 'MikroTik API не настроен или нет пароля',
interfaces: [],
};
}
try {
const client = createRosClient(creds);
const apiRes = await client.print('interface');
const raw = apiRes?.data;
const list = Array.isArray(raw) ? raw : raw ? [raw] : [];
const interfaces = list.map((iface) => {
const rx = iface['rx-byte'] != null ? parseInt(String(iface['rx-byte']), 10) : 0;
const tx = iface['tx-byte'] != null ? parseInt(String(iface['tx-byte']), 10) : 0;
const total = rx + tx;
return {
name: iface.name != null ? String(iface.name) : '',
type: iface.type != null ? String(iface.type) : '',
rxBytes: Number.isNaN(rx) ? 0 : rx,
txBytes: Number.isNaN(tx) ? 0 : tx,
totalBytes: Number.isNaN(total) ? 0 : total,
};
}).filter((i) => i.name && i.totalBytes >= 0);
return {
serverId: server.id,
name: server.name || server.dns || server.ip || server.id,
host: server.mikrotikHost || server.ip || server.dns,
interfaces,
};
} catch (err) {
const msg = err.response?.data?.message || err.message || 'Ошибка запроса к RouterOS';
return {
serverId: server.id,
name: server.name || server.dns || server.ip || server.id,
host: server.mikrotikHost || server.ip || server.dns,
error: msg,
interfaces: [],
};
}
})
);
return res.json({ jumphosts: results });
} catch (error) {
console.error('getInterfaceStats:', error);
return sendError(res, 500, error.message || 'Ошибка сбора статистики трафика', 'E_TRAFFIC');
}
}
module.exports = {
getInterfaceStats,
};
+89 -45
View File
@@ -27,6 +27,15 @@ const filtersRoutes = require('./routes/filtersRoutes');
const serverConfigsRoutes = require('./routes/serverConfigsRoutes');
const miscRoutes = require('./routes/miscRoutes');
const ipsecPasswordsRoutes = require('./routes/ipsecPasswordsRoutes');
const mikrotikConfigRoutes = require('./routes/mikrotikConfigRoutes');
const mikrotikBackupRoutes = require('./routes/mikrotikBackupRoutes');
const trafficRoutes = require('./routes/trafficRoutes');
const resourceStatsRoutes = require('./routes/resourceStatsRoutes');
const { 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 app = express();
const port = Number(process.env.PORT) || 3001;
@@ -301,51 +310,10 @@ app.post('/api/ip-ranges', writeLimiter, ipRangesRoutes.post);
// === JSON DATA ROUTES (используют фабрики) ===
// Servers
const SERVER_TYPES = ['jumphost', 'exit', 'bgp', 'dns'];
const TYPES_NEED_TUNNEL = ['jumphost', 'exit']; // Типы, которым нужен туннель
const TYPES_NEED_GATEWAYS = ['jumphost', 'exit']; // Типы, которым нужны gateways
const serversRoutes = createJsonDataRoutes('servers.json', (server, i) => {
// Базовые обязательные поля
if (!server.ip || !server.dns || !server.country || !server.provider) {
return `Server at index ${i} is missing required fields (ip, dns, country, provider)`;
}
const normalizedType = String(server.type || '').toLowerCase();
if (!SERVER_TYPES.includes(normalizedType)) {
return `Server at index ${i} has invalid type (allowed: ${SERVER_TYPES.join(', ')})`;
}
// Туннель обязателен только для jumphost и exit
if (TYPES_NEED_TUNNEL.includes(normalizedType) && !server.tunnel) {
return `Server at index ${i} (${normalizedType}) requires tunnel type`;
}
// Gateways обязательны только для jumphost и exit
if (TYPES_NEED_GATEWAYS.includes(normalizedType)) {
if (!Array.isArray(server.gateways) || server.gateways.length === 0) {
return `Server at index ${i} must have gateways for type ${normalizedType}`;
}
const primaries = server.gateways.filter(g => g && g.primary);
if (primaries.length !== 1) {
return `Server at index ${i} must have exactly one primary gateway`;
}
for (let j = 0; j < server.gateways.length; j++) {
const gw = server.gateways[j] || {};
if (!gw.name || String(gw.name).trim().length === 0) {
return `Server at index ${i} gateway at index ${j} is missing name`;
}
}
}
// Нормализуем тип, чтобы в S3 всегда лежали одинаковые значения
server.type = normalizedType;
return null;
});
app.get('/api/servers', serversRoutes.get);
app.post('/api/servers', serversRoutes.post);
// Servers (кастомные роуты с поддержкой зашифрованных MikroTik учётных данных для jumphost)
const serversRoutes = require('./routes/serversRoutes');
app.get('/api/servers', serversRoutes.getServers);
app.post('/api/servers', serversRoutes.postServers);
// Server connections for topology graph
const serverConnectionsRoutes = createJsonDataRoutes('server-connections.json', (conn, i) => {
@@ -477,6 +445,8 @@ app.post('/api/update-bgp/background', bgpUpdateLimiter, miscRoutes.updateBgpBac
app.get('/api/ws/url', miscRoutes.getWsUrl);
app.get('/api/ui-settings', miscRoutes.getUiSettings);
app.post('/api/ui-settings', miscRoutes.postUiSettings);
app.get('/api/ping-services-list', miscRoutes.getPingServicesList);
app.get('/api/ping-services', miscRoutes.getPingServices);
// === IPSEC PASSWORDS ===
app.get('/api/ipsec-passwords', ipsecPasswordsRoutes.getIpsecPasswords);
@@ -485,6 +455,60 @@ app.post('/api/ipsec-passwords', writeLimiter, ipsecPasswordsRoutes.createIpsecP
app.put('/api/ipsec-passwords/:id', writeLimiter, ipsecPasswordsRoutes.updateIpsecPassword);
app.delete('/api/ipsec-passwords/:id', writeLimiter, ipsecPasswordsRoutes.deleteIpsecPassword);
// === MIKROTIK CONFIG GENERATION (text + json) ===
app.post('/api/mikrotik/generate', mikrotikConfigRoutes.generateMikrotikConfig);
app.get('/api/mikrotik/generate-interfaces', mikrotikConfigRoutes.generateInterfaces);
app.get('/api/mikrotik/generate-recursive-routes', mikrotikConfigRoutes.generateRecursiveRoutes);
app.post('/api/mikrotik/test-connection', mikrotikConfigRoutes.testMikrotikConnection);
// Простой health-check (используется NetworkConfigManager при открытии модалки применения)
app.get('/api/mikrotik/ping', (req, res) => res.json({ ok: true, ts: Date.now() }));
// Реальный ping через RouterOS по интерфейсу/шлюзу
app.post('/api/mikrotik/ping', writeLimiter, mikrotikConfigRoutes.pingViaInterface);
// Traceroute через RouterOS с выбором сервера и шлюза
app.post('/api/mikrotik/traceroute', writeLimiter, mikrotikConfigRoutes.tracerouteViaGateway);
// Speed-test (TCP upload/download) между MikroTik по туннельному интерфейсу
app.post('/api/mikrotik/speed-test', writeLimiter, mikrotikConfigRoutes.speedTestViaTunnel);
app.post('/api/mikrotik/apply', mikrotikConfigRoutes.applyMikrotikConfig);
app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript);
app.get('/api/mikrotik/address-lists', mikrotikConfigRoutes.getAddressLists);
app.post('/api/mikrotik/address-lists/apply-summary', writeLimiter, mikrotikConfigRoutes.applyAddressListSummary);
// === UPTIME MONITOR (проверка доступности: http / internal-ping / external-ping из настроек) ===
app.get('/api/uptime/cache', mikrotikConfigRoutes.getUptimeCache);
app.post('/api/uptime/check', writeLimiter, mikrotikConfigRoutes.uptimeCheck);
// === TRAFFIC STATS (MikroTik interfaces by jumphost) ===
app.get('/api/traffic/interface-stats', trafficRoutes.getInterfaceStats);
// === RESOURCE STATS (RAM/CPU/HDD по роутерам) ===
app.get('/api/resources/stats', resourceStatsRoutes.getResourceStats);
// === MIKROTIK BACKUPS (S3) ===
app.post('/api/mikrotik/backups', writeLimiter, mikrotikBackupRoutes.createBackup);
app.get('/api/mikrotik/backups', mikrotikBackupRoutes.listBackups);
app.get('/api/mikrotik/backups/item', mikrotikBackupRoutes.getBackup);
app.post('/api/mikrotik/backups/diff', mikrotikBackupRoutes.diffBackups);
app.post('/api/mikrotik/backups/run', writeLimiter, mikrotikBackupRoutes.runBackupNow);
// === NETWORK MAP CACHE (для быстрой загрузки карты сети) ===
app.get('/api/network-map-cache', schedulerRoutes.getNetworkMapCache);
// === SCHEDULER (карта сети: пинг и скорость по расписанию) ===
app.get('/api/scheduler/network-map/settings', schedulerRoutes.getNetworkMapSchedulerSettings);
app.patch('/api/scheduler/network-map/settings', writeLimiter, schedulerRoutes.patchNetworkMapSchedulerSettings);
app.get('/api/scheduler/network-map/logs', schedulerRoutes.getNetworkMapSchedulerLogs);
app.post('/api/scheduler/network-map/run-now', writeLimiter, schedulerRoutes.runNetworkMapSchedulerNow);
// === SCHEDULER (пинг сервисов: обновление кеша по расписанию, по умолчанию каждые 2 мин) ===
app.get('/api/scheduler/ping-services/settings', schedulerRoutes.getPingServicesSchedulerSettings);
app.patch('/api/scheduler/ping-services/settings', writeLimiter, schedulerRoutes.patchPingServicesSchedulerSettings);
app.post('/api/scheduler/ping-services/run-now', writeLimiter, schedulerRoutes.runPingServicesSchedulerNow);
// === SCHEDULER (Uptime Monitor: проверки по расписанию, по умолчанию каждые 2 мин) ===
app.get('/api/scheduler/uptime-monitor/settings', schedulerRoutes.getUptimeMonitorSchedulerSettings);
app.patch('/api/scheduler/uptime-monitor/settings', writeLimiter, schedulerRoutes.patchUptimeMonitorSchedulerSettings);
app.post('/api/scheduler/uptime-monitor/run-now', writeLimiter, schedulerRoutes.runUptimeMonitorSchedulerNow);
// === MIKROTIK VALIDATION ===
app.post('/api/mikrotik/validate', async (req, res) => {
const { config } = req.body;
@@ -512,5 +536,25 @@ app.use(errorHandler);
app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`);
try {
initMikrotikBackupScheduler(logger);
} catch (e) {
logger.error({ component: 'mikrotik-backup', err: e && e.message }, 'Failed to start backup scheduler');
}
try {
initNetworkMapScheduler(logger, port);
} catch (e) {
logger.error({ component: 'network-map-scheduler', err: e && e.message }, 'Failed to start network map scheduler');
}
try {
initPingServicesScheduler(logger);
} catch (e) {
logger.error({ component: 'ping-services-scheduler', err: e && e.message }, 'Failed to start ping services scheduler');
}
try {
initUptimeMonitorScheduler(logger, port);
} catch (e) {
logger.error({ component: 'uptime-monitor-scheduler', err: e && e.message }, 'Failed to start uptime monitor scheduler');
}
});
+460
View File
@@ -0,0 +1,460 @@
/**
* Сервис применения конфигурации MikroTik через RouterOS REST API
* Идемпотентная логика: create / update / skip
* Требует RouterOS 7.1+ с www-ssl (HTTPS, порт 443) или www (HTTP, порт 80)
*/
const http = require('http');
const https = require('https');
/**
* Создать REST API клиент для MikroTik
* Поддерживает HTTP (порт 80) и HTTPS (порт 443)
* @param {object} opts - { host, port?, user, password, secure? }
* @returns {object} client с методами print, add, set, remove, command
*/
function createRosClient(opts) {
const { host, port = 80, user, password, secure = false } = opts;
// Порт может прийти строкой из JSON — приводим к числу
const portNumber = Number(port) || 80;
// REST API по умолчанию висит на HTTP:80, HTTPS обычно на 443/8443
const useHttp = portNumber === 80;
const protocol = useHttp ? http : https;
const makeRequest = (method, urlPath, body = null) => {
return new Promise((resolve, reject) => {
const auth = Buffer.from(`${user}:${password || ''}`).toString('base64');
const cleanPath = (urlPath.startsWith('/') ? urlPath.slice(1) : urlPath).replace(/\\/g, '/');
const pathStr = `/rest/${cleanPath}`.replace(/\/+/g, '/');
const bodyStr = (body !== null && body !== undefined) ? JSON.stringify(body) : '';
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Basic ${auth}`,
};
if (bodyStr) headers['Content-Length'] = Buffer.byteLength(bodyStr, 'utf8');
const options = {
hostname: host,
port: portNumber,
path: pathStr,
method,
headers,
rejectUnauthorized: secure,
};
const req = protocol.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
if ([200, 201, 204].includes(res.statusCode)) {
const parsed = data ? (() => { try { return JSON.parse(data); } catch { return data; } })() : null;
resolve({ data: parsed, code: res.statusCode });
} else {
let errMsg = res.statusMessage || 'Request failed';
try {
const errBody = JSON.parse(data);
errMsg = errBody.detail || errBody.message || errMsg;
} catch (_) {}
const err = new Error(errMsg);
err.response = { status: res.statusCode, data };
reject(err);
}
});
});
req.on('error', reject);
if (bodyStr) req.write(bodyStr);
req.end();
});
};
return {
print: (p) => makeRequest('GET', p),
add: (p, body) => makeRequest('PUT', p, body),
set: (p, body) => makeRequest('PATCH', p, body),
remove: (p) => makeRequest('DELETE', p),
command: (p, body) => makeRequest('POST', p, body),
};
}
/**
* Нормализовать path: убрать ведущий слэш
*/
function normalizePath(path) {
return (path || '').replace(/^\//, '');
}
/**
* Выполнить print с фильтром
* @param {object} client - ros-rest client
* @param {string} path - e.g. '/interface/gre' или 'interface/gre'
* @param {object} filter - e.g. { name: 'gre1' } или { '~comment': 'Recursive' }
*/
async function rosPrint(client, path, filter = {}) {
const pathClean = normalizePath(path);
const entries = Object.entries(filter).filter(([, v]) => v != null && v !== '');
let fullPath = pathClean;
if (entries.length > 0) {
const queryParts = entries.map(([k, v]) => {
const key = k.startsWith('~') ? `~${k.slice(1)}` : k;
return `${encodeURIComponent(key)}=${encodeURIComponent(String(v))}`;
});
fullPath = `${pathClean}?${queryParts.join('&')}`;
}
try {
const res = await client.print(fullPath);
const data = res?.data;
return Array.isArray(data) ? data : (data ? [data] : []);
} catch (err) {
if (err?.response?.status === 404) return [];
throw err;
}
}
/**
* Выполнить print с .query (для сложных фильтров вроде ~comment)
*/
async function rosPrintWithQuery(client, path, queryList) {
const pathClean = normalizePath(path);
const fullPath = `${pathClean}/print`;
try {
const res = await client.command(fullPath, { '.query': queryList });
const data = res?.data;
return Array.isArray(data) ? data : (data ? [data] : []);
} catch (err) {
if (err?.response?.status === 404) return [];
throw err;
}
}
/**
* Выполнить add
*/
async function rosAdd(client, path, params) {
const pathClean = normalizePath(path);
const body = params && typeof params === 'object'
? Object.fromEntries(
Object.entries(params).filter(([, v]) => v != null && v !== '').map(([k, v]) => [k, String(v)])
)
: {};
const res = await client.add(pathClean, body);
return res?.data;
}
/**
* Выполнить set
*/
async function rosSet(client, path, id, params) {
const pathClean = normalizePath(path);
const fullPath = id ? `${pathClean}/${id}` : pathClean;
const body = params && typeof params === 'object'
? Object.fromEntries(
Object.entries(params).filter(([, v]) => v != null && v !== '').map(([k, v]) => [k, String(v)])
)
: {};
const res = await client.set(fullPath, body);
return res?.data;
}
/**
* Выполнить remove
*/
async function rosRemove(client, path, id) {
const pathClean = normalizePath(path);
const fullPath = id ? `${pathClean}/${id}` : pathClean;
await client.remove(fullPath);
}
/**
* Сравнить объект из RouterOS с желаемыми params (только ключевые поля)
*/
function paramsMatch(rosItem, params, keysToCompare) {
if (!rosItem || !params) return false;
for (const k of keysToCompare) {
const rosVal = rosItem[k];
const wantVal = params[k];
if (wantVal == null) continue;
if (String(rosVal || '').trim() !== String(wantVal || '').trim()) return false;
}
return true;
}
/**
* Применить одну операцию с idempotent логикой
*/
async function applyOperation(client, op, dryRun) {
const { path, action, params, meta } = op;
const result = { path, action, params: { ...params }, status: null, details: null, error: null };
if (path === '/interface/list' && action === 'add' && meta?.ensureExists) {
const existing = await rosPrint(client, '/interface/list', { name: params.name });
if (existing.length > 0) {
result.status = 'skip';
result.details = 'Interface list already exists';
return result;
}
if (dryRun) {
result.status = 'would_create';
result.details = `Would create interface list ${params.name}`;
return result;
}
await rosAdd(client, '/interface/list', params);
result.status = 'created';
return result;
}
if (path === '/interface/gre' && action === 'add') {
const name = params.name;
const existing = await rosPrint(client, '/interface/gre', { name });
const compareKeys = ['remote-address', 'local-address', 'mtu', 'ipsec-secret', 'keepalive', 'allow-fast-path'];
if (existing.length > 0) {
const match = paramsMatch(existing[0], params, compareKeys);
if (match) {
result.status = 'skip';
result.details = `Interface ${name} already configured`;
return result;
}
if (dryRun) {
result.status = 'would_update';
result.details = `Would update interface ${name}`;
return result;
}
await rosSet(client, '/interface/gre', existing[0]['.id'], params);
result.status = 'updated';
return result;
}
if (dryRun) {
result.status = 'would_create';
result.details = `Would create GRE interface ${name}`;
return result;
}
await rosAdd(client, '/interface/gre', params);
result.status = 'created';
return result;
}
if (path === '/interface/list/member' && action === 'add') {
const iface = params.interface;
const list = params.list;
const existing = await rosPrint(client, '/interface/list/member', { list, interface: iface });
if (existing.length > 0) {
result.status = 'skip';
result.details = `Member ${iface} already in list ${list}`;
return result;
}
if (dryRun) {
result.status = 'would_create';
result.details = `Would add ${iface} to list ${list}`;
return result;
}
await rosAdd(client, '/interface/list/member', params);
result.status = 'created';
return result;
}
if (path === '/ip/address' && action === 'add') {
const addr = params.address;
const iface = params.interface;
const existing = await rosPrint(client, '/ip/address', { interface: iface });
const match = existing.find(e => (e.address || '').startsWith(addr.split('/')[0]));
if (match) {
result.status = 'skip';
result.details = `Address ${addr} already on ${iface}`;
return result;
}
if (dryRun) {
result.status = 'would_create';
result.details = `Would add ${addr} to ${iface}`;
return result;
}
await rosAdd(client, '/ip/address', params);
result.status = 'created';
return result;
}
if (path === '/ip/route') {
if (action === 'remove' && meta?.findComment) {
// REST API может игнорировать regex в .query и возвращать все маршруты — фильтруем на нашей стороне
const raw = await rosPrintWithQuery(client, '/ip/route', [`~comment=${meta.findComment}`]);
const toRemove = raw.filter(r => (r.comment || '').startsWith('Recursive: '));
if (toRemove.length === 0) {
result.status = 'skip';
result.details = 'No matching routes to remove';
return result;
}
if (dryRun) {
result.status = 'would_remove';
result.details = `Would remove ${toRemove.length} route(s)`;
return result;
}
for (const r of toRemove) {
const id = r['.id'];
if (id) await rosRemove(client, '/ip/route', id);
}
result.status = 'removed';
result.details = `${toRemove.length} route(s) removed`;
return result;
}
if (action === 'add') {
const dst = params['dst-address'];
const gw = params.gateway;
const existing = await rosPrint(client, '/ip/route', { 'dst-address': dst });
const match = existing.find(e => (e.gateway || '').includes((gw || '').split('%')[0]));
if (match) {
result.status = 'skip';
result.details = `Route to ${dst} already exists`;
return result;
}
if (dryRun) {
result.status = 'would_create';
result.details = `Would add route ${dst} via ${gw}`;
return result;
}
await rosAdd(client, '/ip/route', params);
result.status = 'created';
return result;
}
}
result.status = 'skipped';
result.details = `Unsupported operation: ${path} ${action}`;
return result;
}
/**
* Применить блок операций к MikroTik
*/
async function applyBlock(client, block, dryRun) {
const results = [];
const ops = block.operations || [];
for (const op of ops) {
try {
const r = await applyOperation(client, op, dryRun);
results.push(r);
} catch (err) {
results.push({
path: op.path,
action: op.action,
params: op.params,
status: 'error',
error: err.message || String(err),
});
}
}
return results;
}
/**
* Получить полный экспорт конфигурации MikroTik через REST API.
* API не возвращает текст /export напрямую — только при export file=... сохраняется в файл.
* Мы экспортируем во временный файл, читаем его, удаляем.
*
* @param {object} client - ros-rest client (createRosClient)
* @returns {Promise<string>} - текст конфигурации (export compact)
*/
async function fetchExportViaFile(client) {
const crypto = require('crypto');
const basename = `backup_${Date.now()}_${crypto.randomBytes(4).toString('hex')}.rsc`;
// Явно указываем flash/ — экспорт сохраняется в flash
const filePath = `flash/${basename}`;
try {
await client.command('export', { compact: '', file: filePath });
} catch (err) {
// Попробуем без flash/ — старые версии RouterOS могут сохранять иначе
try {
await client.command('export', { compact: '', file: basename });
} catch (err2) {
const msg = err?.message || err2?.message || String(err);
const detail = err?.response?.data || err2?.response?.data;
console.error('[fetchExportViaFile] export failed:', msg, detail);
throw new Error(`Export failed: ${msg}${detail ? ` (${JSON.stringify(detail).slice(0, 200)})` : ''}`);
}
}
// POST file/print — получаем все файлы и ищем по имени (надёжнее GET с query)
let list = [];
try {
const printRes = await client.command('file/print', {
'.proplist': ['.id', 'name', 'contents'],
});
const data = printRes?.data;
const raw = Array.isArray(data) ? data : (data ? [data] : []);
const found = raw.find(
(f) =>
(f.name || '') === basename ||
(f.name || '') === filePath ||
(f.name || '').endsWith('/' + basename) ||
(f.name || '').endsWith(basename),
);
if (found) list = [found];
} catch (err) {
console.error('[fetchExportViaFile] file/print failed:', err?.message, err?.response?.data);
throw new Error(`file/print failed: ${err?.message || String(err)}`);
}
const file = list[0];
if (!file) {
throw new Error('Export file not found after export (check write permissions on router)');
}
let config = String(file.contents || '');
// Для файлов >60KB API не возвращает contents в print — читаем через file/read
if (!config && file.name) {
const chunkSize = 32768;
let offset = 0;
const chunks = [];
try {
for (;;) {
const readRes = await client.command('file/read', {
file: file.name,
offset: String(offset),
'chunk-size': String(chunkSize),
});
const rd = readRes?.data;
const chunk = Array.isArray(rd) ? (rd[0]?.data ?? rd[0]) : (rd?.data ?? rd);
const str = typeof chunk === 'string' ? chunk : (chunk ? String(chunk) : '');
if (!str) break;
chunks.push(str);
offset += str.length;
if (str.length < chunkSize) break;
}
config = chunks.join('');
} catch (err) {
console.error('[fetchExportViaFile] file/read failed:', err?.message);
try {
const id = file['.id'];
if (id) await client.remove(`file/${id}`);
} catch (_) {}
throw new Error(`file/read failed: ${err?.message || String(err)}`);
}
}
if (!config) {
try {
const id = file['.id'];
if (id) await client.remove(`file/${id}`);
} catch (_) {}
throw new Error('File/print and file/read returned no contents (router may restrict file access)');
}
try {
const id = file['.id'];
if (id) await client.remove(`file/${id}`);
} catch (_) {}
return config;
}
module.exports = {
createRosClient,
rosPrint,
rosPrintWithQuery,
rosAdd,
rosSet,
rosRemove,
applyOperation,
applyBlock,
fetchExportViaFile,
};
+185
View File
@@ -0,0 +1,185 @@
/**
* Простейший планировщик автоматических бэкапов MikroTik.
*
* Работает внутри Node-процесса backend:
* - периодически обходит jumphost-сервера из servers.json
* - вызывает RouterOS REST API /rest/export (compact)
* - сохраняет конфиг в S3 через saveBackupForServer
*
* Управляется через переменные окружения:
* - MIKROTIK_BACKUP_ENABLED=true|false (по умолчанию true)
* - MIKROTIK_BACKUP_INTERVAL_MINUTES=60 (интервал между запусками)
* - MIKROTIK_BACKUP_SERVERS="id1,id2" (если не указано — все jumphost)
*/
const { readServersFromS3 } = require('../routes/serversRoutes');
const { createRosClient, fetchExportViaFile } = require('./mikrotikApplyService');
const { saveBackupForServer } = require('../routes/mikrotikBackupRoutes');
const { decrypt } = require('../utils/encryption');
const { readS3TextObject } = require('../services/s3Service');
const DEFAULT_INTERVAL_MIN = 60;
function parseServerIds(envValue) {
if (!envValue) return null;
const parts = String(envValue)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return parts.length > 0 ? parts : null;
}
/**
* Прочитать список серверов для автобэкапов из UI-настроек (rt_ui_settings.json).
* Если поле mikrotikBackupServers отсутствует или пустое — вернуть null.
*/
async function getServerIdsFromUiSettings(logger) {
try {
const data = await readS3TextObject('bgp_data/rt_ui_settings.json').catch(() => null);
if (!data || !data.body) return null;
const parsed = JSON.parse(data.body || '{}');
const list = parsed && Array.isArray(parsed.mikrotikBackupServers)
? parsed.mikrotikBackupServers
: null;
if (!list || list.length === 0) return null;
return list.map((v) => String(v)).filter(Boolean);
} catch (e) {
(logger || console).warn(
{ component: 'mikrotik-backup', err: e && e.message },
'Failed to read mikrotikBackupServers from ui-settings',
);
return null;
}
}
async function fetchRouterExport(client) {
// API не возвращает текст /export напрямую — экспортируем в файл, читаем, удаляем
return fetchExportViaFile(client);
}
async function runBackupOnce(logger) {
const log = logger || console;
try {
const allServers = await readServersFromS3();
const uiIds = await getServerIdsFromUiSettings(log);
const envIds = parseServerIds(process.env.MIKROTIK_BACKUP_SERVERS);
const wantedIds = (uiIds && uiIds.length > 0) ? uiIds : envIds;
const jumphosts = (allServers || []).filter((s) => {
if (!s || String(s.type || '').toLowerCase() !== 'jumphost') return false;
if (!wantedIds) return true;
const id = s.id || s.dns || s.ip;
return id && wantedIds.includes(String(id));
});
if (jumphosts.length === 0) {
log.info({ component: 'mikrotik-backup' }, 'No jumphost servers found for backup');
return;
}
for (const server of jumphosts) {
const id = server.id || server.dns || server.ip || 'unknown';
try {
const host = server.mikrotikHost || server.ip || server.dns;
const port = server.mikrotikPort || 80;
const user = server.mikrotikUser || 'admin';
if (!host) {
log.warn({ component: 'mikrotik-backup', serverId: id }, 'Skip backup: no MikroTik host');
continue;
}
let password = '';
if (server.encryptedMikrotikPassword) {
try {
password = decrypt(server.encryptedMikrotikPassword);
} catch (e) {
log.error(
{ component: 'mikrotik-backup', serverId: id, err: e && e.message },
'Failed to decrypt MikroTik password, skipping backup',
);
continue;
}
} else {
log.warn(
{ component: 'mikrotik-backup', serverId: id },
'Skip backup: no encryptedMikrotikPassword configured',
);
continue;
}
const creds = {
host,
port: Number(port) || 80,
user,
password,
secure: false,
};
const client = createRosClient(creds);
log.info({ component: 'mikrotik-backup', serverId: id, host: creds.host }, 'Starting automatic backup via REST /export');
const configText = await fetchRouterExport(client);
if (!configText || !configText.trim()) {
log.warn({ component: 'mikrotik-backup', serverId: id }, 'Skip backup: empty export result');
continue;
}
const result = await saveBackupForServer(id, configText, {
source: 'scheduler',
comment: 'Automatic MikroTik backup (REST /export)',
});
log.info(
{ component: 'mikrotik-backup', serverId: id, key: result.key },
'Automatic backup saved to S3',
);
} catch (err) {
log.error(
{ component: 'mikrotik-backup', serverId: id, err: err && err.message },
'Failed to create automatic backup',
);
}
}
} catch (err) {
const msg = err && err.message ? err.message : String(err);
(logger || console).error({ component: 'mikrotik-backup', err: msg }, 'Backup run failed');
}
}
function initMikrotikBackupScheduler(logger) {
const enabledEnv = String(process.env.MIKROTIK_BACKUP_ENABLED || 'true').toLowerCase();
const enabled = enabledEnv !== 'false' && enabledEnv !== '0' && enabledEnv !== 'off';
const log = logger || console;
if (!enabled) {
log.info({ component: 'mikrotik-backup' }, 'Automatic MikroTik backup scheduler is disabled');
return;
}
const intervalMin =
Number(process.env.MIKROTIK_BACKUP_INTERVAL_MINUTES || DEFAULT_INTERVAL_MIN) || DEFAULT_INTERVAL_MIN;
const intervalMs = Math.max(5, intervalMin) * 60 * 1000;
const intervalMinutes = intervalMs / 60000;
log.info(
{
component: 'mikrotik-backup',
intervalMinutes,
nextRunInMinutes: intervalMinutes,
servers: process.env.MIKROTIK_BACKUP_SERVERS || 'all jumphost',
},
'Automatic MikroTik backup scheduler started (no backup on startup, first run in %d min)',
intervalMinutes,
);
// Только по расписанию — без бэкапа при перезапуске контейнера
setInterval(() => {
runBackupOnce(log);
}, intervalMs);
}
module.exports = {
initMikrotikBackupScheduler,
runBackupOnce,
};
+460
View File
@@ -0,0 +1,460 @@
/**
* Планировщик пингов и замеров скорости для карты сети.
* Периодически вызывает пинг и speed-test по связям из network-config,
* сохраняет результаты в S3 (кеш по рёбрам + агрегат network-map-cache/latest.json).
* Логи пишет в кольцевой буфер для просмотра в UI.
*/
const { readServersFromS3 } = require('../routes/serversRoutes');
const { loadNetworkConfig } = require('../routes/mikrotikConfigRoutes');
const { readS3TextObject, writeS3JsonObject } = require('./s3Service');
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json';
const DEFAULT_INTERVAL_MINUTES = 5;
const DEFAULT_LOG_MAX_LINES = 500;
/** Кольцевой буфер логов */
class LogBuffer {
constructor(maxLines = 500) {
this.maxLines = Math.max(100, maxLines);
this.lines = [];
this.index = 0;
}
push(line) {
const ts = new Date().toISOString();
const entry = `${ts} ${line}`;
if (this.lines.length < this.maxLines) {
this.lines.push(entry);
} else {
this.lines[this.index] = entry;
this.index = (this.index + 1) % this.maxLines;
}
}
getLines() {
if (this.lines.length < this.maxLines) return [...this.lines];
return [...this.lines.slice(this.index), ...this.lines.slice(0, this.index)];
}
setMaxLines(n) {
this.maxLines = Math.max(100, n);
if (this.lines.length > this.maxLines) {
this.lines = this.getLines().slice(-this.maxLines);
this.index = 0;
}
}
}
const logBuffer = new LogBuffer(DEFAULT_LOG_MAX_LINES);
async function loadUiSettings() {
try {
const data = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
const parsed = JSON.parse(data?.body || '{}');
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (_) {
return {};
}
}
/** Построить список связей как в NetworkMapDashboard (tunnelInterfaces → connections) */
async function buildConnections() {
const [serversList, config, uiSettings] = await Promise.all([
readServersFromS3(),
loadNetworkConfig(),
loadUiSettings(),
]);
const servers = Array.isArray(serversList) ? serversList : [];
const tunnelInterfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : [];
const getServer = (serverId) =>
servers.find((s) => s.id === serverId || s.ip === serverId || s.dns === serverId);
const connList = [];
for (const iface of tunnelInterfaces) {
if (!iface.serverId || !iface.serverId2) continue;
const s1 = getServer(iface.serverId);
const s2 = getServer(iface.serverId2);
if (!s1 || !s2 || s1.ip === s2.ip) continue;
const s1Key = s1.id || s1.dns || s1.ip;
const s2Key = s2.id || s2.dns || s2.ip;
if (!s1Key || !s2Key) continue;
const isJumphost1 = s1 && ['jumphost', 'home'].includes(String(s1.type || '').toLowerCase());
const isJumphost2 = s2 && ['jumphost', 'home'].includes(String(s2.type || '').toLowerCase());
connList.push({
from: s1.ip,
to: s2.ip,
fromKey: s1Key,
toKey: s2Key,
internalFromTo: iface.remoteIp || null,
internalToFrom: iface.localIp || null,
speedTestServerId: isJumphost1 ? s1Key : isJumphost2 ? s2Key : null,
interfaceName: iface.name || null,
});
}
return { connections: connList, servers, uiSettings };
}
function edgePingKey(a, b) {
return [String(a), String(b)].sort().join(':');
}
function speedKey(key1, key2) {
return [String(key1), String(key2)].sort().join(':');
}
/** Параллельно с лимитом */
async function runWithLimit(tasks, limit = 4) {
const results = [];
let index = 0;
async function runNext() {
const i = index++;
if (i >= tasks.length) return;
try {
results[i] = { value: await tasks[i]() };
} catch (err) {
results[i] = { error: err };
}
await runNext();
}
const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => runNext());
await Promise.all(workers);
return results;
}
/** Один тест на ноду в момент времени */
async function runWithOnePerKey(tasks, getKey) {
const n = tasks.length;
const keyToIndices = new Map();
for (let i = 0; i < n; i++) {
const k = String(getKey(i));
if (!keyToIndices.has(k)) keyToIndices.set(k, []);
keyToIndices.get(k).push(i);
}
const results = [];
for (let i = 0; i < n; i++) results[i] = null;
const keys = Array.from(keyToIndices.keys());
let round = 0;
while (true) {
const batch = [];
for (const k of keys) {
const indices = keyToIndices.get(k);
if (round < indices.length) batch.push(indices[round]);
}
if (batch.length === 0) break;
await Promise.all(
batch.map(async (i) => {
try {
results[i] = { value: await tasks[i]() };
} catch (err) {
results[i] = { error: err };
}
})
);
round++;
}
return results;
}
/**
* Выполнить одну итерацию: пинг и speed-test по связям, записать кеш и логи.
* @param {object} opts - { baseUrl, runPing, runSpeedTest, log }
*/
async function runJob(opts) {
const { baseUrl, runPing = true, runSpeedTest = true, log = (msg) => logBuffer.push(msg), trigger = 'scheduled' } = opts;
const pingMap = {};
const speedMap = {};
log(trigger === 'manual' ? '--- Ручной запуск ---' : '--- Запуск по расписанию ---');
try {
const { connections, servers } = await buildConnections();
if (connections.length === 0) {
log('Нет связей в network-config (tunnelInterfaces).');
await writeCache(pingMap, speedMap);
return { pingMap, speedMap };
}
log(`Связей: ${connections.length}. Пинг: ${runPing ? 'да' : 'нет'}, Скорость: ${runSpeedTest ? 'да' : 'нет'}`);
if (runPing) {
const pingTasks = [];
for (const c of connections) {
const from = String(c.from);
const to = String(c.to);
if (!from || !to || from === to) continue;
const srcFrom = servers.find((s) => s.ip === from);
const srcTo = servers.find((s) => s.ip === to);
const canPingFrom = srcFrom && ['jumphost', 'home'].includes(String(srcFrom.type || '').toLowerCase()) && c.internalFromTo;
const canPingTo = srcTo && ['jumphost', 'home'].includes(String(srcTo.type || '').toLowerCase()) && c.internalToFrom;
if (canPingFrom) {
pingTasks.push(async () => {
const ekey = edgePingKey(from, to);
try {
const res = await fetch(`${baseUrl}/api/mikrotik/ping`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: c.fromKey,
target: c.internalFromTo,
gatewayIp: c.internalFromTo,
count: 3,
forceRefresh: true,
}),
});
const data = await res.json().catch(() => ({}));
const ms = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null;
pingMap[ekey] = ms;
const errMsg = !res.ok ? (data?.message || `HTTP ${res.status}`) : null;
log(`Пинг ${from} → ${to}: ${ms != null ? ms + ' ms' : errMsg || 'ошибка'}`);
return ms;
} catch (e) {
pingMap[ekey] = null;
log(`Пинг ${from} → ${to}: ошибка ${e?.message || e}`);
return null;
}
});
} else if (canPingTo) {
pingTasks.push(async () => {
const ekey = edgePingKey(from, to);
try {
const res = await fetch(`${baseUrl}/api/mikrotik/ping`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: c.toKey,
target: c.internalToFrom,
gatewayIp: c.internalToFrom,
count: 3,
forceRefresh: true,
}),
});
const data = await res.json().catch(() => ({}));
const ms = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null;
pingMap[ekey] = ms;
const errMsg2 = !res.ok ? (data?.message || `HTTP ${res.status}`) : null;
log(`Пинг ${to} → ${from}: ${ms != null ? ms + ' ms' : errMsg2 || 'ошибка'}`);
return ms;
} catch (e) {
pingMap[ekey] = null;
log(`Пинг ${to} → ${from}: ошибка ${e?.message || e}`);
return null;
}
});
}
}
await runWithLimit(pingTasks, 4);
}
if (runSpeedTest) {
// Таймаут запроса: замер может долго идти на медленных каналах (download + upload по durationSeconds каждый)
const SPEED_TEST_REQUEST_MS = 300000; // 5 мин
const withSpeed = connections.filter((c) => c.speedTestServerId && c.interfaceName);
const speedTasks = withSpeed.map((c) => async () => {
const key = speedKey(c.fromKey, c.toKey);
const server = servers.find((s) => (s.id || s.dns || s.ip) === c.speedTestServerId);
const serverLabel = server ? (server.dns || server.ip || server.id) : c.speedTestServerId;
const speedLabel = `${serverLabel} / ${c.interfaceName}`;
const ac = new AbortController();
const timeoutId = setTimeout(() => ac.abort(), SPEED_TEST_REQUEST_MS);
try {
const res = await fetch(`${baseUrl}/api/mikrotik/speed-test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: c.speedTestServerId,
interfaceName: c.interfaceName,
forceRefresh: true,
}),
signal: ac.signal,
});
clearTimeout(timeoutId);
const data = await res.json().catch(() => ({}));
if (data?.ok && (data.tcpDownloadBps != null || data.tcpUploadBps != null)) {
speedMap[key] = {
tcpDownloadBps: data.tcpDownloadBps,
tcpUploadBps: data.tcpUploadBps,
cached: data.cached === true,
durationSeconds: data.durationSeconds,
};
const down = data.tcpDownloadBps != null ? (data.tcpDownloadBps / 1e6).toFixed(1) : '—';
const up = data.tcpUploadBps != null ? (data.tcpUploadBps / 1e6).toFixed(1) : '—';
log(`Скорость ${speedLabel}: ↓${down} ↑${up} Mbps`);
return speedMap[key];
}
speedMap[key] = null;
const speedErr = !res.ok ? (data?.message || `HTTP ${res.status}`) : 'нет данных';
log(`Скорость ${speedLabel}: ${speedErr}`);
return null;
} catch (e) {
clearTimeout(timeoutId);
speedMap[key] = null;
const msg = e?.message || e;
const isAbort = String(msg).toLowerCase().includes('abort');
log(`Скорость ${speedLabel}: ошибка ${isAbort ? 'таймаут или отмена запроса' : msg}`);
return null;
}
});
// В один момент — только один тест на один сервер назначения (удалённый конец туннеля),
// иначе несколько замеров к одному хостингу делят канал и дают заниженные результаты
await runWithOnePerKey(speedTasks, (i) => {
const c = withSpeed[i];
return c.speedTestServerId === c.fromKey ? c.toKey : c.fromKey;
});
}
await writeCache(pingMap, speedMap);
log('Кеш карты сети обновлён.');
} catch (err) {
log(`Ошибка: ${err?.message || err}`);
}
return { pingMap, speedMap };
}
async function writeCache(pingMap, speedMap) {
try {
await writeS3JsonObject(NETWORK_MAP_CACHE_KEY, {
pingMap: pingMap || {},
speedMap: speedMap || {},
updatedAt: Date.now(),
});
} catch (e) {
logBuffer.push(`Запись кеша в S3 не удалась: ${e?.message || e}`);
}
}
let schedulerTimer = null;
/** Время последнего запуска по расписанию (для расчёта следующего) */
let lastScheduledRunAt = null;
function getSettingsFromUiSettings(uiSettings) {
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)),
};
}
/**
* Запустить планировщик (setInterval).
* @param {object} logger - pino logger
* @param {number} port - порт сервера для вызова API
*/
function initNetworkMapScheduler(logger, port) {
const log = logger || console;
const baseUrl = `http://127.0.0.1:${port}`;
async function tick() {
lastScheduledRunAt = Date.now();
let uiSettings;
try {
const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
uiSettings = JSON.parse(raw?.body || '{}') || {};
} catch (_) {
return;
}
const settings = getSettingsFromUiSettings(uiSettings);
if (!settings.enabled) return;
logBuffer.setMaxLines(settings.logMaxLines);
logBuffer.push('--- Запуск по расписанию ---');
runJob({
baseUrl,
runPing: settings.runPing,
runSpeedTest: settings.runSpeedTest,
log: (msg) => {
logBuffer.push(msg);
log.info({ component: 'network-map-scheduler' }, msg);
},
}).catch((err) => {
logBuffer.push(`Ошибка: ${err?.message || err}`);
log.error({ component: 'network-map-scheduler', err: err?.message }, 'Scheduler run failed');
});
}
async function schedule() {
let uiSettings;
try {
const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
uiSettings = JSON.parse(raw?.body || '{}') || {};
} catch (_) {
return;
}
const settings = getSettingsFromUiSettings(uiSettings);
if (!settings.enabled) {
log.info({ component: 'network-map-scheduler' }, 'Scheduler disabled in settings');
return;
}
logBuffer.setMaxLines(settings.logMaxLines);
const intervalMs = settings.intervalMinutes * 60 * 1000;
log.info(
{ component: 'network-map-scheduler', intervalMinutes: settings.intervalMinutes },
'Network map scheduler started (first run in 10 s, then every %d min)',
settings.intervalMinutes
);
schedulerTimer = setInterval(tick, intervalMs);
// Первый запуск через 10 сек, чтобы кеш появился вскоре после старта сервера
setTimeout(() => tick(), 10 * 1000);
}
schedule();
}
function stopNetworkMapScheduler() {
if (schedulerTimer) {
clearInterval(schedulerTimer);
schedulerTimer = null;
}
}
function getLogs() {
return logBuffer.getLines();
}
function getLogMaxLines() {
return logBuffer.maxLines;
}
function setLogMaxLines(n) {
const num = Math.max(100, Math.min(2000, parseInt(n, 10) || 500));
logBuffer.setMaxLines(num);
}
/** Добавить строку в лог (для вывода ошибок из роутов) */
function pushLog(msg) {
logBuffer.push(msg);
}
/**
* Время следующего запуска по расписанию (timestamp или null если выключено).
* @param {object} settings - результат getSettingsFromUiSettings()
* @returns {number|null}
*/
function getNextRunAt(settings) {
if (!settings || !settings.enabled) return null;
const intervalMs = (settings.intervalMinutes || DEFAULT_INTERVAL_MINUTES) * 60 * 1000;
const from = lastScheduledRunAt || Date.now();
return from + intervalMs;
}
module.exports = {
runJob,
initNetworkMapScheduler,
stopNetworkMapScheduler,
getLogs,
getLogMaxLines,
setLogMaxLines,
pushLog,
getSettingsFromUiSettings,
getNextRunAt,
loadUiSettings: loadUiSettings,
buildConnections,
LogBuffer,
};
+96
View File
@@ -0,0 +1,96 @@
/**
* Планировщик пинга сервисов из списка настроек.
* Периодически вызывает refreshPingServicesCache и обновляет кеш для GET /api/ping-services.
*/
const { readS3TextObject } = require('./s3Service');
const { refreshPingServicesCache } = require('../routes/miscRoutes');
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
const DEFAULT_INTERVAL_MINUTES = 2;
let schedulerTimer = null;
let lastScheduledRunAt = null;
function getSettingsFromUiSettings(uiSettings) {
return {
enabled: uiSettings.pingServicesSchedulerEnabled !== false,
intervalMinutes: Math.max(1, Math.min(1440, parseInt(uiSettings.pingServicesSchedulerIntervalMinutes, 10) || DEFAULT_INTERVAL_MINUTES)),
};
}
/**
* Время следующего запуска по расписанию (timestamp или null если выключено).
*/
function getNextRunAt(settings) {
if (!settings || !settings.enabled) return null;
const intervalMs = (settings.intervalMinutes || DEFAULT_INTERVAL_MINUTES) * 60 * 1000;
const from = lastScheduledRunAt || Date.now();
return from + intervalMs;
}
/**
* Запустить планировщик пинг-сервисов.
* @param {object} logger - pino logger
*/
function initPingServicesScheduler(logger) {
const log = logger || console;
async function tick() {
lastScheduledRunAt = Date.now();
let uiSettings;
try {
const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
uiSettings = JSON.parse(raw?.body || '{}') || {};
} catch (_) {
return;
}
const settings = getSettingsFromUiSettings(uiSettings);
if (!settings.enabled) return;
log.info({ component: 'ping-services-scheduler' }, 'Running ping services refresh');
refreshPingServicesCache().catch((err) => {
log.error({ component: 'ping-services-scheduler', err: err?.message }, 'Ping services refresh failed');
});
}
async function schedule() {
let uiSettings;
try {
const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
uiSettings = JSON.parse(raw?.body || '{}') || {};
} catch (_) {
return;
}
const settings = getSettingsFromUiSettings(uiSettings);
if (!settings.enabled) {
log.info({ component: 'ping-services-scheduler' }, 'Ping services scheduler disabled in settings');
return;
}
const intervalMs = settings.intervalMinutes * 60 * 1000;
log.info(
{ component: 'ping-services-scheduler', intervalMinutes: settings.intervalMinutes },
'Ping services scheduler started (first run in 15 s, then every %d min)',
settings.intervalMinutes
);
schedulerTimer = setInterval(tick, intervalMs);
setTimeout(() => tick(), 15 * 1000);
}
schedule();
}
function stopPingServicesScheduler() {
if (schedulerTimer) {
clearInterval(schedulerTimer);
schedulerTimer = null;
}
}
module.exports = {
initPingServicesScheduler,
stopPingServicesScheduler,
getSettingsFromUiSettings,
getNextRunAt,
};
+38 -1
View File
@@ -3,7 +3,7 @@
* Централизованные операции чтения/записи/кэширования
*/
const { S3Client, GetObjectCommand, HeadObjectCommand, PutObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
const { S3Client, GetObjectCommand, HeadObjectCommand, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command } = require('@aws-sdk/client-s3');
const { NodeHttpHandler } = require('@smithy/node-http-handler');
const http = require('http');
const https = require('https');
@@ -167,6 +167,42 @@ async function deleteS3Object(key) {
invalidateCacheForKey(key);
}
/**
* Список объектов в S3 по префиксу
* Используется для истории/бэкапов (количество ограничено для безопасности)
*/
async function listS3Objects(prefix, { maxKeys = 100 } = {}) {
const out = [];
let continuationToken = undefined;
while (out.length < maxKeys) {
const resp = await s3.send(new ListObjectsV2Command({
Bucket: BUCKET_NAME,
Prefix: prefix,
ContinuationToken: continuationToken,
MaxKeys: Math.min(1000, maxKeys - out.length),
}));
const contents = resp.Contents || [];
for (const obj of contents) {
out.push({
key: obj.Key,
size: typeof obj.Size === 'number' ? obj.Size : null,
lastModified: obj.LastModified ? new Date(obj.LastModified).toISOString() : null,
etag: obj.ETag || null,
});
if (out.length >= maxKeys) break;
}
if (!resp.IsTruncated || !resp.NextContinuationToken || out.length >= maxKeys) {
break;
}
continuationToken = resp.NextContinuationToken;
}
return out;
}
/**
* Потоковое чтение с пагинацией больших текстовых файлов
*/
@@ -257,5 +293,6 @@ module.exports = {
deleteS3Object,
streamPaginatedText,
invalidateCacheForKey,
listS3Objects,
};
+142
View File
@@ -0,0 +1,142 @@
/**
* Планировщик проверок Uptime Monitor.
* Периодически вызывает POST /api/uptime/check для каждого jumphost/home и обновляет кеш в S3.
* После перезапуска контейнера кеш обновляется по расписанию без необходимости открывать страницу.
*/
const { readS3TextObject } = require('./s3Service');
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
const DEFAULT_INTERVAL_MINUTES = 2;
const PARALLEL_CHECKS = 2;
let schedulerTimer = null;
let lastScheduledRunAt = null;
function getSettingsFromUiSettings(uiSettings) {
return {
enabled: uiSettings.uptimeMonitorSchedulerEnabled !== false,
intervalMinutes: Math.max(1, Math.min(1440, parseInt(uiSettings.uptimeMonitorSchedulerIntervalMinutes, 10) || DEFAULT_INTERVAL_MINUTES)),
};
}
function getNextRunAt(settings) {
if (!settings || !settings.enabled) return null;
const intervalMs = (settings.intervalMinutes || DEFAULT_INTERVAL_MINUTES) * 60 * 1000;
const from = lastScheduledRunAt || Date.now();
return from + intervalMs;
}
/**
* Запустить планировщик Uptime Monitor.
* @param {object} logger - pino logger
* @param {number} port - порт приложения для вызова API
*/
function initUptimeMonitorScheduler(logger, port) {
const log = logger || console;
const baseUrl = `http://127.0.0.1:${port}`;
async function runChecks(serverIds) {
const runOne = async (serverId) => {
try {
const res = await fetch(`${baseUrl}/api/uptime/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverId }),
});
if (!res.ok) {
log.warn({ component: 'uptime-monitor-scheduler', serverId, status: res.status }, 'Uptime check failed');
}
} catch (err) {
log.warn({ component: 'uptime-monitor-scheduler', serverId, err: err?.message }, 'Uptime check error');
}
};
for (let i = 0; i < serverIds.length; i += PARALLEL_CHECKS) {
const chunk = serverIds.slice(i, i + PARALLEL_CHECKS);
await Promise.all(chunk.map(runOne));
}
}
async function tick() {
lastScheduledRunAt = Date.now();
let uiSettings;
try {
const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
uiSettings = JSON.parse(raw?.body || '{}') || {};
} catch (_) {
return;
}
const settings = getSettingsFromUiSettings(uiSettings);
if (!settings.enabled) return;
let servers = [];
try {
const res = await fetch(`${baseUrl}/api/servers`);
const data = res.ok ? await res.json() : [];
servers = Array.isArray(data) ? data : [];
} catch (err) {
log.error({ component: 'uptime-monitor-scheduler', err: err?.message }, 'Failed to fetch servers');
return;
}
const jumphosts = servers.filter(
(s) => s && ['jumphost', 'home'].includes(String(s.type || '').toLowerCase())
);
const serverIds = jumphosts
.map((s) => s.id || s.dns || s.ip)
.filter(Boolean);
if (serverIds.length === 0) {
log.info({ component: 'uptime-monitor-scheduler' }, 'No jumphost/home servers to check');
return;
}
log.info(
{ component: 'uptime-monitor-scheduler', count: serverIds.length },
'Running uptime monitor checks for %d server(s)',
serverIds.length
);
await runChecks(serverIds);
}
async function schedule() {
let uiSettings;
try {
const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
uiSettings = JSON.parse(raw?.body || '{}') || {};
} catch (_) {
return;
}
const settings = getSettingsFromUiSettings(uiSettings);
if (!settings.enabled) {
log.info({ component: 'uptime-monitor-scheduler' }, 'Uptime monitor scheduler disabled in settings');
return;
}
const intervalMs = settings.intervalMinutes * 60 * 1000;
log.info(
{ component: 'uptime-monitor-scheduler', intervalMinutes: settings.intervalMinutes },
'Uptime monitor scheduler started (first run in 20 s, then every %d min)',
settings.intervalMinutes
);
schedulerTimer = setInterval(tick, intervalMs);
setTimeout(() => tick(), 20 * 1000);
}
schedule();
}
function stopUptimeMonitorScheduler() {
if (schedulerTimer) {
clearInterval(schedulerTimer);
schedulerTimer = null;
}
}
module.exports = {
initUptimeMonitorScheduler,
stopUptimeMonitorScheduler,
getSettingsFromUiSettings,
getNextRunAt,
};
+1 -3
View File
@@ -120,9 +120,7 @@ function decrypt(encryptedText) {
}
console.error('Decryption error details:', {
error: error.message,
stack: error.stack,
encryptedTextLength: encryptedText.length,
encryptedTextPreview: encryptedText.substring(0, 100)
encryptedTextLength: encryptedText.length
});
throw error;
}
+15 -2
View File
@@ -1,6 +1,7 @@
/**
* Утилиты для генерации конфигурации MikroTik
* Устраняют дублирование кода генерации конфигурации
* Поддерживает форматы: 'text' (как раньше) и 'json' (структурированные данные для API)
*/
const { buildNestedGatewayBlocks } = require('./helpers');
@@ -8,10 +9,14 @@ const { buildNestedGatewayBlocks } = require('./helpers');
/**
* Генерация конфигурации MikroTik из массива фильтров
* @param {Array} filters - Массив фильтров с полями {community, gateway}
* @returns {string} Сгенерированная конфигурация
* @param {object} options - { format: 'text'|'json' }
* @returns {string|object} Сгенерированная конфигурация (строка или объект)
*/
function generateMikrotikConfig(filters) {
function generateMikrotikConfig(filters, options = {}) {
const { format = 'text' } = options;
if (!Array.isArray(filters) || filters.length === 0) {
if (format === 'json') return { gatewayGroups: {}, config: null };
return '// No filters to generate configuration';
}
@@ -24,6 +29,14 @@ function generateMikrotikConfig(filters) {
gatewayGroups[filter.gateway].push(filter.community);
});
if (format === 'json') {
return {
gatewayGroups,
filterName: 'bgp-in-tmp',
generatedAt: new Date().toISOString(),
};
}
let config = '// Frouting filter configuration for MikroTik 7.14+\n';
config += '// Generated automatically\n';
config += `// Date: ${new Date().toISOString()}\n\n`;
+404
View File
@@ -0,0 +1,404 @@
/**
* Генератор конфигурации MikroTik для интерфейсов и маршрутов
* Поддерживает два формата вывода: 'text' (как раньше) и 'json' (структурированные операции для API)
*/
/**
* @param {string} serverId
* @param {Array} servers
* @returns {object|null}
*/
function getServerInfo(serverId, servers) {
if (!serverId || !Array.isArray(servers)) return null;
return servers.find(s => s.id === serverId || s.ip === serverId || s.dns === serverId) || null;
}
/**
* @param {string} parentId
* @param {object} config
* @param {Array} templateGateways
* @returns {object|null}
*/
function getParentGateway(parentId, config, templateGateways = null) {
if (!parentId || !config) return null;
if (Array.isArray(templateGateways)) {
const g = templateGateways.find(x => x.id === parentId);
if (g) return { parentType: 'gateway', ...g, ip: g.ip };
}
const gateway = config.gateways?.find(g => g.id === parentId);
if (gateway) return { parentType: 'gateway', ...gateway, ip: gateway.ip };
const iface = config.tunnelInterfaces?.find(i => i.id === parentId);
if (iface) {
return {
parentType: 'interface',
...iface,
remoteIp: iface.remoteIp,
localIp: iface.localIp,
name: iface.name,
interfaceType: iface.type,
};
}
return null;
}
/**
* Сериализация одной операции в строку RouterOS
* @param {object} op - { path, action, params }
* @returns {string}
*/
function operationToRouterOSLine(op) {
const { path, action, params } = op;
if (!path || !action) return '';
const pathStr = path.replace(/^\//, '').replace(/\//g, ' ');
const fullPath = pathStr ? `/${pathStr}` : '';
const parts = (params && typeof params === 'object')
? Object.entries(params)
.filter(([, v]) => v != null && v !== '')
.map(([k, v]) => {
const val = String(v);
if (val.includes(' ') || val.includes('"')) return `${k}="${val}"`;
return `${k}=${val}`;
})
: [];
return `${fullPath} ${action} ${parts.join(' ')}`.trim();
}
/**
* Сериализация массива операций в текст
* @param {Array} operations
* @returns {string}
*/
function serializeOperationsToText(operations) {
if (!Array.isArray(operations)) return '';
return operations.map(op => operationToRouterOSLine(op)).filter(Boolean).join('\n');
}
/**
* Построение блоков MikroTik для интерфейсов
* @param {object} config - { tunnelInterfaces, gateways }
* @param {Array} servers
* @param {object} passwordMap - { [ipsecPasswordId]: decryptedPassword }
* @param {object} options - { format: 'text'|'json', serverId?: string }
* @returns {Promise<Array>}
*/
async function buildMikrotikInterfaceBlocks(config, servers, passwordMap, options = {}) {
const { format = 'text', serverId: filterServerId } = options;
const interfaces = (config.tunnelInterfaces || []).filter(
i => i.serverId && i.serverId2 && i.localIp && i.remoteIp
);
if (interfaces.length === 0) return [];
const interfacesByServer = {};
for (const iface of interfaces) {
const server1 = getServerInfo(iface.serverId, servers);
const server2 = getServerInfo(iface.serverId2, servers);
if (!server1 || !server2) continue;
const server1Name = server1.dns || server1.ip || iface.serverId;
const server2Name = server2.dns || server2.ip || iface.serverId2;
const interfaceName1 = iface.name || `${iface.type}-tunnel`;
const interfaceName2 = iface.name2 || iface.name || `${iface.type}-tunnel`;
const ipsecPassword = (iface.ipsecPasswordId && passwordMap[iface.ipsecPasswordId]) || null;
if (!interfacesByServer[server1Name]) {
interfacesByServer[server1Name] = { serverInfo: server1, interfaces: [] };
}
interfacesByServer[server1Name].interfaces.push({
interfaceName: interfaceName1,
localIp: iface.localIp,
remoteIp: iface.remoteIp,
type: iface.type,
server2Name,
server2Info: server2,
ipsecPassword,
mtu: iface.mtu || null,
});
if (!interfacesByServer[server2Name]) {
interfacesByServer[server2Name] = { serverInfo: server2, interfaces: [] };
}
interfacesByServer[server2Name].interfaces.push({
interfaceName: interfaceName2,
localIp: iface.remoteIp,
remoteIp: iface.localIp,
type: iface.type,
server2Name: server1Name,
server2Info: server1,
ipsecPassword,
mtu: iface.mtu || null,
});
}
const sortedServers = Object.entries(interfacesByServer).sort(([a], [b]) => a.localeCompare(b));
const blocks = [];
for (const [serverName, serverData] of sortedServers) {
if (filterServerId) {
const s = getServerInfo(filterServerId, servers);
const matchName = s ? (s.dns || s.ip || filterServerId) : filterServerId;
if (serverName !== matchName && serverData.serverInfo?.id !== filterServerId) continue;
}
const server = serverData.serverInfo;
const isHomeRouter = server.type === 'home';
const hasGreInterfaces = serverData.interfaces.some(i => i.type === 'GRE');
const operations = [];
let code = '';
if (format === 'text') {
code += `# Настройка IP адресов интерфейсов для сервера: ${serverName}\n`;
if (server.provider) code += `# Провайдер: ${server.provider}\n`;
if (server.country) code += `# Страна: ${server.country}\n`;
if (server.ip && server.ip !== serverName) code += `# IP: ${server.ip}\n`;
code += '\n';
}
if (isHomeRouter && hasGreInterfaces) {
if (format === 'text') {
code += `# Создание interface list для GRE туннелей (если не существует)\n`;
code += `:if ([/interface list find name=GREs] = "") do={ /interface list add name=GREs comment="GRE tunnels list" }\n\n`;
} else {
operations.push({
path: '/interface/list',
action: 'add',
params: { name: 'GREs', comment: 'GRE tunnels list' },
meta: { ensureExists: true },
});
}
}
for (let i = 0; i < serverData.interfaces.length; i++) {
const iface = serverData.interfaces[i];
const remoteAddress = iface.server2Info?.dns || iface.server2Info?.ip || iface.server2Name;
const hasIpsecPassword = iface.ipsecPassword && String(iface.ipsecPassword).trim() !== '';
const localAddressParam = isHomeRouter && server.ip ? server.ip : null;
if (format === 'text') {
code += `# Интерфейс ${i + 1}: ${iface.interfaceName} (${iface.type})\n`;
code += `# Связь с сервером: ${iface.server2Name}\n`;
code += `# Local IP: ${iface.localIp}\n`;
code += `# Remote IP: ${iface.remoteIp}\n`;
}
if (iface.type === 'GRE') {
const greParams = {
name: iface.interfaceName,
'remote-address': remoteAddress,
'keepalive': '10s',
'allow-fast-path': 'no',
};
if (localAddressParam) greParams['local-address'] = localAddressParam;
if (iface.mtu) greParams.mtu = String(iface.mtu);
if (hasIpsecPassword) greParams['ipsec-secret'] = iface.ipsecPassword;
const op = { path: '/interface/gre', action: 'add', params: greParams };
operations.push(op);
if (format === 'text') {
if (hasIpsecPassword) code += `# Создание GRE туннеля с IPSec\n`;
else code += `# Создание GRE туннеля (без IPSec)\n`;
code += operationToRouterOSLine(op) + '\n';
}
if (isHomeRouter) {
const listOp = {
path: '/interface/list/member',
action: 'add',
params: { list: 'GREs', interface: iface.interfaceName, comment: `GRE tunnel to ${iface.server2Name}` },
};
operations.push(listOp);
if (format === 'text') code += operationToRouterOSLine(listOp) + '\n';
}
}
const addrOp = {
path: '/ip/address',
action: 'add',
params: {
address: `${iface.localIp}/30`,
interface: iface.interfaceName,
comment: `Interface: ${iface.interfaceName} (${iface.type}) to ${iface.server2Name}`,
},
};
operations.push(addrOp);
if (format === 'text') code += operationToRouterOSLine(addrOp) + '\n';
}
if (format === 'text') {
code += '# Проверка настроенных адресов:\n';
code += '# /ip address print\n';
}
blocks.push({
type: 'interface-addresses',
serverName,
server,
...(format === 'text' ? { code } : { operations }),
});
}
return blocks;
}
/**
* Построение блоков для рекурсивных маршрутов
* @param {object} config
* @param {Array} servers
* @param {object} options - { format: 'text'|'json', serverId?: string }
* @returns {Promise<Array>}
*/
async function buildMikrotikRecursiveRoutes(config, servers, options = {}) {
const { format = 'text', serverId: filterServerId } = options;
const recursiveGateways = (config.gateways || []).filter(g => g.type === 'recursive');
const blocks = [];
if (recursiveGateways.length === 0) return [];
const gatewaysByServer = {};
for (const gw of recursiveGateways) {
const sid = gw.serverId || '__unassigned__';
if (!gatewaysByServer[sid]) gatewaysByServer[sid] = [];
gatewaysByServer[sid].push(gw);
}
for (const [sid, gateways] of Object.entries(gatewaysByServer)) {
const server = getServerInfo(sid, servers);
const serverName = server?.dns || server?.ip || sid;
if (filterServerId && sid !== filterServerId) continue;
const operations = [];
let code = '';
if (format === 'text') {
code += `# Рекурсивные маршруты для сервера: ${serverName}\n`;
if (server?.provider) code += `# Провайдер: ${server.provider}\n`;
if (server?.country) code += `# Страна: ${server.country}\n`;
code += '\n';
code += '# Удаление старых рекурсивных маршрутов (comment начинается с "Recursive: ")\n';
code += '/ip route remove [find comment~"^Recursive: "] \n\n';
} else {
operations.push({
path: '/ip/route',
action: 'remove',
params: {},
meta: { findComment: '^Recursive: ' },
});
}
let validCount = 0;
for (const gw of gateways) {
const parentGatewaysList = (gw.parentGateways && gw.parentGateways.length > 0)
? gw.parentGateways
: (gw.parentGatewayId ? [{ id: gw.parentGatewayId, distance: undefined }] : []);
if (parentGatewaysList.length === 0 || !gw.ip) continue;
for (const parentRef of parentGatewaysList) {
const parent = getParentGateway(parentRef.id, config);
if (!parent) continue;
const parentIp = parent.parentType === 'interface' ? parent.remoteIp : parent.ip;
if (!parentIp) continue;
validCount++;
const gatewayValue = parent.parentType === 'interface'
? `${parentIp}%${parent.name || parent.interfaceType || 'interface'}`
: parentIp;
const distance = parentRef.distance;
const comment = gw.description
? `Recursive: ${gw.description} -> ${parentIp}`
: `Recursive: ${gw.ip} -> ${parentIp}`;
const routeParams = {
'dst-address': `${gw.ip}/32`,
gateway: gatewayValue,
comment,
};
if (distance != null) routeParams.distance = String(distance);
const op = { path: '/ip/route', action: 'add', params: routeParams };
operations.push(op);
if (format === 'text') {
code += `# Рекурсивный gateway: ${gw.ip}\n`;
code += operationToRouterOSLine(op) + '\n';
}
if (gw.description && (gw.description.toLowerCase().includes('default') || gw.description.toLowerCase().includes('0.0.0.0'))) {
const defaultParams = {
'dst-address': '0.0.0.0/0',
gateway: gatewayValue,
comment: `Recursive: ${gw.description || gw.ip} (default)`,
};
if (distance != null) defaultParams.distance = String(distance);
const defaultOp = { path: '/ip/route', action: 'add', params: defaultParams };
operations.push(defaultOp);
if (format === 'text') code += operationToRouterOSLine(defaultOp) + '\n';
}
}
}
if (validCount > 0) {
if (format === 'text') {
code += '# Проверка: /ip route print where comment~"Recursive"\n';
}
blocks.push({
type: 'recursive-routes',
serverName,
server,
...(format === 'text' ? { code } : { operations }),
});
}
}
return blocks;
}
/**
* Комбинированная генерация: интерфейсы + рекурсивные маршруты
* @param {object} config
* @param {Array} servers
* @param {object} passwordMap
* @param {object} options - { format: 'text'|'json', serverId?: string, includeInterfaces?: boolean, includeRecursive?: boolean }
*/
async function buildMikrotikConfig(config, servers, passwordMap, options = {}) {
const {
format = 'text',
serverId,
includeInterfaces = true,
includeRecursive = true,
} = options;
const opts = { format, serverId };
const blocks = [];
if (includeInterfaces) {
const ifBlocks = await buildMikrotikInterfaceBlocks(config, servers, passwordMap, opts);
blocks.push(...ifBlocks);
}
if (includeRecursive) {
const recBlocks = await buildMikrotikRecursiveRoutes(config, servers, opts);
blocks.push(...recBlocks);
}
return blocks;
}
module.exports = {
getServerInfo,
getParentGateway,
operationToRouterOSLine,
serializeOperationsToText,
buildMikrotikInterfaceBlocks,
buildMikrotikRecursiveRoutes,
buildMikrotikConfig,
};
+43
View File
@@ -0,0 +1,43 @@
# Таблицы: UX/UI по Tabler и проекту
Рекомендации по формированию таблиц в приложении — [Tabler Tables](https://docs.tabler.io/ui/components/tables/), [Preview](https://preview.tabler.io/tables.html).
## Классы (Tabler)
- **Базовые:** `table card-table table-vcenter table-nowrap mb-0`
- **Чередование строк:** `table-striped` — улучшает читаемость
- **Компактный режим:** `table-sm` — меньше отступы в ячейках
- **Обёртка:** `table-responsive` вокруг `<table>`
- **Обрезка длинного текста:** для ячейки использовать `td-truncate` (Tabler) или `text-truncate` + `max-width`
## Компонент DataTable
Используйте `DataTable` там, где подходит декларативное описание колонок и общие действия (сортировка, выбор, пагинация):
- `columns`: `{ key, title, sortable?, icon?, render?, tooltip? }` — для подсказки по заголовку задайте `tooltip`
- `striped={true}` по умолчанию
- `compact={true}` — компактные ячейки
- Колонка «Действия» получает класс `td-actions` (фиксированная минимальная ширина в проекте)
## Информативность и лаконичность
1. **Заголовки колонок** — короткие, при необходимости уточнение через `tooltip` (например, значение иконки замка).
2. **Действия в строке** — иконки с `Tooltip` и `aria-label`; колонка «Действия» справа, без лишнего текста.
3. **Копирование** — кнопка/иконка копирования рядом с ячейкой (IP, домен и т.д.) вместо дублирования в «Действия», если это основной сценарий.
4. **Пустое состояние** — через `emptyState` в DataTable или свой блок с призывом к действию (импорт, добавление).
## Доступность
- У заголовков: `scope="col"`.
- У таблицы: `role="grid"` при семантической сетке данных.
- Кнопки действий: `aria-label` и/или Tooltip.
## Где таблица своя, а не DataTable
В IPRangesManager, DomainsNewManager, ServerManager и др. таблицы рендерятся вручную. Для единообразия:
- Те же классы: `table card-table table-vcenter table-nowrap mb-0`, при желании `table-striped`.
- Заголовки: `th` с `scope="col"`, короткий текст, при необходимости иконка и тултип.
- Колонка действий: класс `td-actions` у `th` и `td`, кнопки с тултипами.
При рефакторинге страниц можно постепенно переводить такие таблицы на DataTable с описанием `columns` и общих обработчиков.
+763 -1
View File
@@ -17,9 +17,11 @@
"@tanstack/react-virtual": "^3.10.8",
"@xyflow/react": "^12.3.4",
"axios": "^1.10.0",
"diff": "^8.0.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^6.30.1"
"react-router-dom": "^6.30.1",
"recharts": "^3.7.0"
},
"devDependencies": {
"@eslint/js": "^9.29.0",
@@ -346,6 +348,414 @@
"react": ">=16.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz",
"integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz",
"integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz",
"integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz",
"integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz",
"integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz",
"integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz",
"integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz",
"integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz",
"integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz",
"integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz",
"integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz",
"integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz",
"integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz",
"integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz",
"integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz",
"integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz",
"integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz",
"integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz",
"integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz",
"integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz",
"integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz",
"integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz",
"integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz",
"integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.5",
"cpu": [
@@ -601,6 +1011,42 @@
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@remix-run/router": {
"version": "1.23.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
@@ -628,6 +1074,18 @@
"linux"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@tabler/core": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@tabler/core/-/core-1.3.2.tgz",
@@ -780,6 +1238,12 @@
"@babel/types": "^7.20.7"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
@@ -795,6 +1259,12 @@
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
@@ -804,12 +1274,48 @@
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
@@ -855,6 +1361,12 @@
"@types/react": "^19.0.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "4.6.0",
"dev": true,
@@ -1076,6 +1588,15 @@
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"dev": true,
@@ -1130,6 +1651,18 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
@@ -1170,6 +1703,15 @@
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
@@ -1182,6 +1724,31 @@
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
@@ -1191,6 +1758,42 @@
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
@@ -1251,6 +1854,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"dev": true,
@@ -1263,6 +1872,15 @@
"node": ">=0.4.0"
}
},
"node_modules/diff": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz",
"integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"license": "MIT",
@@ -1317,6 +1935,16 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.25.5",
"dev": true,
@@ -1533,6 +2161,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"dev": true,
@@ -1780,6 +2414,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"dev": true,
@@ -1803,6 +2447,15 @@
"node": ">=0.8.19"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"dev": true,
@@ -2145,6 +2798,36 @@
"react": "^19.1.0"
}
},
"node_modules/react-is": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"dev": true,
@@ -2185,6 +2868,57 @@
"react-dom": ">=16.8"
}
},
"node_modules/recharts": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve-from": {
"version": "4.0.0",
"dev": true,
@@ -2254,6 +2988,12 @@
"node": ">=8"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.14",
"dev": true,
@@ -2346,6 +3086,28 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "7.0.2",
"dev": true,
+3 -1
View File
@@ -21,9 +21,11 @@
"@tanstack/react-virtual": "^3.10.8",
"@xyflow/react": "^12.3.4",
"axios": "^1.10.0",
"diff": "^8.0.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^6.30.1"
"react-router-dom": "^6.30.1",
"recharts": "^3.7.0"
},
"devDependencies": {
"@eslint/js": "^9.29.0",
+383 -349
View File
@@ -23,11 +23,9 @@ import {
IconCopy
} from '@tabler/icons-react';
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
import S3MetaBar from './components/S3MetaBar.jsx';
import PageHeaderActions from './components/PageHeaderActions.jsx';
import PageHeader from './components/PageHeader.jsx';
import EmptyState from './components/EmptyState.jsx';
import Breadcrumbs from './components/Breadcrumbs.jsx';
import ConfirmDialog from './components/ConfirmDialog.jsx';
import WsUpdateModal from './components/WsUpdateModal.jsx';
import ConfirmDiffModal from './components/ConfirmDiffModal.jsx';
@@ -41,7 +39,10 @@ import SavedFilters from './components/SavedFilters.jsx';
import BulkActionsBar from './components/BulkActionsBar.jsx';
import Tooltip from './components/Tooltip.jsx';
import Pagination from './components/Pagination.jsx';
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';
@@ -58,17 +59,18 @@ function ASNsNewManager() {
const [searchTerm, setSearchTerm] = useState('');
const searchTimer = useRef(null);
const didInit = useRef(false);
const [editingAsn, setEditingAsn] = useState(null);
const [editingValue, setEditingValue] = useState('');
const [editModalOpen, setEditModalOpen] = useState(false);
const [itemBeingEdited, setItemBeingEdited] = useState(null);
const [loading, setLoading] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
const [addModalOpen, setAddModalOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [sortField, setSortField] = useState('asn');
const [sortOrder, setSortOrder] = useState('asc');
const [filterCommunity, setFilterCommunity] = useState('');
const editInputRef = useRef(null);
const pageSize = 10;
const [asnNameMap, setAsnNameMap] = useState({});
const [selectedItems, setSelectedItems] = useState(new Set());
@@ -98,16 +100,23 @@ function ASNsNewManager() {
const [wsUrl, setWsUrl] = useState('');
useEffect(() => {
// Load WS URL once
const controller = new AbortController();
(async () => {
try { const r = await api.get('/ws/url'); setWsUrl(String(r.data?.url || '')); } catch {}
try {
const r = await api.get('/ws/url', { signal: controller.signal });
setWsUrl(String(r.data?.url || ''));
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
}
})();
return () => controller.abort();
}, []);
const fetchItems = async () => {
const fetchItems = async (abortSignal) => {
setLoading(true);
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const response = await api.get(`/asns`, { params: { offset: 0, limit: 0, format: 'std' } });
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 total = mapped.length;
@@ -120,6 +129,7 @@ function ASNsNewManager() {
setContentLength(typeof lengthHeader !== 'undefined' ? Number(lengthHeader) : null);
setError('');
} catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
console.error('Error fetching ASNs:', error);
setError('Не удалось загрузить список ASN. Проверьте, запущен ли бэкенд.');
} finally {
@@ -127,41 +137,59 @@ function ASNsNewManager() {
}
};
useEffect(() => { if (!didInit.current) { didInit.current = true; fetchItems(); } }, []);
useEffect(() => {
if (!didInit.current) {
didInit.current = true;
const controller = new AbortController();
fetchItems(controller.signal);
return () => controller.abort();
}
}, []);
const handleAddItem = () => {
const asnOk = isValidAsn(newItem.asn);
const communityOk = isValidCommunity(newItem.community);
setNewInvalid({ asn: !asnOk, community: !communityOk });
const handleAddItem = (item) => {
const asn = item?.asn || newItem.asn;
const community = item?.community || newItem.community;
const asnOk = isValidAsn(asn);
const communityOk = isValidCommunity(community);
if (!asnOk || !communityOk) {
setError('Введите корректный номер ASN и числовой community.');
return;
}
setError('');
setItems([...items, { asn: String(newItem.asn).trim(), community: String(newItem.community).trim() }]);
setNewItem({ asn: '', community: '' });
setNewInvalid({ asn: false, community: false });
const newAsn = { asn: String(asn).trim(), community: String(community).trim() };
setItems([...items, newAsn]);
if (!item) {
setNewItem({ asn: '', community: '' });
setNewInvalid({ asn: false, community: false });
}
};
const handleEdit = (item) => {
setEditingAsn(item.asn);
setItemBeingEdited(item);
setEditingValue(item.community);
setEditModalOpen(true);
};
const handleSaveEdit = (asn) => {
const handleSaveEdit = () => {
if (!itemBeingEdited) return;
if (!isValidCommunity(editingValue)) {
setError('Community должен быть числом.');
return;
}
const updatedItems = items.map(i =>
i.asn === asn ? { ...i, community: String(editingValue).trim() } : i
i.asn === itemBeingEdited.asn ? { ...i, community: String(editingValue).trim() } : i
);
setItems(updatedItems);
setEditingAsn(null);
setEditModalOpen(false);
setItemBeingEdited(null);
};
const handleCancelEdit = () => {
setEditingAsn(null);
setEditModalOpen(false);
setItemBeingEdited(null);
};
const handleDeleteItem = (asnToDelete) => {
@@ -469,13 +497,6 @@ function ASNsNewManager() {
}
};
// Улучшенный инлайн-редакт
useEffect(() => {
if (editingAsn && editInputRef.current) {
editInputRef.current.focus();
}
}, [editingAsn]);
// Глобальные хоткеи: Ctrl+S — сохранить изменения
useEffect(() => {
const onKey = (e) => {
@@ -488,305 +509,245 @@ function ASNsNewManager() {
return () => window.removeEventListener('keydown', onKey);
}, [loading, items, originalItems, etag, sortField, sortOrder, filterCommunity]);
const handleEditKeyDown = (e, asn) => {
if (e.key === 'Enter') handleSaveEdit(asn);
if (e.key === 'Escape') handleCancelEdit();
const handleEditKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveEdit();
}
if (e.key === 'Escape') {
e.preventDefault();
handleCancelEdit();
}
};
return (
<>
<div>
{error && (
<ErrorAlert message={error} onClose={() => setError('')} />
)}
{success && (
<div className="alert alert-success alert-dismissible" role="alert">
<div className="d-flex">
<div>
<IconCheck className="icon alert-icon" />
</div>
<div>
{success}
</div>
<div>{success}</div>
</div>
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
<button
type="button"
className="btn-close"
onClick={() => setSuccess('')}
></button>
</div>
)}
<PageHeader
title="ASNs"
pretitle={(
<Breadcrumbs items={[
{ label: 'Главная', to: '/dashboard' },
{ label: 'Данные' },
{ label: 'ASNs' },
]} />
)}
actions={(
icon={<IconHash size={24} />}
meta={<LastSaved timestamp={lastModified} variant="compact" />}
actions={
<PageHeaderActions
loading={loading}
onPreview={handlePreviewDiff}
disablePreview={loading || items.length === 0}
onRefresh={fetchItems}
disableRefresh={loading}
onImport={handleImport}
onExport={handleExport}
disableExport={items.length === 0}
onClear={clearInvalid}
disableClear={items.length === 0}
onClearCommunities={() => { if (!filterCommunity) { setError('Выберите community в фильтре для очистки'); return; } setClearCommunitiesOpen(true); }}
disableClearCommunities={items.length === 0 || !filterCommunity}
onSave={handleSaveChanges}
disableSave={loading}
onHistory={() => setHistoryOpen(true)}
onOnlineUpdate={() => setWsOpen(true)}
onBackgroundUpdate={async () => {
try {
const res = await fetch('/api/update-bgp/background', { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) {
throw new Error(data?.message || `HTTP ${res.status}`);
}
notifyMutationSuccess('Фоновое обновление запущено', data);
} catch (e) {
window.notify?.error('Не удалось запустить фоновое обновление', String(e));
}
}}
/>
)}
/>
<div className="row">
<div className="col-lg-3">
{/* Classic Add New ASN Card (improved) */}
<div className="card card-md animate-in">
<div className="card-header bg-primary-lt">
<h3 className="card-title text-primary">
<IconPlus className="icon me-2" />
Добавить новый ASN
</h3>
</div>
<div className="card-body">
<form onSubmit={(e) => { e.preventDefault(); handleAddItem(); }}>
<div className="mb-3">
<label className="form-label required">Номер AS</label>
<div className="input-icon">
<span className="input-icon-addon">
<IconHash size={18} />
</span>
<input
type="text"
className={`form-control${newInvalid.asn ? ' is-invalid' : isValidAsn(newItem.asn) ? ' is-valid' : ''}`}
placeholder="12345"
value={newItem.asn}
onChange={(e) => setNewItem({ ...newItem, asn: e.target.value })}
/>
</div>
{newInvalid.asn && <div className="invalid-feedback">Только цифры</div>}
{isValidAsn(newItem.asn) && !newInvalid.asn && (<div className="valid-feedback">Корректный номер AS</div>)}
<div className="form-text text-muted small">Формат: только цифры, например 12345</div>
</div>
<div className="mb-3">
<label className="form-label required">Community</label>
<div className="input-icon">
<span className="input-icon-addon">
<IconHash size={18} />
</span>
<CommunityAutocompleteInput
value={newItem.community}
onChange={(v) => setNewItem({ ...newItem, community: v })}
communities={communities}
placeholder="Начните вводить номер или имя"
className={`form-control${newInvalid.community ? ' is-invalid' : isValidCommunity(newItem.community) ? ' is-valid' : ''}`}
useShortValue={true}
/>
</div>
{newInvalid.community && <div className="invalid-feedback">Неверный формат community</div>}
{isValidCommunity(newItem.community) && !newInvalid.community && (<div className="valid-feedback">Корректный формат community</div>)}
<div className="form-text">Формат: N или N:N (например 65000:100)</div>
{(() => {
const match = communities.find(c => c.value === String(newItem.community).trim());
if (!match) return null;
return (
<div className="alert alert-info alert-dismissible mt-2 mb-0" role="alert">
<div className="d-flex">
<div>
<strong>{match.name || match.value}</strong>
{match.description && <div className="text-muted small">{match.description}</div>}
{match.tags && match.tags.length > 0 && (
<div className="mt-1">
{match.tags.map((tag, idx) => (<span key={idx} className="badge bg-blue-lt text-blue me-1">{tag}</span>))}
</div>
)}
</div>
</div>
</div>
);
})()}
</div>
<div className="form-footer">
<button type="submit" className="btn btn-primary w-100" disabled={!isValidAsn(newItem.asn) || !isValidCommunity(newItem.community)}>
<IconPlus className="icon me-2" />
Добавить ASN
</button>
</div>
</form>
</div>
</div>
{/* Дополнительные варианты */}
<AccordionCard title={'Дополнительные варианты'} defaultOpen={false} className="animate-in" style={{animationDelay: '0.1s'}}>
<QuickAddBar
placeholder={'12345 65000:100'}
help={'Вставьте строки: ASN ПРОБЕЛ COMMUNITY'}
parseLine={(line) => { const [a, c] = String(line).split(/\s+/); return { asn: (a||'').trim(), community: (c||'').trim() }; }}
validateItem={(obj) => isValidAsn(obj.asn) && isValidCommunity(obj.community)}
onApply={(list) => {
setItems(prev => {
const merged = [...prev, ...list];
const map = new Map();
for (const it of merged) {
const key = String(it.asn).trim();
if (!map.has(key)) map.set(key, { asn: key, community: String(it.community).trim() });
}
return Array.from(map.values());
loading={loading}
onPreview={handlePreviewDiff}
disablePreview={loading || items.length === 0}
onRefresh={fetchItems}
disableRefresh={loading}
onImport={handleImport}
onExport={handleExport}
disableExport={items.length === 0}
onClear={clearInvalid}
disableClear={items.length === 0}
onClearCommunities={() => {
if (!filterCommunity) {
setError('Выберите community в фильтре для очистки');
return;
}
setClearCommunitiesOpen(true);
}}
disableClearCommunities={items.length === 0 || !filterCommunity}
onSave={handleSaveChanges}
disableSave={loading}
onHistory={() => setHistoryOpen(true)}
lastModified={lastModified}
onOnlineUpdate={() => setWsOpen(true)}
onBackgroundUpdate={async () => {
try {
const res = await fetch('/api/update-bgp/background', {
method: 'POST',
});
}}
/>
<div className="card card-md border-dashed mt-3 animate-in"
style={{animationDelay: '0.15s'}}
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = 'var(--tblr-primary)'; }}
onDragLeave={(e) => { e.currentTarget.style.borderColor = ''; }}
onDrop={(e) => { e.currentTarget.style.borderColor = ''; onDropImport(e); }}
>
<div className="card-body text-center text-muted py-4">
<IconUpload size={32} className="mb-2 text-muted" />
<div className="fw-medium">Перетащите файл TXT/CSV сюда</div>
<div className="small">Формат: ASN community</div>
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) {
throw new Error(data?.message || `HTTP ${res.status}`);
}
notifyMutationSuccess('Фоновое обновление запущено', data);
} catch (e) {
window.notify?.error(
'Не удалось запустить фоновое обновление',
String(e),
);
}
}}
/>
}
/>
<AddItemModal
open={addModalOpen}
onClose={() => setAddModalOpen(false)}
onSubmit={handleAddItem}
type="asn"
communities={communities}
loading={loading}
/>
<div className="card card-flat">
<div className="card-header">
<div className="row align-items-center w-100 g-2">
<div className="col">
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div className="d-flex align-items-center gap-2">
<IconHash className="icon text-muted" size={18} />
<span className="text-muted text-uppercase small">Список</span>
<span className="badge bg-blue-lt text-blue">
{filtered.length}
</span>
</div>
<div className="d-none d-md-block text-muted small">
Всего записей: {filtered.length}
</div>
</div>
</div>
</AccordionCard>
{/* Карточка действий больше не нужна — действия перенесены в page-header */}
</div>
<div className="col-lg-9">
{/* BulkActionsBar */}
<BulkActionsBar
selectedCount={selectedItems.size}
totalCount={paginatedItems.length}
onSelectAll={selectAll}
onDeselectAll={deselectAll}
onDelete={selectedItems.size > 0 ? () => {
if (confirm(`Удалить ${selectedItems.size} записей?`)) {
handleBulkDelete();
}
} : null}
onExport={selectedItems.size > 0 ? handleBulkExport : null}
/>
<div className="card">
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<h3 className="card-title mb-0">Список ASN <span className="badge bg-blue-lt text-blue ms-2">{items.length}</span></h3>
<div className="d-flex gap-2 flex-wrap">
<SavedFilters
pageKey="asns"
currentFilters={{ searchTerm, filterCommunity, sortField, sortOrder }}
onApplyFilter={(filters) => {
setSearchTerm(filters.searchTerm || '');
setFilterCommunity(filters.filterCommunity || '');
setSortField(filters.sortField || 'asn');
setSortOrder(filters.sortOrder || 'asc');
<div className="col-12 mt-2"></div>
<div className="col d-flex gap-2 flex-wrap align-items-center">
<div className="input-icon" style={{ minWidth: '200px' }}>
<span className="input-icon-addon">
<IconFilter size={18} />
</span>
<select
className="form-select"
value={filterCommunity}
onChange={(e) => {
setFilterCommunity(e.target.value);
setCurrentPage(1);
}}
/>
{/* Фильтр по community */}
<div className="input-icon">
<span className="input-icon-addon">
<IconFilter size={18} />
</span>
<select
className="form-select"
style={{ minWidth: '200px' }}
value={filterCommunity}
onChange={e => { setFilterCommunity(e.target.value); setCurrentPage(1); }}
title={(() => {
const meta = communities.find(c => String(c.value) === String(filterCommunity));
if (!meta) return 'Все community';
const name = meta.name ? meta.name + ' — ' : '';
const desc = meta.description || '';
const tags = meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : '';
return name + desc + tags;
})()}
>
<option value="">Все community</option>
{allCommunities.map(community => {
const meta = communities.find(c => String(c.value) === String(community));
const label = meta && meta.name ? `${community} — ${meta.name}` : community;
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : community;
return (
<option key={community} value={String(community)} title={title}>{label}</option>
);
})}
</select>
</div>
{/* Поиск */}
<div className="input-icon flex-grow-1" style={{ minWidth: '200px', position: 'relative' }}>
<span className="input-icon-addon">
<IconSearch size={18} />
</span>
<input
type="text"
className="form-control"
placeholder="Поиск ASN..."
value={searchTerm}
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
aria-label="Поиск ASN"
style={{ paddingRight: searchTerm ? '2.5rem' : undefined }}
/>
{!!searchTerm && (
<button
type="button"
className="btn btn-ghost-secondary btn-icon btn-sm"
style={{ position: 'absolute', right: '0.25rem', top: '50%', transform: 'translateY(-50%)', zIndex: 2 }}
aria-label="Очистить поиск"
onClick={() => { setSearchTerm(''); setCurrentPage(1); }}
>
<IconX size={16} />
</button>
)}
</div>
{allCommunities.map((community) => (
<option key={community} value={String(community)}>
{community}
</option>
))}
</select>
</div>
<div></div>
</div>
{/* Мета-информация будет показана внизу карточки */}
{loading ? (
<TableSkeleton rows={10} cols={3} />
) : paginatedItems.length === 0 ? (
<TableEmpty cols={3}>
<EmptyState
title="Нет ASN"
description="Импортируйте или добавьте записи, чтобы начать."
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
secondaryAction={<button className="btn btn-outline-primary" onClick={() => {
const header = ['asn','community'];
const lines = [header, ['12345','65000:100']].map(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);
const a = document.createElement('a'); a.href = url; a.download = 'asns_sample.csv'; a.click(); URL.revokeObjectURL(url);
}}>Шаблон CSV</button>}
<div
className="input-icon flex-grow-1"
style={{ minWidth: '200px', position: 'relative' }}
>
<span className="input-icon-addon">
<IconSearch size={18} />
</span>
<input
type="text"
className="form-control"
placeholder="Поиск ASN..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(1);
}}
aria-label="Поиск ASN"
style={{ paddingRight: searchTerm ? '2.5rem' : undefined }}
/>
</TableEmpty>
) : (
{!!searchTerm && (
<button
type="button"
className="btn btn-ghost-secondary btn-icon btn-sm"
style={{
position: 'absolute',
right: '0.25rem',
top: '50%',
transform: 'translateY(-50%)',
zIndex: 2,
}}
aria-label="Очистить поиск"
onClick={() => {
setSearchTerm('');
setCurrentPage(1);
}}
>
<IconX size={16} />
</button>
)}
</div>
<button
className="btn btn-primary"
onClick={() => setAddModalOpen(true)}
title="Добавить новый ASN"
>
<IconPlus className="me-1" size={18} />
Добавить
</button>
</div>
</div>
</div>
<div className="card-body p-0">
{loading ? (
<TableSkeleton rows={10} cols={3} flat />
) : paginatedItems.length === 0 ? (
<TableEmpty cols={3} flat>
<EmptyState
title="Нет ASN"
description="Импортируйте или добавьте записи, чтобы начать."
action={
<button className="btn btn-primary" onClick={handleImport}>
Импорт
</button>
}
secondaryAction={
<button
className="btn btn-outline-primary"
onClick={() => {
const header = ['asn', 'community'];
const lines = [header, ['12345', '65000:100']]
.map((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);
const a = document.createElement('a');
a.href = url;
a.download = 'asns_sample.csv';
a.click();
URL.revokeObjectURL(url);
}}
>
Шаблон CSV
</button>
}
/>
</TableEmpty>
) : (
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<table className="table table-flat table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{width: '40px'}}>
<th style={{ width: '40px' }}>
<input
type="checkbox"
className="form-check-input"
checked={paginatedItems.length > 0 && paginatedItems.every(i => selectedItems.has(i.asn))}
checked={
paginatedItems.length > 0 &&
paginatedItems.every((i) => selectedItems.has(i.asn))
}
onChange={(e) => {
if (e.target.checked) {
selectAll();
@@ -797,27 +758,39 @@ function ASNsNewManager() {
title="Выбрать все на странице"
/>
</th>
<th className="cursor-pointer user-select-none" onClick={() => handleSort('asn')}>
<th
className="cursor-pointer user-select-none"
onClick={() => handleSort('asn')}
>
<div className="d-flex align-items-center">
<IconHash size={16} className="me-1 text-muted" />
Номер AS
{sortField === 'asn' && (
<span className="ms-1">
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
{sortOrder === 'asc' ? (
<IconArrowUp size={14} />
) : (
<IconArrowDown size={14} />
)}
</span>
)}
</div>
</th>
<th>
Имя AS
</th>
<th className="cursor-pointer user-select-none" onClick={() => handleSort('community')}>
<th>Имя AS</th>
<th
className="cursor-pointer user-select-none"
onClick={() => handleSort('community')}
>
<div className="d-flex align-items-center">
<IconHash size={16} className="me-1 text-muted" />
Community
{sortField === 'community' && (
<span className="ms-1">
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
{sortOrder === 'asc' ? (
<IconArrowUp size={14} />
) : (
<IconArrowDown size={14} />
)}
</span>
)}
</div>
@@ -827,7 +800,15 @@ function ASNsNewManager() {
</thead>
<tbody>
{paginatedItems.map((item, idx) => (
<tr key={item.asn} className={editingAsn === item.asn ? 'table-active' : (selectedItems.has(item.asn) ? 'table-selected' : '')} style={{ animation: `fadeIn 0.3s ease ${idx * 0.02}s both` }}>
<tr
key={item.asn}
className={
selectedItems.has(item.asn) ? 'table-selected' : ''
}
style={{
animation: `fadeIn 0.3s ease ${idx * 0.02}s both`,
}}
>
<td>
<input
type="checkbox"
@@ -862,61 +843,68 @@ function ASNsNewManager() {
</td>
<td>{renderCommunityBadge(item.community)}</td>
<td className="text-end">
{editingAsn === item.asn ? (
<>
<CommunityAutocompleteInput
value={editingValue}
onChange={setEditingValue}
communities={communities}
onKeyDown={(e) => handleEditKeyDown(e, item.asn)}
className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
style={{ width: '150px' }}
useShortValue={true}
/>
<Tooltip content="Сохранить (Enter)">
<button className="btn btn-success btn-icon btn-sm me-1" onClick={() => handleSaveEdit(item.asn)} disabled={!isValidCommunity(editingValue)}><IconCheck size={16} /></button>
</Tooltip>
<Tooltip content="Отмена (Esc)">
<button className="btn btn-secondary btn-icon btn-sm" onClick={handleCancelEdit}><IconX size={16} /></button>
</Tooltip>
</>
) : (
<>
<Tooltip content="Редактировать" position="top">
<button className="btn btn-outline-primary btn-icon btn-sm me-1" aria-label="Редактировать" onClick={() => handleEdit(item)} title="Редактировать"><IconEdit size={16} /></button>
</Tooltip>
<Tooltip content="Копировать" position="top">
<button className="btn btn-outline-secondary btn-icon btn-sm me-1" aria-label="Копировать" onClick={() => copyToClipboard(`${item.asn} ${item.community}`)} title="Копировать"><IconCopy size={16} /></button>
</Tooltip>
<Tooltip content="Удалить" position="top">
<button className="btn btn-outline-danger btn-icon btn-sm" aria-label="Удалить" onClick={() => confirmDelete(item)} title="Удалить"><IconTrash size={16} /></button>
</Tooltip>
</>
)}
<div
className="btn-group btn-group-sm"
role="group"
aria-label={`Действия с ASN ${item.asn}`}
>
<Tooltip content="Редактировать" position="top">
<button
className="btn btn-icon btn-outline-primary me-1"
aria-label="Редактировать"
onClick={() => handleEdit(item)}
title="Редактировать"
>
<IconEdit size={16} />
</button>
</Tooltip>
<Tooltip content="Удалить" position="top">
<button
className="btn btn-icon btn-outline-danger"
aria-label="Удалить"
onClick={() => confirmDelete(item)}
title="Удалить"
>
<IconTrash size={16} />
</button>
</Tooltip>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Пагинация */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems || filtered.length}
pageSize={pageSize}
onPageChange={setCurrentPage}
/>
<S3MetaBar keys={["asns"]} />
</div>
)}
</div>
<div className="card-footer border-top-0">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems || filtered.length}
pageSize={pageSize}
onPageChange={setCurrentPage}
/>
</div>
</div>
<BulkActionsBar
selectedCount={selectedItems.size}
totalCount={paginatedItems.length}
onDeselectAll={deselectAll}
onDelete={handleBulkDelete}
onExport={handleBulkExport}
/>
<ConfirmDialog
open={showDeleteModal}
title={'Удалить ASN?'}
message={!itemToDelete ? '' : `Вы уверены, что хотите удалить "${itemToDelete.asn}"? Это действие необратимо.`}
message={
!itemToDelete
? ''
: `Вы уверены, что хотите удалить "${itemToDelete.asn}"? Это действие необратимо.`
}
confirmText={'Удалить'}
cancelText={'Отмена'}
destructive
@@ -924,12 +912,14 @@ function ASNsNewManager() {
onConfirm={executeDelete}
onCancel={() => setShowDeleteModal(false)}
/>
<ConfirmDiffModal
show={confirmSaveOpen}
diff={diff}
onConfirm={performSave}
onClose={() => setConfirmSaveOpen(false)}
/>
<ImportModal
show={importOpen}
title={'Импорт ASN'}
@@ -938,32 +928,76 @@ function ASNsNewManager() {
validateItem={validateItem}
onConfirm={applyImportedItems}
onClose={() => setImportOpen(false)}
sampleHeader={['asn','community']}
sampleHeader={['asn', 'community']}
placeholder={'12345 65000:100'}
/>
<FormModal
show={editModalOpen && !!itemBeingEdited}
onClose={handleCancelEdit}
onSubmit={handleSaveEdit}
title="Редактировать community"
submitLabel="Сохранить"
submitIcon={IconCheck}
size="md"
centered
disabled={!isValidCommunity(editingValue)}
>
{itemBeingEdited && (
<>
<p className="text-muted mb-2">
ASN:{' '}
<span className="font-monospace">{itemBeingEdited.asn}</span>
</p>
<div className="mb-3">
<label className="form-label">Community</label>
<CommunityAutocompleteInput
value={editingValue}
onChange={setEditingValue}
communities={communities}
onKeyDown={handleEditKeyDown}
className={`form-control${
isValidCommunity(editingValue) ? '' : ' is-invalid'
}`}
useShortValue={true}
/>
{!isValidCommunity(editingValue) && (
<div className="invalid-feedback d-block">
Введите корректное значение community (число или N:N).
</div>
)}
</div>
</>
)}
</FormModal>
<HistoryModal
resource="asns"
show={historyOpen}
onClose={() => setHistoryOpen(false)}
onRolledBack={() => fetchItems()}
/>
<WsUpdateModal
show={wsOpen}
url={wsUrl}
onClose={() => setWsOpen(false)}
/>
<ConfirmDialog
open={clearCommunitiesOpen}
title={'Удалить записи community'}
message={`Будут удалены все записи с community "${filterCommunity}". Действие НЕ сохраняет изменения автоматически.`}
confirmText={'Удалить'}
cancelText={'Отмена'}
onConfirm={() => { setItems(prev => prev.filter(i => i.community !== filterCommunity)); setClearCommunitiesOpen(false); }}
onConfirm={() => {
setItems((prev) => prev.filter((i) => i.community !== filterCommunity));
setClearCommunitiesOpen(false);
}}
onCancel={() => setClearCommunitiesOpen(false)}
/>
{/* datalist больше не нужен, т.к. используем кастомный автокомплит */}
</>
</div>
);
}
export default ASNsNewManager;
export default ASNsNewManager;
+201 -3
View File
@@ -1,5 +1,147 @@
/* Custom styles for S3 Lists Manager */
/* Most styles are now provided by @tabler/core */
/* Layout: Tabler Fluid Vertical (https://preview.tabler.io/layout-fluid-vertical.html) */
/* --- Fluid Vertical: page structure --- */
.page {
min-height: 100vh;
}
/* Сайдбар: всегда тёмный (как на референсе Tabler) */
.navbar-vertical {
--tblr-body-color: rgba(255, 255, 255, 0.9);
--tblr-body-color-rgb: 255, 255, 255;
--tblr-border-color: rgba(255, 255, 255, 0.1);
--tblr-bg-surface: #1e293b;
--tblr-bg-surface-hover: rgba(255, 255, 255, 0.05);
--tblr-navbar-active-bg: rgba(255, 255, 255, 0.08);
--tblr-navbar-active-color: #fff;
--tblr-primary: #6ea8fe;
--tblr-muted: rgba(255, 255, 255, 0.6);
background-color: #1e293b !important;
color: rgba(255, 255, 255, 0.9);
}
.navbar-vertical .navbar-brand,
.navbar-vertical .nav-link,
.navbar-vertical .dropdown-item {
color: inherit;
}
.navbar-vertical .navbar-brand:hover,
.navbar-vertical .nav-link:hover,
.navbar-vertical .dropdown-item:hover {
color: #fff;
}
.navbar-vertical .nav-link.active,
.navbar-vertical .dropdown-item.active {
background-color: var(--tblr-navbar-active-bg);
color: var(--tblr-navbar-active-color);
}
.navbar-vertical .dropdown-toggle::after {
border-top-color: rgba(255, 255, 255, 0.6);
}
.navbar-vertical .navbar-toggler {
border-color: rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.9);
}
/* Сайдбар: у бренда убираем лишний отступ и стрелку (Tabler не должен добавлять caret к brand) */
.navbar-vertical .navbar-brand {
padding-left: 0;
padding-right: 0;
}
.navbar-vertical .navbar-brand::after {
display: none !important;
}
.navbar-vertical > .container-fluid {
padding-left: 1rem;
padding-right: 1rem;
display: flex;
flex-direction: column;
min-height: 100%;
}
.navbar-vertical .collapse.navbar-collapse {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.navbar-vertical .collapse .navbar-nav.pt-lg-3 {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
.navbar-vertical .navbar-sidebar-footer {
flex-shrink: 0;
border-top-color: rgba(255, 255, 255, 0.1) !important;
}
.navbar-vertical .navbar-sidebar-footer .nav-link.btn-icon {
padding: 0.35rem;
color: rgba(255, 255, 255, 0.75);
border: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.navbar-vertical .navbar-sidebar-footer .nav-link.btn-icon:hover {
color: #fff;
background: rgba(255, 255, 255, 0.08);
}
.navbar-vertical .navbar-sidebar-footer .nav-link.btn-icon .badge {
font-size: 0.7rem;
padding: 0.2rem 0.35rem;
}
.navbar-vertical .navbar-sidebar-footer .dropdown-toggle::after {
display: none;
}
/* Стрелка только у пунктов с подменю (dropdown-toggle), не у обычных ссылок и не у бренда */
.navbar-vertical .nav-link:not(.dropdown-toggle)::after {
display: none !important;
}
/* Sidebar: nav-link with icon (Tabler vertical) */
.navbar-vertical .nav-link {
display: flex;
align-items: center;
gap: 0.5rem;
}
.navbar-vertical .nav-link-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
}
.navbar-vertical .nav-link-icon .icon {
width: 1.25rem;
height: 1.25rem;
}
/* Sidebar dropdown: inline submenu (no popup) */
.navbar-vertical .nav-item.dropdown .dropdown-menu {
position: static;
float: none;
border: none;
box-shadow: none;
background: transparent;
padding: 0 0 0.5rem 0;
margin: 0;
}
.navbar-vertical .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 {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.navbar-vertical .nav-item.dropdown .dropdown-item .nav-link-icon .icon {
width: 1rem;
height: 1rem;
}
/* Custom avatar background */
.avatar {
@@ -151,6 +293,60 @@
/* Убрали глобальную перекраску .badge, чтобы работали текстовые цвета Tabler
(например, badge bg-blue-lt text-blue) */
/* --- Server sidebar (фильтры на /servers) — Tabler card + list-group --- */
.server-sidebar {
width: 280px;
min-width: 280px;
max-height: calc(100vh - 180px);
display: flex;
flex-direction: column;
}
.server-sidebar .card-header {
flex-shrink: 0;
}
.server-sidebar .card-body.border-bottom,
.server-sidebar .card-footer {
flex-shrink: 0;
}
.server-sidebar-body {
flex: 1;
min-height: 0;
max-height: 50vh;
}
.server-sidebar .filter-section .list-group-header {
cursor: pointer;
font-size: 0.8125rem;
font-weight: 600;
text-transform: none;
color: var(--tblr-body-color);
}
.server-sidebar .filter-section .list-group-header:hover {
background-color: var(--tblr-bg-surface-tertiary);
}
.server-sidebar .list-group-item {
font-size: 0.8125rem;
padding-top: 0.375rem;
padding-bottom: 0.375rem;
}
/* Полные названия в фильтрах (без обрезания), перенос по словам */
.server-sidebar .list-group-item .text-break,
#server-filters-offcanvas .list-group-item span:first-of-type span {
word-break: break-word;
white-space: normal;
min-width: 0;
}
/* Tabler-style subheader (как на preview.tabler.io/settings.html) */
.subheader {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--tblr-muted-color, #6c757d);
margin-bottom: 0.25rem;
padding-left: 0;
}
/* Responsive navbar */
@media (max-width: 991.98px) {
.navbar-collapse {
@@ -251,10 +447,12 @@
height: 100%;
}
.header-actions .btn svg { width: 18px; height: 18px; }
.header-actions .header-actions-right { flex-shrink: 0; }
/* Ensure dropdowns in header-actions overlay tables/cards correctly but stay below modals */
/* Ensure dropdowns in main content (and header) are above the sidebar, below modals */
.header-actions .dropdown-menu,
.page-header .dropdown-menu {
.page-header .dropdown-menu,
.page-wrapper .dropdown-menu {
z-index: 1035; /* above navbar(1030), below modal-backdrop(1040) and modal(1050) */
}
+375 -192
View File
@@ -20,7 +20,14 @@ import {
IconDownload,
IconCreditCard,
IconMenu2,
IconArrowsExchange
IconArrowsExchange,
IconLayoutSidebarLeftExpand,
IconLayoutNavbar,
IconChartPie,
IconChartBar,
IconClock,
IconClockPlay,
IconShield
} from '@tabler/icons-react';
import ServerManager from './ServerManager';
import FilterManager from './FilterManager';
@@ -32,14 +39,25 @@ import AutoUrlManager from './AutoUrlManager';
import BillingManager from './BillingManager';
import CommunitiesManager from './CommunitiesManager';
import NetworkConfigManager from './NetworkConfigManager';
import MikrotikTools from './MikrotikTools.jsx';
import InterfaceSpeedTest from './InterfaceSpeedTest.jsx';
import Dashboard from './Dashboard';
import TrafficDashboard from './TrafficDashboard.jsx';
import NetworkMapDashboard from './NetworkMapDashboard.jsx';
import NetworkMapSchedulerPage from './NetworkMapSchedulerPage.jsx';
import UptimeMonitorPage from './UptimeMonitorPage.jsx';
import MikrotikBackupsManager from './MikrotikBackupsManager.jsx';
import PingServicesManager from './PingServicesManager.jsx';
import FirewallPage from './FirewallPage.jsx';
import SettingsPage from './SettingsPage.jsx';
import ResourceStatsPage from './ResourceStatsPage.jsx';
import './App.css';
import { NotifyProvider } from './components/NotifyProvider.jsx';
import SettingsModal from './components/SettingsModal.jsx';
import ToastContainer from './components/ToastContainer.jsx';
import CommandPalette, { KeyboardShortcutsButton } from './components/CommandPalette.jsx';
import ErrorBoundary from './components/ErrorBoundary.jsx';
import NetworkErrorHandler from './components/NetworkErrorHandler.jsx';
import { PingProvider } from './contexts/PingContext.jsx';
// --- Simple i18n (RU/EN) ---
const LanguageContext = createContext({ lang: 'ru', setLang: () => {}, t: (k) => k });
@@ -50,17 +68,19 @@ function LanguageProvider({ children }) {
const dict = {
ru: {
home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты',
dashboard: 'Панель', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
dashboard: 'Панель', trafficTraffic: 'Расход трафика', resourceStats: 'Статистика ресурсов', networkMap: 'Карта сети', uptimeMonitor: 'Uptime Monitor', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL',
easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки',
light: 'Светлая', dark: 'Тёмная'
easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы', pingServices: 'Пинг сервисов', firewall: 'Firewall',
light: 'Светлая', dark: 'Тёмная',
layoutSidebar: 'Сайдбар', layoutHorizontal: 'Верхнее меню'
},
en: {
home: 'Home', data: 'Data', management: 'Management', tools: 'Tools',
dashboard: 'Dashboard', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs',
dashboard: 'Dashboard', trafficTraffic: 'Traffic Usage', resourceStats: 'Resource Stats', networkMap: 'Network Map', uptimeMonitor: 'Uptime Monitor', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs',
communities: 'Communities', servers: 'Servers', filters: 'Filters', billing: 'Billing', autoUrls: 'Auto URLs',
easySwitch: 'Easy Switch', networkConfig: 'Network Config',
light: 'Light', dark: 'Dark'
easySwitch: 'Easy Switch', networkConfig: 'Network Config', mikrotikBackups: 'MikroTik Backups', pingServices: 'Ping Services', firewall: 'Firewall',
light: 'Light', dark: 'Dark',
layoutSidebar: 'Sidebar', layoutHorizontal: 'Top menu'
}
};
const t = (key) => (dict[lang] && key in dict[lang] ? dict[lang][key] : key);
@@ -103,12 +123,14 @@ function App() {
<QueryClientProvider client={queryClient}>
<LanguageProvider>
<ThemeProvider>
<ToastContainer>
<NotifyProvider>
<NetworkErrorHandler />
<MainLayout />
</NotifyProvider>
</ToastContainer>
<PingProvider>
<ToastContainer>
<NotifyProvider>
<NetworkErrorHandler />
<MainLayout />
</NotifyProvider>
</ToastContainer>
</PingProvider>
</ThemeProvider>
</LanguageProvider>
</QueryClientProvider>
@@ -117,12 +139,18 @@ function App() {
);
}
const LAYOUT_FLUID = 'fluid';
const LAYOUT_HORIZONTAL = 'horizontal';
function MainLayout() {
const location = useLocation();
const { lang, setLang, t } = useContext(LanguageContext);
const { theme, setTheme } = useContext(ThemeContext);
// Навбар стал лаконичным: без неиспользуемых уведомлений/иконок
const [settingsOpen, setSettingsOpen] = useState(false);
const [layout, setLayout] = useState(() => localStorage.getItem('layout') || LAYOUT_FLUID);
useEffect(() => {
localStorage.setItem('layout', layout);
}, [layout]);
// Состояние для управления выпадающими меню
const [dropdownStates, setDropdownStates] = useState({
@@ -131,10 +159,12 @@ function MainLayout() {
tools: false
});
// Закрытие всех выпадающих меню при клике вне их области
// Закрытие выпадающих меню только при клике в сайдбаре вне открытого меню (клики по странице не закрывают)
useEffect(() => {
const handleClickOutside = (event) => {
if (!event.target.closest('.nav-item.dropdown')) {
const inSidebar = event.target.closest('.navbar-vertical');
const inDropdown = event.target.closest('.nav-item.dropdown');
if (inSidebar && !inDropdown) {
setDropdownStates({
data: false,
management: false,
@@ -170,8 +200,13 @@ function MainLayout() {
id: 'home',
title: t('home'),
icon: IconHome,
path: '/dashboard',
single: true
items: [
{ id: 'dashboard', title: t('dashboard'), path: '/dashboard', icon: IconHome },
{ id: 'traffic', title: t('trafficTraffic'), path: '/traffic', icon: IconChartPie },
{ id: 'resource-stats', title: t('resourceStats'), path: '/resource-stats', icon: IconChartBar },
{ id: 'network-map', title: t('networkMap'), path: '/network-map', icon: IconNetwork },
{ id: 'uptime-monitor', title: t('uptimeMonitor'), path: '/uptime-monitor', icon: IconClock }
]
},
{
id: 'data',
@@ -201,10 +236,23 @@ function MainLayout() {
title: t('tools'),
icon: IconSettings,
items: [
{ id: 'auto-urls', title: t('autoUrls'), path: '/auto-urls', icon: IconDownload }
{ id: 'auto-urls', title: t('autoUrls'), path: '/auto-urls', icon: IconDownload },
{ id: 'mikrotik-backups', title: t('mikrotikBackups'), path: '/mikrotik-backups', icon: IconDatabase },
{ id: 'mikrotik-tools', title: 'MikroTik Инструменты', path: '/mikrotik-tools', icon: IconNetwork },
{ id: 'firewall', title: t('firewall'), path: '/firewall', icon: IconShield },
{ id: 'interface-speed', title: 'Скорость интерфейсов', path: '/interface-speed', icon: IconNetwork },
{ id: 'ping-services', title: t('pingServices'), path: '/ping-services', icon: IconNetwork },
{ id: 'uptime-monitor', title: t('uptimeMonitor'), path: '/uptime-monitor', icon: IconClock },
{ id: 'scheduler', title: 'Планировщик карты сети', path: '/scheduler', icon: IconClockPlay }
]
},
// Убрали неиспользуемые/неработающие разделы
{
id: 'settings',
title: 'Настройки',
icon: IconSettings,
single: true,
path: '/settings'
}
];
// Определяем активную вкладку по адресу
@@ -226,190 +274,325 @@ function MainLayout() {
const activeTab = getActiveTab();
// Старый вид: один верхний навбар с горизонтальным меню
if (layout === LAYOUT_HORIZONTAL) {
return (
<div className="page d-flex flex-column min-vh-100">
<a href="#main-content" className="skip-link">Перейти к основному содержанию</a>
<header className="navbar navbar-expand-md navbar-light d-print-none" style={{ boxShadow: '0 1px 0 0 #e9ecef' }}>
<div className="container-xl">
<div className="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pe-0 pe-md-3">
<Link to="/" className="navbar-brand d-flex align-items-center">
<IconBrandTabler className="navbar-brand-icon me-2" />
<span className="fw-bold">S3 Lists Manager</span>
</Link>
</div>
<button
className="navbar-toggler d-md-none"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbar-menu-horizontal"
aria-controls="navbar-menu-horizontal"
aria-expanded="false"
aria-label="Toggle navigation"
>
<IconMenu2 />
</button>
<div className="collapse navbar-collapse" id="navbar-menu-horizontal">
<div className="d-flex flex-column flex-md-row flex-fill align-items-stretch align-items-md-center">
<ul className="navbar-nav">
{navCategories.map(category => (
<li key={category.id} className="nav-item dropdown">
{category.single ? (
<Link
to={category.path}
className={`nav-link${activeTab === category.id ? ' active' : ''}`}
>
<category.icon className="icon icon-tabler" />
<span className="nav-link-title">{category.title}</span>
</Link>
) : (
<>
<a
className={`nav-link dropdown-toggle${activeTab.startsWith(category.id) ? ' active' : ''}`}
href="#"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleDropdown(category.id);
}}
>
<category.icon className="icon icon-tabler" />
<span className="nav-link-title">{category.title}</span>
</a>
{dropdownStates[category.id] && (
<div className="dropdown-menu show" onClick={(e) => e.stopPropagation()}>
<div className="dropdown-menu-columns">
<div className="dropdown-menu-column">
{category.items.map(item => (
<Link
key={item.id}
to={item.path}
className={`dropdown-item${activeTab === item.id ? ' active' : ''}`}
>
<item.icon className="icon icon-tabler" />
{item.title}
</Link>
))}
</div>
</div>
</div>
)}
</>
)}
</li>
))}
</ul>
</div>
</div>
<div className="navbar-nav flex-row order-md-last">
<div className="nav-item dropdown me-1">
<a href="#" className="nav-link px-2 dropdown-toggle" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()}>
{layout === LAYOUT_FLUID ? <IconLayoutSidebarLeftExpand size={20} /> : <IconLayoutNavbar size={20} />}
</a>
<div className="dropdown-menu dropdown-menu-end">
<a href="#" className={`dropdown-item d-flex align-items-center ${layout === LAYOUT_FLUID ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLayout(LAYOUT_FLUID); }}>
<IconLayoutSidebarLeftExpand className="me-2" size={18} /> {t('layoutSidebar')}
</a>
<a href="#" className={`dropdown-item d-flex align-items-center ${layout === LAYOUT_HORIZONTAL ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLayout(LAYOUT_HORIZONTAL); }}>
<IconLayoutNavbar className="me-2" size={18} /> {t('layoutHorizontal')}
</a>
</div>
</div>
<div className="nav-item dropdown me-1">
<a href="#" className="nav-link px-2 dropdown-toggle" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()}>
<span className="badge">{lang.toUpperCase()}</span>
</a>
<div className="dropdown-menu dropdown-menu-end">
<a href="#" className={`dropdown-item ${lang === 'ru' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('ru'); }}>Русский</a>
<a href="#" className={`dropdown-item ${lang === 'en' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('en'); }}>English</a>
</div>
</div>
<div className="nav-item dropdown me-1">
<a href="#" className="nav-link px-2 dropdown-toggle" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()}>
{theme === 'dark' ? '🌙' : '☀️'}
</a>
<div className="dropdown-menu dropdown-menu-end">
<a href="#" className={`dropdown-item ${theme === 'light' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setTheme('light'); }}>☀️ {t('light')}</a>
<a href="#" className={`dropdown-item ${theme === 'dark' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setTheme('dark'); }}>🌙 {t('dark')}</a>
</div>
</div>
<div className="nav-item me-1">
<KeyboardShortcutsButton className="nav-link px-2" />
</div>
<div className="nav-item">
<Link to="/settings" className="nav-link px-2" title="Настройки">
<IconSettings size={20} />
</Link>
</div>
</div>
</div>
</header>
<main id="main-content" className="container-xl mt-4 flex-grow-1" tabIndex="-1">
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/traffic" element={<TrafficDashboard />} />
<Route path="/resource-stats" element={<ResourceStatsPage />} />
<Route path="/network-map" element={<NetworkMapDashboard />} />
<Route path="/uptime-monitor" element={<UptimeMonitorPage />} />
<Route path="/domains" element={<DomainsNewManager />} />
<Route path="/ip-ranges" element={<IPRangesManager />} />
<Route path="/asns" element={<ASNsNewManager />} />
<Route path="/communities" element={<CommunitiesManager />} />
<Route path="/auto-urls" element={<AutoUrlManager />} />
<Route path="/servers" element={<ServerManager />} />
<Route path="/billing" element={<BillingManager />} />
<Route path="/filters" element={<FilterManager />} />
<Route path="/network-config" element={<NetworkConfigManager />} />
<Route path="/easy-switch" element={<EasySwitchManager />} />
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
<Route path="/firewall" element={<FirewallPage />} />
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
<Route path="/ping-services" element={<PingServicesManager />} />
<Route path="/scheduler" element={<NetworkMapSchedulerPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</main>
<footer className="footer mt-auto py-2">
<div className="container-xl text-muted small">Версия UI: {import.meta?.env?.VITE_APP_VERSION || 'dev'}</div>
</footer>
<CommandPalette />
</div>
);
}
// Fluid Vertical: вертикальный сайдбар + верхний навбар + page-wrapper
return (
<div className="page d-flex flex-column min-vh-100">
<div className="page">
{/* Skip link для доступности */}
<a href="#main-content" className="skip-link">
Перейти к основному содержанию
</a>
{/* Верхнее меню Tabler Admin */}
<header className="navbar navbar-expand-md navbar-light d-print-none" style={{boxShadow: '0 1px 0 0 #e9ecef'}}>
<div className="container-xl">
{/* Логотип */}
<div className="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pe-0 pe-md-3">
<Link to="/" className="navbar-brand d-flex align-items-center">
<IconBrandTabler className="navbar-brand-icon me-2" />
<span className="fw-bold">S3 Lists Manager</span>
</Link>
</div>
{/* Кнопка бургера для мобильных */}
<button
className="navbar-toggler d-md-none"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbar-menu"
aria-controls="navbar-menu"
aria-expanded="false"
aria-label="Toggle navigation"
>
<IconMenu2 />
</button>
{/* Основное меню */}
<div className="collapse navbar-collapse" id="navbar-menu">
<div className="d-flex flex-column flex-md-row flex-fill align-items-stretch align-items-md-center">
<ul className="navbar-nav">
{navCategories.map(category => (
<li key={category.id} className="nav-item dropdown">
{category.single ? (
<Link
to={category.path}
className={`nav-link${activeTab === category.id ? ' active' : ''}`}
{/* Левая вертикальная навигация — всегда тёмная (Tabler navbar-vertical) */}
<header className="navbar navbar-vertical navbar-expand-md d-print-none" data-bs-theme="dark">
<div className="container-fluid">
<Link to="/" className="navbar-brand navbar-brand-autodark">
<IconBrandTabler className="navbar-brand-icon" />
<span className="fw-bold">S3 Lists Manager</span>
</Link>
<div className="navbar-nav flex-row d-md-none">
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#sidebar-menu"
aria-controls="sidebar-menu"
aria-expanded="false"
aria-label="Открыть меню"
>
<IconMenu2 />
</button>
</div>
<div className="collapse navbar-collapse" id="sidebar-menu">
<ul className="navbar-nav pt-lg-3">
{navCategories.map(category => (
<li key={category.id} className={`nav-item${category.items ? ' dropdown' : ''}${(category.single && activeTab === category.id) || (category.items && category.items.some(i => activeTab === i.id)) ? ' active' : ''}`}>
{category.single ? (
<Link
to={category.path}
className="nav-link"
>
<span className="nav-link-icon">
<category.icon className="icon" />
</span>
<span className="nav-link-title">{category.title}</span>
</Link>
) : (
<>
<a
className="nav-link dropdown-toggle"
href="#"
onClick={(e) => {
e.preventDefault();
toggleDropdown(category.id);
}}
aria-expanded={dropdownStates[category.id]}
>
<category.icon className="icon icon-tabler" />
<span className="nav-link-icon">
<category.icon className="icon" />
</span>
<span className="nav-link-title">{category.title}</span>
</Link>
) : (
<>
<a
className={`nav-link dropdown-toggle${activeTab.startsWith(category.id) ? ' active' : ''}`}
href="#"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleDropdown(category.id);
}}
>
<category.icon className="icon icon-tabler" />
<span className="nav-link-title">{category.title}</span>
</a>
{dropdownStates[category.id] && (
<div className="dropdown-menu show" onClick={(e) => e.stopPropagation()}>
<div className="dropdown-menu-columns">
<div className="dropdown-menu-column">
{category.items.map(item => (
<Link
key={item.id}
to={item.path}
className={`dropdown-item${activeTab === item.id ? ' active' : ''}`}
>
<item.icon className="icon icon-tabler" />
{item.title}
</Link>
))}
</div>
</div>
</div>
)}
</>
)}
</li>
))}
</ul>
</div>
</div>
{/* Правая часть навбара: язык, тема и действия */}
<div className="navbar-nav flex-row order-md-last">
{/* Язык */}
<div className="nav-item dropdown me-1">
<a
href="#"
className="nav-link px-2 dropdown-toggle"
data-bs-toggle="dropdown"
onClick={(e) => e.preventDefault()}
>
<span className="badge">{lang.toUpperCase()}</span>
</a>
<div className="dropdown-menu dropdown-menu-end">
<a
href="#"
className={`dropdown-item ${lang === 'ru' ? 'active' : ''}`}
onClick={(e) => { e.preventDefault(); setLang('ru'); }}
>
Русский
</a>
<a
href="#"
className={`dropdown-item ${lang === 'en' ? 'active' : ''}`}
onClick={(e) => { e.preventDefault(); setLang('en'); }}
>
English
</a>
</a>
<div className={`dropdown-menu${dropdownStates[category.id] ? ' show' : ''}`}>
{category.items.map(item => (
<Link
key={item.id}
to={item.path}
className={`dropdown-item d-flex align-items-center${activeTab === item.id ? ' active' : ''}`}
>
<span className="nav-link-icon me-2">
<item.icon className="icon" />
</span>
{item.title}
</Link>
))}
</div>
</>
)}
</li>
))}
</ul>
{/* Горизонтальный ряд значков в самом низу сайдбара */}
<div className="navbar-sidebar-footer border-top mt-auto">
<div className="d-flex align-items-center justify-content-center gap-1 py-2 px-2">
<div className="nav-item dropdown dropup">
<a href="#" className="nav-link btn-icon rounded" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()} title="Язык">
<span className="badge bg-secondary">{lang.toUpperCase()}</span>
</a>
<div className="dropdown-menu dropdown-menu-end">
<a href="#" className={`dropdown-item ${lang === 'ru' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('ru'); }}>Русский</a>
<a href="#" className={`dropdown-item ${lang === 'en' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('en'); }}>English</a>
</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">
<a href="#" className={`dropdown-item d-flex align-items-center ${layout === LAYOUT_FLUID ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLayout(LAYOUT_FLUID); }}>
<IconLayoutSidebarLeftExpand className="me-2" size={18} /> {t('layoutSidebar')}
</a>
<a href="#" className={`dropdown-item d-flex align-items-center ${layout === LAYOUT_HORIZONTAL ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLayout(LAYOUT_HORIZONTAL); }}>
<IconLayoutNavbar className="me-2" size={18} /> {t('layoutHorizontal')}
</a>
</div>
</div>
<div className="nav-item dropdown dropup">
<a href="#" className="nav-link btn-icon rounded" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()} title={t('light') + ' / ' + t('dark')}>
{theme === 'dark' ? '🌙' : '☀️'}
</a>
<div className="dropdown-menu dropdown-menu-end">
<a href="#" className={`dropdown-item ${theme === 'light' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setTheme('light'); }}>☀️ {t('light')}</a>
<a href="#" className={`dropdown-item ${theme === 'dark' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setTheme('dark'); }}>🌙 {t('dark')}</a>
</div>
</div>
<div className="nav-item">
<KeyboardShortcutsButton className="nav-link btn-icon rounded" title="Горячие клавиши" />
</div>
<div className="nav-item">
<Link to="/settings" className="nav-link btn-icon rounded" title="Настройки">
<IconSettings size={20} />
</Link>
</div>
</div>
</div>
{/* Тема */}
<div className="nav-item dropdown me-1">
<a
href="#"
className="nav-link px-2 dropdown-toggle"
data-bs-toggle="dropdown"
onClick={(e) => e.preventDefault()}
>
{theme === 'dark' ? '🌙' : '☀️'}
</a>
<div className="dropdown-menu dropdown-menu-end">
<a
href="#"
className={`dropdown-item ${theme === 'light' ? 'active' : ''}`}
onClick={(e) => { e.preventDefault(); setTheme('light'); }}
>
☀️ {t('light')}
</a>
<a
href="#"
className={`dropdown-item ${theme === 'dark' ? 'active' : ''}`}
onClick={(e) => { e.preventDefault(); setTheme('dark'); }}
>
🌙 {t('dark')}
</a>
</div>
</div>
{/* Горячие клавиши */}
<div className="nav-item me-1">
<KeyboardShortcutsButton className="nav-link px-2" />
</div>
{/* Настройки */}
<div className="nav-item">
<a
href="#"
className="nav-link px-2"
onClick={(e) => { e.preventDefault(); setSettingsOpen(true); }}
title="Настройки"
>
<IconSettings size={20} />
</a>
</div>
</div>
</div>
</header>
<main id="main-content" className="container-xl mt-4 flex-grow-1" tabIndex="-1">
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/domains" element={<DomainsNewManager />} />
<Route path="/ip-ranges" element={<IPRangesManager />} />
<Route path="/asns" element={<ASNsNewManager />} />
<Route path="/communities" element={<CommunitiesManager />} />
<Route path="/auto-urls" element={<AutoUrlManager />} />
<Route path="/servers" element={<ServerManager />} />
<Route path="/billing" element={<BillingManager />} />
<Route path="/filters" element={<FilterManager />} />
<Route path="/network-config" element={<NetworkConfigManager />} />
<Route path="/easy-switch" element={<EasySwitchManager />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</main>
<footer className="footer mt-auto py-2">
<div className="container-xl text-muted small">
Версия UI: {import.meta?.env?.VITE_APP_VERSION || 'dev'}
<div className="page-wrapper">
<div className="page-body">
<div className="container-fluid">
<main id="main-content" tabIndex="-1">
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/traffic" element={<TrafficDashboard />} />
<Route path="/resource-stats" element={<ResourceStatsPage />} />
<Route path="/network-map" element={<NetworkMapDashboard />} />
<Route path="/uptime-monitor" element={<UptimeMonitorPage />} />
<Route path="/domains" element={<DomainsNewManager />} />
<Route path="/ip-ranges" element={<IPRangesManager />} />
<Route path="/asns" element={<ASNsNewManager />} />
<Route path="/communities" element={<CommunitiesManager />} />
<Route path="/auto-urls" element={<AutoUrlManager />} />
<Route path="/servers" element={<ServerManager />} />
<Route path="/billing" element={<BillingManager />} />
<Route path="/filters" element={<FilterManager />} />
<Route path="/network-config" element={<NetworkConfigManager />} />
<Route path="/easy-switch" element={<EasySwitchManager />} />
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
<Route path="/firewall" element={<FirewallPage />} />
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
<Route path="/ping-services" element={<PingServicesManager />} />
<Route path="/scheduler" element={<NetworkMapSchedulerPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</main>
</div>
</div>
</footer>
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
<footer className="footer footer-transparent d-print-none">
<div className="container-fluid">
<ul className="list-inline list-inline-dots mb-0 text-muted small">
<li className="list-inline-item">Версия UI: {import.meta?.env?.VITE_APP_VERSION || 'dev'}</li>
</ul>
</div>
</footer>
</div>
<CommandPalette />
</div>
);
+1 -1
View File
@@ -276,7 +276,7 @@ function AutoUrlManager() {
<div>
<PageHeader
title="Автоматические URL"
pretitle="Управление автоматическими URL-адресами для загрузки списков IP и доменов"
icon={<IconLink size={24} />}
actions={(
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div className="btn-list">
+376 -292
View File
@@ -54,6 +54,7 @@ function BillingManager() {
const [filterStatus, setFilterStatus] = useState('');
const [filterUrgency, setFilterUrgency] = useState('');
const [viewMode, setViewMode] = useState('cards'); // 'cards' | 'table'
const [activeTab, setActiveTab] = useState('subscriptions'); // 'subscriptions' | 'payments'
const pageSize = 15;
// Модальные окна
@@ -112,9 +113,12 @@ function BillingManager() {
});
useEffect(() => {
fetchBillingData();
fetchExchangeRates();
fetchServers();
const controller = new AbortController();
const signal = controller.signal;
fetchBillingData(signal);
fetchExchangeRates(signal);
fetchServers(signal);
return () => controller.abort();
}, []);
// Автоматическая синхронизация: добавляем серверы без записей в биллинге
@@ -126,9 +130,11 @@ function BillingManager() {
prev.map(b => b.serverId).filter(Boolean)
);
// Находим серверы без записей в биллинге
// Находим серверы без записей в биллинге (исключаем входные роутеры)
const serversToAdd = servers.filter(server =>
server.id && !existingServerIds.has(server.id)
server.id &&
!existingServerIds.has(server.id) &&
server.type !== 'home' // Входные роутеры не попадают в биллинг
);
if (serversToAdd.length === 0) return prev;
@@ -159,11 +165,12 @@ function BillingManager() {
}, [servers, loading]);
const fetchExchangeRates = async () => {
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');
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;
@@ -177,12 +184,13 @@ function BillingManager() {
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');
const response = await axios.get('https://open.er-api.com/v6/latest/USD', opts);
const rates = response.data?.rates || {};
setExchangeRates({
USD: 1,
@@ -190,6 +198,7 @@ function BillingManager() {
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 });
@@ -198,32 +207,60 @@ function BillingManager() {
}
};
const fetchServers = async () => {
const fetchServers = async (abortSignal) => {
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const res = await api.get(`/servers`);
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 () => {
const fetchBillingData = async (abortSignal) => {
setLoading(true);
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const response = await api.get(`/billing`);
const normalized = (response.data || []).map((item, idx) => ({
id: item.id ?? String(idx + 1),
payments: Array.isArray(item.payments)
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: 'Импортировано' }]
: []
),
...item,
}));
);
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([]);
@@ -272,6 +309,10 @@ function BillingManager() {
return sum + convertToRUB(item.monthlyCost || 0, item.monthlyCostCurrency || 'USD');
}, 0);
const totalPaymentsCount = billingData.reduce((sum, item) => {
return sum + ((item.payments && Array.isArray(item.payments) ? item.payments.length : 0));
}, 0);
const urgentPayments = billingData.filter(item => {
const nextPayment = new Date(item.nextPaymentDate);
const now = new Date();
@@ -293,6 +334,17 @@ function BillingManager() {
return Math.ceil((nextPayment - now) / (1000 * 60 * 60 * 24));
};
// Вычислить nextPaymentDate по платежу: сумма / месячная стоимость = кол-во месяцев
const computeNextPaymentDate = (payment, server) => {
if (!payment?.date) return '';
const amountRUB = convertToRUB(payment.amount, payment.currency || 'USD');
const costRUB = convertToRUB(server.monthlyCost || 0, server.monthlyCostCurrency || 'USD');
const months = costRUB > 0 ? Math.max(1, Math.floor(amountRUB / costRUB)) : 1;
const d = new Date(payment.date);
d.setMonth(d.getMonth() + months);
return d.toISOString().slice(0, 10);
};
// Форматирование
function formatDate(dateString) {
if (!dateString) return '—';
@@ -457,12 +509,14 @@ function BillingManager() {
const payments = [...(s.payments || [])];
payments.splice(index, 1);
const last = payments.slice().sort((a,b) => new Date(b.date) - new Date(a.date))[0];
const nextPaymentDate = last ? computeNextPaymentDate(last, s) : '';
return {
...s,
payments,
lastPaymentDate: last?.date || '',
lastPaymentAmount: last?.amount || 0,
lastPaymentCurrency: last?.currency || 'USD'
lastPaymentCurrency: last?.currency || 'USD',
nextPaymentDate: nextPaymentDate || s.nextPaymentDate
};
}));
setShowDeletePaymentModal(false);
@@ -537,17 +591,41 @@ function BillingManager() {
{/* Заголовок */}
<PageHeader
title="Биллинг"
pretitle="Финансы / Инфраструктура"
icon={<IconCreditCard size={24} />}
actions={
<PageHeaderActions
loading={loading}
onRefresh={() => { fetchBillingData(); fetchServers(); }}
disableRefresh={loading}
onExport={exportData}
disableExport={loading}
onSave={handleSaveChanges}
disableSave={loading}
/>
<div className="d-flex flex-column flex-sm-row align-items-stretch align-items-sm-center gap-2">
<div className="btn-group me-sm-2" role="group">
<button
className={`btn ${activeTab === 'subscriptions' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setActiveTab('subscriptions')}
>
<IconServer size={16} className="me-1 d-none d-sm-inline" />
Подписки
<span className={`badge ms-1 ${activeTab === 'subscriptions' ? 'bg-white text-primary' : 'bg-primary-lt text-primary'}`}>
{billingData.length}
</span>
</button>
<button
className={`btn ${activeTab === 'payments' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setActiveTab('payments')}
>
<IconHistory size={16} className="me-1 d-none d-sm-inline" />
История платежей
<span className={`badge ms-1 ${activeTab === 'payments' ? 'bg-white text-primary' : 'bg-primary-lt text-primary'}`}>
{totalPaymentsCount}
</span>
</button>
</div>
<PageHeaderActions
loading={loading}
onRefresh={() => { fetchBillingData(); fetchServers(); }}
disableRefresh={loading}
onExport={exportData}
disableExport={loading}
onSave={handleSaveChanges}
disableSave={loading}
/>
</div>
}
/>
@@ -617,279 +695,282 @@ function BillingManager() {
</div>
</div>
{/* Основная секция */}
<div className="card">
<div className="card-header">
<div className="d-flex align-items-center gap-3 flex-grow-1">
{/* Основная секция: Подписки */}
{activeTab === 'subscriptions' && (
<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">
Подписки
<span className="badge bg-blue-lt text-blue ms-2">{filteredData.length}</span>
</h3>
{/* Поиск */}
<div className="input-icon flex-grow-1" style={{ maxWidth: '280px' }}>
<span className="input-icon-addon">
<IconSearch size={16} />
</span>
<input
type="text"
className="form-control form-control-sm"
placeholder="Поиск..."
value={searchTerm}
onChange={(e) => { setSearchTerm(e.target.value); setCurrentPage(1); }}
/>
</div>
{/* Быстрые фильтры */}
<div className="d-flex gap-1">
<button
className={`btn btn-sm ${filterUrgency === '' ? 'btn-primary' : 'btn-outline-secondary'}`}
onClick={() => { setFilterUrgency(''); setCurrentPage(1); }}
>
Все
</button>
<button
className={`btn btn-sm ${filterUrgency === 'overdue' ? 'btn-red' : 'btn-outline-secondary'}`}
onClick={() => { setFilterUrgency('overdue'); setCurrentPage(1); }}
>
Просрочено
</button>
<button
className={`btn btn-sm ${filterUrgency === 'urgent' ? 'btn-yellow' : 'btn-outline-secondary'}`}
onClick={() => { setFilterUrgency('urgent'); setCurrentPage(1); }}
>
Срочно
</button>
</div>
{/* Переключатель вида */}
<div className="btn-group btn-group-sm" role="group">
<button
className={`btn ${viewMode === 'cards' ? 'btn-primary' : 'btn-outline-secondary'}`}
onClick={() => setViewMode('cards')}
title="Карточки"
>
<IconLayoutGrid size={16} />
</button>
<button
className={`btn ${viewMode === 'table' ? 'btn-primary' : 'btn-outline-secondary'}`}
onClick={() => setViewMode('table')}
title="Таблица"
>
<IconList size={16} />
</button>
</div>
</div>
<div className="card-actions">
<button className="btn btn-primary" onClick={handleAddItem}>
<IconPlus size={18} className="me-1" />
Добавить
</button>
</div>
</div>
{/* Контент: карточки или таблица */}
{paginatedData.length === 0 ? (
<div className="card-body text-center text-muted py-5">
<IconCreditCard size={48} className="mb-3 text-muted" />
<h3>{searchTerm || filterUrgency ? 'Ничего не найдено' : 'Нет подписок'}</h3>
<p className="text-muted">Добавьте первую подписку для отслеживания платежей</p>
<button className="btn btn-primary" onClick={handleAddItem}>
<IconPlus size={18} className="me-1" />
Добавить подписку
</button>
</div>
) : viewMode === 'cards' ? (
/* Карточный вид */
<div className="card-body">
{paginatedData.map(item => {
const days = getDaysUntilPayment(item.nextPaymentDate);
const linkedServer = item.serverId ? serversById.get(item.serverId) : null;
return (
<BillingCard
key={item.id}
item={item}
linkedServer={linkedServer}
daysUntilPayment={days}
onEdit={handleEditItem}
onDelete={handleDeleteItem}
onAddPayment={(item) => {
setPaymentDraft({
serverId: item.id,
date: new Date().toISOString().slice(0,10),
amount: item.monthlyCost || 0,
currency: item.monthlyCostCurrency || 'USD',
note: ''
});
setShowPaymentModal(true);
}}
formatCurrencyInRUB={formatCurrencyInRUB}
<div className="d-flex flex-wrap align-items-center gap-2 flex-grow-1 justify-content-end">
{/* Поиск */}
<div className="input-icon" style={{ minWidth: '220px', maxWidth: '280px' }}>
<span className="input-icon-addon">
<IconSearch size={16} />
</span>
<input
type="text"
className="form-control form-control-sm"
placeholder="Поиск..."
value={searchTerm}
onChange={(e) => { setSearchTerm(e.target.value); setCurrentPage(1); }}
/>
);
})}
</div>
{/* Быстрые фильтры */}
<div className="btn-group" role="group">
<button
className={`btn ${filterUrgency === '' ? 'btn-outline-primary active' : 'btn-outline-secondary'}`}
onClick={() => { setFilterUrgency(''); setCurrentPage(1); }}
>
Все
</button>
<button
className={`btn ${filterUrgency === 'overdue' ? 'btn-outline-danger active' : 'btn-outline-secondary'}`}
onClick={() => { setFilterUrgency('overdue'); setCurrentPage(1); }}
>
Просрочено
</button>
<button
className={`btn ${filterUrgency === 'urgent' ? 'btn-outline-warning active' : 'btn-outline-secondary'}`}
onClick={() => { setFilterUrgency('urgent'); setCurrentPage(1); }}
>
Срочно
</button>
</div>
{/* Переключатель вида */}
<div className="btn-group" role="group">
<button
className={`btn ${viewMode === 'cards' ? 'btn-outline-primary active' : 'btn-outline-secondary'}`}
onClick={() => setViewMode('cards')}
title="Карточки"
>
<IconLayoutGrid size={16} />
</button>
<button
className={`btn ${viewMode === 'table' ? 'btn-outline-primary active' : 'btn-outline-secondary'}`}
onClick={() => setViewMode('table')}
title="Таблица"
>
<IconList size={16} />
</button>
</div>
<div className="card-actions">
<button className="btn btn-primary" onClick={handleAddItem}>
<IconPlus size={18} className="me-1" />
Добавить
</button>
</div>
</div>
</div>
) : (
/* Табличный вид */
<div className="table-responsive">
<table className="table card-table table-vcenter mb-0">
<thead>
<tr>
<th
className="cursor-pointer"
onClick={() => handleSort('hostName')}
style={{ minWidth: '200px' }}
>
Сервис
{sortField === 'hostName' && (
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</th>
<th
className="cursor-pointer text-end"
onClick={() => handleSort('monthlyCost')}
style={{ width: '140px' }}
>
Стоимость
{sortField === 'monthlyCost' && (
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</th>
<th
className="cursor-pointer text-center"
onClick={() => handleSort('nextPaymentDate')}
style={{ width: '120px' }}
>
Платёж
{sortField === 'nextPaymentDate' && (
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</th>
<th className="text-center" style={{ width: '100px' }}>Срок</th>
<th className="text-center" style={{ width: '80px' }}>Статус</th>
<th style={{ width: '140px' }}></th>
</tr>
</thead>
<tbody>
{paginatedData.map(item => {
const days = getDaysUntilPayment(item.nextPaymentDate);
const linkedServer = item.serverId ? serversById.get(item.serverId) : null;
return (
<tr key={item.id}>
<td>
<div className="d-flex align-items-center">
<div>
<div className="fw-semibold">
{item.hostName}
{item._external && (
<span className="badge bg-yellow-lt text-yellow ms-2" title="Импортирован из серверов, требует заполнения">
не заполнен
</span>
)}
</div>
<div className="text-muted small d-flex align-items-center gap-2">
<span>{item.provider}</span>
{linkedServer && (
<>
<span>•</span>
<span className="text-blue">{linkedServer.ip}</span>
</>
)}
{item.loginUrl && (
<a
href={item.loginUrl.startsWith('http') ? item.loginUrl : `https://${item.loginUrl}`}
target="_blank"
rel="noopener noreferrer"
className="text-muted"
title="Открыть панель"
>
<IconExternalLink size={12} />
</a>
)}
{/* Контент: карточки или таблица */}
{paginatedData.length === 0 ? (
<div className="card-body text-center text-muted py-5">
<IconCreditCard size={48} className="mb-3 text-muted" />
<h3>{searchTerm || filterUrgency ? 'Ничего не найдено' : 'Нет подписок'}</h3>
<p className="text-muted">Добавьте первую подписку для отслеживания платежей</p>
<button className="btn btn-primary" onClick={handleAddItem}>
<IconPlus size={18} className="me-1" />
Добавить подписку
</button>
</div>
) : viewMode === 'cards' ? (
/* Карточный вид */
<div className="card-body">
{paginatedData.map(item => {
const days = getDaysUntilPayment(item.nextPaymentDate);
const linkedServer = item.serverId ? serversById.get(item.serverId) : null;
return (
<BillingCard
key={item.id}
item={item}
linkedServer={linkedServer}
daysUntilPayment={days}
onEdit={handleEditItem}
onDelete={handleDeleteItem}
onAddPayment={(item) => {
setPaymentDraft({
serverId: item.id,
date: new Date().toISOString().slice(0,10),
amount: item.monthlyCost || 0,
currency: item.monthlyCostCurrency || 'USD',
note: ''
});
setShowPaymentModal(true);
}}
formatCurrencyInRUB={formatCurrencyInRUB}
/>
);
})}
</div>
) : (
/* Табличный вид */
<div className="table-responsive">
<table className="table card-table table-vcenter mb-0">
<thead>
<tr>
<th
className="cursor-pointer"
onClick={() => handleSort('hostName')}
style={{ minWidth: '200px' }}
>
Сервис
{sortField === 'hostName' && (
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</th>
<th
className="cursor-pointer text-end"
onClick={() => handleSort('monthlyCost')}
style={{ width: '140px' }}
>
Стоимость
{sortField === 'monthlyCost' && (
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</th>
<th
className="cursor-pointer text-center"
onClick={() => handleSort('nextPaymentDate')}
style={{ width: '120px' }}
>
Платёж
{sortField === 'nextPaymentDate' && (
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</th>
<th className="text-center" style={{ width: '100px' }}>Срок</th>
<th className="text-center" style={{ width: '80px' }}>Статус</th>
<th style={{ width: '140px' }}></th>
</tr>
</thead>
<tbody>
{paginatedData.map(item => {
const days = getDaysUntilPayment(item.nextPaymentDate);
const linkedServer = item.serverId ? serversById.get(item.serverId) : null;
return (
<tr key={item.id}>
<td>
<div className="d-flex align-items-center">
<div>
<div className="fw-semibold">
{item.hostName}
{item._external && (
<span className="badge bg-yellow-lt text-yellow ms-2" title="Импортирован из серверов, требует заполнения">
не заполнен
</span>
)}
</div>
<div className="text-muted small d-flex align-items-center gap-2">
<span>{item.provider}</span>
{linkedServer && (
<>
<span>•</span>
<span className="text-blue">{linkedServer.ip}</span>
</>
)}
{item.loginUrl && (
<a
href={item.loginUrl.startsWith('http') ? item.loginUrl : `https://${item.loginUrl}`}
target="_blank"
rel="noopener noreferrer"
className="text-muted"
title="Открыть панель"
>
<IconExternalLink size={12} />
</a>
)}
</div>
</div>
</div>
</div>
</td>
<td className="text-end">
<div className="fw-semibold">
{formatCurrency(item.monthlyCost, item.monthlyCostCurrency)}
</div>
{item.monthlyCostCurrency !== 'RUB' && (
<div className="text-muted small">
≈ {formatCurrencyInRUB(item.monthlyCost, item.monthlyCostCurrency)}
</td>
<td className="text-end">
<div className="fw-semibold">
{formatCurrency(item.monthlyCost, item.monthlyCostCurrency)}
</div>
)}
</td>
<td className="text-center">
{formatDate(item.nextPaymentDate)}
</td>
<td className="text-center">
<UrgencyBadge days={days} />
</td>
<td className="text-center">
{item.status === 'active' ? (
<span className="badge bg-green-lt text-green">Активен</span>
) : (
<span className="badge bg-secondary-lt text-secondary">Неактивен</span>
)}
</td>
<td>
<div className="btn-list gap-1 justify-content-end flex-nowrap">
<button
className="btn btn-outline-success btn-sm"
onClick={() => {
setPaymentDraft({
serverId: item.id,
date: new Date().toISOString().slice(0,10),
amount: item.monthlyCost || 0,
currency: item.monthlyCostCurrency || 'USD',
note: ''
});
setShowPaymentModal(true);
}}
title="Записать платёж"
>
<IconCreditCard size={14} className="me-1" />
Оплата
</button>
<button
className="btn btn-outline-secondary btn-sm"
onClick={() => handleEditItem(item)}
title="Редактировать"
>
<IconEdit size={14} />
</button>
<button
className="btn btn-outline-danger btn-sm"
onClick={() => handleDeleteItem(item)}
title="Удалить"
>
<IconTrash size={14} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={filteredData.length}
pageSize={pageSize}
onPageChange={setCurrentPage}
/>
)}
</div>
{item.monthlyCostCurrency !== 'RUB' && (
<div className="text-muted small">
≈ {formatCurrencyInRUB(item.monthlyCost, item.monthlyCostCurrency)}
</div>
)}
</td>
<td className="text-center">
{formatDate(item.nextPaymentDate)}
</td>
<td className="text-center">
<UrgencyBadge days={days} />
</td>
<td className="text-center">
{item.status === 'active' ? (
<span className="badge bg-green-lt text-green">Активен</span>
) : (
<span className="badge bg-secondary-lt text-secondary">Неактивен</span>
)}
</td>
<td className="text-end">
<div className="btn-group" role="group">
<button
className="btn btn-outline-success"
onClick={() => {
setPaymentDraft({
serverId: item.id,
date: new Date().toISOString().slice(0,10),
amount: item.monthlyCost || 0,
currency: item.monthlyCostCurrency || 'USD',
note: ''
});
setShowPaymentModal(true);
}}
title="Записать платёж"
>
<IconCreditCard size={14} className="me-1" />
Оплата
</button>
<button
className="btn btn-outline-secondary"
onClick={() => handleEditItem(item)}
title="Редактировать"
>
<IconEdit size={14} />
</button>
<button
className="btn btn-outline-danger"
onClick={() => handleDeleteItem(item)}
title="Удалить"
>
<IconTrash size={14} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={filteredData.length}
pageSize={pageSize}
onPageChange={setCurrentPage}
/>
)}
</div>
)}
{/* История платежей — компактная версия */}
{activeTab === 'payments' && (
<div className="card mt-4">
<div className="card-header">
<h3 className="card-title d-flex align-items-center gap-2">
@@ -1007,6 +1088,7 @@ function BillingManager() {
</div>
</div>
</div>
)}
{/* Модальные окна */}
<FormModal
@@ -1079,7 +1161,8 @@ function BillingManager() {
note: paymentDraft.note || ''
};
const last = payments.slice().sort((a,b) => new Date(b.date) - new Date(a.date))[0];
return { ...s, payments, lastPaymentDate: last?.date || '', lastPaymentAmount: last?.amount || 0, lastPaymentCurrency: last?.currency || 'USD' };
const nextPaymentDate = computeNextPaymentDate(last, s);
return { ...s, payments, lastPaymentDate: last?.date || '', lastPaymentAmount: last?.amount || 0, lastPaymentCurrency: last?.currency || 'USD', nextPaymentDate: nextPaymentDate || s.nextPaymentDate };
}));
setEditingPayment(null);
} else {
@@ -1092,7 +1175,8 @@ function BillingManager() {
note: paymentDraft.note || ''
}];
const last = payments.slice().sort((a,b) => new Date(b.date) - new Date(a.date))[0];
return { ...s, payments, lastPaymentDate: last?.date || '', lastPaymentAmount: last?.amount || 0, lastPaymentCurrency: last?.currency || 'USD' };
const nextPaymentDate = computeNextPaymentDate(last, s);
return { ...s, payments, lastPaymentDate: last?.date || '', lastPaymentAmount: last?.amount || 0, lastPaymentCurrency: last?.currency || 'USD', nextPaymentDate: nextPaymentDate || s.nextPaymentDate };
}));
}
setShowPaymentModal(false);
+320 -297
View File
@@ -14,11 +14,14 @@ import {
IconLink,
IconRefresh,
IconSortAscending,
IconSortDescending
IconSortDescending,
IconUpload,
IconDownload,
IconDeviceFloppy
} from '@tabler/icons-react';
import PageHeaderActions from './components/PageHeaderActions.jsx';
import PageHeader from './components/PageHeader.jsx';
import PortalDropdown from './components/PortalDropdown.jsx';
import EmptyState from './components/EmptyState.jsx';
import Breadcrumbs from './components/Breadcrumbs.jsx';
import CommunityStats from './components/CommunityStats.jsx';
import SavedFilters from './components/SavedFilters.jsx';
import FormModal from './components/FormModal.jsx';
@@ -350,309 +353,329 @@ function CommunitiesManager() {
};
return (
<div className="page-wrapper">
<div className="page-header d-print-none">
<div className="container-xl">
<div className="row g-2 align-items-center">
<div className="col">
<h2 className="page-title">
<IconFilter className="icon me-2" />
Справочник Community
</h2>
<div className="page-pretitle">
<Breadcrumbs items={[
{ label: 'Главная', to: '/dashboard' },
{ label: 'Данные' },
{ label: 'Community' },
]} />
<div>
<PageHeader
title="Справочник Community"
icon={<IconFilter size={24} />}
/>
{/* Уведомления */}
{error && (
<ErrorAlert message={error} onClose={() => setError('')} onRetry={fetchItems} />
)}
{success && (
<div className="alert alert-success alert-dismissible" role="alert">
<div className="d-flex">
<div>
<IconCheck className="icon alert-icon" />
</div>
<div>
{success}
</div>
</div>
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
</div>
)}
{/* Табы и кнопки в одной строке, единый стиль btn-primary */}
<div className="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<div className="btn-group" role="tablist" aria-label="Переключение режимов страницы">
<button
type="button"
className={`btn ${activeTab === 'list' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setActiveTab('list')}
role="tab"
aria-pressed={activeTab === 'list'}
>
<IconList size={16} className="me-1 d-none d-sm-inline" />
Список
<span
className={`badge ms-1 ${
activeTab === 'list'
? 'bg-white text-primary'
: 'bg-primary-lt text-primary'
}`}
>
{filtered.length}
</span>
</button>
<button
type="button"
className={`btn ${activeTab === 'stats' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setActiveTab('stats')}
role="tab"
aria-pressed={activeTab === 'stats'}
>
<IconChartBar size={16} className="me-1 d-none d-sm-inline" />
Статистика
<span
className={`badge ms-1 ${
activeTab === 'stats'
? 'bg-white text-primary'
: 'bg-primary-lt text-primary'
}`}
>
{items.length}
</span>
</button>
</div>
<div className="btn-group">
<button
type="button"
className="btn btn-primary"
onClick={fetchItems}
disabled={loading}
title="Обновить данные"
>
<IconRefresh size={16} className={loading ? 'spin me-1' : 'me-1'} />
Обновить
</button>
<PortalDropdown
buttonClassName="btn btn-primary dropdown-toggle"
buttonProps={{ title: 'Импорт/Экспорт' }}
align="left"
buttonContent={<><IconDownload className="me-1" /> Импорт/Экспорт</>}
>
<button type="button" className="dropdown-item" onClick={handleImport}>
<IconUpload className="me-1" /> Импорт
</button>
<button
type="button"
className="dropdown-item"
onClick={handleExport}
disabled={items.length === 0}
>
<IconDownload className="me-1" /> Экспорт
</button>
</PortalDropdown>
<button
type="button"
className="btn btn-primary"
onClick={handleSave}
disabled={loading}
title="Сохранить"
>
{loading ? (
<span className="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true" />
) : (
<IconDeviceFloppy className="me-1" />
)}
Сохранить
</button>
</div>
</div>
{/* Отображение списка communities */}
{activeTab === 'list' && (
<div className="card card-flat">
<div className="card-header">
<div className="row align-items-center w-100 g-2">
<div className="col">
<h3 className="card-title mb-0">
<IconFilter className="icon me-2" size={20} />
Список community
<span className="badge bg-blue-lt text-blue ms-2">{filtered.length}</span>
</h3>
</div>
<div className="col-auto">
<button
className="btn btn-primary"
onClick={() => setAddModalOpen(true)}
>
<IconPlus className="icon me-1" />
Добавить community
</button>
</div>
</div>
<div className="col-auto ms-auto d-print-none">
<PageHeaderActions
loading={loading}
onRefresh={fetchItems}
disableRefresh={loading}
onImport={handleImport}
onExport={handleExport}
disableExport={items.length === 0}
onSave={handleSave}
disableSave={loading}
</div>
<div className="card-body">
<div className="d-flex gap-2 flex-wrap mb-3 align-items-center">
<SavedFilters
pageKey="communities"
currentFilters={{ searchTerm, sortField, sortOrder }}
onApplyFilter={(filters) => {
if (filters.searchTerm !== undefined) setSearchTerm(filters.searchTerm);
if (filters.sortField !== undefined) setSortField(filters.sortField);
if (filters.sortOrder !== undefined) setSortOrder(filters.sortOrder);
setCurrentPage(1);
}}
/>
</div>
</div>
</div>
</div>
<div className="page-body">
<div className="container-xl">
{/* Уведомления */}
{error && (
<ErrorAlert message={error} onClose={() => setError('')} onRetry={fetchItems} />
)}
{success && (
<div className="alert alert-success alert-dismissible" role="alert">
<div className="d-flex">
<div>
<IconCheck className="icon alert-icon" />
</div>
<div>
{success}
</div>
<div className="input-icon flex-grow-1" style={{ minWidth: '200px', position: 'relative' }}>
<span className="input-icon-addon"><IconSearch size={18} /></span>
<input
className="form-control"
placeholder="Поиск по value, названию, описанию и тегам"
value={searchTerm}
onChange={(e)=>{setSearchTerm(e.target.value); setCurrentPage(1);}}
style={{ paddingRight: searchTerm ? '2.5rem' : undefined }}
/>
{!!searchTerm && (
<button
type="button"
className="btn btn-ghost-secondary btn-icon btn-sm"
style={{ position: 'absolute', right: '0.25rem', top: '50%', transform: 'translateY(-50%)', zIndex: 2 }}
aria-label="Очистить поиск"
onClick={()=>{ setSearchTerm(''); setCurrentPage(1); }}
>
<IconX size={16} />
</button>
)}
</div>
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
</div>
)}
{/* Вкладки: Список / Статистика в стиле Tabler */}
<div className="card mb-3">
<div className="card-header border-0 pb-0">
<ul className="nav nav-tabs card-header-tabs" role="tablist">
<li className="nav-item" role="presentation">
<button
className={`nav-link ${activeTab === 'list' ? 'active' : ''}`}
onClick={() => setActiveTab('list')}
type="button"
role="tab"
aria-selected={activeTab === 'list'}
>
<IconList className="me-2" size={18} />
Список communities
<span className="badge bg-blue-lt text-blue ms-2">{filtered.length}</span>
</button>
</li>
<li className="nav-item" role="presentation">
<button
className={`nav-link ${activeTab === 'stats' ? 'active' : ''}`}
onClick={() => setActiveTab('stats')}
type="button"
role="tab"
aria-selected={activeTab === 'stats'}
>
<IconChartBar className="me-2" size={18} />
Статистика использования
</button>
</li>
</ul>
</div>
</div>
{/* Отображение списка communities */}
{activeTab === 'list' && (
<div className="row g-3">
<div className="col-12">
<div className="card">
<div className="card-header">
<div className="row align-items-center w-100 g-2">
<div className="col">
<h3 className="card-title mb-0">
<IconFilter className="icon me-2" size={20} />
Список community
<span className="badge bg-blue-lt text-blue ms-2">{filtered.length}</span>
</h3>
</div>
<div className="col-auto">
<button
className="btn btn-primary"
onClick={() => setAddModalOpen(true)}
>
<IconPlus className="icon me-1" />
Добавить community
</button>
</div>
</div>
</div>
<div className="card-body">
<div className="d-flex gap-2 flex-wrap mb-3 align-items-center">
<SavedFilters
pageKey="communities"
currentFilters={{ searchTerm, sortField, sortOrder }}
onApplyFilter={(filters) => {
if (filters.searchTerm !== undefined) setSearchTerm(filters.searchTerm);
if (filters.sortField !== undefined) setSortField(filters.sortField);
if (filters.sortOrder !== undefined) setSortOrder(filters.sortOrder);
setCurrentPage(1);
}}
/>
<div className="input-icon flex-grow-1" style={{ minWidth: '200px', position: 'relative' }}>
<span className="input-icon-addon"><IconSearch size={18} /></span>
<input
className="form-control"
placeholder="Поиск по value, названию, описанию и тегам"
value={searchTerm}
onChange={(e)=>{setSearchTerm(e.target.value); setCurrentPage(1);}}
style={{ paddingRight: searchTerm ? '2.5rem' : undefined }}
/>
{!!searchTerm && (
<button
type="button"
className="btn btn-ghost-secondary btn-icon btn-sm"
style={{ position: 'absolute', right: '0.25rem', top: '50%', transform: 'translateY(-50%)', zIndex: 2 }}
aria-label="Очистить поиск"
onClick={()=>{ setSearchTerm(''); setCurrentPage(1); }}
>
<IconX size={16} />
</button>
)}
</div>
<div className="d-flex align-items-center gap-3 flex-wrap">
<label className="form-check form-switch mb-0 d-flex align-items-center gap-2">
<input
className="form-check-input"
type="checkbox"
checked={showOnlyUsedInFilters}
onChange={(e) => { setShowOnlyUsedInFilters(e.target.checked); setCurrentPage(1); }}
/>
<span className="form-check-label">Только используемые в фильтрах</span>
{filterUsageLoading && (
<span className="spinner-border spinner-border-sm text-primary" role="status" aria-hidden="true"></span>
)}
</label>
<button
className="btn btn-outline-secondary btn-sm"
type="button"
onClick={fetchFilterUsage}
disabled={filterUsageLoading}
title="Обновить использование из /filters"
>
<IconRefresh className="icon me-1" size={16} />
Обновить использование
</button>
{filterUsageError && (
<span className="text-danger small">{filterUsageError}</span>
)}
</div>
</div>
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<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" />
Value
{sortField==='value' && (
<span className="ms-1">
{sortOrder==='asc' ? <IconSortAscending size={14} /> : <IconSortDescending size={14} />}
</span>
)}
</div>
</th>
<th className="cursor-pointer user-select-none" onClick={()=>changeSort('name')}>
<div className="d-flex align-items-center">
<IconFilter size={16} className="me-1 text-muted" />
Название
{sortField==='name' && (
<span className="ms-1">
{sortOrder==='asc' ? <IconSortAscending size={14} /> : <IconSortDescending size={14} />}
</span>
)}
</div>
</th>
<th>Описание</th>
<th>Теги</th>
<th>Gateway</th>
<th style={{ width: '10%' }}>Исп. в фильтрах</th>
<th className="text-end">Действия</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={7} className="text-center text-muted py-4">Загрузка...</td></tr>
) : paginated.length === 0 ? (
<tr><td colSpan={7} className="text-center text-muted py-4">
<EmptyState
title="Нет community"
description="Добавьте запись, чтобы начать."
action={
<button className="btn btn-primary" onClick={() => setAddModalOpen(true)}>
<IconPlus className="me-2" />
Добавить
</button>
}
/>
</td></tr>
) : paginated.map((item) => {
const usageCount = filterUsage[item.value] || 0;
return (
<tr key={item.value}>
<td>
<code className="text-blue">{item.value}</code>
{item._external && (
<span className="badge bg-yellow-lt text-yellow ms-2" title="Не внесён в справочник">не в справ.</span>
)}
</td>
<td>{item.name || <span className="text-muted">—</span>}</td>
<td>
<span className="text-muted" title={item.description}>{item.description || '—'}</span>
</td>
<td>
{(item.tags || []).map(t => (
<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>
) : (
<span className="text-muted">0</span>
)}
</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)}
title="Редактировать"
>
<IconEdit size={16} />
</button>
<button
className="btn btn-outline-danger btn-icon btn-sm"
onClick={()=>handleDeleteClick(item)}
title="Удалить"
>
<IconTrash size={16} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{totalPages > 1 && (
<Pagination
currentPage={page}
totalPages={totalPages}
totalItems={filtered.length}
pageSize={pageSize}
onPageChange={setCurrentPage}
/>
<div className="d-flex align-items-center gap-3 flex-wrap">
<label className="form-check form-switch mb-0 d-flex align-items-center gap-2">
<input
className="form-check-input"
type="checkbox"
checked={showOnlyUsedInFilters}
onChange={(e) => { setShowOnlyUsedInFilters(e.target.checked); setCurrentPage(1); }}
/>
<span className="form-check-label">Только используемые в фильтрах</span>
{filterUsageLoading && (
<span className="spinner-border spinner-border-sm text-primary" role="status" aria-hidden="true"></span>
)}
</div>
</label>
<button
className="btn btn-outline-secondary btn-sm"
type="button"
onClick={fetchFilterUsage}
disabled={filterUsageLoading}
title="Обновить использование из /filters"
>
<IconRefresh className="icon me-1" size={16} />
Обновить использование
</button>
{filterUsageError && (
<span className="text-danger small">{filterUsageError}</span>
)}
</div>
</div>
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<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" />
Value
{sortField==='value' && (
<span className="ms-1">
{sortOrder==='asc' ? <IconSortAscending size={14} /> : <IconSortDescending size={14} />}
</span>
)}
</div>
</th>
<th className="cursor-pointer user-select-none" onClick={()=>changeSort('name')}>
<div className="d-flex align-items-center">
<IconFilter size={16} className="me-1 text-muted" />
Название
{sortField==='name' && (
<span className="ms-1">
{sortOrder==='asc' ? <IconSortAscending size={14} /> : <IconSortDescending size={14} />}
</span>
)}
</div>
</th>
<th>Описание</th>
<th>Теги</th>
<th>Gateway</th>
<th style={{ width: '10%' }}>Исп. в фильтрах</th>
<th className="text-end">Действия</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={7} className="text-center text-muted py-4">Загрузка...</td></tr>
) : paginated.length === 0 ? (
<tr><td colSpan={7} className="text-center text-muted py-4">
<EmptyState
title="Нет community"
description="Добавьте запись, чтобы начать."
action={
<button className="btn btn-primary" onClick={() => setAddModalOpen(true)}>
<IconPlus className="me-2" />
Добавить
</button>
}
/>
</td></tr>
) : paginated.map((item) => {
const usageCount = filterUsage[item.value] || 0;
return (
<tr key={item.value}>
<td>
<code className="text-blue">{item.value}</code>
{item._external && (
<span className="badge bg-yellow-lt text-yellow ms-2" title="Не внесён в справочник">не в справ.</span>
)}
</td>
<td>{item.name || <span className="text-muted">—</span>}</td>
<td>
<span className="text-muted" title={item.description}>{item.description || '—'}</span>
</td>
<td>
{(item.tags || []).map(t => (
<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>
) : (
<span className="text-muted">0</span>
)}
</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)}
title="Редактировать"
>
<IconEdit size={16} />
</button>
<button
className="btn btn-outline-danger btn-icon btn-sm"
onClick={()=>handleDeleteClick(item)}
title="Удалить"
>
<IconTrash size={16} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{totalPages > 1 && (
<Pagination
currentPage={page}
totalPages={totalPages}
totalItems={filtered.length}
pageSize={pageSize}
onPageChange={setCurrentPage}
/>
)}
</div>
)}
{/* Отображение статистики communities */}
{activeTab === 'stats' && (
<CommunityStats communities={items} />
)}
</div>
</div>
)}
{/* Отображение статистики communities */}
{activeTab === 'stats' && (
<CommunityStats communities={items} />
)}
{/* Модальное окно добавления */}
<FormModal
show={addModalOpen}
+197 -15
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import api from './lib/api.js';
import { formatDateTime } from './lib/datetime.js';
@@ -15,14 +15,17 @@ import {
IconFilter,
IconDownload,
IconCreditCard,
IconSearch
IconSearch,
IconChevronDown,
IconChevronUp,
} from '@tabler/icons-react';
import { getIconById } from './lib/brandIcons.js';
import PageHeader from './components/PageHeader.jsx';
import Breadcrumbs from './components/Breadcrumbs.jsx';
import TopNStats from './components/TopNStats.jsx';
import TrendIndicator from './components/TrendIndicator.jsx';
import LastSaved from './components/LastSaved.jsx';
import Tooltip from './components/Tooltip.jsx';
import Sparkline from './components/Sparkline.jsx';
function StatCard({ icon: Icon, color, value, title, subtitle, to, trend, previousValue }) {
return (
@@ -75,6 +78,105 @@ function MetricCard({ title, value, icon: Icon, color, description }) {
);
}
/** Обёртка для Sparkline по ширине контейнера */
function PingSparklineWrap({ history, color }) {
const wrapRef = useRef(null);
const [width, setWidth] = useState(280);
useEffect(() => {
const el = wrapRef.current;
if (!el) return;
const ro = new ResizeObserver(() => setWidth(el.offsetWidth || 280));
ro.observe(el);
setWidth(el.offsetWidth || 280);
return () => ro.disconnect();
}, []);
return (
<div ref={wrapRef} className="mt-2" style={{ height: 56, width: '100%', minWidth: 0 }}>
<Sparkline
data={history}
width={width}
height={56}
strokeColor={color ? `var(--tblr-${color})` : 'var(--tblr-primary)'}
fillColor={color ? `var(--tblr-${color})` : 'var(--tblr-primary)'}
strokeWidth={2}
/>
</div>
);
}
/** Разворачиваемая карточка сервиса: свёрнутый вид — иконка и пинг; развёрнутый — тренд и мини-график (как Revenue в Tabler) */
function PingServiceCard({ config, ms, previousMs, history = [], loading, isExpanded, onToggle }) {
const { name, host, color } = config;
const { Icon } = getIconById(config.icon);
const value = loading ? '...' : (ms != null ? `${ms} мс` : '—');
const currentNum = typeof ms === 'number' ? ms : null;
return (
<div
className={`card h-100 position-relative ${isExpanded ? '' : 'cursor-pointer'}`}
style={isExpanded ? { minHeight: '200px' } : undefined}
role="button"
tabIndex={0}
onClick={onToggle}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onToggle()}
aria-expanded={isExpanded}
aria-label={isExpanded ? `Свернуть ${name}` : `Развернуть ${name}, пинг ${value}`}
>
{!isExpanded ? (
<div className="card-body d-flex align-items-center">
<span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0 rounded`}>
<Icon size={28} stroke={1.5} />
</span>
<div className="flex-grow-1 min-w-0">
<div className="h4 mb-0 fw-bold">{value}</div>
<div className="text-muted small text-truncate" title={`${name} (${host})`}>
{name} <span className="opacity-75">({host})</span>
</div>
</div>
<IconChevronDown size={18} className="text-muted ms-1 flex-shrink-0" />
</div>
) : (
<div className="card-body">
<div className="d-flex align-items-center justify-content-between mb-2">
<span className="text-muted text-uppercase small fw-semibold">{name}</span>
<span className="text-muted small d-flex align-items-center">
Последние замеры
<IconChevronUp size={14} className="ms-1" title="Свернуть" />
</span>
</div>
<div className="d-flex align-items-baseline flex-wrap gap-2 mb-3">
<div className="h3 mb-0 fw-bold">
{loading ? '...' : currentNum != null ? `${currentNum} мс` : '—'}
</div>
{!loading && currentNum != null && previousMs != null && (
<TrendIndicator
value={currentNum}
previousValue={previousMs}
format="percent"
inverse
/>
)}
</div>
{history.length > 0 && (() => {
const sum = history.reduce((a, b) => a + b, 0);
const avg = Math.round(sum / history.length);
return (
<div className="text-muted small mb-2">
Среднее за период: <span className="fw-semibold text-body">{avg} мс</span>
</div>
);
})()}
{history.length > 0 ? (
<PingSparklineWrap history={history} color={color} />
) : (
<div className="text-muted small mt-2">Нет истории замеров. Данные накапливаются при обновлении.</div>
)}
</div>
)}
</div>
);
}
function Dashboard() {
// Глобальный поиск удалён по требованию UX
const [stats, setStats] = useState({
@@ -93,20 +195,28 @@ function Dashboard() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [lastFetchTime, setLastFetchTime] = useState(null);
const [pingServices, setPingServices] = useState(null);
const [pingServicesConfig, setPingServicesConfig] = useState([]);
const [pingLoading, setPingLoading] = useState(true);
const [pingHistory, setPingHistory] = useState(() => ({}));
const [expandedPingId, setExpandedPingId] = useState(null);
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
async function fetchStats() {
setLoading(true);
setError(null);
try {
// Загружаем данные с обработкой ошибок для каждого endpoint
const opts = { signal };
const results = await Promise.allSettled([
api.get('/domains-new', { params: { countOnly: true } }),
api.get('/ip-ranges', { params: { countOnly: true } }),
api.get('/asns', { params: { countOnly: true } }),
api.get('/servers'),
api.get('/servers/availability', { params: { ttlSeconds: 60 } }),
api.get('/s3/last-modified')
api.get('/domains-new', { params: { countOnly: true }, ...opts }),
api.get('/ip-ranges', { params: { countOnly: true }, ...opts }),
api.get('/asns', { params: { countOnly: true }, ...opts }),
api.get('/servers', opts),
api.get('/servers/availability', { params: { ttlSeconds: 60 }, ...opts }),
api.get('/s3/last-modified', opts)
]);
// Обрабатываем результаты
@@ -171,6 +281,7 @@ function Dashboard() {
console.warn('Некоторые API недоступны:', errors.map(e => e.reason?.message));
}
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
console.error('Критическая ошибка загрузки статистики:', e);
setError('Не удалось загрузить статистику');
setStats({
@@ -189,6 +300,44 @@ function Dashboard() {
}
}
fetchStats();
return () => controller.abort();
}, []);
useEffect(() => {
const controller = new AbortController();
api.get('/ping-services-list', { signal: controller.signal })
.then(({ data }) => {
if (Array.isArray(data?.list)) setPingServicesConfig(data.list);
})
.catch((e) => {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
setPingServicesConfig([]);
});
return () => controller.abort();
}, []);
function fetchPingServices(forceRefresh = false, abortSignal) {
setPingLoading(true);
const opts = abortSignal ? { signal: abortSignal } : {};
api.get('/ping-services', { params: forceRefresh ? { refresh: 1 } : {}, ...opts })
.then(({ data }) => {
if (!data || typeof data !== 'object') return;
const byId = data.byId ?? data;
const history = data.history ?? {};
setPingServices(byId);
setPingHistory(typeof history === 'object' ? history : {});
})
.catch((e) => {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
setPingServices(null);
})
.finally(() => setPingLoading(false));
}
useEffect(() => {
const controller = new AbortController();
fetchPingServices(false, controller.signal);
return () => controller.abort();
}, []);
if (error) {
@@ -206,11 +355,7 @@ function Dashboard() {
{/* Заголовок страницы */}
<PageHeader
title="Панель"
pretitle={(
<Breadcrumbs items={[
{ label: 'Главная' },
]} />
)}
icon={<IconCloud size={24} />}
actions={(
<div className="d-flex align-items-center gap-3">
{lastFetchTime && (
@@ -225,6 +370,43 @@ function Dashboard() {
)}
/>
{/* Пинг до сервисов (список из настроек / Пинг сервисов) */}
<div className="d-flex align-items-center justify-content-between mb-2">
<h3 className="mb-0">Пинг сервисов</h3>
<Tooltip content="Принудительно обновить пинг (без учёта кэша), история и тренд накапливаются">
<button
type="button"
className="btn btn-outline-primary btn-sm"
onClick={() => fetchPingServices(true)}
disabled={pingLoading}
>
<IconRefresh className={pingLoading ? 'spin me-1' : 'me-1'} size={16} />
Обновить пинг
</button>
</Tooltip>
</div>
<div className="row g-3 mb-4">
{pingServicesConfig.map((config) => (
<div
key={config.id}
className={expandedPingId === config.id ? 'col-12' : 'col-6 col-md-3'}
>
<PingServiceCard
config={config}
ms={pingServices?.[config.id]?.ms ?? null}
previousMs={(() => {
const hist = pingHistory[config.id] || [];
return hist.length >= 2 ? hist[hist.length - 2] : null;
})()}
history={pingHistory[config.id] || []}
loading={pingLoading}
isExpanded={expandedPingId === config.id}
onToggle={() => setExpandedPingId((id) => (id === config.id ? null : config.id))}
/>
</div>
))}
</div>
{/* Основные метрики */}
<div className="row g-3 mb-4">
<div className="col-md-3">
+274 -365
View File
@@ -29,9 +29,7 @@ import {
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
import PageHeader from './components/PageHeader.jsx';
import EmptyState from './components/EmptyState.jsx';
import Breadcrumbs from './components/Breadcrumbs.jsx';
import { IconAlertTriangle } from '@tabler/icons-react';
import S3MetaBar from './components/S3MetaBar.jsx';
import PageHeaderActions from './components/PageHeaderActions.jsx';
import ConfirmDialog from './components/ConfirmDialog.jsx';
import WsUpdateModal from './components/WsUpdateModal.jsx';
@@ -47,6 +45,9 @@ import BulkActionsBar from './components/BulkActionsBar.jsx';
import FormField from './components/FormField.jsx';
import Tooltip from './components/Tooltip.jsx';
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 { useToast } from './components/ToastContainer.jsx';
const API_URL = '/api';
@@ -65,17 +66,18 @@ function DomainsNewManager() {
const [searchTerm, setSearchTerm] = useState('');
const searchTimer = useRef(null);
const didInit = useRef(false);
const [editingDomain, setEditingDomain] = useState(null);
const [editingValue, setEditingValue] = useState('');
const [editModalOpen, setEditModalOpen] = useState(false);
const [itemBeingEdited, setItemBeingEdited] = useState(null);
const [loading, setLoading] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
const [addModalOpen, setAddModalOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [sortField, setSortField] = useState('domain');
const [sortOrder, setSortOrder] = useState('asc');
const [filterCommunity, setFilterCommunity] = useState('');
const editInputRef = useRef(null);
const pageSize = 10;
// Bulk operations
@@ -84,14 +86,25 @@ function DomainsNewManager() {
// Справочник community для подсказок
const [communities, setCommunities] = useState([]);
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
(async () => {
try {
const res = await api.get(`/communities`);
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;
}
})();
(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;
}
})();
return () => controller.abort();
}, []);
const renderCommunityBadge = (value) => (<CommunityBadge value={value} communities={communities} />);
@@ -108,14 +121,11 @@ function DomainsNewManager() {
const [wsOpen, setWsOpen] = useState(false);
const [wsUrl, setWsUrl] = useState('');
useEffect(() => {
(async () => { try { const r = await api.get('/ws/url'); setWsUrl(String(r.data?.url || '')); } catch {} })();
}, []);
const fetchItems = async () => {
const fetchItems = async (abortSignal) => {
setLoading(true);
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const response = await api.get(`/domains-new`, { params: { offset: 0, limit: 0, format: 'std' } });
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 total = payload.length;
setItems(payload);
@@ -127,6 +137,7 @@ function DomainsNewManager() {
setContentLength(typeof lengthHeader !== 'undefined' ? Number(lengthHeader) : null);
setError('');
} catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
console.error('Error fetching domains-new:', error);
setError('Не удалось загрузить домены. Проверьте, запущен ли бэкенд.');
} finally {
@@ -134,42 +145,58 @@ function DomainsNewManager() {
}
};
// Подгрузка при изменении страницы/поиска/фильтра
useEffect(() => { if (!didInit.current) { didInit.current = true; fetchItems(); } }, []);
useEffect(() => {
if (!didInit.current) {
didInit.current = true;
const controller = new AbortController();
fetchItems(controller.signal);
return () => controller.abort();
}
}, []);
const handleAddItem = () => {
const domainOk = isValidDomain(newItem.domain);
const communityOk = isValidCommunity(newItem.community);
setNewInvalid({ domain: !domainOk, community: !communityOk });
const handleAddItem = (item) => {
// Если item передан (из модалки), используем его, иначе из state (старый способ)
const domain = item?.domain || newItem.domain;
const community = item?.community || newItem.community;
const domainOk = isValidDomain(domain);
const communityOk = isValidCommunity(community);
if (!domainOk || !communityOk) {
setError('Заполните корректный домен (example.com) и числовой community.');
return;
}
setError('');
setItems([...items, { domain: newItem.domain.trim().toLowerCase(), community: String(newItem.community).trim() }]);
setNewItem({ domain: '', community: '' });
const newDomain = { domain: domain.trim().toLowerCase(), community: String(community).trim() };
setItems([...items, newDomain]);
if (!item) setNewItem({ domain: '', community: '' }); // Сброс только если не из модалки
setNewInvalid({ domain: false, community: false });
};
const handleEdit = (item) => {
setEditingDomain(item.domain);
setItemBeingEdited(item);
setEditingValue(item.community);
setEditModalOpen(true);
};
const handleSaveEdit = (domainName) => {
const handleSaveEdit = () => {
if (!itemBeingEdited) return;
if (!isValidCommunity(editingValue)) {
setError('Community должен быть числом.');
return;
}
const updatedItems = items.map(i =>
i.domain === domainName ? { ...i, community: String(editingValue).trim() } : i
i.domain === itemBeingEdited.domain ? { ...i, community: String(editingValue).trim() } : i
);
setItems(updatedItems);
setEditingDomain(null);
setEditModalOpen(false);
setItemBeingEdited(null);
};
const handleCancelEdit = () => {
setEditingDomain(null);
setEditModalOpen(false);
setItemBeingEdited(null);
};
const handleDeleteItem = (domainNameToDelete) => {
@@ -419,13 +446,6 @@ function DomainsNewManager() {
}
};
// Улучшенный инлайн-редакт
useEffect(() => {
if (editingDomain && editInputRef.current) {
editInputRef.current.focus();
}
}, [editingDomain]);
// Глобальные хоткеи: Ctrl+S — сохранить изменения
useEffect(() => {
const onKey = (e) => {
@@ -438,9 +458,15 @@ function DomainsNewManager() {
return () => window.removeEventListener('keydown', onKey);
}, [loading, items, originalItems, etag, sortField, sortOrder, filterCommunity]);
const handleEditKeyDown = (e, domainName) => {
if (e.key === 'Enter') handleSaveEdit(domainName);
if (e.key === 'Escape') handleCancelEdit();
const handleEditKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveEdit();
}
if (e.key === 'Escape') {
e.preventDefault();
handleCancelEdit();
}
};
// Bulk operations handlers
@@ -507,180 +533,54 @@ function DomainsNewManager() {
<PageHeader
title="Домены"
pretitle={(
<Breadcrumbs
items={[
{ label: 'Главная', to: '/dashboard' },
{ label: 'Данные' },
{ label: 'Домены', count: filtered.length },
]}
showBack={false}
filterInfo={filterCommunity ? `Community: ${filterCommunity}` : (searchTerm ? `Поиск: "${searchTerm}"` : null)}
count={filtered.length}
/>
)}
icon={<IconWorld size={24} />}
meta={<LastSaved timestamp={lastModified} variant="compact" />}
actions={(
<PageHeaderActions
loading={loading}
onPreview={handlePreviewDiff}
disablePreview={loading || items.length === 0}
onRefresh={fetchItems}
disableRefresh={loading}
onImport={handleImport}
onExport={handleExport}
disableExport={items.length === 0}
onClear={clearInvalid}
disableClear={items.length === 0}
onClearCommunities={() => { if (!filterCommunity) { setError('Выберите community в фильтре для очистки'); return; } setClearCommunitiesOpen(true); }}
disableClearCommunities={items.length === 0 || !filterCommunity}
onSave={handleSaveChanges}
disableSave={loading}
onHistory={() => setHistoryOpen(true)}
onOnlineUpdate={() => setWsOpen(true)}
onBackgroundUpdate={async () => {
try {
const res = await fetch('/api/update-bgp/background', { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) {
throw new Error(data?.message || `HTTP ${res.status}`);
}
notifyMutationSuccess('Фоновое обновление запущено', data);
} catch (e) {
window.notify?.error('Не удалось запустить фоновое обновление', String(e));
loading={loading}
onPreview={handlePreviewDiff}
disablePreview={loading || items.length === 0}
onRefresh={fetchItems}
disableRefresh={loading}
onImport={handleImport}
onExport={handleExport}
disableExport={items.length === 0}
onClear={clearInvalid}
disableClear={items.length === 0}
onClearCommunities={() => { if (!filterCommunity) { setError('Выберите community в фильтре для очистки'); return; } setClearCommunitiesOpen(true); }}
disableClearCommunities={items.length === 0 || !filterCommunity}
onSave={handleSaveChanges}
disableSave={loading}
onHistory={() => setHistoryOpen(true)}
lastModified={lastModified}
onOnlineUpdate={() => setWsOpen(true)}
onBackgroundUpdate={async () => {
try {
const res = await fetch('/api/update-bgp/background', { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) {
throw new Error(data?.message || `HTTP ${res.status}`);
}
}}
notifyMutationSuccess('Фоновое обновление запущено', data);
} catch (e) {
window.notify?.error('Не удалось запустить фоновое обновление', String(e));
}
}}
/>
)}
/>
<AddItemModal
open={addModalOpen}
onClose={() => setAddModalOpen(false)}
onSubmit={handleAddItem}
type="domain"
communities={communities}
loading={loading}
/>
<div className="row">
<div className="col-lg-3">
{/* Classic Add New Domain Card (improved) */}
<div className="card card-md animate-in">
<div className="card-header bg-primary-lt">
<h3 className="card-title text-primary">
<IconPlus className="icon me-2" />
Добавить новый домен
</h3>
</div>
<div className="card-body">
<form onSubmit={(e) => { e.preventDefault(); handleAddItem(); }}>
<div className="mb-3">
<label className="form-label required">Домен</label>
<div className="input-icon">
<span className="input-icon-addon">
<IconWorld size={18} />
</span>
<input
type="text"
className={`form-control${newInvalid.domain ? ' is-invalid' : isValidDomain(newItem.domain) ? ' is-valid' : ''}`}
placeholder="example.com"
value={newItem.domain}
onChange={(e) => setNewItem({ ...newItem, domain: e.target.value })}
/>
</div>
{newInvalid.domain && <div className="invalid-feedback">Неверный формат домена</div>}
{isValidDomain(newItem.domain) && !newInvalid.domain && (
<div className="valid-feedback">Корректный формат домена</div>
)}
<div className="form-text text-muted small">
Формат: sub.domain.tld (например foo.example.org)
</div>
</div>
<div className="mb-3">
<label className="form-label required">Community</label>
<div className="input-icon">
<span className="input-icon-addon">
<IconHash size={18} />
</span>
<CommunityAutocompleteInput
value={newItem.community}
onChange={(v) => setNewItem({ ...newItem, community: v })}
communities={communities}
placeholder="Начните вводить номер или имя"
className={`form-control${newInvalid.community ? ' is-invalid' : isValidCommunity(newItem.community) ? ' is-valid' : ''}`}
useShortValue={true}
/>
</div>
{newInvalid.community && <div className="invalid-feedback">Неверный формат community</div>}
{isValidCommunity(newItem.community) && !newInvalid.community && (
<div className="valid-feedback">Корректный формат community</div>
)}
<div className="form-text text-muted small">
Формат: N или N:N (например 65000:100)
</div>
{(() => {
const match = communities.find(c => c.value === String(newItem.community).trim());
if (!match) return null;
return (
<div className="alert alert-info alert-dismissible mt-2 mb-0" role="alert">
<div className="d-flex">
<div>
<strong>{match.name || match.value}</strong>
{match.description && <div className="text-muted small">{match.description}</div>}
{match.tags && match.tags.length > 0 && (
<div className="mt-1">
{match.tags.map((tag, idx) => (
<span key={idx} className="badge bg-blue-lt text-blue me-1">{tag}</span>
))}
</div>
)}
</div>
</div>
</div>
);
})()}
</div>
<div className="form-footer">
<button
type="submit"
className="btn btn-primary w-100"
disabled={!isValidDomain(newItem.domain) || !isValidCommunity(newItem.community)}
>
<IconPlus className="icon me-2" />
Добавить домен
</button>
</div>
</form>
</div>
</div>
{/* Дополнительные варианты */}
<AccordionCard title={'Дополнительные варианты'} defaultOpen={false} className="animate-in" style={{animationDelay: '0.1s'}}>
<QuickAddBar
placeholder={'example.com 65000:100'}
help={'Вставьте строки: DOMAIN ПРОБЕЛ COMMUNITY'}
parseLine={(line) => { const [d, c] = String(line).split(/\s+/); return { domain: (d||'').toLowerCase(), community: (c||'').trim() }; }}
validateItem={(obj) => isValidDomain(obj.domain) && isValidCommunity(obj.community)}
onApply={(list) => {
setItems(prev => {
const merged = [...prev, ...list];
const map = new Map();
for (const it of merged) {
const key = String(it.domain).trim().toLowerCase();
if (!map.has(key)) map.set(key, { domain: key, community: String(it.community).trim() });
}
return Array.from(map.values());
});
}}
/>
<div
className="card card-md border-dashed mt-3 animate-in"
style={{animationDelay: '0.15s'}}
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = 'var(--tblr-primary)'; }}
onDragLeave={(e) => { e.currentTarget.style.borderColor = ''; }}
onDrop={(e) => { e.currentTarget.style.borderColor = ''; onDropImport(e); }}
>
<div className="card-body text-center text-muted py-4">
<IconUpload size={32} className="mb-2 text-muted" />
<div className="fw-medium">Перетащите файл TXT/CSV сюда</div>
<div className="small">Формат: domain community</div>
</div>
</div>
</AccordionCard>
</div>
<div className="col-lg-9">
<div className="col-12">
{/* Bulk Actions Bar */}
<BulkActionsBar
selectedCount={selectedDomains.size}
@@ -691,16 +591,22 @@ function DomainsNewManager() {
onExport={handleBulkExport}
/>
<div className="card animate-in" style={{animationDelay: '0.3s'}}>
<div className="card card-flat animate-in" style={{animationDelay: '0.3s'}}>
<div className="card-header">
<div className="row align-items-center w-100 g-2">
<div className="col-auto">
<h3 className="card-title mb-0">
<IconWorld className="icon me-2" size={20} />
Список доменов
<span className="badge bg-blue-lt text-blue ms-2">{filtered.length}</span>
</h3>
<div className="col">
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div className="d-flex align-items-center gap-2">
<IconWorld className="icon text-muted" size={18} />
<span className="text-muted text-uppercase small">Список</span>
<span className="badge bg-blue-lt text-blue">{filtered.length}</span>
</div>
<div className="d-none d-md-block text-muted small">
Всего записей: {filtered.length}
</div>
</div>
</div>
<div className="col-12 mt-2"></div>
<div className="col">
<div className="d-flex gap-2 flex-wrap">
{/* Сохранённые фильтры */}
@@ -781,142 +687,120 @@ function DomainsNewManager() {
</div>
</div>
</div>
<div className="col-auto">
<button
className="btn btn-primary"
onClick={() => setAddModalOpen(true)}
title="Добавить новый домен"
>
<IconPlus className="me-1" size={18} />
Добавить
</button>
</div>
</div>
</div>
{/* Мета-информация будет показана внизу карточки */}
{loading ? (
<TableSkeleton rows={10} cols={4} hasCheckbox={true} />
) : paginatedItems.length === 0 ? (
<TableEmpty cols={3}>
<EmptyState
title="Нет доменов"
description="Импортируйте или добавьте записи, чтобы начать."
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
secondaryAction={<button className="btn btn-outline-primary" onClick={() => {
const header = ['domain','community'];
const lines = [header, ['example.com','65000:100']].map(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);
const a = document.createElement('a'); a.href = url; a.download = 'domains_sample.csv'; a.click(); URL.revokeObjectURL(url);
}}>Шаблон CSV</button>}
/>
</TableEmpty>
) : (
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{ width: '40px' }}>
<input
type="checkbox"
className="form-check-input"
checked={selectedDomains.size === paginatedItems.length && paginatedItems.length > 0}
onChange={(e) => e.target.checked ? handleSelectAll() : handleDeselectAll()}
/>
</th>
<th className="cursor-pointer user-select-none" onClick={() => handleSort('domain')}>
<div className="d-flex align-items-center">
<IconWorld size={16} className="me-1 text-muted" />
Домен
{sortField === 'domain' && (
<span className="ms-1">
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
</span>
)}
</div>
</th>
<th className="cursor-pointer user-select-none" onClick={() => handleSort('community')}>
<div className="d-flex align-items-center">
<IconHash size={16} className="me-1 text-muted" />
Community
{sortField === 'community' && (
<span className="ms-1">
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
</span>
)}
</div>
</th>
<th className="text-end">Действия</th>
</tr>
</thead>
<tbody>
{paginatedItems.map((item, idx) => (
<tr
key={item.domain}
className={editingDomain === item.domain ? 'table-active' : (selectedDomains.has(item.domain) ? 'table-selected' : '')}
style={{
animation: `fadeIn 0.3s ease ${idx * 0.02}s both`
}}
>
<td>
<input
type="checkbox"
className="form-check-input"
checked={selectedDomains.has(item.domain)}
onChange={() => handleToggleSelect(item.domain)}
aria-label={`Выбрать ${item.domain}`}
onClick={(e) => e.stopPropagation()}
/>
</td>
<td>
<div className="d-flex align-items-center">
<IconWorld size={16} className="me-2 text-muted" />
<span className="font-monospace">{item.domain}</span>
<Tooltip content="Копировать домен" position="top">
<button
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
onClick={() => copyToClipboard(item.domain)}
aria-label="Копировать"
title="Копировать домен"
>
<IconCopy size={14} />
</button>
</Tooltip>
</div>
</td>
<td>
<div className="d-flex align-items-center">
{renderCommunityBadge(item.community)}
</div>
</td>
<td className="text-end">
<div className="btn-group">
{editingDomain === item.domain ? (
<>
<CommunityAutocompleteInput
value={editingValue}
onChange={setEditingValue}
communities={communities}
onKeyDown={(e) => handleEditKeyDown(e, item.domain)}
className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
style={{ width: '150px' }}
useShortValue={true}
<div className="card-body p-0">
{/* Таблица с лёгкой карточной подложкой (плоский стиль) */}
{loading ? (
<TableSkeleton rows={10} cols={4} hasCheckbox={true} flat />
) : paginatedItems.length === 0 ? (
<TableEmpty cols={3} flat>
<EmptyState
title="Нет доменов"
description="Импортируйте или добавьте записи, чтобы начать."
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
secondaryAction={<button className="btn btn-outline-primary" onClick={() => {
const header = ['domain','community'];
const lines = [header, ['example.com','65000:100']].map(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);
const a = document.createElement('a'); a.href = url; a.download = 'domains_sample.csv'; a.click(); URL.revokeObjectURL(url);
}}>Шаблон CSV</button>}
/>
</TableEmpty>
) : (
<div className="table-responsive">
<table className="table table-flat table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{ width: '40px' }}>
<input
type="checkbox"
className="form-check-input"
checked={selectedDomains.size === paginatedItems.length && paginatedItems.length > 0}
onChange={(e) => e.target.checked ? handleSelectAll() : handleDeselectAll()}
/>
</th>
<th className="cursor-pointer user-select-none" onClick={() => handleSort('domain')}>
<div className="d-flex align-items-center">
<IconWorld size={16} className="me-1 text-muted" />
Домен
{sortField === 'domain' && (
<span className="ms-1">
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
</span>
)}
</div>
</th>
<th className="cursor-pointer user-select-none" onClick={() => handleSort('community')}>
<div className="d-flex align-items-center">
<IconHash size={16} className="me-1 text-muted" />
Community
{sortField === 'community' && (
<span className="ms-1">
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
</span>
)}
</div>
</th>
<th className="text-end">Действия</th>
</tr>
</thead>
<tbody>
{paginatedItems.map((item, idx) => (
<tr
key={item.domain}
className={selectedDomains.has(item.domain) ? 'table-selected' : ''}
style={{
animation: `fadeIn 0.3s ease ${idx * 0.02}s both`
}}
>
<td>
<input
type="checkbox"
className="form-check-input"
checked={selectedDomains.has(item.domain)}
onChange={() => handleToggleSelect(item.domain)}
aria-label={`Выбрать ${item.domain}`}
onClick={(e) => e.stopPropagation()}
/>
<Tooltip content="Сохранить (Enter)">
<button
className="btn btn-success btn-icon btn-sm"
onClick={() => handleSaveEdit(item.domain)}
disabled={!isValidCommunity(editingValue)}
aria-label="Сохранить"
</td>
<td>
<div className="d-flex align-items-center">
<IconWorld size={16} className="me-2 text-muted" />
<span className="font-monospace">{item.domain}</span>
<Tooltip content="Копировать домен" position="top">
<button
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
onClick={() => copyToClipboard(item.domain)}
aria-label="Копировать"
title="Копировать домен"
>
<IconCheck size={16} />
<IconCopy size={14} />
</button>
</Tooltip>
<Tooltip content="Отмена (Esc)">
<button
className="btn btn-secondary btn-icon btn-sm"
onClick={handleCancelEdit}
aria-label="Отмена"
>
<IconX size={16} />
</button>
</Tooltip>
</>
) : (
<>
</div>
</td>
<td>
<div className="d-flex align-items-center">
{renderCommunityBadge(item.community)}
</div>
</td>
<td className="text-end">
<div className="btn-group btn-group-sm" role="group" aria-label={`Действия с доменом ${item.domain}`}>
<Tooltip content="Редактировать" position="top">
<button
className="btn btn-outline-primary btn-icon btn-sm"
className="btn btn-icon btn-outline-primary"
aria-label="Редактировать"
onClick={() => handleEdit(item)}
title="Редактировать"
@@ -924,19 +808,9 @@ function DomainsNewManager() {
<IconEdit size={16} />
</button>
</Tooltip>
<Tooltip content="Копировать" position="top">
<button
className="btn btn-outline-secondary btn-icon btn-sm"
aria-label="Копировать"
onClick={() => copyToClipboard(`${item.domain} ${item.community}`)}
title="Копировать"
>
<IconCopy size={16} />
</button>
</Tooltip>
<Tooltip content="Удалить" position="top">
<button
className="btn btn-outline-danger btn-icon btn-sm"
className="btn btn-icon btn-outline-danger"
aria-label="Удалить"
onClick={() => confirmDelete(item)}
title="Удалить"
@@ -944,25 +818,24 @@ function DomainsNewManager() {
<IconTrash size={16} />
</button>
</Tooltip>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="card-footer border-top-0">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems || filtered.length}
pageSize={pageSize}
onPageChange={setCurrentPage}
/>
</div>
)}
{/* Пагинация */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems || filtered.length}
pageSize={pageSize}
onPageChange={setCurrentPage}
/>
<S3MetaBar keys={["domainsNew"]} />
</div>
</div>
</div>
@@ -1013,6 +886,42 @@ function DomainsNewManager() {
url={wsUrl}
onClose={() => setWsOpen(false)}
/>
{/* Модальное окно редактирования community */}
<FormModal
show={editModalOpen && !!itemBeingEdited}
onClose={handleCancelEdit}
onSubmit={handleSaveEdit}
title="Редактировать community"
submitLabel="Сохранить"
submitIcon={IconCheck}
size="md"
centered
disabled={!isValidCommunity(editingValue)}
>
{itemBeingEdited && (
<>
<p className="text-muted mb-2">
Домен: <span className="font-monospace">{itemBeingEdited.domain}</span>
</p>
<div className="mb-3">
<label className="form-label">Community</label>
<CommunityAutocompleteInput
value={editingValue}
onChange={setEditingValue}
communities={communities}
onKeyDown={handleEditKeyDown}
className={`form-control${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
useShortValue={true}
/>
{!isValidCommunity(editingValue) && (
<div className="invalid-feedback d-block">
Введите корректное значение community (число или N:N).
</div>
)}
</div>
</>
)}
</FormModal>
<ConfirmModal
show={clearCommunitiesOpen}
onClose={() => setClearCommunitiesOpen(false)}
+306 -115
View File
@@ -1,5 +1,7 @@
import { useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import api from './lib/api.js';
import { usePing } from './contexts/PingContext.jsx';
import {
IconSearch,
IconRefresh,
@@ -15,6 +17,7 @@ import {
IconChevronDown,
IconChevronUp
} from '@tabler/icons-react';
import PageHeader from './components/PageHeader.jsx';
// Функция для получения флага по коду страны
const countryToFlag = (code) => {
@@ -34,6 +37,8 @@ function EasySwitchManager() {
const [servers, setServers] = useState([]);
const [inventoryServers, setInventoryServers] = useState([]);
const [communitiesDirectory, setCommunitiesDirectory] = useState([]); // Справочник communities
// Gateways и интерфейсы из /network-config (единый источник правды для шлюзов)
const [networkConfigGateways, setNetworkConfigGateways] = useState([]);
// Поиск и фильтры
const [searchQuery, setSearchQuery] = useState('');
@@ -47,77 +52,170 @@ function EasySwitchManager() {
const [hasChanges, setHasChanges] = useState(false);
const [expandedServers, setExpandedServers] = useState(new Set()); // Развернутые серверы
const [groupByTags, setGroupByTags] = useState(true); // Группировка по тегам
// Единый источник пингов (кеш + API) — общий с страницей фильтров
const { pingMap, requestPings } = usePing();
const location = useLocation();
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
const initData = async () => {
try {
// Загружаем communities
const commRes = await api.get(`/communities`);
const commRes = await api.get(`/communities`, { signal });
const communities = Array.isArray(commRes.data) ? commRes.data : [];
setCommunitiesDirectory(communities);
// Загружаем inventory servers
const invRes = await api.get(`/servers`);
const invRes = await api.get(`/servers`, { signal });
const inventory = Array.isArray(invRes.data) ? invRes.data : [];
setInventoryServers(inventory);
// Загружаем network-config (gateways и туннели)
let ncGateways = [];
try {
const ncRes = await api.get(`/network-config`, { signal });
const ncData = ncRes.data || {};
ncGateways = Array.isArray(ncData.gateways) ? ncData.gateways : [];
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
// Если файла нет или ошибка - используем пустой массив (fallback на старую логику)
console.warn('EasySwitch: не удалось загрузить /network-config, используется fallback:', e?.message || e);
ncGateways = [];
}
setNetworkConfigGateways(ncGateways);
// Загружаем данные серверов и фильтров
await loadDataWithParams(communities, inventory);
await loadDataWithParams(communities, inventory, ncGateways, signal);
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
console.error('Error initializing data:', e);
setError('Ошибка загрузки данных');
}
};
initData();
return () => controller.abort();
}, []);
const loadDataWithParams = async (communities, inventory) => {
// При переходе на раздел Easy Switch перезапрашиваем пинги (устаревшие по TTL обновятся)
useEffect(() => {
if (location.pathname !== '/easy-switch' || !servers.length || !inventoryServers.length) return;
const pingTasks = [];
servers.forEach((server) => {
const inv = inventoryServers.find(srv =>
String(srv.dns || '').trim() === server.name ||
String(srv.hostName || '').trim() === server.name ||
String(srv.ip || '').trim() === server.name
);
const routerId = inv?.id || inv?.dns || inv?.ip;
if (!routerId) return;
(server.gateways || []).forEach((gw) => {
if (gw.ip) pingTasks.push({ routerId, gatewayIp: gw.ip });
});
});
requestPings(pingTasks);
}, [location.pathname, servers, inventoryServers, requestPings]);
const loadDataWithParams = async (communities, inventory, ncGatewaysParam, signal) => {
setLoading(true);
setError('');
const requestConfig = signal ? { signal } : {};
try {
// Загружаем список серверов из server-configs
const serversResponse = await api.get(`/server-configs`);
const serversResponse = await api.get(`/server-configs`, requestConfig);
const serversList = Array.isArray(serversResponse.data) ? serversResponse.data : [];
// Загружаем фильтры для каждого сервера (для определения активных gateways)
const serversWithData = await Promise.all(
serversList.map(async (server) => {
try {
// Загружаем filters для сервера
const filtersResponse = await api.get(`/server-filters/${server.id}`);
const filtersResponse = await api.get(`/server-filters/${server.id}`, requestConfig);
const filters = Array.isArray(filtersResponse.data) ? filtersResponse.data : [];
// Получаем gateways сервера из inventory или server-configs
const matchedInventoryServer = inventory.find(srv =>
// Получаем gateways сервера:
// 1) из /network-config (основной источник, как на странице фильтров)
// 2) fallback на inventory (/servers)
// 3) fallback на server-configs
const matchedInventoryServer = inventory.find((srv) =>
String(srv.dns || '').trim() === server.name ||
String(srv.hostName || '').trim() === server.name ||
String(srv.ip || '').trim() === server.name
);
const serverDns = matchedInventoryServer?.dns || matchedInventoryServer?.hostName || server.name || '';
const serverId =
matchedInventoryServer?.id ||
matchedInventoryServer?.ip ||
server.id ||
server.name ||
'';
const serverCountry = matchedInventoryServer?.country || server.country || '';
const serverProvider = matchedInventoryServer?.provider || server.provider || '';
let serverGateways = [];
// Берем gateways из inventory
if (matchedInventoryServer && Array.isArray(matchedInventoryServer.gateways)) {
// ПРИОРИТЕТ 1: Gateways из /network-config (совпадают со страницей фильтров)
const allNetworkGateways = Array.isArray(ncGatewaysParam)
? ncGatewaysParam
: networkConfigGateways;
if (allNetworkGateways.length > 0) {
const networkGatewaysForServer = allNetworkGateways.filter((gw) => {
if (!gw.serverId) return false;
return (
gw.serverId === serverId ||
gw.serverId === matchedInventoryServer?.ip ||
gw.serverId === matchedInventoryServer?.dns ||
gw.serverId === server.name ||
gw.serverId === serverDns
);
});
networkGatewaysForServer.forEach((gw) => {
if (!gw.ip) return; // Пропускаем gateway без IP
serverGateways.push({
// Используем IP как имя, чтобы совпадало с FilterManager и значением gateway в фильтрах
name: gw.ip,
ip: gw.ip,
comment: gw.description || '',
primary: false,
country: gw.country || serverCountry,
provider: gw.provider || serverProvider,
});
});
}
// ПРИОРИТЕТ 2: Fallback на старые gateways из inventoryServers (если нет в network-config)
if (serverGateways.length === 0 && matchedInventoryServer && Array.isArray(matchedInventoryServer.gateways)) {
serverGateways = matchedInventoryServer.gateways
.filter(gw => gw.name && gw.name !== server.name)
.map(gw => ({
// Пропускаем шлюзы, у которых имя совпадает с названием сервера (fallback)
.filter((gw) => gw.name && gw.name !== serverDns && gw.name !== server.name)
.map((gw) => ({
name: gw.name,
ip: gw.ip || '',
comment: gw.comment || '',
primary: gw.primary || false
primary: gw.primary || false,
country: gw.country || serverCountry,
provider: gw.provider || serverProvider,
}));
}
// Если нет в inventory, берем из server-configs
// ПРИОРИТЕТ 3: Fallback на gateways из server-configs
if (serverGateways.length === 0 && Array.isArray(server.gateways)) {
serverGateways = server.gateways
.filter(gw => gw.name && gw.name !== server.name)
.map(gw => ({
.filter((gw) => gw.name && gw.name !== serverDns && gw.name !== server.name)
.map((gw) => ({
name: gw.name,
ip: gw.ip || '',
comment: gw.comment || '',
primary: gw.primary || false
primary: gw.primary || false,
country: gw.country || serverCountry,
provider: gw.provider || serverProvider,
}));
}
@@ -146,6 +244,27 @@ function EasySwitchManager() {
// Фильтруем серверы, у которых есть gateways
const serversWithGateways = serversWithData.filter(s => s.gateways.length > 0);
setServers(serversWithGateways);
// Единая точка входа: запрашиваем пинги через контекст (кеш общий с фильтрами)
const pingTasks = [];
serversWithGateways.forEach((server) => {
const inv = inventory.find(srv =>
String(srv.dns || '').trim() === server.name ||
String(srv.hostName || '').trim() === server.name ||
String(srv.ip || '').trim() === server.name
);
const routerId = inv?.id || inv?.dns || inv?.ip;
if (!routerId) return;
(server.gateways || []).forEach((gw) => {
if (gw.ip) pingTasks.push({ routerId, gatewayIp: gw.ip });
});
});
requestPings(pingTasks);
// Отладочная информация
try {
console.log('[EasySwitch] serversWithGateways:', serversWithGateways);
console.log('[EasySwitch] communitiesDirectory:', communities);
} catch (_) {}
// Автоматически разворачиваем первый сервер
if (serversWithGateways.length > 0 && expandedServers.size === 0) {
@@ -167,8 +286,13 @@ function EasySwitchManager() {
});
setActiveGateways(initialActive);
setInitialGateways({ ...initialActive }); // Сохраняем исходное состояние
try {
console.log('[EasySwitch] initialActiveGateways:', initialActive);
} catch (_) {}
} catch (err) {
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
console.error('Error loading data:', err);
setError('Ошибка загрузки данных. Проверьте подключение к серверу.');
} finally {
@@ -326,9 +450,21 @@ function EasySwitchManager() {
const invRes = await api.get(`/servers`);
const inventory = Array.isArray(invRes.data) ? invRes.data : [];
setInventoryServers(inventory);
// Загружаем network-config (gateways и туннели)
let ncGateways = [];
try {
const ncRes = await api.get(`/network-config`);
const ncData = ncRes.data || {};
ncGateways = Array.isArray(ncData.gateways) ? ncData.gateways : [];
} catch (e) {
console.warn('EasySwitch: не удалось обновить /network-config, используется fallback:', e?.message || e);
ncGateways = [];
}
setNetworkConfigGateways(ncGateways);
// Загружаем данные серверов и фильтров
await loadDataWithParams(communities, inventory);
// Загружаем данные серверов и фильтров с учетом network-config gateways
await loadDataWithParams(communities, inventory, ncGateways);
} catch (e) {
console.error('Error refreshing data:', e);
setError('Ошибка обновления данных');
@@ -370,18 +506,12 @@ function EasySwitchManager() {
if (loading && servers.length === 0) {
return (
<div className="container-xl">
<div className="page-header">
<div className="row align-items-center">
<div className="col">
<h2 className="page-title">
<IconWorld className="me-2" />
Easy Switch
</h2>
<div className="text-muted mt-1">Быстрое переключение шлюзов</div>
</div>
</div>
</div>
<PageHeader
title="Easy Switch"
icon={<IconWorld size={24} />}
meta="Быстрое переключение шлюзов"
/>
<div className="card">
<div className="card-body text-center py-5">
<div className="spinner-border text-primary" role="status">
@@ -396,41 +526,32 @@ function EasySwitchManager() {
return (
<div className="container-xl">
{/* Заголовок */}
<div className="page-header mb-3">
<div className="row align-items-center">
<div className="col">
<h2 className="page-title">
<IconWorld className="me-2" />
Easy Switch
</h2>
<div className="text-muted mt-1">
Быстрое переключение шлюзов • {servers.length} {servers.length === 1 ? 'сервер' : 'серверов'} • {communitiesDirectory.length} communities
</div>
</div>
<div className="col-auto">
<div className="btn-list">
{hasChanges && (
<button
className="btn btn-success"
onClick={handleApplyChanges}
>
<IconDeviceFloppy size={18} className="me-2" />
Применить изменения
</button>
)}
<button
className="btn btn-primary"
onClick={handleRefresh}
disabled={loading}
<PageHeader
title="Easy Switch"
icon={<IconWorld size={24} />}
meta={`Быстрое переключение шлюзов • ${servers.length} ${servers.length === 1 ? 'сервер' : 'серверов'} • ${communitiesDirectory.length} communities`}
actions={
<div className="btn-list">
{hasChanges && (
<button
className="btn btn-success"
onClick={handleApplyChanges}
>
<IconRefresh size={18} className="me-2" />
Обновить
<IconDeviceFloppy size={18} className="me-2" />
Применить изменения
</button>
</div>
)}
<button
className="btn btn-primary"
onClick={handleRefresh}
disabled={loading}
>
<IconRefresh size={18} className="me-2" />
Обновить
</button>
</div>
</div>
</div>
}
/>
{/* Вкладки */}
<div className="mb-3">
@@ -711,7 +832,12 @@ function EasySwitchManager() {
{server.gateways.map((gw, idx) => {
const isActive = activeGw === gw.name;
const isFastest = idx === 0;
const ping = 20 + idx * 10 + Math.floor(Math.random() * 15);
const pingKey = (() => {
const inv = getServerMetadata(server.name);
const routerId = inv?.id || inv?.dns || inv?.ip;
return routerId && gw.ip ? `${routerId}:${gw.ip}` : null;
})();
const ping = pingKey && pingMap[pingKey] !== undefined ? pingMap[pingKey] : null;
return (
<div key={gw.name} className="col-12 col-sm-4">
@@ -766,26 +892,45 @@ function EasySwitchManager() {
)}
</div>
{/* Название шлюза */}
<div className={`fw-bold mb-1 ${isActive ? 'text-white' : ''}`} style={{ fontSize: '0.85rem', lineHeight: '1.2' }}>
{gw.name}
{/* Флаг и название (комментарий) шлюза */}
<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)',
}}
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' }}
>
{gw.comment || gw.name}
</div>
</div>
</div>
{/* Описание шлюза */}
{gw.comment && (
<div className={`small mb-1 ${isActive ? 'text-white' : 'text-muted'}`} style={{ fontSize: '0.7rem', opacity: isActive ? 0.8 : 1, lineHeight: '1.2' }}>
{gw.comment}
{/* IP шлюза ниже */}
{gw.ip && (
<div
className={`small text-truncate ${
isActive ? 'text-white-75' : 'text-muted'
}`}
style={{ fontSize: '0.75rem' }}
>
{gw.ip}
</div>
)}
{/* Протокол */}
<div className={`small mb-1 ${isActive ? 'text-white' : 'text-muted'}`} style={{ fontSize: '0.7rem', opacity: isActive ? 0.85 : 1 }}>
{serverMeta?.provider || 'vless'}
</div>
{/* Метрика (пинг) */}
<div className={`fw-bold mt-1 ${isActive ? 'text-white' : 'text-success'}`} style={{ fontSize: '1.1rem' }}>
{ping}
{ping != null ? ping : '—'}
</div>
</div>
</div>
@@ -846,7 +991,12 @@ function EasySwitchManager() {
{server.gateways.map((gw, idx) => {
const isActive = activeGw === gw.name;
const isFastest = idx === 0;
const ping = 20 + idx * 10 + Math.floor(Math.random() * 15);
const pingKey = (() => {
const inv = getServerMetadata(server.name);
const routerId = inv?.id || inv?.dns || inv?.ip;
return routerId && gw.ip ? `${routerId}:${gw.ip}` : null;
})();
const ping = pingKey && pingMap[pingKey] !== undefined ? pingMap[pingKey] : null;
return (
<div key={gw.name} className="col-12 col-sm-4">
@@ -901,26 +1051,45 @@ function EasySwitchManager() {
)}
</div>
{/* Название шлюза */}
<div className={`fw-bold mb-1 ${isActive ? 'text-white' : ''}`} style={{ fontSize: '0.85rem', lineHeight: '1.2' }}>
{gw.name}
{/* Флаг и название (комментарий) шлюза */}
<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)',
}}
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' }}
>
{gw.comment || gw.name}
</div>
</div>
</div>
{/* Описание шлюза */}
{gw.comment && (
<div className={`small mb-1 ${isActive ? 'text-white' : 'text-muted'}`} style={{ fontSize: '0.7rem', opacity: isActive ? 0.8 : 1, lineHeight: '1.2' }}>
{gw.comment}
{/* IP шлюза ниже */}
{gw.ip && (
<div
className={`small text-truncate ${
isActive ? 'text-white-75' : 'text-muted'
}`}
style={{ fontSize: '0.75rem' }}
>
{gw.ip}
</div>
)}
{/* Протокол */}
<div className={`small mb-1 ${isActive ? 'text-white' : 'text-muted'}`} style={{ fontSize: '0.7rem', opacity: isActive ? 0.85 : 1 }}>
{serverMeta?.provider || 'vless'}
</div>
{/* Метрика (пинг) */}
<div className={`fw-bold mt-1 ${isActive ? 'text-white' : 'text-success'}`} style={{ fontSize: '1.1rem' }}>
{ping}
{ping != null ? ping : '—'}
</div>
</div>
</div>
@@ -970,7 +1139,12 @@ function EasySwitchManager() {
{server.gateways.map((gw, idx) => {
const isActive = activeGw === gw.name;
const isFastest = idx === 0;
const ping = 20 + idx * 10 + Math.floor(Math.random() * 15);
const pingKey = (() => {
const inv = getServerMetadata(server.name);
const routerId = inv?.id || inv?.dns || inv?.ip;
return routerId && gw.ip ? `${routerId}:${gw.ip}` : null;
})();
const ping = pingKey && pingMap[pingKey] !== undefined ? pingMap[pingKey] : null;
return (
<div key={gw.name} className="col-12 col-sm-4">
@@ -1025,26 +1199,43 @@ function EasySwitchManager() {
)}
</div>
{/* Название шлюза */}
<div className={`fw-bold mb-1 ${isActive ? 'text-white' : ''}`} style={{ fontSize: '0.85rem', lineHeight: '1.2' }}>
{gw.name}
{/* Флаг и название (комментарий) шлюза */}
<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)',
}}
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' }}
>
{gw.comment || gw.name}
</div>
</div>
</div>
{/* Описание шлюза */}
{gw.comment && (
<div className={`small mb-1 ${isActive ? 'text-white' : 'text-muted'}`} style={{ fontSize: '0.7rem', opacity: isActive ? 0.8 : 1, lineHeight: '1.2' }}>
{gw.comment}
{/* IP шлюза ниже */}
{gw.ip && (
<div
className={`small text-truncate mb-1 ${isActive ? 'text-white-75' : 'text-muted'}`}
style={{ fontSize: '0.75rem' }}
>
{gw.ip}
</div>
)}
{/* Протокол */}
<div className={`small mb-1 ${isActive ? 'text-white' : 'text-muted'}`} style={{ fontSize: '0.7rem', opacity: isActive ? 0.85 : 1 }}>
{serverMeta?.provider || 'vless'}
</div>
{/* Метрика (пинг) */}
<div className={`fw-bold mt-1 ${isActive ? 'text-white' : 'text-success'}`} style={{ fontSize: '1.1rem' }}>
{ping}
{ping != null ? ping : '—'}
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
+623
View File
@@ -0,0 +1,623 @@
import { useState, useEffect, useMemo } from 'react';
import api from './lib/api.js';
import PageHeader from './components/PageHeader.jsx';
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
import EmptyState from './components/EmptyState.jsx';
import ErrorAlert from './components/ErrorAlert.jsx';
import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx';
import Pagination from './components/Pagination.jsx';
import Tooltip from './components/Tooltip.jsx';
import Modal from './components/Modal.jsx';
import ConfirmDialog from './components/ConfirmDialog.jsx';
import {
IconShield,
IconRefresh,
IconSearch,
IconCopy,
IconAlertCircle,
IconList,
IconChartBar,
} from '@tabler/icons-react';
const PAGE_SIZE = 15;
const DEFAULT_MIN_COUNT_N = 5;
/** Одиночный IP: без маски или /32 */
function isSingleIp(addr) {
const s = String(addr || '').trim();
if (!s) return false;
if (!s.includes('/')) return true;
const mask = s.split('/')[1];
return mask === '32' || mask === '128';
}
/** Базовая подсеть /24 для группировки */
function getBase24(addr) {
const s = String(addr || '').trim();
const ipPart = s.includes('/') ? s.split('/')[0] : s;
const parts = ipPart.split('.');
if (parts.length !== 4) return null;
const [a, b, c] = parts.map((x) => parseInt(x, 10));
if (Number.isNaN(a) || Number.isNaN(b) || Number.isNaN(c)) return null;
return `${a}.${b}.${c}.0/24`;
}
/**
* Анализ списка ban: группировка одиночных IP по /24, предложения суммаризации если в подсети > N.
*/
function analyzeBanList(banEntries, minCountN) {
const groupBy = new Map();
for (const entry of banEntries || []) {
if (!entry?.address || !isSingleIp(entry.address)) continue;
const base = getBase24(entry.address);
if (!base) continue;
if (!groupBy.has(base)) groupBy.set(base, { subnet: base, ids: [], entries: [] });
const g = groupBy.get(base);
g.ids.push(entry.id);
g.entries.push(entry);
}
const suggestions = [];
for (const [, g] of groupBy) {
if (g.entries.length > minCountN) {
suggestions.push({
subnet: g.subnet,
count: g.entries.length,
ids: g.ids,
addEntry: { address: g.subnet, list: 'ban', comment: `суммаризация ${g.entries.length} IP` },
});
}
}
return { suggestions };
}
/** Нормализация записи address-list из RouterOS (поля могут приходить в разном регистре) */
function normalizeEntry(entry) {
if (!entry || typeof entry !== 'object') return null;
const addr = entry.address ?? entry.Address ?? '';
const list = entry.list ?? entry.List ?? '';
const timeout = entry.timeout ?? entry.Timeout ?? '';
const comment = entry.comment ?? entry.Comment ?? '';
const id = entry['.id'] ?? entry.id ?? '';
const disabled = entry.disabled ?? entry.Disabled ?? '';
const dynamic = entry.dynamic ?? entry.Dynamic ?? '';
return {
id: id || addr || Math.random().toString(36).slice(2),
address: String(addr).trim(),
list: String(list).trim(),
timeout: String(timeout).trim(),
comment: String(comment).trim(),
disabled: String(disabled).trim(),
dynamic: String(dynamic).trim(),
};
}
function AddressListTable({ title, listKey, items, loading, emptyMessage }) {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const filtered = useMemo(() => {
const term = String(search || '').toLowerCase().trim();
if (!term) return items;
return items.filter((e) => {
const a = (e.address || '').toLowerCase();
const c = (e.comment || '').toLowerCase();
return a.includes(term) || c.includes(term);
});
}, [items, search]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const paginated = useMemo(
() => filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[filtered, page]
);
useEffect(() => {
setPage(1);
}, [search]);
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text).then(() => {
window.notify?.success?.('Скопировано в буфер обмена');
}).catch(() => {
window.notify?.error?.('Не удалось скопировать');
});
};
return (
<div className="card card-flat mb-4">
<div className="card-header">
<div className="row align-items-center w-100 g-2">
<div className="col">
<div className="d-flex align-items-center gap-2">
<IconList className="icon text-muted" size={18} />
<span className="text-muted text-uppercase small">{title}</span>
<span className="badge bg-blue-lt text-blue">{filtered.length}</span>
</div>
</div>
<div className="col-12 mt-2" />
<div className="col">
<div className="input-icon flex-grow-1" style={{ maxWidth: '320px' }}>
<span className="input-icon-addon">
<IconSearch size={18} />
</span>
<input
type="text"
className="form-control"
placeholder="Поиск по IP или комментарию..."
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label="Поиск"
/>
</div>
</div>
</div>
</div>
<div className="card-body p-0">
{loading ? (
<TableSkeleton rows={8} cols={4} flat />
) : paginated.length === 0 ? (
<TableEmpty cols={4} flat>
<EmptyState
title={items.length === 0 ? emptyMessage : 'Нет совпадений'}
description={items.length === 0 ? `Список ${listKey} пуст на роутере.` : 'Измените поисковый запрос.'}
/>
</TableEmpty>
) : (
<div className="table-responsive">
<table className="table table-flat table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{ width: '40px' }}>#</th>
<th>IP-адрес</th>
<th>Таймаут</th>
<th>Комментарий</th>
<th className="text-end" style={{ width: '50px' }}></th>
</tr>
</thead>
<tbody>
{paginated.map((entry, idx) => (
<tr key={entry.id || entry.address || idx}>
<td className="text-muted small">{(page - 1) * PAGE_SIZE + idx + 1}</td>
<td className="font-monospace">
<div className="d-flex align-items-center">
<span>{entry.address || '—'}</span>
<Tooltip content="Копировать IP" position="top">
<button
type="button"
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
onClick={() => copyToClipboard(entry.address)}
aria-label="Копировать"
>
<IconCopy size={14} />
</button>
</Tooltip>
</div>
</td>
<td className="text-muted">{entry.timeout || '—'}</td>
<td className="text-muted small">{entry.comment || '—'}</td>
<td className="text-end"></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{!loading && filtered.length > PAGE_SIZE && (
<Pagination
currentPage={page}
totalPages={totalPages}
totalItems={filtered.length}
pageSize={PAGE_SIZE}
onPageChange={setPage}
/>
)}
</div>
);
}
export default function FirewallPage() {
const [servers, setServers] = useState([]);
const [serverId, setServerId] = useState('');
const [serverMeta, setServerMeta] = useState(null);
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [loadingServers, setLoadingServers] = useState(true);
const [error, setError] = useState('');
const [analysisOpen, setAnalysisOpen] = useState(false);
const [minCountN, setMinCountN] = useState(DEFAULT_MIN_COUNT_N);
const [analysisResult, setAnalysisResult] = useState(null);
const [applyConfirmOpen, setApplyConfirmOpen] = useState(false);
const [applying, setApplying] = useState(false);
const [ipInfoMap, setIpInfoMap] = useState({});
const [ipInfoLoading, setIpInfoLoading] = useState(false);
const [applyConfirmRow, setApplyConfirmRow] = useState(null);
const [applyingRow, setApplyingRow] = useState(null);
const routerServers = useMemo(
() =>
(servers || []).filter(
(s) => String(s.type || '').toLowerCase() === 'jumphost' || String(s.type || '').toLowerCase() === 'home'
),
[servers]
);
useEffect(() => {
let cancelled = false;
(async () => {
setLoadingServers(true);
try {
const res = await api.get('/servers');
const list = Array.isArray(res.data) ? res.data : [];
if (!cancelled) setServers(list);
} catch (e) {
if (!cancelled) setError('Не удалось загрузить список серверов.');
} finally {
if (!cancelled) setLoadingServers(false);
}
})();
return () => { cancelled = true; };
}, []);
const fetchAddressLists = async () => {
if (!serverId) {
setData(null);
return;
}
setLoading(true);
setError('');
try {
const res = await api.get('/mikrotik/address-lists', { params: { serverId } });
const payload = res?.data ?? {};
setData({
serverLabel: payload.serverLabel || serverId,
ban: (payload.ban || []).map(normalizeEntry).filter(Boolean),
ban_counter: (payload.ban_counter || []).map(normalizeEntry).filter(Boolean),
});
} catch (e) {
console.error('Firewall fetch:', e);
setData(null);
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить address-list с роутера.');
} finally {
setLoading(false);
}
};
useEffect(() => {
if (serverId) fetchAddressLists();
else setData(null);
}, [serverId]);
const runAnalysis = () => {
if (!data?.ban?.length) return;
const n = Math.max(2, parseInt(minCountN, 10) || DEFAULT_MIN_COUNT_N);
setMinCountN(n);
const result = analyzeBanList(data.ban, n);
setAnalysisResult(result);
setIpInfoMap({});
};
// Подгрузка AS/орг с ipinfo.io для подсетей из анализа (лимит 30, с задержкой между запросами)
useEffect(() => {
const suggestions = analysisResult?.suggestions;
if (!suggestions?.length) return;
const maxFetch = 30;
const slice = suggestions.slice(0, maxFetch);
let cancelled = false;
setIpInfoLoading(true);
(async () => {
for (let i = 0; i < slice.length && !cancelled; i++) {
const s = slice[i];
const firstIp = s.subnet.split('/')[0];
try {
const res = await fetch(`https://ipinfo.io/${firstIp}/json`, { method: 'GET' });
if (!res.ok) continue;
const info = await res.json();
if (!cancelled) {
setIpInfoMap((prev) => ({
...prev,
[s.subnet]: { org: info.org, country: info.country, city: info.city },
}));
}
} catch (_) {
if (!cancelled) setIpInfoMap((prev) => ({ ...prev, [s.subnet]: null }));
}
if (i < slice.length - 1) await new Promise((r) => setTimeout(r, 180));
}
if (!cancelled) setIpInfoLoading(false);
})();
return () => { cancelled = true; };
}, [analysisResult?.suggestions]);
const applyPayload = applyConfirmRow
? { removeIds: applyConfirmRow.ids, addEntries: [applyConfirmRow.addEntry] }
: analysisResult?.suggestions?.length
? {
removeIds: analysisResult.suggestions.flatMap((s) => s.ids),
addEntries: analysisResult.suggestions.map((s) => s.addEntry),
}
: null;
const handleApplySummary = async () => {
if (!serverId || !applyPayload) return;
const isRow = !!applyConfirmRow;
if (isRow) setApplyingRow(applyConfirmRow.subnet);
else setApplying(true);
try {
await api.post('/mikrotik/address-lists/apply-summary', {
serverId,
removeIds: applyPayload.removeIds,
addEntries: applyPayload.addEntries,
});
const removed = applyPayload.removeIds.length;
const added = applyPayload.addEntries.length;
window.notify?.success?.(`Удалено ${removed} записей, добавлено ${added} подсетей.`);
setApplyConfirmOpen(false);
setApplyConfirmRow(null);
if (isRow && analysisResult?.suggestions) {
setAnalysisResult((prev) => ({
...prev,
suggestions: prev.suggestions.filter((s) => s.subnet !== applyConfirmRow.subnet),
}));
} else {
setAnalysisOpen(false);
setAnalysisResult(null);
}
await fetchAddressLists();
} catch (e) {
window.notify?.error?.(e?.response?.data?.message || e?.message || 'Ошибка применения');
} finally {
setApplying(false);
setApplyingRow(null);
}
};
const currentServerLabel = serverMeta?.dns || serverMeta?.ip || serverId || 'Не выбран';
return (
<div>
{error && (
<ErrorAlert message={error} onClose={() => setError('')} />
)}
<PageHeader
title="Firewall"
icon={<IconShield size={24} />}
meta={`Списки блокировки (address-list) с роутера: ${currentServerLabel}`}
actions={
<div className="btn-list">
<button
type="button"
className="btn btn-outline-primary"
onClick={() => {
setAnalysisOpen(true);
setAnalysisResult(null);
}}
disabled={!serverId || !data?.ban?.length || loading}
title="Анализ списка ban и суммаризация по /24"
>
<IconChartBar className="me-1" size={18} />
Анализ
</button>
<button
type="button"
className="btn btn-outline-primary"
onClick={fetchAddressLists}
disabled={!serverId || loading}
>
<IconRefresh className={loading ? 'icon spin me-1' : 'me-1'} size={18} />
Обновить
</button>
</div>
}
/>
<div className="card card-flat mb-4">
<div className="card-header py-3">
<h3 className="card-title mb-0">Сервер (MikroTik)</h3>
<p className="text-muted small mb-0 mt-1">
Выберите jumphost или home-роутер для просмотра списков <strong>ban</strong> и <strong>ban_counter</strong>.
</p>
</div>
<div className="card-body">
{loadingServers ? (
<div className="text-muted">Загрузка списка серверов...</div>
) : (
<div style={{ maxWidth: '420px' }}>
<ServerAutocompleteInput
value={serverId}
onChange={setServerId}
onSelectMeta={setServerMeta}
servers={routerServers}
placeholder="Выберите сервер..."
/>
</div>
)}
</div>
</div>
{!serverId && (
<div className="card card-flat">
<div className="card-body text-center py-5">
<IconAlertCircle className="icon text-muted mb-2" size={48} />
<p className="text-muted mb-0">Выберите сервер выше, чтобы загрузить address-list (ban и ban_counter).</p>
</div>
</div>
)}
{serverId && data && (
<>
<AddressListTable
title="Список ban"
listKey="ban"
items={data.ban}
loading={loading}
emptyMessage="Список ban пуст"
/>
<AddressListTable
title="Список ban_counter"
listKey="ban_counter"
items={data.ban_counter}
loading={loading}
emptyMessage="Список ban_counter пуст"
/>
</>
)}
{serverId && loading && !data && (
<div className="card card-flat">
<div className="card-body py-5 text-center text-muted">
<IconRefresh className="icon spin me-2" size={20} />
Загрузка списков с роутера...
</div>
</div>
)}
<Modal
show={analysisOpen}
onClose={() => {
setAnalysisOpen(false);
setAnalysisResult(null);
setApplyConfirmOpen(false);
setApplyConfirmRow(null);
setIpInfoMap({});
}}
title="Анализ списка ban"
size="xl"
>
<div className="mb-3">
<label className="form-label">Суммаризировать подсети /24, в которых одиночных IP больше чем</label>
<div className="d-flex align-items-center gap-2">
<input
type="number"
className="form-control"
style={{ width: '80px' }}
min={2}
max={100}
value={minCountN}
onChange={(e) => setMinCountN(e.target.value)}
/>
<span className="text-muted small">(по умолчанию {DEFAULT_MIN_COUNT_N})</span>
</div>
</div>
<div className="mb-3">
<button type="button" className="btn btn-primary" onClick={runAnalysis}>
<IconChartBar className="me-1" size={18} />
Анализировать
</button>
</div>
{analysisResult && (
<>
{analysisResult.suggestions.length === 0 ? (
<p className="text-muted mb-0">Нет подсетей для суммаризации (в каждой /24 не больше {minCountN} одиночных IP).</p>
) : (
<>
<p className="mb-2">
Найдено <strong>{analysisResult.suggestions.length}</strong> подсетей /24 для замены: удалить одиночные IP и добавить одну запись на подсеть.
{ipInfoLoading && (
<span className="ms-2 text-muted small">(загрузка AS/хостер с ipinfo.io…)</span>
)}
</p>
<div className="table-responsive">
<table className="table table-sm table-vcenter mb-0">
<thead>
<tr>
<th>Подсеть</th>
<th className="text-end">Кол-во IP</th>
<th>AS / Хостер</th>
<th>Ссылка</th>
<th>Действие</th>
<th className="text-end" style={{ width: '120px' }}>Применить</th>
</tr>
</thead>
<tbody>
{analysisResult.suggestions.map((s) => {
const firstIp = s.subnet.split('/')[0];
const info = ipInfoMap[s.subnet];
const rowApplying = applyingRow === s.subnet;
return (
<tr key={s.subnet}>
<td className="font-monospace">{s.subnet}</td>
<td className="text-end">{s.count}</td>
<td className="small">
{info === undefined
? (ipInfoLoading ? '…' : '—')
: info
? (info.org || info.country ? [info.org, info.country].filter(Boolean).join(' · ') : '—')
: '—'}
</td>
<td>
<a
href={`https://ipinfo.io/${firstIp}`}
target="_blank"
rel="noopener noreferrer"
className="text-primary"
>
ipinfo.io
</a>
</td>
<td className="text-muted small">Удалить {s.count} записей → добавить 1</td>
<td className="text-end">
<button
type="button"
className="btn btn-sm btn-warning"
disabled={rowApplying || applying}
onClick={() => {
setApplyConfirmRow(s);
setApplyConfirmOpen(true);
}}
>
{rowApplying ? (
<span className="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true" />
) : null}
Применить
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="mt-3">
<button
type="button"
className="btn btn-warning"
onClick={() => {
setApplyConfirmRow(null);
setApplyConfirmOpen(true);
}}
>
Применить всё на роутере
</button>
</div>
</>
)}
</>
)}
</Modal>
<ConfirmDialog
open={applyConfirmOpen}
title={applyConfirmRow ? `Применить для ${applyConfirmRow.subnet}?` : 'Применить суммаризацию на роутере?'}
message={
applyConfirmRow
? `Удалить ${applyConfirmRow.count} записей и добавить 1 запись (подсеть ${applyConfirmRow.subnet}). Продолжить?`
: applyPayload
? `Будет удалено ${applyPayload.removeIds.length} записей и добавлено ${applyPayload.addEntries.length} записей (подсети /24). Продолжить?`
: ''
}
confirmText="Применить"
cancelText="Отмена"
destructive
size="md"
loading={applying || !!applyingRow}
onConfirm={handleApplySummary}
onCancel={() => {
setApplyConfirmOpen(false);
setApplyConfirmRow(null);
}}
/>
</div>
);
}
+21 -3
View File
@@ -14,7 +14,10 @@ import '@xyflow/react/dist/style.css';
import { IconZoomIn, IconZoomOut, IconMaximize, IconMinimize, IconSearch, IconX, IconRefresh } from '@tabler/icons-react';
import Tooltip from './components/Tooltip.jsx';
function GraphView({ servers, connections, onCreateConnection }) {
/**
* @param {Object} [pingMap] - опционально: ключ "fromIp:toIp" -> число (мс). Пинг между серверами для подписи на рёбрах.
*/
function GraphView({ servers, connections, onCreateConnection, pingMap }) {
const flowRef = useRef(null);
const instanceRef = useRef(null);
const containerRef = useRef(null);
@@ -144,17 +147,32 @@ function GraphView({ servers, connections, onCreateConnection }) {
}, [servers, connections]);
const initialEdgesData = useMemo(() => {
const getPingLabel = (fromIp, toIp) => {
if (!pingMap || typeof pingMap !== 'object') return '';
const fwd = pingMap[`${fromIp}:${toIp}`];
const rev = pingMap[`${toIp}:${fromIp}`];
const parts = [];
if (typeof fwd === 'number') parts.push(`→ ${fwd} ms`);
if (typeof rev === 'number') parts.push(`← ${rev} ms`);
if (parts.length === 0) return '';
return parts.join(' · ');
};
return (connections || []).map((c, idx) => {
const style = getTunnelStyle(c.tunnelType);
const baseLabel = c.tunnelType || 'TUNNEL';
const ipLabel = c.ipA && c.ipB ? `${c.ipA} ⇄ ${c.ipB}` : '';
const pingLabel = getPingLabel(String(c.from), String(c.to));
const labelParts = [baseLabel];
if (ipLabel) labelParts.push(ipLabel);
if (pingLabel) labelParts.push(pingLabel);
const fullLabel = labelParts.join(' · ');
const edgeId = `${c.from}-${c.to}-${idx}`;
const isHighlighted = highlightedEdges.has(edgeId);
return {
id: edgeId,
source: String(c.from),
target: String(c.to),
label: ipLabel ? `${baseLabel} · ${ipLabel}` : baseLabel,
label: fullLabel,
type: 'smoothstep',
style: {
stroke: style.color,
@@ -176,7 +194,7 @@ function GraphView({ servers, connections, onCreateConnection }) {
animated: false,
};
});
}, [connections, getTunnelStyle, highlightedEdges, highlightedNodeId, selectedNodeId]);
}, [connections, getTunnelStyle, highlightedEdges, highlightedNodeId, selectedNodeId, pingMap]);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdgesData);
File diff suppressed because it is too large Load Diff
+506
View File
@@ -0,0 +1,506 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import api from './lib/api.js';
import { useNotify } from './components/NotifyProvider.jsx';
import PageHeader from './components/PageHeader.jsx';
import TableSkeleton from './components/TableSkeleton.jsx';
import { countryToFlag } from './utils/serverUtils.js';
import {
IconTopologyRing,
IconNetwork,
IconSearch,
IconRefresh,
} from '@tabler/icons-react';
export default function InterfaceSpeedTest() {
const notify = useNotify();
const [servers, setServers] = useState([]);
const [networkConfig, setNetworkConfig] = useState(null);
const [loading, setLoading] = useState(true);
const [serverSearch, setServerSearch] = useState('');
const [serverId, setServerId] = useState('');
const [selectedInterfaceName, setSelectedInterfaceName] = useState('');
const [running, setRunning] = useState(false);
const [speedResult, setSpeedResult] = useState(null);
const [speedSettings, setSpeedSettings] = useState({
protocol: 'tcp',
durationSeconds: 10,
cacheMinutes: 0,
});
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
const load = async () => {
setLoading(true);
try {
const [serversRes, netRes, uiRes] = await Promise.all([
api.get('/servers', { signal }),
api.get('/network-config', { signal }).catch(() => ({ data: null })),
api.get('/ui-settings', { signal }).catch(() => ({ data: {} })),
]);
setServers(Array.isArray(serversRes?.data) ? serversRes.data : []);
setNetworkConfig(
netRes?.data && typeof netRes.data === 'object'
? netRes.data
: { gateways: [], tunnelInterfaces: [] }
);
const ui = uiRes?.data || {};
const proto =
String(ui.interfaceSpeedTestProtocol || 'tcp').toLowerCase() ===
'udp'
? 'udp'
: 'tcp';
const duration = Math.max(
1,
Math.min(
600,
parseInt(ui.interfaceSpeedTestDurationSeconds, 10) || 5
)
);
const cacheMinutes = Math.max(
0,
parseInt(ui.interfaceSpeedTestCacheMinutes, 10) || 0
);
setSpeedSettings({
protocol: proto,
durationSeconds: duration,
cacheMinutes,
});
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
console.error('[InterfaceSpeedTest] load failed', e);
notify.error('Не удалось загрузить данные для замера скорости');
} finally {
setLoading(false);
}
};
load();
return () => controller.abort();
}, [notify]);
const gateways = useMemo(
() =>
networkConfig?.gateways && Array.isArray(networkConfig.gateways)
? networkConfig.gateways
: [],
[networkConfig]
);
const tunnelInterfaces = useMemo(
() =>
networkConfig?.tunnelInterfaces &&
Array.isArray(networkConfig.tunnelInterfaces)
? networkConfig.tunnelInterfaces
: [],
[networkConfig]
);
const jumphostServers = useMemo(
() =>
(servers || []).filter((s) =>
['jumphost', 'home'].includes(
String(s.type || '').toLowerCase()
)
),
[servers]
);
const makeServerId = (s) => (s.id || s.dns || s.ip || '').toString();
const jumphostInputServers = useMemo(
() => jumphostServers.map((s) => ({ ...s, id: makeServerId(s) })),
[jumphostServers]
);
const visibleJumphostServers = useMemo(() => {
if (!serverSearch.trim()) return jumphostInputServers;
const q = serverSearch.trim().toLowerCase();
return jumphostInputServers.filter((s) => {
const fields = [
s.id,
s.ip,
s.dns,
s.extIp,
s.internalIp,
s.country,
s.provider,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return fields.includes(q);
});
}, [jumphostInputServers, serverSearch]);
const currentServer = useMemo(
() => jumphostServers.find((s) => makeServerId(s) === serverId) || null,
[jumphostServers, serverId]
);
const interfacesForServer = useMemo(() => {
if (!serverId || !tunnelInterfaces.length) return [];
const ids = new Set(
[serverId, currentServer?.id, currentServer?.ip, currentServer?.dns]
.filter(Boolean)
.map(String)
);
return tunnelInterfaces.filter(
(i) =>
i &&
i.name &&
(ids.has(String(i.serverId)) || ids.has(String(i.serverId2)))
);
}, [tunnelInterfaces, serverId, currentServer]);
const currentServerLabel =
currentServer?.dns ||
currentServer?.ip ||
makeServerId(currentServer || {}) ||
'Не выбран';
const isInitialLoading = loading && servers.length === 0;
const formatMbps = (bps) => {
if (bps == null || Number.isNaN(bps)) return '—';
const mbps = bps / 1_000_000;
if (!Number.isFinite(mbps)) return '—';
return `${mbps.toFixed(2)} Мбит/с`;
};
/** Запросить только кеш (без замера). При выборе сервера/интерфейса показываем кеш, если есть. */
const fetchCachedResult = useCallback(async () => {
if (!serverId || !selectedInterfaceName) {
setSpeedResult(null);
return;
}
try {
const res = await api.post('/mikrotik/speed-test', {
serverId,
interfaceName: selectedInterfaceName,
cacheOnly: true,
});
const data = res?.data || {};
if (data.ok !== false && !data.notInCache && (data.tcpDownloadBps != null || data.tcpUploadBps != null)) {
setSpeedResult(data);
} else {
setSpeedResult(null);
}
} catch {
setSpeedResult(null);
}
}, [serverId, selectedInterfaceName]);
useEffect(() => {
fetchCachedResult();
}, [fetchCachedResult]);
const handleRunTest = async () => {
if (!serverId) {
notify.error('Выберите сервер (jumphost/home) для замера скорости');
return;
}
if (!selectedInterfaceName) {
notify.error('Выберите туннельный интерфейс для замера скорости');
return;
}
setRunning(true);
setSpeedResult(null);
try {
const body = {
serverId,
interfaceName: selectedInterfaceName,
durationSeconds: speedSettings.durationSeconds,
forceRefresh: true,
};
const res = await api.post('/mikrotik/speed-test', body, {
timeout: 120000,
});
const data = res?.data || {};
if (data.ok === false) {
notify.error(
data.error || 'Замер скорости по интерфейсу завершился с ошибкой'
);
return;
}
setSpeedResult(data);
} catch (e) {
console.error('[InterfaceSpeedTest] test failed', e);
notify.error(
e?.response?.data?.message || e?.message || 'Ошибка замера скорости'
);
} finally {
setRunning(false);
}
};
const handleReset = () => {
setServerId('');
setSelectedInterfaceName('');
setSpeedResult(null);
};
return (
<div className="page">
<PageHeader
title="Скорость интерфейсов MikroTik"
icon={<IconTopologyRing size={24} />}
meta={`Текущий сервер: ${currentServerLabel}`}
/>
<div className="row g-2">
<div className="col-md-5">
<div className="card mb-2">
<div className="card-header py-2 d-flex align-items-center justify-content-between">
<div>
<h3 className="card-title mb-0">Серверы (jumphost / home)</h3>
<p className="text-muted small mb-0 mt-0">
Выберите роутер с настроенным MikroTik API
</p>
</div>
<div className="input-icon input-icon-sm" style={{ minWidth: 200 }}>
<input
type="text"
className="form-control form-control-sm"
placeholder="Поиск серверов..."
value={serverSearch}
onChange={(e) => setServerSearch(e.target.value)}
/>
<span className="input-icon-addon">
<IconSearch size={14} className="text-muted" />
</span>
</div>
</div>
<div className="card-body py-2">
{isInitialLoading ? (
<TableSkeleton rows={2} columns={2} />
) : jumphostServers.length === 0 ? (
<div className="text-muted small">
Нет серверов типа <code>jumphost</code> или <code>home</code>.
Добавьте их в разделе «Серверы», чтобы использовать замер
скорости.
</div>
) : visibleJumphostServers.length === 0 ? (
<div className="text-muted small">
По вашему запросу серверы не найдены. Попробуйте изменить фильтр
поиска.
</div>
) : (
<div className="row row-cards g-1">
{visibleJumphostServers.map((s) => {
const id = s.id;
const isActive = id === serverId;
const flag = s.country ? countryToFlag(s.country) : '';
const line = [s.dns, s.ip].filter(Boolean).join(' · ');
return (
<div
className="col-12 col-sm-6 col-md-6"
key={id}
>
<div
className={`card card-sm h-100 cursor-pointer ${
isActive ? 'border-primary card-hover' : 'card-hover'
}`}
onClick={() => setServerId(id)}
>
<div className="card-body py-2 px-2 d-flex align-items-center gap-2">
<span
className="avatar avatar-sm bg-primary-lt"
title={s.country || ''}
>
{flag || s.ip?.slice(0, 2) || '?'}
</span>
<div className="flex-grow-1 min-w-0">
<div
className="fw-semibold text-truncate small"
title={s.dns || s.ip || id}
>
{s.dns || s.ip || id}
</div>
<div
className="text-muted small text-truncate"
title={line}
>
{line ||
[s.country, s.provider]
.filter(Boolean)
.join(' · ') ||
'—'}
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
<div className="d-flex flex-wrap align-items-center gap-2 mt-2">
<span className="badge bg-primary-lt text-primary">
Сервер: {currentServerLabel}
</span>
</div>
</div>
</div>
<div className="card">
<div className="card-header py-2">
<h3 className="card-title mb-0">Интерфейсы туннелей</h3>
</div>
<div className="card-body py-2">
{loading && !networkConfig ? (
<div className="text-muted small">Загрузка сетевых настроек…</div>
) : !serverId ? (
<div className="text-muted small">
Выберите сервер слева, затем интерфейс для замера скорости.
</div>
) : interfacesForServer.length === 0 ? (
<div className="text-muted small">
Для выбранного сервера нет туннельных интерфейсов в
<code> /network-config</code>. Добавьте интерфейсы в разделе
«Сетевые настройки».
</div>
) : (
<>
<div className="mb-2">
<select
className="form-select form-select-sm"
value={selectedInterfaceName}
onChange={(e) => setSelectedInterfaceName(e.target.value)}
>
<option value="">Не выбран</option>
{interfacesForServer.map((iface) => (
<option key={iface.name} value={iface.name}>
{iface.name} ({iface.localIp} ⇄ {iface.remoteIp})
</option>
))}
</select>
</div>
<div className="text-muted small">
Для сопоставления используется имя интерфейса из
<code> /network-config.tunnelInterfaces</code>. В RouterOS
имя должно совпадать.
</div>
</>
)}
</div>
</div>
</div>
<div className="col-md-7">
<div className="card mb-2">
<div className="card-header py-2 d-flex align-items-center justify-content-between">
<h3 className="card-title mb-0">Параметры замера</h3>
<div className="btn-list">
<button
type="button"
className="btn btn-outline-secondary btn-sm"
onClick={handleReset}
disabled={running}
>
<IconRefresh size={16} className="me-1" />
Сбросить
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleRunTest}
disabled={running || !serverId || !selectedInterfaceName}
>
{running ? 'Замер…' : 'Запустить замер'}
</button>
</div>
</div>
<div className="card-body py-2">
<div className="row g-2">
<div className="col-md-4">
<label className="form-label small mb-0">Протокол</label>
<div className="d-flex align-items-center gap-1">
<IconNetwork size={16} className="text-muted" />
<span className="small text-uppercase">
{speedSettings.protocol === 'udp' ? 'UDP' : 'TCP'}
</span>
</div>
<div className="text-muted small">
Значение берётся из настроек интерфейса.
</div>
</div>
<div className="col-md-4">
<label className="form-label small mb-0">
Время замера (сек)
</label>
<div className="fw-semibold">
{speedSettings.durationSeconds}
</div>
</div>
<div className="col-md-4">
<label className="form-label small mb-0">
Кеш результата (мин)
</label>
<div className="fw-semibold">
{speedSettings.cacheMinutes}
</div>
</div>
</div>
</div>
</div>
<div className="card">
<div className="card-header py-2">
<h3 className="card-title mb-0">
Скорость по интерфейсу MikroTik
</h3>
</div>
<div className="card-body">
{!speedResult ? (
<div className="text-muted small">
Выберите сервер и интерфейс, затем запустите замер. Средние
скорости приёма и передачи появятся здесь.
</div>
) : (
<div className="row g-3">
<div className="col-12 col-md-6">
<div className="card bg-blue-lt">
<div className="card-body py-2">
<div className="text-muted small mb-1">Интерфейс</div>
<div className="fw-semibold">
{speedResult.interfaceName || selectedInterfaceName}
</div>
<div className="text-muted small mt-1">
Замер за {speedResult.durationSeconds} сек
{speedResult.cached ? ' (из кеша)' : ''}
</div>
</div>
</div>
</div>
<div className="col-12 col-md-6">
<div className="card bg-azure-lt">
<div className="card-body py-2">
<div className="text-muted small mb-1">
TCP скорость
</div>
<div className="fw-bold">
Download: {formatMbps(speedResult.tcpDownloadBps)}
</div>
<div className="fw-bold mt-1">
Upload: {formatMbps(speedResult.tcpUploadBps)}
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}
+999
View File
@@ -0,0 +1,999 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import * as Diff from 'diff';
import api from './lib/api.js';
import { useNotify } from './components/NotifyProvider.jsx';
import PageHeader from './components/PageHeader.jsx';
import TableSkeleton from './components/TableSkeleton.jsx';
import EmptyState from './components/EmptyState.jsx';
import Tooltip from './components/Tooltip.jsx';
import DataTable from './components/DataTable.jsx';
import { LargeModal, FullscreenModal } from './components/Modal.jsx';
import { countryToFlag } from './utils/serverUtils.js';
import {
IconRefresh,
IconAlertTriangle,
IconEye,
IconArrowsLeftRight,
IconDownload,
IconCopy,
IconSearch,
} from '@tabler/icons-react';
/**
* Строит две колонки строк для side-by-side diff (как git diff).
* leftLines/rightLines — массивы { text, type: 'unchanged'|'removed'|'added'|'empty' }.
*/
function getDiffLines(oldStr, newStr) {
const left = [];
const right = [];
const changes = Diff.diffLines(oldStr || '', newStr || '');
for (const part of changes) {
const lines = (part.value || '').split('\n');
if (part.value && part.value.endsWith('\n') && lines.length > 0) lines.pop();
for (const line of lines) {
if (part.added) {
left.push({ text: '', type: 'empty' });
right.push({ text: line, type: 'added' });
} else if (part.removed) {
left.push({ text: line, type: 'removed' });
right.push({ text: '', type: 'empty' });
} else {
left.push({ text: line, type: 'unchanged' });
right.push({ text: line, type: 'unchanged' });
}
}
}
return { left, right };
}
const diffPanelStyle = {
maxHeight: 360,
overflow: 'auto',
fontSize: 12,
fontFamily: 'ui-monospace, monospace',
margin: 0,
padding: '0.5rem 0.75rem',
borderRadius: 4,
backgroundColor: 'var(--tblr-code-bg, #0f172a)', // как тёмный code-блок Tabler
border: '1px solid rgba(15, 23, 42, 0.9)',
};
const diffLineStyle = {
display: 'block',
minHeight: '1.25em',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
padding: '0 4px',
margin: 0,
color: 'var(--tblr-code-color, #e5e7eb)',
};
const diffRemovedBg = {
background: 'rgba(248, 81, 73, 0.2)',
borderLeft: '3px solid rgba(248, 81, 73, 0.9)',
};
const diffAddedBg = {
background: 'rgba(63, 185, 80, 0.2)',
borderLeft: '3px solid rgba(63, 185, 80, 0.9)',
};
function DiffPanel({ lines, side, innerRef, onScroll }) {
return (
<pre style={diffPanelStyle} ref={innerRef} onScroll={onScroll}>
{lines.length === 0 ? (
<span style={diffLineStyle}># пусто</span>
) : (
lines.map((item, i) => (
<span
key={i}
style={{
...diffLineStyle,
...(item.type === 'removed' && side === 'left' ? diffRemovedBg : null),
...(item.type === 'added' && side === 'right' ? diffAddedBg : null),
}}
>
{item.type === 'empty' ? '\u00A0' : item.text || ''}
</span>
))
)}
</pre>
);
}
function formatDateTime(value) {
if (!value) return '';
try {
const d = new Date(value);
return d.toLocaleString();
} catch {
return String(value);
}
}
function bytesToHuman(size) {
if (size == null) return '';
const n = Number(size);
if (!Number.isFinite(n) || n < 0) return '';
if (n < 1024) return `${n} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let v = n;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i += 1;
}
return `${v.toFixed(1)} ${units[i]}`;
}
function MikrotikBackupsManager() {
const notify = useNotify();
const [servers, setServers] = useState([]);
const [selectedServerId, setSelectedServerId] = useState('');
const [uiSettings, setUiSettings] = useState({});
const [uiSettingsEtag, setUiSettingsEtag] = useState('');
const [backupServerIds, setBackupServerIds] = useState(new Set());
const [backups, setBackups] = useState([]);
const [loadingServers, setLoadingServers] = useState(false);
const [loadingBackups, setLoadingBackups] = useState(false);
const [selectedKeys, setSelectedKeys] = useState([]);
const [viewConfigKey, setViewConfigKey] = useState(null);
const [viewConfigText, setViewConfigText] = useState('');
const [viewLoading, setViewLoading] = useState(false);
const [viewModalOpen, setViewModalOpen] = useState(false);
const [diffResult, setDiffResult] = useState(null);
const [diffLoading, setDiffLoading] = useState(false);
const [diffModalOpen, setDiffModalOpen] = useState(false);
const leftDiffRef = useRef(null);
const rightDiffRef = useRef(null);
const isSyncingScrollRef = useRef(false);
// UI-состояние выбора сервера для просмотра бэкапов (в стиле /filters)
const [serverSearch, setServerSearch] = useState('');
// UI-состояние для таблицы выбора серверов автобэкапов
const [backupSearch, setBackupSearch] = useState('');
const [backupSortField, setBackupSortField] = useState('ip');
const [backupSortOrder, setBackupSortOrder] = useState('asc');
const [backupPage, setBackupPage] = useState(1);
const backupPageSize = 10;
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
fetchServers(signal);
fetchUiSettings(signal);
return () => controller.abort();
}, []);
useEffect(() => {
if (selectedServerId) {
const controller = new AbortController();
fetchBackups(selectedServerId, controller.signal);
return () => controller.abort();
} else {
setBackups([]);
setSelectedKeys([]);
}
}, [selectedServerId]);
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],
);
// Список серверов для выбора "текущего сервера" бэкапов с поиском (как в /filters)
const visibleJumphostServers = useMemo(() => {
if (!serverSearch.trim()) return jumphostInputServers;
const q = serverSearch.trim().toLowerCase();
return jumphostInputServers.filter((s) => {
const fields = [
s.id,
s.ip,
s.dns,
s.extIp,
s.internalIp,
s.country,
s.provider,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return fields.includes(q);
});
}, [jumphostInputServers, serverSearch]);
const currentServer = useMemo(
() => jumphostServers.find((s) => makeServerId(s) === selectedServerId) || null,
[jumphostServers, selectedServerId],
);
const backupServerIdsList = useMemo(() => Array.from(backupServerIds), [backupServerIds]);
// Отфильтрованный и отсортированный список Jumphost-серверов для блока автобэкапов
const filteredBackupServers = useMemo(() => {
let list = [...jumphostInputServers];
if (backupSearch.trim()) {
const q = backupSearch.trim().toLowerCase();
list = list.filter((s) => {
const fields = [
s.id,
s.ip,
s.dns,
s.extIp,
s.internalIp,
s.country,
s.provider,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return fields.includes(q);
});
}
list.sort((a, b) => {
const field = backupSortField || 'ip';
let av = a[field] ?? '';
let bv = b[field] ?? '';
if (typeof av === 'string') av = av.toLowerCase();
if (typeof bv === 'string') bv = bv.toLowerCase();
if (av < bv) return backupSortOrder === 'asc' ? -1 : 1;
if (av > bv) return backupSortOrder === 'asc' ? 1 : -1;
return 0;
});
return list;
}, [jumphostInputServers, backupSearch, backupSortField, backupSortOrder]);
const backupTotalPages = useMemo(
() => Math.max(1, Math.ceil(filteredBackupServers.length / backupPageSize)),
[filteredBackupServers.length],
);
const paginatedBackupServers = useMemo(() => {
const page = Math.min(Math.max(1, backupPage), backupTotalPages);
const start = (page - 1) * backupPageSize;
return filteredBackupServers.slice(start, start + backupPageSize);
}, [filteredBackupServers, backupPage, backupTotalPages]);
const diffComputed = useMemo(() => {
if (diffResult?.configA == null || diffResult?.configB == null) {
return {
left: [],
right: [],
stats: { removed: 0, added: 0, total: 0 },
};
}
const { left, right } = getDiffLines(diffResult.configA, diffResult.configB);
const removed = left.filter((l) => l.type === 'removed').length;
const added = right.filter((l) => l.type === 'added').length;
return {
left,
right,
stats: { removed, added, total: removed + added },
};
}, [diffResult?.configA, diffResult?.configB]);
const syncScroll = (source) => {
const leftEl = leftDiffRef.current;
const rightEl = rightDiffRef.current;
if (!leftEl || !rightEl) return;
const current = source === 'left' ? leftEl : rightEl;
const other = source === 'left' ? rightEl : leftEl;
if (isSyncingScrollRef.current) return;
isSyncingScrollRef.current = true;
const maxCurrent = current.scrollHeight - current.clientHeight;
const ratio = maxCurrent > 0 ? current.scrollTop / maxCurrent : 0;
const maxOther = other.scrollHeight - other.clientHeight;
other.scrollTop = ratio * (maxOther > 0 ? maxOther : 0);
isSyncingScrollRef.current = false;
};
const handleBackupSort = (field) => {
setBackupSortField((prevField) => {
if (prevField === field) {
setBackupSortOrder((prevOrder) => (prevOrder === 'asc' ? 'desc' : 'asc'));
return prevField;
}
setBackupSortOrder('asc');
return field;
});
};
const handleBackupSelectAll = () => {
const allIds = filteredBackupServers.map((s) => String(s.id));
setBackupServerIds(new Set(allIds));
};
const handleBackupDeselectAll = () => {
setBackupServerIds(new Set());
};
const 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)) {
return prev.filter((k) => k !== key);
}
if (prev.length >= 2) {
return [prev[1], key];
}
return [...prev, key];
});
setDiffResult(null);
};
const handleViewConfig = async (key) => {
if (!key) return;
try {
setViewLoading(true);
setViewConfigKey(key);
setViewConfigText('');
setViewModalOpen(true);
const res = await api.get('/mikrotik/backups/item', { params: { key } });
setViewConfigText(res.data?.config || '');
} catch (err) {
console.error('Error reading backup:', err);
notify.error('Не удалось загрузить содержимое бэкапа');
} finally {
setViewLoading(false);
}
};
const handleDiff = async () => {
if (selectedKeys.length !== 2) {
notify.warning('Выберите два бэкапа для сравнения');
return;
}
try {
setDiffModalOpen(true);
setDiffLoading(true);
setDiffResult(null);
const res = await api.post('/mikrotik/backups/diff', {
keyA: selectedKeys[0],
keyB: selectedKeys[1],
});
setDiffResult(res.data || null);
} catch (err) {
console.error('Error diffing backups:', err);
notify.error('Не удалось сравнить бэкапы');
} finally {
setDiffLoading(false);
}
};
const handleDownload = async (key) => {
try {
const res = await api.get('/mikrotik/backups/item', { params: { key } });
const text = res.data?.config || '';
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const shortKey = key.split('/').slice(-1)[0] || 'backup';
a.download = shortKey.endsWith('.rsc') ? shortKey : `${shortKey}.rsc`;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
console.error('Error downloading backup:', err);
notify.error('Не удалось скачать бэкап');
}
};
const handleCopyToClipboard = async (text) => {
try {
if (!text) return;
if (navigator.clipboard) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
notify.success('Текст бэкапа скопирован в буфер обмена');
} catch (err) {
console.error('Error copying backup:', err);
notify.error('Не удалось скопировать текст бэкапа');
}
};
const toggleBackupServer = (serverId) => {
setBackupServerIds((prev) => {
const next = new Set(prev);
if (next.has(serverId)) next.delete(serverId);
else next.add(serverId);
return next;
});
};
const saveBackupServers = async () => {
try {
const settingsPayload = {
...uiSettings,
mikrotikBackupServers: backupServerIdsList,
};
const payload = {
settings: settingsPayload,
etag: uiSettingsEtag,
};
const res = await api.post('/ui-settings', payload);
const meta = res?.data || {};
setUiSettings(settingsPayload);
setUiSettingsEtag(String(meta?.etag || ''));
notify.success('Список серверов для автобэкапа сохранён');
} catch (err) {
console.error('Error saving backup servers to ui-settings:', err);
notify.error(err?.response?.data?.message || 'Не удалось сохранить список серверов для автобэкапа');
}
};
const actions = (
<div className="d-flex flex-wrap gap-2 align-items-center">
<button
type="button"
className="btn btn-outline-primary btn-sm d-inline-flex align-items-center"
onClick={() => selectedServerId && fetchBackups(selectedServerId)}
disabled={!selectedServerId || loadingBackups}
>
<IconRefresh size={16} className="me-1" />
Обновить список
</button>
<button
type="button"
className="btn btn-primary btn-sm d-inline-flex align-items-center"
onClick={async () => {
if (!selectedServerId) return;
try {
const serverId = selectedServerId;
notify.info('Запуск ручного бэкапа MikroTik...');
const res = await api.post('/mikrotik/backups/run', { serverId });
if (res?.data?.ok) {
notify.success('Ручной бэкап успешно создан');
await fetchBackups(serverId);
} else {
notify.error(res?.data?.message || 'Не удалось выполнить ручной бэкап');
}
} catch (err) {
console.error('Error running manual backup:', err);
const msg =
err?.response?.data?.message ||
err?.response?.data ||
'Ошибка при выполнении ручного бэкапа';
notify.error(msg);
}
}}
disabled={!selectedServerId || loadingBackups}
>
Создать бэкап сейчас
</button>
<Tooltip content="Выберите два бэкапа в таблице" position="top">
<button
type="button"
className="btn btn-outline-secondary btn-sm d-inline-flex align-items-center"
onClick={handleDiff}
disabled={selectedKeys.length !== 2 || diffLoading}
>
<IconArrowsLeftRight size={16} className="me-1" />
Сравнить выбранные
</button>
</Tooltip>
<div className="text-muted small ms-2">
Автобэкапы включаются для выбранных ниже Jumphost-серверов. Интервал и включение/выключение
планировщика настраиваются через переменные окружения
<code className="ms-1">MIKROTIK_BACKUP_ENABLED</code> и
<code className="ms-1">MIKROTIK_BACKUP_INTERVAL_MINUTES</code>.
</div>
</div>
);
const currentServerLabel =
currentServer?.dns || currentServer?.ip || makeServerId(currentServer || {}) || 'Не выбран';
return (
<div className="page">
<PageHeader
title="MikroTik Backups"
icon={<IconDownload size={24} />}
meta={`Текущий сервер: ${currentServerLabel}`}
actions={actions}
/>
{/* Выбор сервера для просмотра бэкапов (как в /filters) */}
<div className="card mb-3">
<div className="card-header">
<div className="row align-items-center g-2">
<div className="col">
<h3 className="card-title mb-1">Сервер для просмотра бэкапов</h3>
<div className="text-muted small">
Доступно Jumphost-серверов: {jumphostServers.length}.{' '}
{selectedServerId
? 'Кликните по другой карточке, чтобы сменить сервер.'
: 'Выберите сервер, чтобы увидеть его бэкапы.'}
</div>
</div>
<div className="col-auto">
<div className="input-icon" style={{ minWidth: 240 }}>
<input
type="text"
className="form-control form-control-sm"
placeholder="Поиск серверов по IP, DNS, провайдеру..."
value={serverSearch}
onChange={(e) => setServerSearch(e.target.value)}
/>
<span className="input-icon-addon">
<IconSearch size={16} />
</span>
</div>
</div>
</div>
</div>
<div className="card-body">
{jumphostServers.length === 0 ? (
<div className="text-muted small">
Нет серверов типа <code>jumphost</code>. Добавьте их в разделе «Серверы», чтобы смотреть
бэкапы.
</div>
) : visibleJumphostServers.length === 0 ? (
<div className="text-muted small">
По вашему запросу серверы не найдены. Попробуйте изменить фильтр поиска.
</div>
) : (
<div className="row row-cards g-2">
{visibleJumphostServers.map((s) => {
const id = s.id;
const isActive = id === selectedServerId;
const flag = s.country ? countryToFlag(s.country) : '';
const line = [s.dns, s.ip].filter(Boolean).join(' · ');
return (
<div className="col-12 col-sm-6 col-md-4 col-lg-3" key={id}>
<div
className={`card card-sm h-100 cursor-pointer ${
isActive ? 'border-primary card-hover' : 'card-hover'
}`}
onClick={() => setSelectedServerId(id)}
>
<div className="card-body d-flex align-items-start gap-2">
<span className="avatar bg-primary-lt" title={s.country || ''}>
{flag || s.ip?.slice(0, 2) || '?'}
</span>
<div className="flex-grow-1 min-w-0">
<div className="d-flex align-items-center gap-2">
<div className="fw-bold text-truncate" title={s.dns || s.ip || id}>
{s.dns || s.ip || id}
</div>
</div>
{line && (
<div className="text-muted small text-truncate" title={line}>
{line}
</div>
)}
<div className="text-muted small mt-1">
{[s.country, s.provider].filter(Boolean).join(' · ') || '—'}
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
<div className="text-muted small mt-2">
Текущий сервер:&nbsp;
<span className="fw-semibold">{currentServerLabel}</span>
</div>
</div>
</div>
{/* Таблица бэкапов для выбранного сервера */}
{loadingServers ? (
<TableSkeleton rows={5} columns={4} />
) : !selectedServerId ? (
<EmptyState
title="Нет доступных Jumphost-серверов"
description='Создайте хотя бы один сервер типа "jumphost" в разделе Серверы, чтобы включить бэкапы.'
/>
) : loadingBackups ? (
<TableSkeleton rows={5} columns={4} />
) : backups.length === 0 ? (
<EmptyState
title="Для этого сервера бэкапов ещё нет"
description="Автоматические бэкапы создаются планировщиком на backend. Можно также отправить бэкап вручную через API."
/>
) : (
<div className="card">
<div className="card-header d-flex align-items-center justify-content-between">
<div>
<div className="card-title mb-0">Бэкапы для {currentServerLabel}</div>
<div className="text-muted small">
Всего: {backups.length}. Выбрано для diff: {selectedKeys.length}/2.
</div>
</div>
<div className="text-muted small d-flex align-items-center">
<IconAlertTriangle size={16} className="me-1 text-warning" />
Восстановление выполняется вручную через Winbox/SSH, используя экспортированный .rsc
</div>
</div>
<div className="table-responsive">
<table className="table table-vcenter table-striped">
<thead>
<tr>
<th style={{ width: 40 }}></th>
<th>Создан</th>
<th>Размер</th>
<th>Ключ S3</th>
<th style={{ width: 220 }}>Действия</th>
</tr>
</thead>
<tbody>
{backups.map((b) => {
const active = selectedKeys.includes(b.key);
const shortKey = b.key.split('/').slice(-1)[0] || b.key;
return (
<tr key={b.key} className={active ? 'table-active' : ''}>
<td>
<input
type="checkbox"
className="form-check-input"
checked={active}
onChange={() => handleSelectKey(b.key)}
/>
</td>
<td>{formatDateTime(b.createdAt)}</td>
<td>{bytesToHuman(b.size)}</td>
<td className="text-truncate" style={{ maxWidth: 260 }} title={b.key}>
{shortKey}
</td>
<td>
<div className="btn-list">
<button
type="button"
className="btn btn-outline-secondary btn-sm"
onClick={() => handleViewConfig(b.key)}
>
<IconEye size={16} className="me-1" />
Просмотр
</button>
<button
type="button"
className="btn btn-outline-secondary btn-sm"
onClick={() => handleDownload(b.key)}
>
<IconDownload size={16} className="me-1" />
Скачать
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* Блок выбора серверов для автобэкапа – ниже как "настройки" */}
<div className="card mb-4 mt-4">
<div className="card-header">
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<div className="card-title mb-0">Настройки автоматических бэкапов</div>
<div className="text-muted small">
Отмеченные здесь Jumphost-серверы будут периодически бэкапиться планировщиком на backend.
</div>
<div className="text-muted small mt-1">
Выбрано для автобэкапов:{' '}
<span className="fw-semibold">
{backupServerIds.size} из {jumphostServers.length}
</span>
</div>
</div>
<div className="d-flex flex-wrap align-items-center gap-2">
<div className="input-icon">
<span className="input-icon-addon">
<svg
xmlns="http://www.w3.org/2000/svg"
className="icon"
width="16"
height="16"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10 4a6 6 0 1 0 4 10" />
<path d="M21 21l-6 -6" />
</svg>
</span>
<input
type="text"
className="form-control form-control-sm"
placeholder="Поиск по IP, DNS, провайдеру..."
value={backupSearch}
onChange={(e) => {
setBackupSearch(e.target.value);
setBackupPage(1);
}}
/>
</div>
<button
type="button"
className="btn btn-outline-primary btn-sm d-inline-flex align-items-center"
onClick={saveBackupServers}
disabled={jumphostServers.length === 0}
>
Сохранить список
</button>
</div>
</div>
</div>
<div className="card-body">
{jumphostServers.length === 0 ? (
<div className="text-muted small">
Нет серверов типа <code>jumphost</code>. Добавьте их в разделе «Серверы».
</div>
) : (
<DataTable
columns={[
{
key: 'ip',
title: 'Сервер',
sortable: true,
render: (_value, item) => (
<div>
<div className="fw-medium">{item.dns || item.ip || item.id}</div>
<div className="small text-muted">
{item.ip}
{item.dns && item.ip ? ' · ' : ''}
{item.dns}
</div>
</div>
),
},
{
key: 'country',
title: 'Страна',
sortable: true,
render: (value) => <span className="small">{value || '-'}</span>,
},
{
key: 'provider',
title: 'Провайдер',
sortable: true,
render: (value) => <span className="small">{value || '-'}</span>,
},
]}
items={paginatedBackupServers}
itemKey="id"
loading={loadingServers}
sortField={backupSortField}
sortOrder={backupSortOrder}
onSort={handleBackupSort}
selectedItems={backupServerIds}
onSelectItem={(id) => id && toggleBackupServer(String(id))}
onSelectAll={handleBackupSelectAll}
onDeselectAll={handleBackupDeselectAll}
pagination={{
currentPage: backupPage,
totalPages: backupTotalPages,
totalItems: filteredBackupServers.length,
pageSize: backupPageSize,
onPageChange: setBackupPage,
}}
emptyState={{
title: 'Серверы не найдены',
description:
backupSearch.trim().length > 0
? 'По вашему запросу нет Jumphost-серверов. Измените фильтры поиска.'
: 'Нет доступных Jumphost-серверов для автобэкапа.',
}}
skeletonRows={5}
/>
)}
</div>
</div>
<LargeModal
show={viewModalOpen}
onClose={() => {
setViewModalOpen(false);
}}
title="Содержимое бэкапа MikroTik"
footer={(
<div className="d-flex justify-content-between w-100">
<div className="text-muted small text-truncate" title={viewConfigKey || ''}>
{viewConfigKey || 'Бэкап не выбран'}
</div>
<div className="btn-list mb-0">
<button
type="button"
className="btn btn-outline-secondary btn-sm"
onClick={() => handleCopyToClipboard(viewConfigText)}
disabled={!viewConfigText}
>
<IconCopy size={16} className="me-1" />
Копировать
</button>
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => setViewModalOpen(false)}
>
Закрыть
</button>
</div>
</div>
)}
>
{viewLoading ? (
<TableSkeleton rows={6} columns={1} />
) : (
<pre
className="bg-dark text-white p-3 rounded"
style={{ maxHeight: 500, overflow: 'auto', fontSize: 12 }}
>
<code>{viewConfigText || '# Нет данных для отображения'}</code>
</pre>
)}
</LargeModal>
<FullscreenModal
show={diffModalOpen}
onClose={() => {
setDiffModalOpen(false);
}}
title="Сравнение бэкапов MikroTik"
footer={(
<div className="d-flex justify-content-between w-100">
<div className="text-muted small">
{diffResult?.same
? 'Конфигурации идентичны'
: 'Конфигурации отличаются — проверьте различия перед откатом.'}
</div>
<button
type="button"
className="btn btn-secondary"
onClick={() => setDiffModalOpen(false)}
>
Закрыть
</button>
</div>
)}
>
{diffLoading ? (
<TableSkeleton rows={8} columns={2} />
) : !diffResult ? (
<div className="text-muted small">
Не удалось загрузить результат сравнения. Попробуйте ещё раз.
</div>
) : (
<>
<div className="alert alert-info mb-3 small d-flex justify-content-between align-items-center">
<span>
{!diffResult.same ? (
<>
<IconAlertTriangle size={16} className="me-1 text-warning" />
Конфигурации отличаются — слева A, справа B. Проверьте различия перед откатом.
</>
) : (
'Конфигурации идентичны — изменений нет.'
)}
</span>
<span>
Всего изменений:{' '}
<span className="fw-semibold">{diffComputed.stats.total}</span>
<span className="text-success ms-2">+{diffComputed.stats.added}</span>
<span className="text-danger ms-2">−{diffComputed.stats.removed}</span>
</span>
</div>
<div className="row">
<div className="col-md-6 mb-3 mb-md-0">
<div className="mb-2 small text-muted text-truncate" title={diffResult.a?.key}>
A: {diffResult.a?.key}
</div>
<DiffPanel
lines={diffComputed.left}
side="left"
innerRef={leftDiffRef}
onScroll={() => syncScroll('left')}
/>
</div>
<div className="col-md-6">
<div className="mb-2 small text-muted text-truncate" title={diffResult.b?.key}>
B: {diffResult.b?.key}
</div>
<DiffPanel
lines={diffComputed.right}
side="right"
innerRef={rightDiffRef}
onScroll={() => syncScroll('right')}
/>
</div>
</div>
{!diffResult.same && (
<div className="mt-3 text-muted small">
<span style={{ background: 'rgba(248, 81, 73, 0.35)', padding: '0 4px', borderRadius: 2 }}> удалено (A)</span>
{' · '}
<span style={{ background: 'rgba(63, 185, 80, 0.35)', padding: '0 4px', borderRadius: 2 }}> добавлено (B)</span>
</div>
)}
</>
)}
</FullscreenModal>
</div>
);
}
export default MikrotikBackupsManager;
+930
View File
@@ -0,0 +1,930 @@
import { useEffect, useMemo, useState } from 'react';
import api from './lib/api.js';
import { useNotify } from './components/NotifyProvider.jsx';
import PageHeader from './components/PageHeader.jsx';
import TableSkeleton from './components/TableSkeleton.jsx';
import EmptyState from './components/EmptyState.jsx';
import { countryToFlag } from './utils/serverUtils.js';
import {
IconRoute,
IconWorld,
IconAlertTriangle,
IconRefresh,
IconSearch,
IconBrandGoogle,
IconBrandYandex,
IconBrandCloudflare,
} from '@tabler/icons-react';
/** Популярные цели для ping/traceroute — карточки с логотипами */
const POPULAR_TARGETS = [
{ id: 'yandex', name: 'Yandex', target: 'yandex.ru', Icon: IconBrandYandex, color: 'red' },
{ id: 'google', name: 'Google', target: '8.8.8.8', Icon: IconBrandGoogle, color: 'blue' },
{ id: 'cloudflare', name: 'Cloudflare DNS', target: '1.1.1.1', Icon: IconBrandCloudflare, color: 'orange' },
];
/**
* Раздел "Инструменты → MikroTik": проверка маршрута (traceroute)
* с выбором сервера, gateway и домена/IP.
*/
function MikrotikTools() {
const notify = useNotify();
const [servers, setServers] = useState([]);
const [networkConfig, setNetworkConfig] = useState(null);
const [loading, setLoading] = useState(true);
const [serverId, setServerId] = useState('');
const [gatewayRef, setGatewayRef] = useState('');
const [gatewayMeta, setGatewayMeta] = useState(null);
const [target, setTarget] = useState('');
const [maxHops, setMaxHops] = useState(30);
const [mode, setMode] = useState('traceroute'); // 'traceroute' | 'ping'
const [running, setRunning] = useState(false);
const [hops, setHops] = useState([]);
const [analysis, setAnalysis] = useState([]);
const [tracerouteTab, setTracerouteTab] = useState('table'); // 'table' | 'analysis'
const [pingResult, setPingResult] = useState(null);
const [useDns, setUseDns] = useState(true);
const [serverSearch, setServerSearch] = useState('');
// Загрузка серверов и сетевого конфига
useEffect(() => {
const load = async () => {
setLoading(true);
try {
const [serversRes, netRes] = await Promise.all([
api.get('/servers'),
api.get('/network-config'),
]);
setServers(Array.isArray(serversRes.data) ? serversRes.data : []);
setNetworkConfig(netRes.data && typeof netRes.data === 'object' ? netRes.data : { gateways: [], tunnelInterfaces: [] });
} catch (error) {
console.error('[MikrotikTools] failed to load initial data', error);
notify.error('Не удалось загрузить данные для инструментов MikroTik');
} finally {
setLoading(false);
}
};
load();
}, [notify]);
const gateways = useMemo(
() => (networkConfig?.gateways && Array.isArray(networkConfig.gateways) ? networkConfig.gateways : []),
[networkConfig]
);
const interfaces = useMemo(
() => (networkConfig?.tunnelInterfaces && Array.isArray(networkConfig.tunnelInterfaces) ? networkConfig.tunnelInterfaces : []),
[networkConfig]
);
const handleSelectGatewayMeta = (meta) => {
setGatewayMeta(meta);
// Если цель не задана — подставляем IP gateway как target
if (!target && meta && meta.ip) {
setTarget(meta.ip);
}
};
const handleRunCheck = async () => {
setRunning(true);
setHops([]);
setAnalysis([]);
setPingResult(null);
try {
if (mode === 'traceroute') {
if (!serverId) {
notify.error('Выберите сервер (jumphost)');
return;
}
const trimmedTarget = String(target || '').trim();
if (!trimmedTarget) {
notify.error('Укажите домен или IP назначения');
return;
}
const body = {
serverId,
target: trimmedTarget,
useDns,
};
if (gatewayMeta?.ip) {
body.gatewayIp = gatewayMeta.ip;
}
const hopsNum = Number(maxHops);
if (Number.isFinite(hopsNum) && hopsNum > 0) {
body.maxHops = hopsNum;
}
const res = await api.post('/mikrotik/traceroute', body);
const data = res?.data || {};
if (!data.ok) {
notify.error(data.error || 'Трассировка завершилась с ошибкой');
return;
}
const hopsList = Array.isArray(data.hops) ? data.hops : [];
setHops(hopsList);
setAnalysis(buildTracerouteAnalysis(hopsList, networkConfig, servers, body.target));
} else if (mode === 'ping') {
if (!serverId) {
notify.error('Выберите сервер (jumphost)');
return;
}
const trimmedTarget = String(target || '').trim();
if (!trimmedTarget) {
notify.error('Укажите домен или IP назначения');
return;
}
const body = {
serverId,
target: trimmedTarget,
count: 4,
};
if (gatewayMeta?.ip) {
body.gatewayIp = gatewayMeta.ip;
}
const res = await api.post('/mikrotik/ping', body);
const data = res?.data || {};
if (data.ok === false) {
notify.error(data.error || 'Ping завершился с ошибкой');
return;
}
setPingResult({
target: trimmedTarget,
...data,
});
}
} catch (error) {
console.error('[MikrotikTools] check failed', error);
notify.error(error?.message || 'Ошибка при выполнении проверки');
} finally {
setRunning(false);
}
};
const handleReset = () => {
setGatewayRef('');
setGatewayMeta(null);
setTarget('');
setHops([]);
setAnalysis([]);
setPingResult(null);
};
const jumphostServers = useMemo(
() => (servers || []).filter((s) => String(s.type || '').toLowerCase() === 'jumphost'),
[servers]
);
const makeServerId = (s) => (s.id || s.dns || s.ip || '').toString();
const jumphostInputServers = useMemo(
() => jumphostServers.map((s) => ({ ...s, id: makeServerId(s) })),
[jumphostServers]
);
const visibleJumphostServers = useMemo(() => {
if (!serverSearch.trim()) return jumphostInputServers;
const q = serverSearch.trim().toLowerCase();
return jumphostInputServers.filter((s) => {
const fields = [
s.id,
s.ip,
s.dns,
s.extIp,
s.internalIp,
s.country,
s.provider,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return fields.includes(q);
});
}, [jumphostInputServers, serverSearch]);
const currentServer = useMemo(
() => jumphostServers.find((s) => makeServerId(s) === serverId) || null,
[jumphostServers, serverId]
);
const interfacesForServer = useMemo(() => {
if (!serverId || !interfaces.length) return [];
const ids = new Set(
[serverId, currentServer?.id, currentServer?.ip, currentServer?.dns]
.filter(Boolean)
.map(String)
);
return interfaces.filter(
(i) =>
i &&
i.name &&
(ids.has(String(i.serverId)) || ids.has(String(i.serverId2)))
);
}, [interfaces, serverId, currentServer]);
const gatewaysForServer = useMemo(() => {
if (!serverId || !gateways.length) return [];
const ids = currentServer
? new Set(
[serverId, currentServer.id, currentServer.ip, currentServer.dns].filter(Boolean).map(String)
)
: new Set([String(serverId)]);
return gateways.filter((g) => g && g.ip && ids.has(String(g.serverId)));
}, [serverId, currentServer, gateways]);
const currentServerLabel =
currentServer?.dns || currentServer?.ip || makeServerId(currentServer || {}) || 'Не выбран';
const isInitialLoading = loading && servers.length === 0;
return (
<div className="page">
<PageHeader
title="MikroTik Tools"
icon={<IconRoute size={24} />}
meta={`Текущий сервер: ${currentServerLabel}`}
/>
{/* Сервер, шлюз, быстрые цели — один компактный блок */}
<div className="card mb-2">
<div className="card-header py-2">
<div className="row align-items-center g-2">
<div className="col">
<h3 className="card-title mb-0">Сервер и шлюз</h3>
<p className="text-muted small mb-0 mt-0">
{jumphostServers.length} серверов · {serverId ? 'шлюз опционален' : 'выберите сервер'}
</p>
</div>
<div className="col-auto">
<div className="input-icon input-icon-sm">
<input
type="text"
className="form-control form-control-sm"
placeholder="Поиск серверов..."
value={serverSearch}
onChange={(e) => setServerSearch(e.target.value)}
/>
<span className="input-icon-addon">
<IconSearch size={14} className="text-muted" />
</span>
</div>
</div>
</div>
</div>
<div className="card-body py-2">
{isInitialLoading ? (
<TableSkeleton rows={2} columns={2} />
) : jumphostServers.length === 0 ? (
<div className="text-muted small">
Нет серверов типа <code>jumphost</code>. Добавьте их в разделе «Серверы», чтобы использовать инструменты MikroTik.
</div>
) : visibleJumphostServers.length === 0 ? (
<div className="text-muted small">
По вашему запросу серверы не найдены. Попробуйте изменить фильтр поиска.
</div>
) : (
<div className="row row-cards g-1">
{visibleJumphostServers.map((s) => {
const id = s.id;
const isActive = id === serverId;
const flag = s.country ? countryToFlag(s.country) : '';
const line = [s.dns, s.ip].filter(Boolean).join(' · ');
return (
<div className="col-12 col-sm-6 col-md-4 col-lg-3" key={id}>
<div
className={`card card-sm h-100 cursor-pointer ${
isActive ? 'border-primary card-hover' : 'card-hover'
}`}
onClick={() => setServerId(id)}
>
<div className="card-body py-2 px-2 d-flex align-items-center gap-2">
<span className="avatar avatar-sm bg-primary-lt" title={s.country || ''}>
{flag || s.ip?.slice(0, 2) || '?'}
</span>
<div className="flex-grow-1 min-w-0">
<div className="fw-semibold text-truncate small" title={s.dns || s.ip || id}>
{s.dns || s.ip || id}
</div>
<div className="text-muted small text-truncate" title={line}>
{line || [s.country, s.provider].filter(Boolean).join(' · ') || '—'}
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
<div className="d-flex flex-wrap align-items-center gap-2 mt-2">
<span className="badge bg-primary-lt text-primary">Сервер: {currentServerLabel}</span>
{serverId && (
<span className="badge bg-azure-lt text-azure">
Шлюз: {gatewayRef || gatewayMeta?.ip || '—'}
</span>
)}
</div>
{/* Шлюз — только при выбранном сервере */}
{serverId && (
<>
<hr className="my-2" />
<div className="d-flex align-items-center gap-2 mb-1">
<span className="fw-semibold small">Шлюз</span>
{gatewaysForServer.length === 0 && (
<span className="text-muted small">нет настроенных</span>
)}
</div>
{loading && !networkConfig ? (
<div className="text-muted small">Загрузка сетевых настроек…</div>
) : (
<>
{gatewaysForServer.length > 0 && (
<div className="row row-cards g-1 mb-2">
{gatewaysForServer.map((g) => {
const isSelected = gatewayRef === g.ip || gatewayRef === g.id;
const flag = g.country ? countryToFlag(g.country) : '';
return (
<div className="col-12 col-sm-6 col-md-4 col-lg-3" key={g.id || g.ip}>
<div
className={`card card-sm h-100 cursor-pointer ${
isSelected ? 'border-primary card-hover' : 'card-hover'
}`}
onClick={() => {
const next = isSelected ? '' : (g.ip || g.id);
setGatewayRef(next);
if (!next) setGatewayMeta(null);
else {
setGatewayMeta({ ip: g.ip, gateway: g });
if (!target && g.ip) setTarget(g.ip);
}
}}
>
<div className="card-body py-2 px-2 d-flex align-items-center gap-2">
<span className="avatar avatar-sm bg-primary-lt" title={g.country || ''}>
{flag || (g.ip ? g.ip.slice(0, 2) : '?')}
</span>
<div className="flex-grow-1 min-w-0">
<div className="fw-semibold text-truncate small" title={g.ip || ''}>
{g.ip || '—'}
</div>
<div className="text-muted small text-truncate">
{g.description || [g.country, g.provider].filter(Boolean).join(' · ') || '—'}
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</>
)}
{/* Цели — карточки в стиле сервера/gateway */}
<div className="row row-cards g-1 mt-2">
{POPULAR_TARGETS.map(({ id: tid, name, target: tgt, Icon, color }) => {
const isSelected = target === tgt;
const bgClass = color === 'red' ? 'bg-red-lt' : color === 'blue' ? 'bg-blue-lt' : 'bg-orange-lt';
const textClass = color === 'red' ? 'text-red' : color === 'blue' ? 'text-blue' : 'text-orange';
return (
<div className="col-12 col-sm-6 col-md-4" key={tid}>
<div
className={`card card-sm h-100 cursor-pointer ${isSelected ? 'border-primary card-hover' : 'card-hover'}`}
onClick={() => setTarget(tgt)}
title={tgt}
>
<div className="card-body py-2 px-2 d-flex align-items-center gap-2">
<span className={`avatar avatar-sm ${bgClass} ${textClass}`}>
<Icon size={20} stroke={1.5} />
</span>
<div className="flex-grow-1 min-w-0">
<div className="fw-semibold text-truncate small">{name}</div>
<div className="text-muted small text-truncate">{tgt}</div>
</div>
</div>
</div>
</div>
);
})}
</div>
{/* Режим и действия — под выбором цели, стандартный размер Tabler */}
<div className="d-flex flex-wrap align-items-center gap-2 mt-3 pt-2 border-top">
<ul className="nav nav-pills mb-0" role="tablist">
<li className="nav-item">
<button
type="button"
className={`nav-link ${
mode === 'traceroute' ? 'active' : ''
}`}
onClick={() => {
setMode('traceroute');
setHops([]);
setAnalysis([]);
setPingResult(null);
}}
role="tab"
>
Traceroute
</button>
</li>
<li className="nav-item">
<button
type="button"
className={`nav-link ${
mode === 'ping' ? 'active' : ''
}`}
onClick={() => {
setMode('ping');
setHops([]);
setAnalysis([]);
setPingResult(null);
}}
role="tab"
>
Ping
</button>
</li>
</ul>
<div className="btn-list">
<button type="button" className="btn btn-outline-secondary" onClick={handleReset} disabled={running}>
<IconRefresh size={16} className="me-1" />
Сбросить
</button>
<button type="button" className="btn btn-primary" onClick={handleRunCheck} disabled={running || loading}>
{running ? 'Выполнение…' : (mode === 'traceroute' ? 'Запустить трассировку' : 'Запустить ping')}
</button>
</div>
</div>
</>
)}
</div>
</div>
{/* Параметры + результаты */}
<div className="row g-2">
<div className="col-md-4">
<div className="card mb-2">
<div className="card-header py-2">
<h3 className="card-title mb-0">Параметры</h3>
</div>
<div className="card-body py-2">
{loading && !networkConfig ? (
<div className="text-muted small">Загрузка…</div>
) : !serverId ? (
<div className="text-muted small">Выберите сервер выше.</div>
) : (
<div className="row g-2">
<div className="col-12">
<label className="form-label small mb-0">Домен или IP</label>
<div className="input-group input-group-flat input-group-sm">
<span className="input-group-text"><IconWorld size={14} className="text-muted" /></span>
<input
type="text"
className="form-control"
placeholder="example.com или 8.8.8.8"
value={target}
onChange={(e) => setTarget(e.target.value)}
/>
{gatewayMeta?.ip && (
<button type="button" className="btn btn-outline-secondary btn-sm" onClick={() => setTarget(gatewayMeta.ip)}>
IP gateway
</button>
)}
</div>
</div>
{mode === 'traceroute' && (
<div className="col-6 col-md-5">
<label className="form-label small mb-0">Макс. хопов</label>
<input type="number" className="form-control form-control-sm" min={1} max={64} value={maxHops} onChange={(e) => setMaxHops(e.target.value)} />
</div>
)}
<div className="col-12">
<div className="form-check form-switch form-check-inline">
<input
className="form-check-input"
type="checkbox"
id="useDnsToggle"
checked={useDns}
onChange={(e) => setUseDns(e.target.checked)}
/>
<label
className="form-check-label small"
htmlFor="useDnsToggle"
>
{useDns ? 'DNS+IP' : 'Только IP'}
</label>
</div>
</div>
</div>
)}
</div>
</div>
</div>
<div className="col-md-8">
{mode === 'ping' && (
<div className="card mb-2">
<div className="card-header py-2">
<h3 className="card-title mb-0">Результаты ping</h3>
</div>
<div className="card-body p-0">
{!pingResult ? (
<div className="p-3 text-muted small">
Результаты ping появятся здесь после запуска проверки.
</div>
) : (
<div className="table-responsive">
<table className="table table-vcenter card-table mb-0">
<thead>
<tr>
<th>Цель</th>
<th style={{ width: 130 }}>Среднее, мс</th>
<th style={{ width: 140 }}>Мин / Макс, мс</th>
<th style={{ width: 110 }}>Потери</th>
<th style={{ width: 200 }}>Оценка</th>
</tr>
</thead>
<tbody>
{(() => {
const avg = typeof pingResult.avgMs === 'number' ? pingResult.avgMs : null;
const loss = typeof pingResult.loss === 'number' ? pingResult.loss : null;
let severity = 'ok';
if (loss != null && loss >= 50) severity = 'danger';
else if ((loss != null && loss > 0) || (avg != null && avg > 200)) severity = 'warn';
const rowClass =
severity === 'danger'
? 'table-danger'
: severity === 'warn'
? 'table-warning'
: '';
return (
<tr className={rowClass}>
<td>
<div className="d-flex flex-column">
<span className="fw-semibold">
{pingResult.target || target || '—'}
</span>
{gatewayMeta?.ip && (
<span className="text-muted small">
через gateway {gatewayMeta.ip}
</span>
)}
</div>
</td>
<td>
{avg != null ? (
<span
className={
avg > 200
? 'text-danger fw-semibold'
: avg > 120
? 'text-warning fw-semibold'
: ''
}
>
{avg.toFixed(1)}
</span>
) : (
'—'
)}
</td>
<td>
{pingResult.minMs != null ? pingResult.minMs.toFixed(1) : '—'} /{' '}
{pingResult.maxMs != null ? pingResult.maxMs.toFixed(1) : '—'}
</td>
<td>
{loss != null ? (
<span
className={
loss >= 50
? 'badge bg-red-lt text-red'
: loss > 0
? 'badge bg-yellow-lt text-yellow'
: 'badge bg-green-lt text-green'
}
>
{loss}%
</span>
) : (
'—'
)}
</td>
<td>
{severity === 'danger' && (
<span className="badge bg-red-lt text-red me-1">Плохое качество</span>
)}
{severity === 'warn' && (
<span className="badge bg-yellow-lt text-yellow me-1">
Возможные проблемы
</span>
)}
<span className="text-muted small">
{loss === 0
? 'Все пакеты доставлены'
: loss != null
? `Потеряно пакетов: ${loss}%`
: 'Нет данных о потере пакетов'}
</span>
</td>
</tr>
);
})()}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
{mode === 'traceroute' && (
<div className="card mb-2">
<div className="card-header py-2">
<ul className="nav nav-tabs card-header-tabs" role="tablist">
<li className="nav-item" role="presentation">
<button
type="button"
className={`nav-link ${tracerouteTab === 'table' ? 'active' : ''}`}
onClick={() => setTracerouteTab('table')}
role="tab"
>
Результаты
</button>
</li>
<li className="nav-item" role="presentation">
<button
type="button"
className={`nav-link ${tracerouteTab === 'analysis' ? 'active' : ''}`}
onClick={() => setTracerouteTab('analysis')}
role="tab"
>
Расшифровка
</button>
</li>
</ul>
</div>
{tracerouteTab === 'table' ? (
<div className="card-body p-0">
{hops.length === 0 ? (
<div className="p-3 text-muted small">
Результаты трассировки появятся здесь после запуска проверки.
</div>
) : (
<div className="table-responsive">
<table className="table table-vcenter card-table table-hover mb-0">
<thead>
<tr>
<th style={{ width: 70 }}>Хоп</th>
<th>Адрес / узел</th>
<th style={{ width: 130 }}>Среднее, мс</th>
<th style={{ width: 140 }}>Мин / Макс, мс</th>
<th style={{ width: 110 }}>Потери</th>
<th style={{ width: 200 }}>Статус</th>
</tr>
</thead>
<tbody>
{hops.map((h) => {
const ip = h.ip || '';
const dns = h.dns || '';
let mainLabel;
if (useDns) {
if (dns && ip) mainLabel = `${dns} (${ip})`;
else mainLabel = dns || ip || h.host || '—';
} else {
mainLabel = ip || dns || h.host || '—';
}
const avg = typeof h.avgMs === 'number' ? h.avgMs : null;
const loss = typeof h.loss === 'number' ? h.loss : null;
const statusRaw = (h.status || '').toString();
const statusLower = statusRaw.toLowerCase();
const hasTimeout =
statusLower.includes('timeout') ||
statusLower.includes('no reply') ||
statusLower.includes('unreachable');
let severity = 'ok';
if (hasTimeout || (loss != null && loss >= 50)) {
severity = 'danger';
} else if ((loss != null && loss > 0) || (avg != null && avg > 200)) {
severity = 'warn';
}
const rowClass =
severity === 'danger'
? 'table-danger'
: severity === 'warn'
? 'table-warning'
: '';
return (
<tr key={h.hop || `${ip || h.host}-${Math.random()}`} className={rowClass}>
<td>
<span className="badge bg-blue-lt text-blue">
{h.hop}
</span>
</td>
<td>
<div className="d-flex flex-column">
<span className="fw-semibold text-truncate" title={mainLabel}>
{mainLabel}
</span>
{ip && dns && (
<span className="text-muted small text-truncate" title={ip}>
IP: {ip}
</span>
)}
</div>
</td>
<td>
{avg != null ? (
<span
className={
avg > 200
? 'text-danger fw-semibold'
: avg > 120
? 'text-warning fw-semibold'
: ''
}
>
{avg.toFixed(1)}
</span>
) : (
'—'
)}
</td>
<td>
{h.bestMs != null ? h.bestMs.toFixed(1) : '—'} /{' '}
{h.worstMs != null ? h.worstMs.toFixed(1) : '—'}
</td>
<td>
{loss != null ? (
<span
className={
loss >= 50
? 'badge bg-red-lt text-red'
: loss > 0
? 'badge bg-yellow-lt text-yellow'
: 'badge bg-green-lt text-green'
}
>
{loss}%
</span>
) : (
'—'
)}
</td>
<td>
{severity === 'danger' && (
<span className="badge bg-red-lt text-red me-1">Проблемный хоп</span>
)}
{severity === 'warn' && (
<span className="badge bg-yellow-lt text-yellow me-1">Возможная проблема</span>
)}
<span className="text-muted small">
{statusRaw || (loss != null || avg != null ? 'OK' : '—')}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
) : (
<div className="card-body">
{analysis.length === 0 ? (
<div className="text-muted small">
После выполнения трассировки здесь появится человекочитаемое описание пути и возможных проблем.
</div>
) : (
<ul className="list-unstyled mb-0">
{analysis.map((line, idx) => (
<li key={idx} className="mb-1">
{line}
</li>
))}
</ul>
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
function buildTracerouteAnalysis(hops, networkConfig, servers, target) {
if (!Array.isArray(hops) || hops.length === 0) return [];
const lines = [];
const gateways = (networkConfig?.gateways && Array.isArray(networkConfig.gateways)) ? networkConfig.gateways : [];
const ifaces = (networkConfig?.tunnelInterfaces && Array.isArray(networkConfig.tunnelInterfaces)) ? networkConfig.tunnelInterfaces : [];
const findServerLabel = (serverId) => {
if (!serverId) return null;
const s = (servers || []).find((srv) => srv.id === serverId || srv.ip === serverId || srv.dns === serverId);
if (!s) return serverId;
return s.dns || s.ip || serverId;
};
const targetStr = String(target || '').trim();
lines.push(`Цель трассировки: ${targetStr || 'не указана'}. Хопов в маршруте: ${hops.length}.`);
let firstTimeoutHop = null;
let worstLatencyHop = null;
hops.forEach((hop, index) => {
const ip = hop.ip || hop.host || hop.address || '';
const dns = hop.dns || null;
const hostDisplay = dns ? `${dns}${ip ? ` (${ip})` : ''}` : (ip || hop.host || '');
const avg = hop.avgMs;
const loss = hop.loss;
let descr = `Хоп ${hop.hop || index + 1}: ${hostDisplay || 'неизвестный узел'}`;
const details = [];
if (avg != null) details.push(`среднее время ≈ ${avg.toFixed(1)} мс`);
if (hop.bestMs != null && hop.worstMs != null) {
details.push(`мин/макс ${hop.bestMs.toFixed(1)}/${hop.worstMs.toFixed(1)} мс`);
}
if (loss != null) details.push(`потеря пакетов ${loss}%`);
// Привязка к gateway (по IP)
const gw = gateways.find((g) => g.ip && g.ip === ip);
if (gw) {
const srvLabel = findServerLabel(gw.serverId);
details.push(`gateway "${gw.description || gw.ip}" на сервере ${srvLabel || gw.serverId}`);
}
// Привязка к туннельному интерфейсу (по IP)
const iface = ifaces.find((i) => i.localIp === ip || i.remoteIp === ip);
if (iface) {
const s1 = findServerLabel(iface.serverId);
const s2 = findServerLabel(iface.serverId2);
details.push(
`туннель ${iface.type || ''} ${iface.name || ''} (${iface.localIp} ⇄ ${iface.remoteIp})` +
(s1 || s2 ? ` между ${s1 || 'сервером 1'} и ${s2 || 'сервером 2'}` : '')
);
}
// Статус/таймауты
if (hop.status) {
const st = String(hop.status).toLowerCase();
if (st.includes('timeout') || st.includes('no reply') || st.includes('unreachable')) {
details.push('узел не отвечает на ICMP (возможная фильтрация или недоступность)');
if (!firstTimeoutHop) firstTimeoutHop = hop;
}
}
if (details.length > 0) {
descr += ` — ${details.join('; ')}`;
}
lines.push(descr);
if (avg != null) {
if (!worstLatencyHop || avg > (worstLatencyHop.avgMs || 0)) {
worstLatencyHop = hop;
}
}
});
if (worstLatencyHop && worstLatencyHop.avgMs != null && worstLatencyHop.avgMs > 150) {
lines.push(
`Максимальная средняя задержка на хопе ${worstLatencyHop.hop}: около ${worstLatencyHop.avgMs.toFixed(
1
)} мс — возможен "узкое место" или удалённый сегмент сети.`
);
}
if (firstTimeoutHop) {
lines.push(
`Начиная с хопа ${firstTimeoutHop.hop} (${firstTimeoutHop.dns ? `${firstTimeoutHop.dns}${firstTimeoutHop.ip ? ` (${firstTimeoutHop.ip})` : ''}` : (firstTimeoutHop.ip || firstTimeoutHop.host || 'неизвестный узел')}) наблюдаются таймауты — цель может быть недоступна или где-то по пути фильтруется ICMP.`
);
} else {
lines.push('Таймаутов или явной потери пакетов по пути не обнаружено (по данным MikroTik).');
}
return lines;
}
export default MikrotikTools;
File diff suppressed because it is too large Load Diff
+434
View File
@@ -0,0 +1,434 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import api from './lib/api.js';
import { IconRefresh, IconTopologyRing, IconClock } from '@tabler/icons-react';
import PageHeader from './components/PageHeader.jsx';
import NetworkMapUnifi from './NetworkMapUnifi.jsx';
/** Параллельно выполняем промисы с лимитом одновременных */
async function runWithLimit(tasks, limit = 4) {
const results = [];
let index = 0;
async function runNext() {
const i = index++;
if (i >= tasks.length) return;
const task = tasks[i];
try {
const value = await task();
results[i] = { value };
} catch (err) {
results[i] = { error: err };
}
await runNext();
}
const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => runNext());
await Promise.all(workers);
return results;
}
/**
* Выполняет задачи так, что на каждый ключ (ноду) в один момент времени
* выполняется не более одной задачи. Разные ноды — параллельно.
* getKey(i) возвращает ключ для задачи с индексом i.
*/
async function runWithOnePerKey(tasks, getKey) {
const n = tasks.length;
const keyToIndices = new Map();
for (let i = 0; i < n; i++) {
const k = String(getKey(i));
if (!keyToIndices.has(k)) keyToIndices.set(k, []);
keyToIndices.get(k).push(i);
}
const results = [];
for (let i = 0; i < n; i++) results[i] = null;
const keys = Array.from(keyToIndices.keys());
let round = 0;
while (true) {
const batch = [];
for (const k of keys) {
const indices = keyToIndices.get(k);
if (round < indices.length) batch.push(indices[round]);
}
if (batch.length === 0) break;
await Promise.all(
batch.map(async (i) => {
try {
const value = await tasks[i]();
results[i] = { value };
} catch (err) {
results[i] = { error: err };
}
})
);
round++;
}
return results;
}
export default function NetworkMapDashboard() {
const [servers, setServers] = useState([]);
const [connections, setConnections] = useState([]);
const [pingMap, setPingMap] = useState({});
const [speedMap, setSpeedMap] = useState({});
const [pingStaleMap, setPingStaleMap] = useState({});
const [speedStaleMap, setSpeedStaleMap] = useState({});
const pingTimestampsRef = useRef({});
const lastSuccessfulPingRef = useRef({});
const lastSuccessfulSpeedRef = useRef({});
const [pingCacheSeconds, setPingCacheSeconds] = useState(0);
const [loading, setLoading] = useState(true);
const [pingLoading, setPingLoading] = useState(false);
const [speedLoading, setSpeedLoading] = useState(false);
const [error, setError] = useState(null);
const pingAbortRef = useRef(false);
const speedAbortRef = useRef(false);
/** Время применения кеша от планировщика (чтобы не запускать пинг/скорость повторно при открытии) */
const cacheAppliedAtRef = useRef(null);
/** Связи строятся из интерфейсов раздела «Сетевые настройки» (/network-config), tunnelInterfaces */
const fetchData = useCallback(async (abortSignal) => {
setLoading(true);
setError(null);
cacheAppliedAtRef.current = null;
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const [serversRes, configRes, uiSettingsRes, cacheRes] = await Promise.all([
api.get('/servers', opts),
api.get('/network-config', opts).catch(() => ({ data: null })),
api.get('/ui-settings', opts).catch(() => ({ data: {} })),
api.get('/network-map-cache', opts).catch(() => ({ data: {} })),
]);
const serversList = Array.isArray(serversRes?.data) ? serversRes.data : [];
const config = configRes?.data || {};
const tunnelInterfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : [];
const uiSettings = uiSettingsRes?.data || {};
const cache = cacheRes?.data || {};
const ttlSeconds = Math.max(
0,
parseInt(uiSettings.networkMapPingCacheSeconds, 10) || 0
);
setPingCacheSeconds(ttlSeconds);
const getServer = (serverId) =>
serversList.find((s) => s.id === serverId || s.ip === serverId || s.dns === serverId);
const connList = [];
tunnelInterfaces.forEach((iface) => {
if (!iface.serverId || !iface.serverId2) return;
const s1 = getServer(iface.serverId);
const s2 = getServer(iface.serverId2);
if (!s1 || !s2 || s1.ip === s2.ip) return;
const s1Key = s1.id || s1.dns || s1.ip;
const s2Key = s2.id || s2.dns || s2.ip;
if (!s1Key || !s2Key) return;
const isJumphost1 = s1 && ['jumphost', 'home'].includes(String(s1.type || '').toLowerCase());
const isJumphost2 = s2 && ['jumphost', 'home'].includes(String(s2.type || '').toLowerCase());
connList.push({
from: s1.ip,
to: s2.ip,
tunnelType: iface.type || 'GRE',
interfaceName: iface.name || null,
fromKey: s1Key,
toKey: s2Key,
internalFromTo: iface.remoteIp || null,
internalToFrom: iface.localIp || null,
speedTestServerId: isJumphost1 ? s1Key : isJumphost2 ? s2Key : null,
});
});
setServers(serversList);
setConnections(connList);
const cacheMaxAgeMs = Math.max((ttlSeconds || 60) * 1000, 60 * 1000);
const cachedPingMap = cache.pingMap && typeof cache.pingMap === 'object' ? cache.pingMap : {};
const cachedSpeedMap = cache.speedMap && typeof cache.speedMap === 'object' ? cache.speedMap : {};
const hasCacheData = Object.keys(cachedPingMap).length > 0 || Object.keys(cachedSpeedMap).length > 0;
const cacheValid =
cache.updatedAt &&
Date.now() - cache.updatedAt < cacheMaxAgeMs &&
hasCacheData;
if (hasCacheData) {
// Всегда показываем кеш, если он есть — чтобы на карте отображались хотя бы прошлые пинг/скорость
setPingMap(cachedPingMap);
setSpeedMap(cachedSpeedMap);
Object.keys(cachedPingMap).forEach((k) => {
if (typeof cachedPingMap[k] === 'number') {
lastSuccessfulPingRef.current[k] = cachedPingMap[k];
}
pingTimestampsRef.current[k] = cache.updatedAt;
});
Object.keys(cachedSpeedMap).forEach((k) => {
if (cachedSpeedMap[k] && typeof cachedSpeedMap[k] === 'object') {
lastSuccessfulSpeedRef.current[k] = cachedSpeedMap[k];
}
});
cacheAppliedAtRef.current = cache.updatedAt;
// Устаревший кеш (старше TTL) помечаем как stale, но данные всё равно отображаем
if (cacheValid) {
setPingStaleMap({});
setSpeedStaleMap({});
} else {
const stalePing = {};
const staleSpeed = {};
Object.keys(cachedPingMap).forEach((k) => { stalePing[k] = true; });
Object.keys(cachedSpeedMap).forEach((k) => { staleSpeed[k] = true; });
setPingStaleMap(stalePing);
setSpeedStaleMap(staleSpeed);
}
} else {
setSpeedMap({});
setPingStaleMap({});
setSpeedStaleMap({});
}
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
console.error('NetworkMap fetch:', e);
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить данные');
setServers([]);
setConnections([]);
} finally {
setLoading(false);
}
}, []);
/** Один ключ пинга на ребро (маршруты статичны — одна сторона) */
const edgePingKey = (a, b) => [String(a), String(b)].sort().join(':');
const requestPings = useCallback(async () => {
if (connections.length === 0) return;
pingAbortRef.current = false;
setPingLoading(true);
const ttlMs = Math.max(0, (pingCacheSeconds || 0) * 1000);
const tasks = [];
connections.forEach((c) => {
const from = String(c.from);
const to = String(c.to);
if (!from || !to || from === to) return;
const srcFrom = servers.find((s) => s.ip === from);
const srcTo = servers.find((s) => s.ip === to);
const canPingFrom =
srcFrom && ['jumphost', 'home'].includes(String(srcFrom.type || '').toLowerCase()) && c.internalFromTo;
const canPingTo =
srcTo && ['jumphost', 'home'].includes(String(srcTo.type || '').toLowerCase()) && c.internalToFrom;
const ekey = edgePingKey(from, to);
if (ttlMs > 0 && pingTimestampsRef.current[ekey] && Date.now() - pingTimestampsRef.current[ekey] < ttlMs) {
return;
}
// Один пинг на ребро — с той стороны, где есть jumphost
if (canPingFrom) {
tasks.push(async () => {
if (pingAbortRef.current) return;
try {
const { data } = await api.post('/mikrotik/ping', {
serverId: c.fromKey,
target: c.internalFromTo,
gatewayIp: c.internalFromTo,
count: 3,
});
const ms = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null;
if (ms != null) {
pingTimestampsRef.current[ekey] = Date.now();
lastSuccessfulPingRef.current[ekey] = ms;
setPingMap((prev) => ({ ...prev, [ekey]: ms }));
setPingStaleMap((prev) => ({ ...prev, [ekey]: false }));
}
} catch {
pingTimestampsRef.current[ekey] = Date.now();
// При ошибке сохраняем последнее успешное значение с пометкой устаревшего
const lastSuccess = lastSuccessfulPingRef.current[ekey];
if (lastSuccess != null) {
setPingMap((prev) => ({ ...prev, [ekey]: lastSuccess }));
setPingStaleMap((prev) => ({ ...prev, [ekey]: true }));
} else {
setPingMap((prev) => ({ ...prev, [ekey]: null }));
setPingStaleMap((prev) => ({ ...prev, [ekey]: false }));
}
}
});
} else if (canPingTo) {
tasks.push(async () => {
if (pingAbortRef.current) return;
try {
const { data } = await api.post('/mikrotik/ping', {
serverId: c.toKey,
target: c.internalToFrom,
gatewayIp: c.internalToFrom,
count: 3,
});
const ms = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null;
if (ms != null) {
pingTimestampsRef.current[ekey] = Date.now();
lastSuccessfulPingRef.current[ekey] = ms;
setPingMap((prev) => ({ ...prev, [ekey]: ms }));
setPingStaleMap((prev) => ({ ...prev, [ekey]: false }));
}
} catch {
pingTimestampsRef.current[ekey] = Date.now();
// При ошибке сохраняем последнее успешное значение с пометкой устаревшего
const lastSuccess = lastSuccessfulPingRef.current[ekey];
if (lastSuccess != null) {
setPingMap((prev) => ({ ...prev, [ekey]: lastSuccess }));
setPingStaleMap((prev) => ({ ...prev, [ekey]: true }));
} else {
setPingMap((prev) => ({ ...prev, [ekey]: null }));
setPingStaleMap((prev) => ({ ...prev, [ekey]: false }));
}
}
});
}
});
await runWithLimit(tasks, 4);
if (pingAbortRef.current) return;
setPingLoading(false);
}, [connections, servers, pingCacheSeconds]);
/** Ключ скорости по паре серверов (без учёта порядка) */
const speedKey = (key1, key2) =>
[String(key1), String(key2)].sort().join(':');
const requestSpeeds = useCallback(async () => {
const withSpeed = connections.filter(
(c) => c.speedTestServerId && c.interfaceName
);
if (withSpeed.length === 0) return;
speedAbortRef.current = false;
setSpeedLoading(true);
const tasks = withSpeed.map((c) => async () => {
if (speedAbortRef.current) return;
const key = speedKey(c.fromKey, c.toKey);
try {
const { data } = await api.post(
'/mikrotik/speed-test',
{
serverId: c.speedTestServerId,
interfaceName: c.interfaceName,
},
{ timeout: 120000 }
);
if (data?.ok && (data.tcpDownloadBps != null || data.tcpUploadBps != null)) {
const speedData = {
tcpDownloadBps: data.tcpDownloadBps,
tcpUploadBps: data.tcpUploadBps,
cached: data.cached === true,
durationSeconds: data.durationSeconds,
};
lastSuccessfulSpeedRef.current[key] = speedData;
setSpeedMap((prev) => ({ ...prev, [key]: speedData }));
setSpeedStaleMap((prev) => ({ ...prev, [key]: false }));
}
} catch {
// При ошибке сохраняем последнее успешное значение с пометкой устаревшего
const lastSuccess = lastSuccessfulSpeedRef.current[key];
if (lastSuccess != null) {
setSpeedMap((prev) => ({ ...prev, [key]: lastSuccess }));
setSpeedStaleMap((prev) => ({ ...prev, [key]: true }));
} else {
setSpeedMap((prev) => ({ ...prev, [key]: null }));
setSpeedStaleMap((prev) => ({ ...prev, [key]: false }));
}
}
});
// В один момент — только один тест на один сервер назначения (удалённый конец туннеля),
// иначе несколько замеров к одному хостингу делят канал и дают заниженные результаты
await runWithOnePerKey(tasks, (i) => {
const c = withSpeed[i];
return c.speedTestServerId === c.fromKey ? c.toKey : c.fromKey;
});
if (speedAbortRef.current) return;
setSpeedLoading(false);
}, [connections]);
useEffect(() => {
const controller = new AbortController();
fetchData(controller.signal);
return () => controller.abort();
}, [fetchData]);
// Пинг и скорость не запускаются автоматически при открытии карты (даже если кеш просрочен).
// Обновление только по кнопкам «Пинг» и «Скорость» или по планировщику.
const handleRefreshPings = () => {
requestPings();
};
const handleRefreshSpeeds = () => {
requestSpeeds();
};
if (loading) {
return (
<div>
<PageHeader
title="Карта сети"
icon={<IconTopologyRing size={24} />}
meta="Серверы и пинг между ними"
/>
<div className="text-center py-5 text-muted">Загрузка серверов и связей…</div>
</div>
);
}
if (error) {
return (
<div>
<PageHeader
title="Карта сети"
icon={<IconTopologyRing size={24} />}
meta="Серверы и пинг между ними"
/>
<div className="alert alert-danger">{error}</div>
</div>
);
}
return (
<div>
<PageHeader
title="Карта сети"
icon={<IconTopologyRing size={24} />}
meta="Связи из раздела «Сетевые настройки» → Интерфейсы; на рёбрах — пинг и скорость"
actions={
<div className="btn-list">
<button
type="button"
className="btn btn-outline-primary"
onClick={handleRefreshPings}
disabled={pingLoading}
>
<IconRefresh className={pingLoading ? 'spin me-2' : 'me-2'} size={18} />
{pingLoading ? 'Пинг…' : 'Пинг'}
</button>
<button
type="button"
className="btn btn-outline-primary"
onClick={handleRefreshSpeeds}
disabled={speedLoading}
>
<IconRefresh className={speedLoading ? 'spin me-2' : 'me-2'} size={18} />
{speedLoading ? 'Скорость…' : 'Скорость'}
</button>
</div>
}
/>
{servers.length === 0 ? (
<div className="alert alert-info">
Нет серверов. Добавьте серверы в разделе «Серверы».
</div>
) : (
<NetworkMapUnifi
servers={servers}
connections={connections}
pingMap={pingMap}
speedMap={speedMap}
pingStaleMap={pingStaleMap}
speedStaleMap={speedStaleMap}
/>
)}
</div>
);
}
+325
View File
@@ -0,0 +1,325 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import api from './lib/api.js';
import PageHeader from './components/PageHeader.jsx';
import {
IconClockPlay,
IconSettings,
IconTerminal2,
IconPlayerPlay,
IconRefresh,
} from '@tabler/icons-react';
const POLL_LOGS_MS = 2500;
const INTERVAL_MIN = 1;
const INTERVAL_MAX = 1440;
const LOG_LINES_MIN = 100;
const LOG_LINES_MAX = 2000;
export default function NetworkMapSchedulerPage() {
const [settings, setSettings] = useState({
enabled: false,
intervalMinutes: 5,
runPing: true,
runSpeedTest: true,
logMaxLines: 500,
});
const [nextRunAt, setNextRunAt] = useState(null);
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [running, setRunning] = useState(false);
const [error, setError] = useState(null);
const logEndRef = useRef(null);
const logContainerRef = useRef(null);
const [autoScroll, setAutoScroll] = useState(true);
const fetchSettings = useCallback(async (abortSignal) => {
try {
const opts = abortSignal ? { signal: abortSignal } : {};
const { data } = await api.get('/scheduler/network-map/settings', opts);
setSettings({
enabled: Boolean(data.enabled),
intervalMinutes: Math.max(INTERVAL_MIN, Math.min(INTERVAL_MAX, Number(data.intervalMinutes) || 5)),
runPing: data.runPing !== false,
runSpeedTest: data.runSpeedTest !== false,
logMaxLines: Math.max(LOG_LINES_MIN, Math.min(LOG_LINES_MAX, Number(data.logMaxLines) || 500)),
});
setNextRunAt(typeof data.nextRunAt === 'number' ? data.nextRunAt : null);
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить настройки');
}
}, []);
const fetchLogs = useCallback(async () => {
try {
const { data } = await api.get('/scheduler/network-map/logs');
setLogs(Array.isArray(data?.lines) ? data.lines : []);
} catch (_) {
// не показываем ошибку при опросе логов
}
}, []);
useEffect(() => {
const controller = new AbortController();
(async () => {
setLoading(true);
await fetchSettings(controller.signal);
setLoading(false);
})();
return () => controller.abort();
}, [fetchSettings]);
useEffect(() => {
fetchLogs();
const t = setInterval(fetchLogs, POLL_LOGS_MS);
return () => clearInterval(t);
}, [fetchLogs]);
useEffect(() => {
if (!settings.enabled || !nextRunAt) return;
const t = setInterval(fetchSettings, 60 * 1000);
return () => clearInterval(t);
}, [settings.enabled, nextRunAt, fetchSettings]);
useEffect(() => {
if (autoScroll && logEndRef.current) {
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [logs, autoScroll]);
const handleSave = async () => {
setSaving(true);
setError(null);
try {
await api.patch('/scheduler/network-map/settings', {
networkMapSchedulerEnabled: settings.enabled,
networkMapSchedulerIntervalMinutes: settings.intervalMinutes,
networkMapSchedulerRunPing: settings.runPing,
networkMapSchedulerRunSpeedTest: settings.runSpeedTest,
networkMapSchedulerLogMaxLines: settings.logMaxLines,
});
await fetchSettings();
} catch (e) {
setError(e?.response?.data?.message || e?.message || 'Не удалось сохранить');
} finally {
setSaving(false);
}
};
const handleRunNow = async () => {
setRunning(true);
setError(null);
try {
await api.post('/scheduler/network-map/run-now');
setTimeout(fetchLogs, 500);
} catch (e) {
setError(e?.response?.data?.message || e?.message || 'Не удалось запустить');
} finally {
setRunning(false);
}
};
if (loading) {
return (
<div>
<PageHeader
title="Планировщик карты сети"
icon={<IconClockPlay size={24} />}
meta="Пинг и замер скорости между серверами по расписанию"
/>
<div className="text-center py-5 text-muted">Загрузка настроек…</div>
</div>
);
}
return (
<div>
<PageHeader
title="Планировщик карты сети"
icon={<IconClockPlay size={24} />}
meta="Пинг и замер скорости между серверами по расписанию; кеш ускоряет загрузку карты сети."
actions={
<div className="btn-list">
<button
type="button"
className="btn btn-primary"
onClick={handleRunNow}
disabled={running}
>
<IconPlayerPlay className={running ? 'spin me-2' : 'me-2'} size={18} />
{running ? 'Запуск…' : 'Запустить сейчас'}
</button>
</div>
}
/>
{error && (
<div className="alert alert-danger alert-dismissible fade show mb-3" role="alert">
{error}
<button type="button" className="btn-close" aria-label="Закрыть" onClick={() => setError(null)} />
</div>
)}
<div className="row row-deck row-cards g-3">
<div className="col-12 col-lg-5">
<div className="card">
<div className="card-header">
<h3 className="card-title d-flex align-items-center">
<IconSettings className="me-2" size={20} />
Настройки
</h3>
<div className="card-actions">
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleSave}
disabled={saving}
>
{saving ? 'Сохранение…' : 'Сохранить'}
</button>
</div>
</div>
<div className="card-body">
<div className="mb-3">
<label className="form-check form-switch">
<input
className="form-check-input"
type="checkbox"
checked={settings.enabled}
onChange={(e) => setSettings((s) => ({ ...s, enabled: e.target.checked }))}
/>
<span className="form-check-label">Включить планировщик</span>
</label>
<div className="form-hint">Периодически обновлять пинг и скорость по связям из «Сетевые настройки»</div>
{settings.enabled && nextRunAt != null && (
<div className="mt-2 text-muted small">
Следующий запуск: {new Date(nextRunAt).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'medium' })}
</div>
)}
</div>
<div className="mb-3">
<label className="form-label">Интервал (минут)</label>
<input
type="number"
className="form-control"
min={INTERVAL_MIN}
max={INTERVAL_MAX}
value={settings.intervalMinutes}
onChange={(e) =>
setSettings((s) => ({
...s,
intervalMinutes: Math.max(INTERVAL_MIN, Math.min(INTERVAL_MAX, parseInt(e.target.value, 10) || INTERVAL_MIN)),
}))
}
/>
<div className="form-hint">От 1 до 1440 (сутки). По умолчанию 5 мин.</div>
</div>
<div className="mb-3">
<label className="form-label">Задачи</label>
<div>
<label className="form-check form-switch me-3">
<input
className="form-check-input"
type="checkbox"
checked={settings.runPing}
onChange={(e) => setSettings((s) => ({ ...s, runPing: e.target.checked }))}
/>
<span className="form-check-label">Пинг</span>
</label>
<label className="form-check form-switch">
<input
className="form-check-input"
type="checkbox"
checked={settings.runSpeedTest}
onChange={(e) => setSettings((s) => ({ ...s, runSpeedTest: e.target.checked }))}
/>
<span className="form-check-label">Замер скорости</span>
</label>
</div>
</div>
<div className="mb-0">
<label className="form-label">Строк в логе</label>
<input
type="number"
className="form-control"
min={LOG_LINES_MIN}
max={LOG_LINES_MAX}
value={settings.logMaxLines}
onChange={(e) =>
setSettings((s) => ({
...s,
logMaxLines: Math.max(LOG_LINES_MIN, Math.min(LOG_LINES_MAX, parseInt(e.target.value, 10) || 500)),
}))
}
/>
<div className="form-hint">Кольцевой буфер: от 100 до 2000 строк.</div>
</div>
</div>
</div>
</div>
<div className="col-12 col-lg-7">
<div className="card flex-fill">
<div className="card-header d-flex align-items-center">
<h3 className="card-title d-flex align-items-center mb-0">
<IconTerminal2 className="me-2" size={20} />
Логи
</h3>
<div className="card-actions ms-auto">
<label className="form-check form-switch form-check-inline mb-0 me-2">
<input
className="form-check-input"
type="checkbox"
checked={autoScroll}
onChange={(e) => setAutoScroll(e.target.checked)}
/>
<span className="form-check-label small">Автопрокрутка</span>
</label>
<button
type="button"
className="btn btn-outline-secondary btn-sm"
onClick={fetchLogs}
aria-label="Обновить логи"
>
<IconRefresh size={16} />
</button>
</div>
</div>
<div
ref={logContainerRef}
className="card-body p-0"
style={{ minHeight: 320 }}
>
<pre
className="mb-0 p-3 bg-dark text-light rounded-0 rounded-bottom"
style={{
fontSize: 12,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
maxHeight: 420,
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{logs.length === 0 ? (
<span className="text-muted">Логов пока нет. Включите планировщик и нажмите «Запустить сейчас» или дождитесь следующего запуска.</span>
) : (
logs.map((line, i) => (
<span key={i} style={{ display: 'block' }}>
{line}
</span>
))
)}
<span ref={logEndRef} />
</pre>
</div>
</div>
</div>
</div>
</div>
);
}
+692
View File
@@ -0,0 +1,692 @@
import { useMemo, useCallback, useState, useRef, useEffect } from 'react';
import { IconRefresh, IconMaximize, IconMinimize, IconClock } from '@tabler/icons-react';
const FLAG_CDN = 'https://flagcdn.com';
const UNIFI_BG = '#0f172a';
const UNIFI_CARD_BG = '#1e293b';
const UNIFI_CARD_BORDER = '#334155';
const UNIFI_GREEN = '#22c55e';
const UNIFI_TEXT = '#f1f5f9';
const UNIFI_TEXT_MUTED = '#94a3b8';
const NODE_WIDTH = 88;
const NODE_HEIGHT = 52;
const STORAGE_KEY = 'network-map-unifi-positions';
function computeInitialLayout(servers, size) {
const positions = {};
if (!servers.length) return positions;
// Группируем: сначала home, затем jumphost, затем exit, затем остальные
const homes = servers.filter((s) => String(s.type || '').toLowerCase() === 'home');
const jumphosts = servers.filter((s) => String(s.type || '').toLowerCase() === 'jumphost');
const exits = servers.filter((s) => String(s.type || '').toLowerCase() === 'exit');
const others = servers.filter((s) => {
const t = String(s.type || '').toLowerCase();
return t !== 'home' && t !== 'jumphost' && t !== 'exit';
});
const columns = [homes, jumphosts, exits, others].filter((col) => col.length > 0);
const colCount = columns.length || 1;
const paddingX = 80;
const usableWidth = Math.max(200, size.w - paddingX * 2);
const colStep = usableWidth / Math.max(1, colCount - 1);
const baseX = paddingX;
columns.forEach((col, colIdx) => {
// В пределах колонки сортируем по dns/ip для стабильности
const sorted = [...col].sort((a, b) => {
const la = (a.dns || a.ip || '').toString();
const lb = (b.dns || b.ip || '').toString();
return la.localeCompare(lb);
});
const n = sorted.length;
const x = baseX + colStep * colIdx;
const topPadding = 60;
const bottomPadding = 60;
const usableHeight = Math.max(120, size.h - topPadding - bottomPadding);
sorted.forEach((s, i) => {
const t = n > 1 ? i / (n - 1) : 0.5;
const y = topPadding + usableHeight * t;
positions[String(s.ip)] = { x, y };
});
});
return positions;
}
/**
* Карта сети в стиле UNIFI: тёмный фон, простые иконки узлов, зелёные линии, пинг на рёбрах.
* Узлы можно перетаскивать; раскладка сохраняется в localStorage. Круг — только начальная раскладка.
*/
function formatMbps(bps) {
if (bps == null || Number.isNaN(bps)) return '—';
const mbps = bps / 1_000_000;
if (!Number.isFinite(mbps)) return '—';
return `${mbps.toFixed(1)} Мбит/с`;
}
/** Короткий формат скорости для подписи на ребре: "↓287 ↑170 Мбит/с" */
function formatSpeedShort(downloadBps, uploadBps) {
const fmt = (v) => {
if (v == null || Number.isNaN(v)) return '—';
const mbps = v / 1_000_000;
if (!Number.isFinite(mbps)) return '—';
return mbps >= 10 ? Math.round(mbps) : mbps.toFixed(1);
};
const down = fmt(downloadBps);
const up = fmt(uploadBps);
if (down === '—' && up === '—') return null;
if (down === '—') return `↑${up} Мбит/с`;
if (up === '—') return `↓${down} Мбит/с`;
return `↓${down} ↑${up} Мбит/с`;
}
function speedKey(key1, key2) {
return [String(key1), String(key2)].sort().join(':');
}
export default function NetworkMapUnifi({ servers = [], connections = [], pingMap = {}, speedMap = {}, pingStaleMap = {}, speedStaleMap = {} }) {
const containerRef = useRef(null);
const svgRef = useRef(null);
const [size, setSize] = useState({ w: 800, h: 520 });
const [positions, setPositions] = useState({});
const [drag, setDrag] = useState(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [hoveredEdgeKey, setHoveredEdgeKey] = useState(null);
const [selectedEdge, setSelectedEdge] = useState(null);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const { width, height } = entries[0]?.contentRect ?? {};
if (width > 0 && height > 0) setSize({ w: width, h: height });
});
ro.observe(el);
return () => ro.disconnect();
}, []);
// Трек полноэкранного режима
useEffect(() => {
const handler = () => {
const fs = Boolean(document.fullscreenElement);
setIsFullscreen(fs);
};
document.addEventListener('fullscreenchange', handler);
return () => document.removeEventListener('fullscreenchange', handler);
}, []);
// Инициализация позиций: из localStorage или по кругу
useEffect(() => {
if (servers.length === 0) {
setPositions({});
return;
}
let saved = {};
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw) saved = JSON.parse(raw) || {};
} catch {}
const initial = computeInitialLayout(servers, size);
const merged = {};
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 };
} else {
merged[ip] = initial[ip] ?? { x: size.w / 2, y: size.h / 2 };
}
});
setPositions(merged);
}, [servers.length, size.w, size.h]);
const savePositions = useCallback((next) => {
setPositions(next);
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {}
}, []);
const getSvgCoords = useCallback((clientX, clientY) => {
const svg = svgRef.current;
if (!svg) return null;
const pt = svg.createSVGPoint();
pt.x = clientX;
pt.y = clientY;
const p = pt.matrixTransform(svg.getScreenCTM().inverse());
return { x: p.x, y: p.y };
}, []);
useEffect(() => {
if (!drag) return;
const onMove = (e) => {
const p = getSvgCoords(e.clientX, e.clientY);
if (!p) return;
setPositions((prev) => {
const pos = prev[drag.nodeId];
if (!pos) return prev;
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 {}
return next;
});
};
const onUp = () => setDrag(null);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, [drag, getSvgCoords]);
const resetLayout = useCallback(() => {
const initial = computeInitialLayout(servers, size);
setPositions(initial);
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(initial));
} catch {}
}, [servers, size]);
const onNodeMouseDown = useCallback(
(e, ip) => {
e.preventDefault();
const p = getSvgCoords(e.clientX, e.clientY);
const pos = positions[ip];
if (!p || !pos) return;
setDrag({ nodeId: ip, startX: pos.x, startY: pos.y, mouseX: p.x, mouseY: p.y });
},
[getSvgCoords, positions]
);
const edgePingKey = (a, b) => [String(a), String(b)].sort().join(':');
const edgesWithPing = useMemo(() => {
return (connections || []).map((c) => {
const from = String(c.from);
const to = String(c.to);
const p1 = positions[from];
const p2 = positions[to];
const ekey = edgePingKey(from, to);
const ms = pingMap[ekey];
const pingStale = pingStaleMap[ekey] === true;
const pingLabel = typeof ms === 'number' ? `${ms} ms` : null;
const sk = speedKey(c.fromKey, c.toKey);
const speed = speedMap[sk];
const speedStale = speedStaleMap[sk] === true;
const speedLabel =
speed && (speed.tcpDownloadBps != null || speed.tcpUploadBps != null)
? formatSpeedShort(speed.tcpDownloadBps, speed.tcpUploadBps)
: null;
return {
edgeKey: `${from}-${to}-${c.tunnelType || ''}`,
from,
to,
p1,
p2,
pingLabel,
speedLabel,
pingStale,
speedStale,
speed,
tunnelType: c.tunnelType,
raw: c,
};
}).filter((e) => e.p1 && e.p2);
}, [connections, positions, pingMap, speedMap, pingStaleMap, speedStaleMap]);
// Вычисляем старт/финиш и одну контрольную точку:
// рёбра выходят из карточек под 90°, дальше сразу плавная дуга без прямых сегментов.
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);
let sx = p1.x;
let sy = p1.y;
let ex = p2.x;
let ey = p2.y;
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 ddx = ex - sx;
const ddy = ey - sy;
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;
return { sx, sy, ex, ey, 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}`;
}, [computeCurvePoints]);
/** Точка для подписи: середина ребра + смещение по перпендикуляру (чтобы подпись была у ребра, а не уезжала) */
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]);
if (servers.length === 0) return null;
return (
<div
ref={containerRef}
className="network-map-unifi"
style={{
width: '100%',
height: 520,
minHeight: 400,
background: UNIFI_BG,
borderRadius: 12,
overflow: 'hidden',
position: 'relative',
userSelect: 'none',
}}
>
<svg
ref={svgRef}
width="100%"
height="100%"
viewBox={`0 0 ${size.w} ${size.h}`}
preserveAspectRatio="xMidYMid meet"
style={{ display: 'block' }}
>
{/* Рёбра: зелёные кривые (стиль UNIFI — проводные соединения) */}
<g>
{(() => {
const used = [];
const LABEL_W = 100;
const LABEL_H = 18;
const LABEL_GAP = 6;
return edgesWithPing.map((e, idx) => {
const dimFactor =
hoveredEdgeKey && hoveredEdgeKey !== e.edgeKey ? 0.25 : 1;
const strokeWidth = hoveredEdgeKey === e.edgeKey ? 3.2 : 2.5;
return (
<g
key={e.edgeKey || `${e.from}-${e.to}-${idx}`}
onMouseEnter={() => setHoveredEdgeKey(e.edgeKey)}
onMouseLeave={() =>
setHoveredEdgeKey((prev) => (prev === e.edgeKey ? null : prev))
}
onClick={() => setSelectedEdge(e)}
style={{ cursor: 'pointer' }}
>
<path
d={pathD(e)}
fill="none"
stroke={UNIFI_GREEN}
strokeWidth={strokeWidth}
strokeLinecap="round"
opacity={dimFactor}
/>
{(() => {
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;
let level = 0;
let x; let y;
while (level < 10) {
const pos = labelPlacement(e, level);
x = pos.x;
y = pos.y;
const overlaps = used.some(
(p) => Math.hypot(p.x - x, p.y - y) < minDist
);
if (!overlaps) break;
level += 1;
}
used.push({ x, y });
const pad = 8;
const staleColor = '#f59e0b';
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>
);
})}
</g>
);
})()}
</g>
);
});
})()}
</g>
{/* Узлы: карточки серверов в стиле UNIFI (перетаскиваемые) */}
{servers.map((s) => {
const pos = positions[String(s.ip)];
if (!pos) return null;
const label = s.dns?.split('.')[0] || s.ip || '?';
const isDragging = drag?.nodeId === s.ip;
return (
<g
key={s.ip}
transform={`translate(${pos.x - NODE_WIDTH / 2}, ${pos.y - NODE_HEIGHT / 2})`}
onMouseDown={(e) => onNodeMouseDown(e, s.ip)}
style={{ cursor: isDragging ? 'grabbing' : 'grab' }}
>
<rect
width={NODE_WIDTH}
height={NODE_HEIGHT}
rx={8}
ry={8}
fill={UNIFI_CARD_BG}
stroke={UNIFI_CARD_BORDER}
strokeWidth={1}
/>
<g transform={`translate(${NODE_WIDTH / 2}, ${NODE_HEIGHT / 2})`}>
{/* Флаг страны в левом верхнем углу карточки (картинка, т.к. эмодзи в SVG часто отображаются как буквы) */}
{s.country && (() => {
const cc = String(s.country).trim().toLowerCase();
if (cc.length !== 2) return null;
const flagW = 22;
const flagH = 16;
return (
<image
href={`${FLAG_CDN}/w40/${cc}.png`}
x={-NODE_WIDTH / 2 + 6}
y={-NODE_HEIGHT / 2 + 6}
width={flagW}
height={flagH}
preserveAspectRatio="xMidYMid meet"
style={{ pointerEvents: 'none' }}
className="network-map-node-flag"
>
<title>{s.country.toUpperCase()}</title>
</image>
);
})()}
{/* Иконка сервера в стиле 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} />
</g>
<text
x={0}
y={8}
textAnchor="middle"
fill={UNIFI_TEXT}
fontSize={12}
fontWeight={600}
>
{label.length > 12 ? label.slice(0, 11) + '…' : label}
</text>
<text
x={0}
y={22}
textAnchor="middle"
fill={UNIFI_TEXT_MUTED}
fontSize={10}
>
{s.ip}
</text>
</g>
</g>
);
})}
</svg>
{selectedEdge && (() => {
const fromServer = servers.find((s) => String(s.ip) === selectedEdge.from);
const toServer = servers.find((s) => String(s.ip) === selectedEdge.to);
const ekey = edgePingKey(selectedEdge.from, selectedEdge.to);
const pingMs = pingMap[ekey];
const pingStale = pingStaleMap[ekey] === true;
const sk = speedKey(selectedEdge.raw?.fromKey, selectedEdge.raw?.toKey);
const speed = speedMap[sk] ?? selectedEdge.speed;
const speedStale = speedStaleMap[sk] === true;
return (
<div
className="network-map-edge-modal-backdrop"
style={{
position: 'absolute',
inset: 0,
background: 'rgba(15,23,42,0.55)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 50,
}}
onClick={() => setSelectedEdge(null)}
>
<div
className="card shadow-lg"
style={{
minWidth: 360,
maxWidth: 520,
background: '#0f172a',
color: '#e5e7eb',
borderRadius: 12,
border: `1px solid ${UNIFI_CARD_BORDER}`,
}}
onClick={(e) => e.stopPropagation()}
>
<div className="card-header d-flex align-items-center justify-content-between">
<div className="fw-semibold">Детали соединения</div>
<button
type="button"
className="btn-close btn-close-white"
aria-label="Закрыть"
onClick={() => setSelectedEdge(null)}
style={{ filter: 'invert(1)' }}
/>
</div>
<div className="card-body" style={{ fontSize: 13 }}>
<div className="mb-2">
<div className="text-muted text-uppercase mb-1" style={{ fontSize: 11 }}>
От
</div>
<div>
<strong>{fromServer?.dns || fromServer?.ip || selectedEdge.from}</strong>{' '}
<span className="text-muted">({fromServer?.ip || '—'})</span>{' '}
<span className="badge bg-blue-lt text-blue ms-1">
{String(fromServer?.type || '').toUpperCase() || 'SERVER'}
</span>
</div>
</div>
<div className="mb-3">
<div className="text-muted text-uppercase mb-1" style={{ fontSize: 11 }}>
К
</div>
<div>
<strong>{toServer?.dns || toServer?.ip || selectedEdge.to}</strong>{' '}
<span className="text-muted">({toServer?.ip || '—'})</span>{' '}
<span className="badge bg-blue-lt text-blue ms-1">
{String(toServer?.type || '').toUpperCase() || 'SERVER'}
</span>
</div>
</div>
<div className="mb-3">
<div className="text-muted text-uppercase mb-1" style={{ fontSize: 11 }}>
Туннель
</div>
<div>
<span className="badge bg-green-lt text-green me-2">
{selectedEdge.tunnelType || 'TUNNEL'}
</span>
{selectedEdge.raw?.internalFromTo && (
<span className="text-muted">
{selectedEdge.raw.internalToFrom} ⇄ {selectedEdge.raw.internalFromTo}
</span>
)}
</div>
</div>
<div className="mb-3">
<div className="text-muted text-uppercase mb-1 d-flex align-items-center" style={{ fontSize: 11 }}>
Пинг
{pingStale && (
<span className="badge bg-warning text-dark ms-2" style={{ fontSize: 9 }}>
<IconClock size={10} className="me-1" />
Устаревшие данные
</span>
)}
</div>
<div>
{typeof pingMs === 'number' ? `${pingMs} ms` : 'нет данных'}
</div>
</div>
{speed && (speed.tcpDownloadBps != null || speed.tcpUploadBps != null) && (
<div className="mb-1">
<div className="text-muted text-uppercase mb-1 d-flex align-items-center" style={{ fontSize: 11 }}>
Скорость по туннелю
{speed.cached && (
<span className="ms-1 badge bg-secondary" style={{ fontSize: 10 }}>кеш</span>
)}
{speedStale && (
<span className="ms-1 badge bg-warning text-dark" style={{ fontSize: 9 }}>
<IconClock size={10} className="me-1" />
Устаревшие данные
</span>
)}
</div>
<div>
<div>
<span className="text-muted">↓ Download </span>
{formatMbps(speed.tcpDownloadBps)}
</div>
<div>
<span className="text-muted">↑ Upload </span>
{formatMbps(speed.tcpUploadBps)}
</div>
{speed.durationSeconds != null && (
<div className="text-muted small mt-1">
Замер за {speed.durationSeconds} сек
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
})()}
<div
style={{
position: 'absolute',
right: 12,
top: 12,
zIndex: 10,
}}
>
<div className="btn-group btn-group-sm">
<button
type="button"
className="btn"
onClick={resetLayout}
style={{
background: 'rgba(30,41,59,0.95)',
color: UNIFI_TEXT_MUTED,
border: `1px solid ${UNIFI_CARD_BORDER}`,
}}
title="Вернуть узлы в исходную раскладку"
>
<IconRefresh size={14} className="me-1" />
Сбросить
</button>
<button
type="button"
className="btn"
onClick={() => {
const el = containerRef.current;
if (!el) return;
if (!document.fullscreenElement) {
el.requestFullscreen?.();
} else {
document.exitFullscreen?.();
}
}}
style={{
background: 'rgba(30,41,59,0.95)',
color: UNIFI_TEXT_MUTED,
border: `1px solid ${UNIFI_CARD_BORDER}`,
borderLeft: 'none',
}}
title={isFullscreen ? 'Выйти из полноэкранного режима' : 'Открыть карту на весь экран'}
>
{isFullscreen ? <IconMinimize size={14} /> : <IconMaximize size={14} />}
</button>
</div>
</div>
</div>
);
}
+415
View File
@@ -0,0 +1,415 @@
import { useState, useEffect } from 'react';
import api from './lib/api.js';
import {
IconPlus,
IconEdit,
IconTrash,
IconDeviceFloppy,
IconCloud,
IconSearch,
} from '@tabler/icons-react';
import PageHeader from './components/PageHeader.jsx';
import FormModal from './components/FormModal.jsx';
import FormField from './components/FormField.jsx';
import ConfirmModal from './components/ConfirmModal.jsx';
import ErrorAlert from './components/ErrorAlert.jsx';
import IconPicker from './components/IconPicker.jsx';
import { getIconById } from './lib/brandIcons.js';
const COLOR_OPTIONS = [
{ value: 'primary', label: 'Основной' },
{ value: 'blue', label: 'Синий' },
{ value: 'green', label: 'Зелёный' },
{ value: 'orange', label: 'Оранжевый' },
{ value: 'red', label: 'Красный' },
{ value: 'pink', label: 'Розовый' },
{ value: 'purple', label: 'Фиолетовый' },
{ value: 'cyan', label: 'Голубой' },
{ value: 'yellow', label: 'Жёлтый' },
];
function slugId(name) {
const s = String(name || '').trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
return s || `svc-${Date.now()}`;
}
export default function PingServicesManager() {
const [items, setItems] = useState([]);
const [rawSettings, setRawSettings] = useState({});
const [etag, setEtag] = useState('');
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [addModalOpen, setAddModalOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
const [newItem, setNewItem] = useState({
id: '',
name: '',
host: '',
port: '443',
icon: 'IconBrandGoogle',
color: 'blue',
});
const [editingItem, setEditingItem] = useState(null);
const [editDraft, setEditDraft] = useState({ id: '', name: '', host: '', port: '443', icon: 'IconBrandGoogle', color: 'blue' });
const loadSettings = async (abortSignal) => {
setLoading(true);
setError('');
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const res = await api.get('/ui-settings', opts);
const data = res?.data || {};
setRawSettings(data);
setEtag(String(res?.headers?.etag || res?.headers?.ETag || ''));
const list = Array.isArray(data.pingServicesList) ? data.pingServicesList : [];
setItems(list.map((s) => ({
id: String(s?.id ?? '').trim() || slugId(s?.name),
name: String(s?.name ?? '').trim(),
host: String(s?.host ?? '').trim(),
port: Math.max(1, parseInt(s?.port, 10) || 443),
icon: typeof s?.icon === 'string' ? s.icon : 'IconWorld',
color: typeof s?.color === 'string' ? s.color : 'primary',
})).filter((s) => s.host));
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
console.error(e);
setError('Не удалось загрузить настройки');
} finally {
setLoading(false);
}
};
useEffect(() => {
const controller = new AbortController();
loadSettings(controller.signal);
return () => controller.abort();
}, []);
const saveSettings = async () => {
setSaving(true);
setError('');
setSuccess('');
const payloadToSave = {
...rawSettings,
pingServicesList: items.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
port: s.port,
icon: s.icon,
color: s.color,
})),
};
try {
let currentEtag = etag;
let res = await api.post('/ui-settings', { settings: payloadToSave, etag: currentEtag });
setRawSettings(payloadToSave);
setEtag(String(res?.data?.etag || currentEtag));
setSuccess('Список сервисов сохранён');
setTimeout(() => setSuccess(''), 3000);
} catch (e) {
if (e?.response?.status === 412) {
try {
const fresh = await api.get('/ui-settings');
const freshData = fresh?.data || {};
const freshEtag = String(fresh?.headers?.etag || fresh?.headers?.ETag || '');
const retryPayload = { ...freshData, pingServicesList: payloadToSave.pingServicesList };
const retryRes = await api.post('/ui-settings', { settings: retryPayload, etag: freshEtag });
setRawSettings(retryPayload);
setEtag(String(retryRes?.data?.etag || freshEtag));
setSuccess('Список сервисов сохранён (настройки были обновлены)');
setTimeout(() => setSuccess(''), 3000);
} catch (retryErr) {
setError(retryErr?.response?.data?.message || 'Настройки изменились. Обновите страницу и сохраните снова.');
}
} else {
setError(e?.response?.data?.message || 'Не удалось сохранить');
}
} finally {
setSaving(false);
}
};
const openAdd = () => {
setNewItem({
id: '',
name: '',
host: '',
port: '443',
icon: 'IconBrandGoogle',
color: 'blue',
});
setAddModalOpen(true);
};
const submitAdd = () => {
const name = String(newItem.name || '').trim();
const host = String(newItem.host || '').trim();
if (!name) { setError('Укажите название'); return; }
if (!host) { setError('Укажите хост'); return; }
const id = newItem.id?.trim() || slugId(name);
if (items.some((i) => i.id === id)) { setError('Такой ID уже есть'); return; }
setError('');
setItems((prev) => [...prev, {
id,
name,
host,
port: Math.max(1, parseInt(newItem.port, 10) || 443),
icon: newItem.icon || 'IconWorld',
color: newItem.color || 'primary',
}]);
setAddModalOpen(false);
setSuccess('Сервис добавлен. Нажмите «Сохранить».');
setTimeout(() => setSuccess(''), 3000);
};
const openEdit = (item) => {
setEditingItem(item);
setEditDraft({
id: item.id,
name: item.name,
host: item.host,
port: String(item.port),
icon: item.icon || 'IconWorld',
color: item.color || 'primary',
});
setEditModalOpen(true);
};
const submitEdit = () => {
const name = String(editDraft.name || '').trim();
const host = String(editDraft.host || '').trim();
if (!name) { setError('Укажите название'); return; }
if (!host) { setError('Укажите хост'); return; }
const id = editDraft.id?.trim() || editingItem.id;
if (id !== editingItem.id && items.some((i) => i.id === id)) { setError('Такой ID уже есть'); return; }
setError('');
setItems((prev) => prev.map((i) => (i.id === editingItem.id ? {
id,
name,
host,
port: Math.max(1, parseInt(editDraft.port, 10) || 443),
icon: editDraft.icon || 'IconWorld',
color: editDraft.color || 'primary',
} : i)));
setEditModalOpen(false);
setEditingItem(null);
setSuccess('Сервис обновлён. Нажмите «Сохранить».');
setTimeout(() => setSuccess(''), 3000);
};
const openDelete = (item) => {
setItemToDelete(item);
setDeleteModalOpen(true);
};
const confirmDelete = () => {
if (itemToDelete) {
setItems((prev) => prev.filter((i) => i.id !== itemToDelete.id));
setDeleteModalOpen(false);
setItemToDelete(null);
setSuccess('Сервис удалён. Нажмите «Сохранить».');
setTimeout(() => setSuccess(''), 3000);
}
};
const [searchTerm, setSearchTerm] = useState('');
const filtered = items.filter((i) => {
const q = searchTerm.trim().toLowerCase();
if (!q) return true;
return (i.name || '').toLowerCase().includes(q) || (i.host || '').toLowerCase().includes(q) || (i.id || '').toLowerCase().includes(q);
});
return (
<div>
<PageHeader
title="Пинг сервисов"
icon={<IconCloud size={24} />}
actions={(
<div className="btn-list">
<button
type="button"
className="btn btn-primary"
onClick={openAdd}
disabled={loading}
>
<IconPlus size={18} className="me-1" />
Добавить
</button>
<button
type="button"
className="btn btn-outline-primary"
onClick={saveSettings}
disabled={saving || loading}
>
{saving && <span className="spinner-border spinner-border-sm me-2" />}
<IconDeviceFloppy size={18} className="me-1" />
Сохранить
</button>
</div>
)}
/>
{error && <ErrorAlert message={error} onClose={() => setError('')} onRetry={loadSettings} />}
{success && (
<div className="alert alert-success alert-dismissible">
{success}
<button type="button" className="btn-close" onClick={() => setSuccess('')} aria-label="Закрыть" />
</div>
)}
<div className="card">
<div className="card-header d-flex align-items-center gap-2 flex-wrap">
<div className="input-group input-group-flat" style={{ maxWidth: 280 }}>
<span className="input-group-text">
<IconSearch size={16} />
</span>
<input
type="text"
className="form-control"
placeholder="Поиск по названию, хосту, ID..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<span className="text-muted small ms-2">
{filtered.length} из {items.length}
</span>
</div>
<div className="table-responsive">
<table className="table table-vcenter card-table table-striped">
<thead>
<tr>
<th style={{ width: 56 }}>Иконка</th>
<th>ID</th>
<th>Название</th>
<th>Хост</th>
<th>Порт</th>
<th>Цвет</th>
<th className="w-1">Действия</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="text-center py-4 text-muted">Загрузка...</td>
</tr>
) : filtered.length === 0 ? (
<tr>
<td colSpan={7} className="text-center py-4 text-muted">
Нет сервисов. Нажмите «Добавить» или сохраните список — на главной будут использоваться сервисы по умолчанию.
</td>
</tr>
) : (
filtered.map((item) => {
const iconInfo = 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} />
</span>
</td>
<td><code className="small">{item.id}</code></td>
<td>{item.name}</td>
<td><code className="small">{item.host}</code></td>
<td>{item.port}</td>
<td>
<span className={`badge bg-${item.color}-lt text-${item.color}`}>{item.color}</span>
</td>
<td>
<div className="btn-list">
<button
type="button"
className="btn btn-sm btn-outline-primary"
onClick={() => openEdit(item)}
>
<IconEdit size={14} />
</button>
<button
type="button"
className="btn btn-sm btn-outline-danger"
onClick={() => openDelete(item)}
>
<IconTrash size={14} />
</button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
<FormModal
show={addModalOpen}
onClose={() => setAddModalOpen(false)}
onSubmit={submitAdd}
title="Добавить сервис для пинга"
submitLabel="Добавить"
>
<FormField label="Название" name="name" value={newItem.name} onChange={(v) => setNewItem((p) => ({ ...p, name: v }))} placeholder="Google" />
<FormField label="Хост (IP или домен)" name="host" value={newItem.host} onChange={(v) => setNewItem((p) => ({ ...p, host: v }))} placeholder="8.8.8.8" />
<FormField label="Порт" name="port" type="number" value={newItem.port} onChange={(v) => setNewItem((p) => ({ ...p, port: v }))} min={1} max={65535} />
<IconPicker value={newItem.icon} onChange={(id) => setNewItem((p) => ({ ...p, icon: id }))} />
<div className="mb-2">
<label className="form-label small">Цвет</label>
<select
className="form-select form-select-sm"
value={newItem.color}
onChange={(e) => setNewItem((p) => ({ ...p, color: e.target.value }))}
>
{COLOR_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<FormField label="ID (необязательно)" name="id" value={newItem.id} onChange={(v) => setNewItem((p) => ({ ...p, id: v }))} placeholder="auto из названия" helpText="Уникальный ключ. Пусто — из названия." />
</FormModal>
<FormModal
show={editModalOpen}
onClose={() => { setEditModalOpen(false); setEditingItem(null); }}
onSubmit={submitEdit}
title="Редактировать сервис"
>
<FormField label="ID" name="id" value={editDraft.id} onChange={(v) => setEditDraft((p) => ({ ...p, id: v }))} />
<FormField label="Название" name="name" value={editDraft.name} onChange={(v) => setEditDraft((p) => ({ ...p, name: v }))} />
<FormField label="Хост" name="host" value={editDraft.host} onChange={(v) => setEditDraft((p) => ({ ...p, host: v }))} />
<FormField label="Порт" name="port" type="number" value={editDraft.port} onChange={(v) => setEditDraft((p) => ({ ...p, port: v }))} min={1} max={65535} />
<IconPicker value={editDraft.icon} onChange={(id) => setEditDraft((p) => ({ ...p, icon: id }))} />
<div className="mb-2">
<label className="form-label small">Цвет</label>
<select
className="form-select form-select-sm"
value={editDraft.color}
onChange={(e) => setEditDraft((p) => ({ ...p, color: e.target.value }))}
>
{COLOR_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
</FormModal>
<ConfirmModal
show={deleteModalOpen}
title="Удалить сервис?"
message={itemToDelete ? `Сервис «${itemToDelete.name}» (${itemToDelete.host}) будет удалён из списка.` : ''}
confirmLabel="Удалить"
variant="danger"
onConfirm={confirmDelete}
onClose={() => { setDeleteModalOpen(false); setItemToDelete(null); }}
/>
</div>
);
}
+241
View File
@@ -0,0 +1,241 @@
import { useState, useEffect, useCallback } from 'react';
import api from './lib/api.js';
import {
IconServer,
IconRefresh,
IconChartBar,
IconAlertCircle,
} from '@tabler/icons-react';
import PageHeader from './components/PageHeader.jsx';
function formatBytes(bytes) {
if (bytes == null || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
/** Цвет прогресс-бара по проценту: зелёный / жёлтый / красный */
function progressVariant(percent) {
if (percent == null) return 'secondary';
if (percent >= 90) return 'danger';
if (percent >= 70) return 'warning';
return 'success';
}
/** Один KPI-блок: заголовок, значение, прогресс-бар (как на референсной карточке) */
function KpiBlock({ label, value, percent, variant }) {
const barVariant = variant || progressVariant(percent);
return (
<div className="mb-3">
<div className="text-uppercase small fw-semibold text-secondary mb-1">{label}</div>
<div className="d-flex align-items-baseline gap-2 flex-wrap">
<span className="h4 mb-0 fw-bold">{value}</span>
{percent != null && (
<span className={`small fw-medium text-${barVariant}`}>{percent}%</span>
)}
</div>
{percent != null && (
<div className="progress progress-sm mt-1" style={{ height: 6 }}>
<div
className={`progress-bar bg-${barVariant}`}
role="progressbar"
style={{ width: `${Math.min(100, percent)}%` }}
aria-valuenow={percent}
aria-valuemin={0}
aria-valuemax={100}
/>
</div>
)}
</div>
);
}
/** Карточка одного роутера: иконка перед названием, сводка, KPI (RAM, CPU, HDD) */
function RouterResourceCard({ router }) {
const { name, host, error, resource } = router;
const hasResource = resource && !error;
return (
<div className="card rounded-3 shadow-sm border-0 overflow-hidden h-100">
<div className="card-body p-4">
<div className="d-flex align-items-center mb-3">
<span className="avatar avatar-lg bg-primary-lt text-primary rounded-3 me-3 flex-shrink-0">
<IconServer size={28} />
</span>
<div className="min-w-0">
<h3 className="h4 fw-bold mb-0 text-body text-truncate">
{name}
</h3>
<p className="text-secondary small mb-0 mt-1">
{host}
{resource?.version && (
<span className="ms-1"> · RouterOS {resource.version}</span>
)}
</p>
</div>
</div>
<div>
{error && (
<div className="alert alert-danger py-2 mb-3 d-flex align-items-center">
<IconAlertCircle size={20} className="me-2 flex-shrink-0" />
<span className="small">{error}</span>
</div>
)}
{hasResource && (
<div className="row g-3">
{/* RAM */}
<div className="col-12 col-md-4">
<KpiBlock
label="RAM"
value={
resource.usedMemory != null && resource.totalMemory != null
? `${formatBytes(resource.usedMemory)} / ${formatBytes(resource.totalMemory)}`
: '—'
}
percent={resource.memoryUsagePercent}
/>
</div>
{/* CPU */}
<div className="col-12 col-md-4">
<KpiBlock
label="CPU"
value={
resource.cpuLoad != null
? `${resource.cpuLoad}%`
: '—'
}
percent={resource.cpuLoad}
/>
</div>
{/* HDD */}
<div className="col-12 col-md-4">
<KpiBlock
label="HDD"
value={
resource.usedHddSpace != null && resource.totalHddSpace != null
? `${formatBytes(resource.usedHddSpace)} / ${formatBytes(resource.totalHddSpace)}`
: resource.totalHddSpace != null
? formatBytes(resource.totalHddSpace)
: '—'
}
percent={resource.hddUsagePercent}
/>
</div>
</div>
)}
{hasResource && (resource.boardName || resource.uptime) && (
<div className="mt-2 pt-2 border-top small text-muted">
{resource.boardName && <span>{resource.boardName}</span>}
{resource.uptime && (
<span className="ms-2"> · Uptime: {resource.uptime}</span>
)}
</div>
)}
</div>
</div>
</div>
);
}
export default function ResourceStatsPage() {
const [routers, setRouters] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchStats = useCallback(async (abortSignal) => {
setLoading(true);
setError(null);
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const res = await api.get('/resources/stats', opts);
const data = res?.data;
setRouters(Array.isArray(data?.routers) ? data.routers : []);
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить статистику');
// Не очищаем список — пользователь видит старые данные и сообщение об ошибке
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
const controller = new AbortController();
fetchStats(controller.signal);
return () => controller.abort();
}, [fetchStats]);
return (
<div className="page-body">
<PageHeader
title="Статистика ресурсов"
pretitle="Роутеры"
icon={<IconChartBar size={28} />}
meta="Потребление RAM, CPU и HDD по каждому роутеру (MikroTik)"
actions={
<button
type="button"
className="btn btn-outline-primary"
onClick={() => fetchStats()}
disabled={loading}
>
<IconRefresh size={18} className={loading ? 'spin' : ''} />
<span className="ms-1">Обновить</span>
</button>
}
/>
{error && (
<div className="alert alert-danger d-flex align-items-center">
<IconAlertCircle size={24} className="me-2" />
<span>{error}</span>
</div>
)}
{loading && routers.length === 0 ? (
<div className="row">
{[1, 2, 3].map((i) => (
<div key={i} className="col-12 col-lg-6 col-xl-4 mb-4">
<div className="card rounded-3 border-0 placeholder-glow">
<div className="card-body p-4">
<div className="placeholder col-6 mb-2" />
<div className="placeholder col-8 mb-3" />
<div className="placeholder col-12" style={{ height: 60 }} />
</div>
</div>
</div>
))}
</div>
) : routers.length === 0 ? (
<div className="card rounded-3 border-0">
<div className="card-body text-center py-5 text-muted">
<IconServer size={48} className="mb-2 opacity-50" />
<p className="mb-0">Нет роутеров с настроенным MikroTik API (jumphost/home).</p>
</div>
</div>
) : (
<div className="row">
{routers.map((router) => (
<div key={router.serverId || router.name} className="col-12 col-lg-6 col-xl-4 mb-4">
<RouterResourceCard router={router} />
</div>
))}
</div>
)}
<style>{`
.spin {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</div>
);
}
+239 -75
View File
@@ -31,8 +31,11 @@ import {
LinkGeneratorModal,
ServerSidebar,
ServerCard,
ServerGroupCard,
ServerBulkActions
} from './components/server/index.js';
import PageHeader from './components/PageHeader.jsx';
import PageHeaderActions from './components/PageHeaderActions.jsx';
const API_URL = '/api';
@@ -52,12 +55,12 @@ function ServerManager() {
// === Основные данные ===
const [servers, setServers] = useState([]);
const [connections, setConnections] = useState([]);
const [networkConfigGateways, setNetworkConfigGateways] = useState([]);
const [loading, setLoading] = useState(false);
// === UI State ===
const [activeTab, setActiveTab] = useState('servers'); // 'servers' | 'connections'
const [viewMode, setViewMode] = useState('cards'); // 'table' | 'cards'
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
// === Фильтры и поиск ===
const [filters, setFilters] = useState({
@@ -124,9 +127,26 @@ function ServerManager() {
};
// === Загрузка данных ===
const fetchNetworkConfig = async (abortSignal) => {
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const res = await api.get('/network-config', opts);
const config = res.data || {};
setNetworkConfigGateways(Array.isArray(config.gateways) ? config.gateways : []);
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
console.warn('Network config not loaded:', e?.message || e);
setNetworkConfigGateways([]);
}
};
useEffect(() => {
fetchServers();
fetchConnections();
const controller = new AbortController();
const signal = controller.signal;
fetchServers(signal);
fetchConnections(signal);
fetchNetworkConfig(signal);
return () => controller.abort();
}, []);
useEffect(() => {
@@ -141,10 +161,11 @@ function ServerManager() {
}
}, []);
const fetchServers = async () => {
const fetchServers = async (abortSignal) => {
setLoading(true);
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const response = await api.get(`/servers`);
const response = await api.get(`/servers`, opts);
const normalized = (Array.isArray(response.data) ? response.data : []).map((srv) => ({
...srv,
type: normalizeServerType(srv.type),
@@ -152,6 +173,7 @@ function ServerManager() {
}));
setServers(normalized);
} catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
console.error('Error fetching servers:', error);
notify.error('Не удалось загрузить серверы. Проверьте, запущен ли бэкенд.');
} finally {
@@ -159,19 +181,47 @@ function ServerManager() {
}
};
const fetchConnections = async () => {
const fetchConnections = async (abortSignal) => {
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const response = await api.get(`/server-connections`);
const response = await api.get(`/server-connections`, opts);
if (Array.isArray(response.data)) {
setConnections(response.data);
} else {
setConnections([]);
}
} catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
console.error('Error fetching server connections:', error);
}
};
// Шлюзы для карточки: из /network-config по serverId, иначе из server.gateways
const getGatewaysForServer = useMemo(() => {
const gatewaysByServerId = {};
(networkConfigGateways || []).forEach((gw) => {
if (!gw.serverId) return;
if (!gatewaysByServerId[gw.serverId]) gatewaysByServerId[gw.serverId] = [];
gatewaysByServerId[gw.serverId].push(gw);
});
return (server) => {
const ids = [server?.id, server?.ip, server?.dns].filter(Boolean);
for (const id of ids) {
const gws = gatewaysByServerId[id];
if (gws?.length > 0) {
return gws.map((gw, idx) => ({
id: gw.id,
name: gw.description || gw.id || gw.ip,
ip: gw.ip,
comment: gw.description,
primary: idx === 0,
}));
}
}
return server?.gateways || [];
};
}, [networkConfigGateways]);
// === Фильтрация и сортировка ===
const filteredServers = useMemo(() => {
let result = [...servers];
@@ -216,9 +266,44 @@ function ServerManager() {
return result;
}, [servers, filters, searchTerm, sortField, sortOrder]);
// Элементы для отображения: серверы с одинаковым groupKey (и type home) объединяются в одну карточку
const displayItems = useMemo(() => {
const keyToServers = {};
filteredServers.forEach((s) => {
const k = (s.groupKey && String(s.groupKey).trim()) || null;
if (k) {
if (!keyToServers[k]) keyToServers[k] = [];
keyToServers[k].push(s);
}
});
const seenKeys = new Set();
const items = [];
for (const s of filteredServers) {
const k = (s.groupKey && String(s.groupKey).trim()) || null;
if (!k) {
items.push({ type: 'server', server: s });
} else {
if (!seenKeys.has(k)) {
seenKeys.add(k);
const group = keyToServers[k];
items.push(group.length >= 2 ? { type: 'group', groupKey: k, servers: group } : { type: 'server', server: group[0] });
}
}
}
return items;
}, [filteredServers]);
// === Пагинация ===
const totalPages = Math.ceil(filteredServers.length / pageSize);
const totalPagesCards = Math.ceil(displayItems.length / pageSize);
const totalPagesTable = Math.ceil(filteredServers.length / pageSize);
const totalPages = viewMode === 'cards' ? totalPagesCards : totalPagesTable;
const paginatedDisplayItems = displayItems.slice((currentPage - 1) * pageSize, currentPage * pageSize);
const paginatedServers = filteredServers.slice((currentPage - 1) * pageSize, currentPage * pageSize);
const totalItemsCount = viewMode === 'cards' ? displayItems.length : filteredServers.length;
useEffect(() => {
if (currentPage > totalPages && totalPages >= 1) setCurrentPage(1);
}, [viewMode, totalPages]);
// === Handlers ===
const handleSort = (field) => {
@@ -247,11 +332,22 @@ function ServerManager() {
setSelectedServers(newSet);
};
const toggleGroupSelection = (servers, selectAll) => {
setSelectedServers((prev) => {
const next = new Set(prev);
servers.forEach((s) => {
if (selectAll) next.add(s.ip);
else next.delete(s.ip);
});
return next;
});
};
const selectAllServers = () => {
if (selectedServers.size === filteredServers.length) {
setSelectedServers(new Set());
} else {
setSelectedServers(new Set(filteredServers.map(s => s.ip)));
setSelectedServers(new Set(filteredServers.map((s) => s.ip)));
}
};
@@ -550,6 +646,12 @@ function ServerManager() {
}
};
const handleRefreshAll = () => {
fetchServers();
fetchConnections();
fetchNetworkConfig();
};
// === Connections ===
const addConnectionInternal = (draft) => {
if (!draft.from || !draft.to || !draft.tunnelType || !draft.ipA || !draft.ipB) {
@@ -586,28 +688,32 @@ function ServerManager() {
return (
<div className="server-manager-layout">
{/* Header */}
<div className="page-header d-print-none mb-3">
<div className="row align-items-center">
<div className="col">
<h2 className="page-title d-flex align-items-center">
<IconServer className="me-2" />
Управление серверами
</h2>
<div className="page-pretitle">
{filteredServers.length} из {servers.length} серверов
{selectedServers.size > 0 && (
<span className="ms-2 text-primary">• {selectedServers.size} выбрано</span>
)}
</div>
</div>
<div className="col-auto ms-auto d-print-none">
<div className="btn-list">
{/* Tabs */}
<div className="btn-group me-2" role="group">
<PageHeader
pretitle="Управление"
title="Серверы"
icon={<IconServer size={24} />}
meta={
<span className="text-muted">
Всего серверов: <strong>{servers.length}</strong>
</span>
}
actions={
<>
<PageHeaderActions
loading={loading}
onRefresh={handleRefreshAll}
disableRefresh={loading}
onExport={exportAllLinks}
disableExport={servers.length === 0}
onSave={handleSaveChanges}
disableSave={loading}
/>
<div className="mt-3 d-flex flex-wrap align-items-center justify-content-end gap-2">
<div className="btn-group" role="group" aria-label="Переключение режимов">
<button
className={`btn ${activeTab === 'servers' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setActiveTab('servers')}
type="button"
>
<IconServer size={16} className="me-1" />
Серверы
@@ -615,59 +721,104 @@ function ServerManager() {
<button
className={`btn ${activeTab === 'connections' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setActiveTab('connections')}
type="button"
>
<IconGitBranch size={16} className="me-1" />
Связи
</button>
</div>
{/* Actions */}
<button type="button" className="btn btn-outline-secondary" onClick={exportAllLinks} disabled={servers.length === 0}>
<IconDownload size={16} className="me-1" /> Экспорт
</button>
<button type="button" className="btn btn-outline-primary" onClick={handleSaveChanges} disabled={loading}>
<IconDatabase size={16} className={loading ? 'spin me-1' : 'me-1'} /> Сохранить
</button>
<button type="button" className="btn btn-primary" onClick={handleOpenAddServerModal}>
<button
type="button"
className="btn btn-primary"
onClick={handleOpenAddServerModal}
>
<IconPlus size={16} className="me-1" /> Добавить
</button>
</div>
</div>
</div>
</div>
</>
}
/>
{/* Main content with sidebar */}
{/* Offcanvas: фильтры (только для вкладки «Серверы») */}
{activeTab === 'servers' && (
<div className="d-flex" style={{ gap: '1rem' }}>
{/* Sidebar */}
{!sidebarCollapsed && (
<ServerSidebar
servers={servers}
filters={filters}
onFiltersChange={(newFilters) => { setFilters(newFilters); setCurrentPage(1); }}
searchTerm={searchTerm}
onSearch={(term) => { setSearchTerm(term); setCurrentPage(1); }}
onReset={handleResetFilters}
/>
)}
{/* Toggle sidebar button */}
<button
className="btn btn-ghost-secondary btn-icon position-absolute"
style={{ left: sidebarCollapsed ? 10 : 290, top: 200, zIndex: 100 }}
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
title={sidebarCollapsed ? 'Показать фильтры' : 'Скрыть фильтры'}
<>
<div
className="offcanvas offcanvas-start"
id="server-filters-offcanvas"
tabIndex={-1}
aria-labelledby="server-filters-offcanvas-label"
style={{ width: '340px', maxWidth: '90vw' }}
>
<IconChevronRight size={16} style={{ transform: sidebarCollapsed ? 'rotate(0deg)' : 'rotate(180deg)', transition: '0.2s' }} />
</button>
<div className="offcanvas-header border-bottom">
<h5 className="offcanvas-title d-flex align-items-center" id="server-filters-offcanvas-label">
<IconFilter size={18} className="text-primary me-2" />
Фильтры
{(Object.values(filters).filter(Boolean).length + (searchTerm ? 1 : 0)) > 0 && (
<span className="badge bg-primary-lt text-primary ms-2">
{Object.values(filters).filter(Boolean).length + (searchTerm ? 1 : 0)}
</span>
)}
</h5>
<div className="d-flex align-items-center gap-1">
{(Object.values(filters).filter(Boolean).length + (searchTerm ? 1 : 0)) > 0 && (
<button
type="button"
className="btn btn-ghost-secondary btn-sm"
onClick={handleResetFilters}
title="Сбросить фильтры"
>
Сбросить
</button>
)}
<button
type="button"
className="btn-close"
data-bs-dismiss="offcanvas"
aria-label="Закрыть"
/>
</div>
</div>
<div className="offcanvas-body px-3 py-0 d-flex flex-column overflow-hidden">
<ServerSidebar
servers={servers}
filters={filters}
onFiltersChange={(newFilters) => { setFilters(newFilters); setCurrentPage(1); }}
searchTerm={searchTerm}
onSearch={(term) => { setSearchTerm(term); setCurrentPage(1); }}
onReset={handleResetFilters}
embedded
/>
</div>
</div>
</>
)}
{/* Servers list */}
{/* Main content */}
{activeTab === 'servers' && (
<div>
<div className="flex-grow-1">
{/* 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">
{/* Открыть фильтры (offcanvas) */}
<button
type="button"
className="btn btn-outline-secondary btn-sm d-flex align-items-center"
data-bs-toggle="offcanvas"
data-bs-target="#server-filters-offcanvas"
aria-controls="server-filters-offcanvas"
title="Фильтры"
>
<IconFilter size={16} className="me-1" />
Фильтры
{(Object.values(filters).filter(Boolean).length + (searchTerm ? 1 : 0)) > 0 && (
<span className="badge bg-primary-lt text-primary ms-1">
{Object.values(filters).filter(Boolean).length + (searchTerm ? 1 : 0)}
</span>
)}
</button>
{/* Select all */}
<button
className="btn btn-outline-secondary btn-sm d-flex align-items-center"
@@ -737,18 +888,31 @@ function ServerManager() {
</div>
</div>
) : viewMode === 'cards' ? (
/* Cards view */
/* Cards view: одиночные серверы и группы (groupKey) */
<div>
{paginatedServers.map(server => (
<ServerCard
key={server.id || server.ip}
server={server}
selected={selectedServers.has(server.ip)}
onSelect={toggleServerSelection}
onEdit={handleEdit}
onDelete={confirmDelete}
/>
))}
{paginatedDisplayItems.map((item) =>
item.type === 'group' ? (
<ServerGroupCard
key={`group-${item.groupKey}`}
groupKey={item.groupKey}
servers={item.servers.map((s) => ({ ...s, gateways: getGatewaysForServer(s) }))}
selectedSet={selectedServers}
onSelect={toggleServerSelection}
onSelectGroup={toggleGroupSelection}
onEdit={handleEdit}
onDelete={confirmDelete}
/>
) : (
<ServerCard
key={item.server.id || item.server.ip}
server={{ ...item.server, gateways: getGatewaysForServer(item.server) }}
selected={selectedServers.has(item.server.ip)}
onSelect={toggleServerSelection}
onEdit={handleEdit}
onDelete={confirmDelete}
/>
)
)}
</div>
) : (
/* Table view */
@@ -837,7 +1001,7 @@ function ServerManager() {
<div className="card mt-3">
<div className="card-footer d-flex align-items-center justify-content-between">
<div className="text-muted">
{(currentPage - 1) * pageSize + 1} – {Math.min(currentPage * pageSize, filteredServers.length)} из {filteredServers.length}
{(currentPage - 1) * pageSize + 1} – {Math.min(currentPage * pageSize, totalItemsCount)} из {totalItemsCount}
</div>
<ul className="pagination m-0">
<li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}>
File diff suppressed because it is too large Load Diff
+391
View File
@@ -0,0 +1,391 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import api from './lib/api.js';
import {
IconChartPie,
IconRefresh,
IconServer,
IconAlertCircle,
} from '@tabler/icons-react';
import {
PieChart,
Pie,
Cell,
ResponsiveContainer,
Tooltip,
} from 'recharts';
import PageHeader from './components/PageHeader.jsx';
/** Палитра в стиле UniFi: зелёные, жёлтые, оранжевые, красные, фиолетовые, серые */
const CHART_COLORS = [
'#2fb344', '#5eba00', '#94c748', '#fab005', '#fd7e14', '#f76707',
'#e64980', '#ae3ec9', '#7950f2', '#5c7cfa', '#748cab', '#868e96',
'#adb5bd', '#ced4da', '#206bc4', '#20c997',
];
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
const DONUT_SIZE = 200;
const DONUT_OUTER = 88;
const DONUT_INNER = 52;
function JumphostCard({ jumphost }) {
const { name, host, error, interfaces } = jumphost;
const hasData = Array.isArray(interfaces) && interfaces.length > 0;
const totalBytes = hasData ? interfaces.reduce((acc, i) => acc + (i.totalBytes || 0), 0) : 0;
const chartData = hasData
? interfaces
.filter((i) => (i.totalBytes || 0) > 0)
.map((i, idx) => ({
name: i.name,
value: i.totalBytes,
rxBytes: i.rxBytes,
txBytes: i.txBytes,
fill: CHART_COLORS[idx % CHART_COLORS.length],
}))
: [];
return (
<div className="card h-100">
<div className="card-header d-flex align-items-center">
<span className="avatar avatar-sm me-2 bg-blue-lt text-blue">
<IconServer size={18} />
</span>
<div className="flex-grow-1 min-w-0">
<h3 className="card-title mb-0 text-truncate" title={name}>
{name || host || 'Jumphost'}
</h3>
{host && (
<div className="text-muted small text-truncate" title={host}>
{host}
</div>
)}
</div>
</div>
<div className="card-body">
{error && (
<div className="alert alert-warning py-2 mb-0 d-flex align-items-center">
<IconAlertCircle className="me-2 flex-shrink-0" size={18} />
<span className="small">{error}</span>
</div>
)}
{!error && !hasData && (
<div className="text-muted text-center py-4 small">
Нет данных по интерфейсам
</div>
)}
{!error && hasData && chartData.length === 0 && (
<div className="text-muted text-center py-4 small">
Все счётчики нулевые
</div>
)}
{!error && chartData.length > 0 && (
<div className="traffic-donut-wrapper d-flex flex-wrap align-items-start gap-3">
<div
className="position-relative flex-shrink-0"
style={{ width: DONUT_SIZE, height: DONUT_SIZE }}
>
<ResponsiveContainer width={DONUT_SIZE} height={DONUT_SIZE}>
<PieChart margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
<Pie
data={chartData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={DONUT_INNER}
outerRadius={DONUT_OUTER}
paddingAngle={0}
stroke="none"
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Pie>
<Tooltip
formatter={(value) => formatBytes(value)}
contentStyle={{ fontSize: '12px' }}
labelFormatter={(name, payload) => {
const p = payload?.[0];
if (!p) return name;
return `${name} — RX: ${formatBytes(p.rxBytes || 0)}, TX: ${formatBytes(p.txBytes || 0)}`;
}}
/>
</PieChart>
</ResponsiveContainer>
<div
className="position-absolute top-50 start-50 translate-middle text-center text-body"
style={{ pointerEvents: 'none', width: '60%' }}
>
<div className="fw-bold lh-1" style={{ fontSize: '1.1rem' }}>
{formatBytes(totalBytes)}
</div>
<div className="text-muted small mt-1" style={{ fontSize: '0.65rem', letterSpacing: '0.02em' }}>
TRAFFIC
</div>
</div>
</div>
<div className="traffic-legend flex-grow-1 min-w-0" style={{ maxWidth: 240 }}>
{chartData.map((item) => (
<div
key={item.name}
className="d-flex align-items-center mb-2 small"
title={`${item.name}: RX ${formatBytes(item.rxBytes)}, TX ${formatBytes(item.txBytes)}`}
>
<span
className="flex-shrink-0 rounded"
style={{
width: 10,
height: 10,
backgroundColor: item.fill,
}}
/>
<span className="ms-2 text-truncate">
{item.name} - {formatBytes(item.value)}
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
/** Ключ jumphost для сопоставления с настройками (как в SettingsPage) */
function getJumphostKey(jh) {
return String(jh?.serverId ?? jh?.host ?? jh?.name ?? '');
}
/** Множество выбранных пар (serverKey + interfaceName). Пустое = учитывать все интерфейсы. */
function getTrafficInterfaceFilter(settings) {
const raw = settings?.trafficInterfaces;
if (!raw || !Array.isArray(raw)) return null;
const pairs = raw
.filter((p) => p && (p.serverKey != null || p.serverId != null) && (p.interfaceName != null || p.name != null))
.map((p) => `${String(p.serverKey ?? p.serverId ?? '')}\n${String(p.interfaceName ?? p.name ?? '')}`);
return pairs.length > 0 ? new Set(pairs) : null;
}
export default function TrafficDashboard() {
const [jumphosts, setJumphosts] = useState([]);
const [uiSettings, setUiSettings] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const interfaceFilter = getTrafficInterfaceFilter(uiSettings);
const fetchStats = useCallback(async (abortSignal) => {
setLoading(true);
setError(null);
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const [statsRes, settingsRes] = await Promise.all([
api.get('/traffic/interface-stats', opts),
api.get('/ui-settings', opts).catch(() => ({ data: {} })),
]);
const data = statsRes?.data || {};
setJumphosts(Array.isArray(data?.jumphosts) ? data.jumphosts : []);
setUiSettings(settingsRes?.data || {});
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
console.error('Traffic stats:', e);
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить статистику');
setJumphosts([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
const controller = new AbortController();
fetchStats(controller.signal);
return () => controller.abort();
}, [fetchStats]);
const filteredJumphosts = useMemo(() => {
if (!interfaceFilter) return jumphosts;
return jumphosts.map((jh) => {
const sk = getJumphostKey(jh);
return {
...jh,
interfaces: Array.isArray(jh.interfaces)
? jh.interfaces.filter(
(i) =>
i.name != null &&
interfaceFilter.has(`${sk}\n${String(i.name).trim()}`)
)
: [],
};
});
}, [jumphosts, interfaceFilter]);
// Суммарная статистика по всем отображаемым системам (без ошибок, с данными)
const summary = (() => {
const withData = (filteredJumphosts || []).filter((jh) => !jh.error && Array.isArray(jh.interfaces) && jh.interfaces.length > 0);
let totalBytes = 0;
let totalRx = 0;
let totalTx = 0;
const byJumphost = [];
for (const jh of withData) {
let jhTotal = 0;
let jhRx = 0;
let jhTx = 0;
for (const i of jh.interfaces) {
jhTotal += i.totalBytes || 0;
jhRx += i.rxBytes || 0;
jhTx += i.txBytes || 0;
}
totalBytes += jhTotal;
totalRx += jhRx;
totalTx += jhTx;
if (jhTotal > 0) {
byJumphost.push({
name: jh.name || jh.host || 'Jumphost',
value: jhTotal,
fill: CHART_COLORS[byJumphost.length % CHART_COLORS.length],
});
}
}
return {
totalBytes,
totalRx,
totalTx,
systemsCount: withData.length,
byJumphost,
};
})();
const hasSummary = summary.systemsCount > 0 && summary.totalBytes > 0;
return (
<div>
<PageHeader
title="Расход трафика"
icon={<IconChartPie size={24} />}
meta="Статистика по интерфейсам MikroTik RouterOS (jumphost)"
actions={
<button
type="button"
className="btn btn-outline-primary"
onClick={() => fetchStats()}
disabled={loading}
>
<IconRefresh className={loading ? 'spin me-2' : 'me-2'} size={18} />
{loading ? 'Загрузка…' : 'Обновить'}
</button>
}
/>
{error && (
<div className="alert alert-danger d-flex align-items-center">
<IconAlertCircle className="me-2" size={20} />
{error}
</div>
)}
{loading && jumphosts.length === 0 && !error && (
<div className="text-center py-5 text-muted">
Загрузка статистики с роутеров…
</div>
)}
{!loading && !error && jumphosts.length === 0 && (
<div className="alert alert-info">
Нет jumphost-серверов с настроенным MikroTik API. Добавьте серверы типа «Jumphost» и укажите учётные данные RouterOS.
</div>
)}
{!loading && jumphosts.length > 0 && (
<>
{hasSummary && (
<div className="card mb-4">
<div className="card-header">
<h3 className="card-title mb-0">Суммарно по всем системам</h3>
</div>
<div className="card-body">
<div className="d-flex flex-wrap align-items-stretch gap-4">
<div className="position-relative flex-shrink-0" style={{ width: 220, height: 220 }}>
<ResponsiveContainer width={220} height={220}>
<PieChart margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
<Pie
data={summary.byJumphost}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={58}
outerRadius={98}
paddingAngle={0}
stroke="none"
>
{summary.byJumphost.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Pie>
<Tooltip formatter={(value) => formatBytes(value)} />
</PieChart>
</ResponsiveContainer>
<div
className="position-absolute top-50 start-50 translate-middle text-center text-body"
style={{ pointerEvents: 'none', width: '55%' }}
>
<div className="fw-bold lh-1" style={{ fontSize: '1.35rem' }}>
{formatBytes(summary.totalBytes)}
</div>
<div className="text-muted small mt-1" style={{ fontSize: '0.7rem', letterSpacing: '0.02em' }}>
TRAFFIC
</div>
</div>
</div>
<div className="d-flex flex-wrap gap-4 align-items-center flex-grow-1">
<div className="traffic-summary-legend">
{summary.byJumphost.map((item) => (
<div key={item.name} className="d-flex align-items-center mb-2 small">
<span
className="flex-shrink-0 rounded"
style={{ width: 10, height: 10, backgroundColor: item.fill }}
/>
<span className="ms-2">
{item.name} - {formatBytes(item.value)}
</span>
</div>
))}
</div>
<div className="d-flex flex-wrap gap-3 ms-md-auto">
<div className="px-3 py-2 rounded bg-blue-lt text-blue">
<span className="d-block small text-muted">Всего трафика</span>
<span className="fw-bold">{formatBytes(summary.totalBytes)}</span>
</div>
<div className="px-3 py-2 rounded bg-green-lt text-green">
<span className="d-block small text-muted">RX</span>
<span className="fw-bold">{formatBytes(summary.totalRx)}</span>
</div>
<div className="px-3 py-2 rounded bg-orange-lt text-orange">
<span className="d-block small text-muted">TX</span>
<span className="fw-bold">{formatBytes(summary.totalTx)}</span>
</div>
<div className="px-3 py-2 rounded bg-azure-lt text-azure">
<span className="d-block small text-muted">Систем</span>
<span className="fw-bold">{summary.systemsCount}</span>
</div>
</div>
</div>
</div>
</div>
</div>
)}
<div className="row row-cards g-3">
{filteredJumphosts.map((jh) => (
<div key={jh.serverId || jh.host || jh.name} className="col-12 col-md-6 col-xl-4">
<JumphostCard jumphost={jh} />
</div>
))}
</div>
</>
)}
</div>
);
}
+473
View File
@@ -0,0 +1,473 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import api from './lib/api.js';
import {
IconClock,
IconRefresh,
IconServer,
IconCircleCheck,
IconCircleX,
IconChartLine,
} from '@tabler/icons-react';
import {
AreaChart,
Area,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import PageHeader from './components/PageHeader.jsx';
import { formatRelative } from './lib/datetime.js';
const DEFAULT_CHECK_INTERVAL_SEC = 120;
const PARALLEL_CHECKS = 4; // количество одновременных проверок
const HISTORY_MAX = 500;
const STORAGE_KEY = 'uptime-monitor-history';
function loadHistoryFromStorage() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') return parsed;
} catch (_) {}
return {};
}
function saveHistoryToStorage(historyMap) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(historyMap));
} catch (_) {}
}
/** Группировка истории по дням: массив { day, dayLabel, incidents } */
function historyToIncidentsByDay(history) {
if (!Array.isArray(history) || history.length === 0) return [];
const byDay = new Map(); // dayKey -> count of down checks
history.forEach(({ ts, ok }) => {
if (ok) return;
const d = new Date(ts);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
byDay.set(key, (byDay.get(key) || 0) + 1);
});
return Array.from(byDay.entries())
.map(([day, incidents]) => ({
day,
dayLabel: new Date(day + 'T12:00:00').toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'short',
}),
incidents,
}))
.sort((a, b) => a.day.localeCompare(b.day));
}
/** История в точки для графика задержек: { time, ts, ms } */
function historyToResponseTimeData(history) {
if (!Array.isArray(history) || history.length === 0) return [];
return history.map(({ ts, ok, ms }) => ({
ts,
time: new Date(ts).toLocaleString('ru-RU', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}),
ms: ok && ms != null ? Math.round(ms) : null,
}));
}
export default function UptimeMonitorPage() {
const [servers, setServers] = useState([]);
const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false);
const [error, setError] = useState(null);
const [statusMap, setStatusMap] = useState({});
/** По serverId: массив { ts, ok, ms } */
const [historyMap, setHistoryMap] = useState(loadHistoryFromStorage);
const [selectedServerId, setSelectedServerId] = useState(null);
const [checkIntervalSeconds, setCheckIntervalSeconds] = useState(DEFAULT_CHECK_INTERVAL_SEC);
/** serverId, по которым сейчас идёт проверка — показываем «Проверка…» в строке (до PARALLEL_CHECKS одновременно) */
const [currentCheckingServerIds, setCurrentCheckingServerIds] = useState([]);
const intervalRef = useRef(null);
const checkingRef = useRef(false);
const jumphosts = useMemo(
() =>
servers.filter((s) =>
['jumphost', 'home'].includes(String(s.type || '').toLowerCase())
),
[servers]
);
const controllerRef = useRef(null);
const fetchServers = useCallback(async (abortSignal) => {
setLoading(true);
setError(null);
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const [serversRes, settingsRes] = await Promise.all([
api.get('/servers', opts),
api.get('/ui-settings', opts).catch(() => ({ data: {} })),
]);
const data = serversRes?.data;
setServers(Array.isArray(data) ? data : []);
const ui = settingsRes?.data || {};
const interval = Math.max(30, Math.min(86400, parseInt(ui.uptimeMonitorIntervalSeconds, 10) || DEFAULT_CHECK_INTERVAL_SEC));
setCheckIntervalSeconds(interval);
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить серверы');
setServers([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
const c = new AbortController();
controllerRef.current = c;
fetchServers(c.signal);
return () => {
c.abort();
controllerRef.current = null;
};
}, [fetchServers]);
// При заходе на страницу подставляем кеш — сразу видны последние проверки
useEffect(() => {
if (jumphosts.length === 0) return;
const signal = controllerRef.current?.signal;
if (!signal) return;
api.get('/uptime/cache', { signal })
.then(({ data }) => {
if (data?.results && typeof data.results === 'object' && Object.keys(data.results).length > 0) {
setStatusMap((prev) => ({ ...prev, ...data.results }));
}
})
.catch((e) => {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
});
}, [jumphosts]);
const checkIntervalMs = checkIntervalSeconds * 1000;
/** Параллельный запуск проверок (до PARALLEL_CHECKS одновременно), результат по мере готовности */
const runChecks = useCallback(async () => {
if (jumphosts.length === 0) return;
if (checkingRef.current) return;
checkingRef.current = true;
setChecking(true);
setCurrentCheckingServerIds([]);
let index = 0;
const getNext = () => {
const i = index++;
return i < jumphosts.length ? jumphosts[i] : null;
};
const checkOne = async () => {
let server;
while ((server = getNext()) != null) {
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;
try {
const { data: checkData } = await api.post('/uptime/check', { serverId });
ok = checkData?.ok === true;
ms = typeof checkData?.ms === 'number' ? checkData.ms : Date.now() - t0;
} catch (_) {
ms = Date.now() - t0;
}
const lastCheckTs = Date.now();
const entry = { ts: lastCheckTs, ok, ms };
setStatusMap((prev) => ({ ...prev, [serverId]: { ok, lastCheckTs } }));
setHistoryMap((prev) => {
const list = Array.isArray(prev[serverId]) ? prev[serverId] : [];
const merged = [...list, entry].slice(-HISTORY_MAX);
return { ...prev, [serverId]: merged };
});
setCurrentCheckingServerIds((prev) => prev.filter((id) => id !== serverId));
}
};
await Promise.all(Array.from({ length: PARALLEL_CHECKS }, checkOne));
setCurrentCheckingServerIds([]);
checkingRef.current = false;
setChecking(false);
}, [jumphosts]);
// Первый запуск проверки через несколько секунд после загрузки (после перезапуска контейнера кеш может быть пустым или устаревшим)
useEffect(() => {
if (jumphosts.length === 0) return;
const t = setTimeout(runChecks, 4000);
return () => clearTimeout(t);
}, [jumphosts.length, runChecks]);
// Автопроверка по интервалу в фоне
useEffect(() => {
if (jumphosts.length === 0) return;
intervalRef.current = setInterval(runChecks, checkIntervalMs);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [jumphosts, runChecks, checkIntervalMs]);
// Сохраняем историю в localStorage при изменении
useEffect(() => {
saveHistoryToStorage(historyMap);
}, [historyMap]);
const selectedServer = selectedServerId
? jumphosts.find((s) => (s.id || s.dns || s.ip) === selectedServerId)
: null;
const selectedHistory = selectedServerId ? historyMap[selectedServerId] || [] : [];
const responseTimeData = useMemo(
() => historyToResponseTimeData(selectedHistory),
[selectedHistory]
);
const incidentsByDay = useMemo(
() => historyToIncidentsByDay(selectedHistory),
[selectedHistory]
);
return (
<div className="page-body">
<div className="container-fluid">
<PageHeader
title="Uptime Monitor"
icon={<IconClock size={28} />}
pretitle="Extra"
meta="Проверка доступности всех Jumphost и домашних роутеров (MikroTik API)"
actions={
<button
type="button"
className="btn btn-primary"
onClick={runChecks}
disabled={jumphosts.length === 0 || checking}
>
<IconRefresh className={checking ? 'spin' : ''} size={18} />
{checking ? ' Проверка…' : ' Проверить все'}
</button>
}
/>
{error && (
<div className="alert alert-danger" role="alert">
{error}
</div>
)}
{!loading && jumphosts.length === 0 && (
<div className="empty">
<div className="empty-icon">
<IconChartLine size={48} />
</div>
<p className="empty-title">Нет Jumphost</p>
<p className="empty-subtitle text-muted">
Добавьте серверы типа «Jumphost» или «Home» с настроенным MikroTik API — здесь будет отображаться их доступность.
</p>
</div>
)}
{!loading && jumphosts.length > 0 && (
<>
<div className="card">
<div className="card-header">
<h3 className="card-title">Доступность Jumphost и домашних роутеров</h3>
<div className="card-actions text-muted small">
Проверка каждые {checkIntervalSeconds} сек · Выберите сервер для графиков
</div>
</div>
<div className="table-responsive">
<table className="table table-vcenter card-table table-striped">
<thead>
<tr>
<th>Сервер</th>
<th>Статус</th>
<th>Последняя проверка</th>
</tr>
</thead>
<tbody>
{jumphosts.map((s) => {
const id = s.id || s.dns || s.ip;
const status = statusMap[id];
const label = s.dns || s.name || s.ip || id;
const isSelected = selectedServerId === id;
return (
<tr
key={id}
role="button"
tabIndex={0}
onClick={() => setSelectedServerId(id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setSelectedServerId(id);
}
}}
className={isSelected ? 'table-active' : ''}
style={{ cursor: 'pointer' }}
>
<td>
<div className="d-flex align-items-center">
<IconServer size={18} className="me-2 text-muted" />
{label}
</div>
</td>
<td>
{currentCheckingServerIds.includes(id) && (
<span className="badge bg-blue-lt text-blue">
<span className="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true" />
Проверка…
</span>
)}
{!currentCheckingServerIds.includes(id) && status == null && (
<span className="badge bg-secondary">—</span>
)}
{!currentCheckingServerIds.includes(id) && status?.ok === true && (
<span className="badge bg-success-lt text-success">
<IconCircleCheck size={14} /> Доступен
</span>
)}
{!currentCheckingServerIds.includes(id) && status?.ok === false && (
<span className="badge bg-danger-lt text-danger">
<IconCircleX size={14} /> Недоступен
</span>
)}
</td>
<td className="text-muted">
{currentCheckingServerIds.includes(id)
? '…'
: status?.lastCheckTs
? formatRelative(status.lastCheckTs)
: '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{selectedServer && (
<div className="row mt-4">
<div className="col-lg-6 mb-4">
<div className="card h-100">
<div className="card-header">
<h3 className="card-title">Время ответа за период</h3>
<div className="card-actions text-muted small">
{selectedServer.dns || selectedServer.name || selectedServer.ip}
</div>
</div>
<div className="card-body">
{responseTimeData.length === 0 ? (
<p className="text-muted mb-0">
Нет данных. Проверки накапливаются со временем (хранятся в браузере).
</p>
) : (
<ResponsiveContainer width="100%" height={260}>
<AreaChart
data={responseTimeData}
margin={{ top: 8, right: 16, left: 8, bottom: 8 }}
>
<defs>
<linearGradient id="colorMs" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#206bc4" stopOpacity={0.3} />
<stop offset="95%" stopColor="#206bc4" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e9ecef" />
<XAxis
dataKey="time"
tick={{ fontSize: 11 }}
interval="preserveStartEnd"
/>
<YAxis
unit=" мс"
tick={{ fontSize: 11 }}
domain={[0, (dataMax) => Math.max(dataMax || 0, 100)]}
/>
<Tooltip
formatter={(value) => [value != null ? `${value} мс` : '—', 'Задержка']}
labelFormatter={(_, payload) =>
payload[0]?.payload?.time ?? ''
}
/>
<Area
type="monotone"
dataKey="ms"
stroke="#206bc4"
fill="url(#colorMs)"
strokeWidth={2}
connectNulls={false}
dot={false}
activeDot={{ r: 4 }}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
</div>
</div>
<div className="col-lg-6 mb-4">
<div className="card h-100">
<div className="card-header">
<h3 className="card-title">Инциденты по дням</h3>
<div className="card-actions text-muted small">
Недоступность (кол-во проверок)
</div>
</div>
<div className="card-body">
{incidentsByDay.length === 0 ? (
<p className="text-muted mb-0">
Нет инцидентов за сохранённый период или нет данных.
</p>
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart
data={incidentsByDay}
margin={{ top: 8, right: 16, left: 8, bottom: 8 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e9ecef" />
<XAxis
dataKey="dayLabel"
tick={{ fontSize: 11 }}
interval="preserveStartEnd"
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 11 }}
domain={[0, (dataMax) => Math.max(dataMax || 0, 1)]}
/>
<Tooltip
formatter={(value) => [value, 'Инцидентов']}
labelFormatter={(_, payload) =>
payload[0]?.payload?.day ?? ''
}
/>
<Bar
dataKey="incidents"
fill="#d63939"
radius={[2, 2, 0, 0]}
name="Инциденты"
/>
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}
+262
View File
@@ -0,0 +1,262 @@
import { useState, useEffect } from 'react';
import FormModal from './FormModal.jsx';
import FormField from './FormField.jsx';
import CommunityAutocompleteInput from './CommunityAutocompleteInput.jsx';
import { IconPlus, IconWorld, IconNetwork, IconHash } from '@tabler/icons-react';
/**
* Универсальное модальное окно для добавления записи (домен/IP-диапазон/ASN)
*
* @param {Object} props
* @param {boolean} props.open - Открыто ли модальное окно
* @param {function} props.onClose - Закрытие модального окна
* @param {function} props.onSubmit - Обработчик добавления (item) => void
* @param {string} props.type - Тип записи: 'domain' | 'ipRange' | 'asn'
* @param {Array} props.communities - Список community для автодополнения
* @param {boolean} props.loading - Состояние загрузки
*/
export default function AddItemModal({
open,
onClose,
onSubmit,
type = 'domain',
communities = [],
loading = false
}) {
const [formData, setFormData] = useState({ value: '', community: '' });
const [errors, setErrors] = useState({ value: '', community: '' });
// Сброс формы при закрытии
useEffect(() => {
if (!open) {
setFormData({ value: '', community: '' });
setErrors({ value: '', community: '' });
}
}, [open]);
const validators = {
domain: (v) => {
const val = String(v).trim().toLowerCase();
return /^([a-z0-9-]+\.)+[a-z]{2,}$/i.test(val);
},
ipRange: (v) => {
const val = String(v).trim();
const parts = val.split('/');
if (parts.length !== 2) return false;
const [ip, mask] = parts;
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) return false;
const octets = ip.split('.');
if (!octets.every(o => /^\d{1,3}$/.test(o) && Number(o) >= 0 && Number(o) <= 255)) return false;
if (!/^\d{1,2}$/.test(mask)) return false;
const m = Number(mask);
return m >= 0 && m <= 32;
},
asn: (v) => {
const val = String(v).trim();
return /^[0-9]+$/.test(val);
}
};
const isValidCommunity = (v) => /^(\d+|\d+:\d+)$/.test(String(v).trim());
const validate = () => {
const valueValid = validators[type]?.(formData.value);
const communityValid = isValidCommunity(formData.community);
setErrors({
value: valueValid ? '' : getValueError(),
community: communityValid ? '' : 'Неверный формат community (N или N:N)'
});
return valueValid && communityValid;
};
const getValueError = () => {
switch (type) {
case 'domain':
return 'Неверный формат домена (sub.domain.tld)';
case 'ipRange':
return 'Неверный формат IP-диапазона (A.B.C.D/Mask)';
case 'asn':
return 'Неверный формат ASN (только цифры, например 12345)';
default:
return 'Неверный формат';
}
};
const getValueLabel = () => {
switch (type) {
case 'domain':
return 'Домен';
case 'ipRange':
return 'IP-диапазон';
case 'asn':
return 'ASN';
default:
return 'Значение';
}
};
const getValuePlaceholder = () => {
switch (type) {
case 'domain':
return 'example.com';
case 'ipRange':
return '192.168.1.0/24';
case 'asn':
return '12345';
default:
return '';
}
};
const getValueHelp = () => {
switch (type) {
case 'domain':
return 'Формат: sub.domain.tld (например foo.example.org)';
case 'ipRange':
return 'Формат: A.B.C.D/Mask, например 10.0.0.0/24';
case 'asn':
return 'Формат: только цифры, например 12345';
default:
return '';
}
};
const getValueIcon = () => {
switch (type) {
case 'domain':
return IconWorld;
case 'ipRange':
return IconNetwork;
case 'asn':
return IconHash;
default:
return IconHash;
}
};
const getTitle = () => {
switch (type) {
case 'domain':
return 'Добавить домен';
case 'ipRange':
return 'Добавить IP-диапазон';
case 'asn':
return 'Добавить ASN';
default:
return 'Добавить запись';
}
};
const handleSubmit = (e) => {
e.preventDefault();
if (!validate()) return;
const normalizedValue = type === 'domain'
? formData.value.trim().toLowerCase()
: formData.value.trim();
const item = {
[type === 'domain' ? 'domain' : type === 'ipRange' ? 'ipRange' : 'asn']: normalizedValue,
community: String(formData.community).trim()
};
onSubmit?.(item);
onClose?.();
};
const ValueIcon = getValueIcon();
return (
<FormModal
show={open}
onClose={onClose}
onSubmit={handleSubmit}
title={getTitle()}
submitLabel="Добавить"
submitIcon={IconPlus}
loading={loading}
size="md"
centered
>
<FormField
label={getValueLabel()}
name="value"
type="text"
value={formData.value}
onChange={(v) => {
setFormData({ ...formData, value: v });
if (errors.value) setErrors({ ...errors, value: '' });
}}
onValidate={(v) => ({
valid: validators[type]?.(v) ?? false,
message: validators[type]?.(v) ? 'Корректный формат' : getValueError()
})}
placeholder={getValuePlaceholder()}
helpText={getValueHelp()}
disabled={loading}
autoFocus
icon={ValueIcon}
/>
<div className="mb-3">
<label className="form-label">
Community
<span className="text-danger ms-1" aria-label="обязательное поле">*</span>
</label>
<div className="input-icon">
<span className="input-icon-addon">
<IconHash size={18} />
</span>
<CommunityAutocompleteInput
value={formData.community}
onChange={(v) => {
setFormData({ ...formData, community: v });
if (errors.community) setErrors({ ...errors, community: '' });
}}
communities={communities}
placeholder="# Начните вводить номер или имя"
className={`form-control${errors.community ? ' is-invalid' : isValidCommunity(formData.community) ? ' is-valid' : ''}`}
useShortValue={true}
disabled={loading}
/>
</div>
{errors.community && (
<div className="invalid-feedback d-block">{errors.community}</div>
)}
{isValidCommunity(formData.community) && !errors.community && (
<div className="valid-feedback d-block">Корректный формат</div>
)}
{!errors.community && !isValidCommunity(formData.community) && (
<div className="form-text text-muted small">
Формат: N или N:N (например 65000:100)
</div>
)}
</div>
{(() => {
if (!formData.community) return null;
const match = communities.find(c => c.value === String(formData.community).trim());
if (!match) return null;
return (
<div className="alert alert-info alert-dismissible mt-2 mb-0" role="alert">
<div className="d-flex">
<div className="flex-grow-1">
<strong>{match.name || match.value}</strong>
{match.description && <div className="text-muted small mt-1">{match.description}</div>}
{match.tags && match.tags.length > 0 && (
<div className="mt-2">
{match.tags.map((tag, idx) => (
<span key={idx} className="badge bg-blue-lt text-blue me-1">{tag}</span>
))}
</div>
)}
</div>
</div>
</div>
);
})()}
</FormModal>
);
}
+57 -104
View File
@@ -1,10 +1,12 @@
import { IconTrash, IconEdit, IconDownload, IconX, IconChecks } from '@tabler/icons-react'
import { IconTrash, IconEdit, IconDownload } from '@tabler/icons-react';
import FloatingBulkActionsBar from './FloatingBulkActionsBar.jsx';
/**
* Панель массовых действий для таблиц
* Показывается когда выбраны элементы
* Панель массовых действий для таблиц (всплывающая, как на скрине).
* Показывается когда выбраны элементы. Использует единый дизайн тёмной плавающей панели.
* «Выбрать все» остаётся в тулбаре/шапке таблицы (чекбокс).
*/
function BulkActionsBar({
function BulkActionsBar({
selectedCount = 0,
totalCount = 0,
onSelectAll = () => {},
@@ -12,109 +14,60 @@ function BulkActionsBar({
onDelete = null,
onEdit = null,
onExport = null,
customActions = [], // [{icon: Icon, label, onClick, variant}]
className = ''
customActions = [],
className = '',
}) {
if (selectedCount === 0) return null
const actions = [];
const allSelected = selectedCount === totalCount
if (onExport) {
actions.push({
type: 'button',
label: 'Экспорт',
icon: IconDownload,
onClick: onExport,
title: 'Экспортировать выбранное',
});
}
if (onEdit) {
actions.push({
type: 'button',
label: 'Изменить',
icon: IconEdit,
onClick: onEdit,
title: 'Редактировать выбранное',
});
}
customActions.forEach((action) => {
actions.push({
type: 'button',
label: action.label,
icon: action.icon,
onClick: action.onClick,
variant: action.variant === 'btn-outline-danger' || action.variant === 'danger' ? 'danger' : undefined,
title: action.title,
});
});
if (onDelete) {
actions.push({
type: 'button',
label: 'Удалить',
icon: IconTrash,
onClick: onDelete,
variant: 'danger',
title: 'Удалить выбранное',
});
}
return (
<div className={`card mb-3 ${className}`} style={{
position: 'sticky',
top: '10px',
zIndex: 10,
boxShadow: '0 0.5rem 1rem rgba(0,0,0,.15)'
}}>
<div className="card-body py-2 px-3">
<div className="d-flex align-items-center justify-content-between">
{/* Левая часть: информация о выборе */}
<div className="d-flex align-items-center gap-3">
<div className="d-flex align-items-center">
<IconChecks size={20} className="text-blue me-2" />
<span className="fw-bold">
Выбрано: <span className="text-blue">{selectedCount}</span>
</span>
</div>
{/* Кнопка выбрать все / снять выбор */}
{!allSelected && totalCount > selectedCount && (
<button
className="btn btn-sm btn-outline-primary"
onClick={onSelectAll}
>
Выбрать все ({totalCount})
</button>
)}
<button
className="btn btn-sm btn-ghost-secondary"
onClick={onDeselectAll}
>
<IconX size={16} className="me-1" />
Снять выбор
</button>
</div>
{/* Правая часть: действия */}
<div className="d-flex align-items-center gap-2">
{/* Экспорт */}
{onExport && (
<button
className="btn btn-sm btn-outline-primary"
onClick={onExport}
title="Экспортировать выбранное"
>
<IconDownload size={16} className="me-1" />
Экспорт
</button>
)}
{/* Редактирование */}
{onEdit && (
<button
className="btn btn-sm btn-outline-primary"
onClick={onEdit}
title="Редактировать выбранное"
>
<IconEdit size={16} className="me-1" />
Изменить
</button>
)}
{/* Пользовательские действия */}
{customActions.map((action, idx) => {
const Icon = action.icon
return (
<button
key={idx}
className={`btn btn-sm ${action.variant || 'btn-outline-primary'}`}
onClick={action.onClick}
title={action.title}
>
{Icon && <Icon size={16} className="me-1" />}
{action.label}
</button>
)
})}
{/* Удаление */}
{onDelete && (
<button
className="btn btn-sm btn-outline-danger"
onClick={onDelete}
title="Удалить выбранное"
>
<IconTrash size={16} className="me-1" />
Удалить ({selectedCount})
</button>
)}
</div>
</div>
</div>
</div>
)
<FloatingBulkActionsBar
selectedCount={selectedCount}
onClearSelection={onDeselectAll}
actions={actions}
/>
);
}
export default BulkActionsBar
export default BulkActionsBar;
+12 -1
View File
@@ -10,7 +10,12 @@ import {
IconDatabase,
IconCreditCard,
IconDownload,
IconKeyboard
IconKeyboard,
IconChartPie,
IconSettings,
IconClock,
IconClockPlay,
IconShield
} from '@tabler/icons-react'
/**
@@ -38,14 +43,20 @@ function CommandPalette() {
// Список команд
const commands = [
{ icon: IconHome, label: 'Главная', description: 'Панель управления', action: () => navigate('/dashboard'), keywords: ['главная', 'панель', 'dashboard'] },
{ icon: IconChartPie, label: 'Расход трафика', description: 'Статистика по интерфейсам MikroTik', action: () => navigate('/traffic'), keywords: ['трафик', 'traffic', 'mikrotik', 'интерфейсы'] },
{ icon: IconNetwork, label: 'Карта сети', description: 'Граф серверов и пинг между ними', action: () => navigate('/network-map'), keywords: ['карта', 'сеть', 'network', 'map', 'пинг', 'ping'] },
{ icon: IconClock, label: 'Uptime Monitor', description: 'Мониторинг доступности по ping с выбранного роутера', action: () => navigate('/uptime-monitor'), keywords: ['uptime', 'мониторинг', 'доступность', 'ping', 'роутер'] },
{ icon: IconClockPlay, label: 'Планировщик карты сети', description: 'Пинг и скорость по расписанию, логи', action: () => navigate('/scheduler'), keywords: ['планировщик', 'scheduler', 'карта', 'сеть', 'логи'] },
{ icon: IconWorld, label: 'Домены', description: 'Управление доменами', action: () => navigate('/domains'), keywords: ['домены', 'domains'] },
{ icon: IconNetwork, label: 'IP-диапазоны', description: 'Управление IP диапазонами', action: () => navigate('/ip-ranges'), keywords: ['ip', 'диапазоны', 'ranges'] },
{ icon: IconNetwork, label: 'ASN', description: 'Управление Autonomous Systems', action: () => navigate('/asns'), keywords: ['asn', 'as', 'autonomous'] },
{ icon: IconFilter, label: 'Community', description: 'Справочник BGP Community', action: () => navigate('/communities'), keywords: ['community', 'справочник'] },
{ icon: IconServer, label: 'Серверы', description: 'Управление серверами', action: () => navigate('/servers'), keywords: ['серверы', 'servers'] },
{ icon: IconFilter, label: 'Фильтры', description: 'Filter Manager', action: () => navigate('/filters'), keywords: ['фильтры', 'filters', 'mikrotik'] },
{ icon: IconShield, label: 'Firewall', description: 'Списки блокировки ban и ban_counter с роутера', action: () => navigate('/firewall'), keywords: ['firewall', 'ban', 'address-list', 'блокировка', 'роутер'] },
{ icon: IconCreditCard, label: 'Биллинг', description: 'Управление биллингом', action: () => navigate('/billing'), keywords: ['биллинг', 'billing', 'оплата'] },
{ icon: IconDownload, label: 'Авто-URL', description: 'Генератор ссылок', action: () => navigate('/auto-urls'), keywords: ['url', 'ссылки', 'генератор'] },
{ icon: IconSettings, label: 'Настройки интерфейса', description: 'WebSocket, DoH, пинг, PTR зона', action: () => navigate('/settings'), keywords: ['настройки', 'settings', 'интерфейс', 'doh', 'websocket'] },
]
// Фильтрация команд по поисковому запросу
+29 -12
View File
@@ -34,6 +34,9 @@ import Tooltip from './Tooltip.jsx';
* - [{ icon: Component, label: string, onClick: (item) => void, variant?: string }]
* @param {boolean} props.selectable - Включить чекбоксы выбора (default: true)
* @param {number} props.skeletonRows - Количество строк скелетона (default: 10)
* @param {boolean} props.striped - Чередование фона строк (Tabler UX, default: true)
* @param {boolean} props.compact - Уменьшенные отступы ячеек (default: false)
* @param {boolean} props.flat - Таблица без карточной подложки: только горизонтальные разделители (default: false)
*/
function DataTable({
columns = [],
@@ -56,11 +59,14 @@ function DataTable({
actions = [],
selectable = true,
skeletonRows = 10,
striped = true,
compact = false,
flat = false,
}) {
const hasActions = onEdit || onDelete || onCopy || actions.length > 0;
const isEditing = (item) => inlineEdit && item[itemKey] === inlineEdit.editingKey;
// Рендер заголовка колонки
// Рендер заголовка колонки (col.title — подпись, col.tooltip — подсказка для UX)
const renderColumnHeader = (col) => {
const isSortable = col.sortable !== false && onSort;
const isActive = sortField === col.key;
@@ -77,22 +83,27 @@ function DataTable({
</div>
);
const thProps = {
key: col.key,
scope: 'col',
style: col.width ? { width: col.width } : undefined,
};
if (isSortable) {
return (
<th
key={col.key}
<th
{...thProps}
className="cursor-pointer user-select-none"
onClick={() => onSort(col.key)}
style={col.width ? { width: col.width } : undefined}
>
{content}
{col.tooltip ? <Tooltip content={col.tooltip} position="top">{content}</Tooltip> : content}
</th>
);
}
return (
<th key={col.key} style={col.width ? { width: col.width } : undefined}>
{content}
<th {...thProps}>
{col.tooltip ? <Tooltip content={col.tooltip} position="top">{content}</Tooltip> : content}
</th>
);
};
@@ -210,15 +221,21 @@ function DataTable({
}
const allSelected = items.length > 0 && items.every(i => selectedItems.has(i[itemKey]));
const tableClass = [
'table table-vcenter table-nowrap mb-0',
flat ? 'table-flat' : 'card-table',
!flat && striped && 'table-striped',
compact && 'table-sm',
].filter(Boolean).join(' ');
return (
<>
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<table className={tableClass} role="grid">
<thead>
<tr>
{selectable && (
<th style={{ width: '40px' }}>
<th scope="col" style={{ width: '40px' }}>
<input
type="checkbox"
className="form-check-input"
@@ -229,7 +246,7 @@ function DataTable({
</th>
)}
{columns.map(renderColumnHeader)}
{hasActions && <th className="text-end">Действия</th>}
{hasActions && <th scope="col" className="text-end td-actions">Действия</th>}
</tr>
</thead>
<tbody>
@@ -261,7 +278,7 @@ function DataTable({
</td>
))}
{hasActions && (
<td className="text-end">
<td className="text-end td-actions">
{renderActions(item)}
</td>
)}
@@ -0,0 +1,185 @@
import { IconX } from '@tabler/icons-react';
/**
* Всплывающая панель массовых действий в стиле со скрина:
* тёмная панель по центру снизу, счётчик "N выбрано", кнопки действий, закрыть.
* Используется на страницах типа Manager при выборе элементов.
*
* @param {number} selectedCount - количество выбранных элементов
* @param {() => void} onClearSelection - снять выделение (кнопка X)
* @param {Array<{ type: 'button', label: string, icon: React.ComponentType, onClick: () => void, variant?: 'danger', title?: string } | { type: 'dropdown', label: string, icon: React.ComponentType, items: Array<{ label: string, onClick: () => void }>, title?: string }>} actions - кнопки и выпадающие меню
*/
function FloatingBulkActionsBar({ selectedCount = 0, onClearSelection, actions = [] }) {
if (selectedCount === 0) return null;
return (
<div
className="floating-bulk-actions-bar"
style={{
position: 'fixed',
bottom: '20px',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1050,
maxWidth: '720px',
width: '100%',
padding: '0 12px',
boxSizing: 'border-box',
}}
>
<div
className="d-flex align-items-center justify-content-between gap-3 rounded-3 shadow-lg px-3 py-2"
style={{
animation: 'floatingBulkSlideUp 0.2s ease',
background: 'linear-gradient(135deg, #1e293b 0%, #334155 100%)',
border: '1px solid rgba(255,255,255,0.1)',
}}
>
{/* Счётчик выбранных */}
<div className="d-flex align-items-center gap-2">
<span
className="rounded"
style={{
background: '#3b82f6',
color: '#fff',
padding: '6px 10px',
fontWeight: 600,
fontSize: '0.95rem',
}}
>
{selectedCount}
</span>
<span style={{ color: '#e2e8f0', fontWeight: 500 }}>выбрано</span>
</div>
{/* Действия */}
<div className="d-flex align-items-center gap-2">
{actions.map((action, idx) => {
if (action.type === 'dropdown') {
const Icon = action.icon;
return (
<div key={idx} className="dropdown">
<button
type="button"
className="floating-bulk-btn dropdown-toggle"
data-bs-toggle="dropdown"
title={action.title}
>
{Icon && <Icon size={15} />}
<span>{action.label}</span>
</button>
<div className="dropdown-menu dropdown-menu-dark">
{action.items?.map((item, i) => (
<button
key={i}
type="button"
className="dropdown-item"
onClick={() => item.onClick()}
>
{item.label}
</button>
))}
</div>
</div>
);
}
const Icon = action.icon;
const isDanger = action.variant === 'danger';
return (
<button
key={idx}
type="button"
className={`floating-bulk-btn ${isDanger ? 'floating-bulk-btn-danger' : ''}`}
onClick={action.onClick}
title={action.title}
>
{Icon && <Icon size={15} />}
<span>{action.label}</span>
</button>
);
})}
{actions.length > 0 && (
<div
style={{
width: 1,
height: 20,
background: 'rgba(255,255,255,0.2)',
margin: '0 4px',
}}
/>
)}
<button
type="button"
className="floating-bulk-btn-close"
onClick={onClearSelection}
title="Снять выделение"
aria-label="Снять выделение"
>
<IconX size={16} />
</button>
</div>
</div>
<style>{`
@keyframes floatingBulkSlideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.floating-bulk-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.1);
color: #e2e8f0;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.floating-bulk-btn:hover {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
.floating-bulk-btn-danger {
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
}
.floating-bulk-btn-danger:hover {
background: rgba(239, 68, 68, 0.4);
color: #fef2f2;
}
.floating-bulk-btn-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.05);
color: #94a3b8;
cursor: pointer;
transition: all 0.15s ease;
}
.floating-bulk-btn-close:hover {
background: rgba(255, 255, 255, 0.15);
color: #fff;
}
`}</style>
</div>
);
}
export default FloatingBulkActionsBar;
@@ -10,6 +10,8 @@ function GatewayAutocompleteInput({
onChange,
gateways = [],
interfaces = [],
templateGateways = [], // Gateways из шаблона, которые еще не созданы
serverId = null, // ID сервера для фильтрации интерфейсов и gateways
excludeGatewayId = null, // ID gateway, который нужно исключить (текущий редактируемый)
placeholder = '',
className = 'form-control',
@@ -25,9 +27,16 @@ function GatewayAutocompleteInput({
const allItems = useMemo(() => {
const items = [];
// Добавляем прямые gateway
gateways
.filter(g => g.id !== excludeGatewayId && g.type === 'direct' && g.ip)
// Добавляем прямые gateway из шаблона (которые еще не созданы)
// Фильтруем по serverId, если указан
templateGateways
.filter(g => {
if (g.id === excludeGatewayId) return false;
if (g.type !== 'direct' || !g.ip) return false;
// Если указан serverId, фильтруем по нему
if (serverId && g.serverId !== serverId) return false;
return true;
})
.forEach(g => {
items.push({
type: 'gateway',
@@ -36,13 +45,46 @@ function GatewayAutocompleteInput({
label: g.ip,
description: g.description || '',
ip: g.ip,
isTemplate: true, // Помечаем как шаблонный gateway
search: [g.ip, g.description, 'gateway', 'template'].filter(Boolean).join(' ').toLowerCase(),
});
});
// Добавляем прямые gateway (уже созданные)
// Фильтруем по serverId, если указан
gateways
.filter(g => {
if (g.id === excludeGatewayId) return false;
if (g.type !== 'direct' || !g.ip) return false;
// Если указан serverId, фильтруем по нему
if (serverId && g.serverId !== serverId) return false;
return true;
})
.forEach(g => {
items.push({
type: 'gateway',
id: g.id,
value: g.id,
label: g.ip,
description: g.description || '',
ip: g.ip,
isTemplate: false,
search: [g.ip, g.description, 'gateway'].filter(Boolean).join(' ').toLowerCase(),
});
});
// Добавляем интерфейсы
// Фильтруем по serverId, если указан (интерфейс должен принадлежать серверу)
interfaces
.filter(i => i.remoteIp)
.filter(i => {
if (!i.remoteIp) return false;
// Если указан serverId, фильтруем интерфейсы, которые принадлежат этому серверу
if (serverId) {
// Интерфейс принадлежит серверу, если serverId === i.serverId или serverId === i.serverId2
return i.serverId === serverId || i.serverId2 === serverId;
}
return true;
})
.forEach(i => {
items.push({
type: 'interface',
@@ -57,7 +99,7 @@ function GatewayAutocompleteInput({
});
return items;
}, [gateways, interfaces, excludeGatewayId]);
}, [gateways, interfaces, templateGateways, excludeGatewayId, serverId]);
const suggestions = useMemo(() => {
const q = String(value || '').toLowerCase();
@@ -182,16 +224,17 @@ function GatewayAutocompleteInput({
onMouseEnter={() => setActiveIndex(globalIdx)}
>
<div className="d-flex align-items-start">
<span className="avatar me-2 bg-green-lt text-green border-0" style={{ width: 24, height: 24 }}>
<span className={`avatar me-2 ${s.isTemplate ? 'bg-yellow-lt text-yellow' : 'bg-green-lt text-green'} border-0`} style={{ width: 24, height: 24 }}>
<IconWorld size={14} />
</span>
<div className="flex-fill text-start">
<div className="fw-medium">
<code>{s.label}</code>
{s.description && <span className="text-muted ms-2">({s.description})</span>}
{s.isTemplate && <span className="badge bg-yellow-lt text-yellow ms-2" style={{ fontSize: '0.7rem' }}>Шаблон</span>}
</div>
<div className="text-muted small text-truncate" style={{ maxWidth: '100%' }}>
Gateway
Gateway {s.isTemplate ? '(из шаблона)' : ''}
</div>
</div>
</div>
+105
View File
@@ -0,0 +1,105 @@
import { useState, useMemo, useRef, useEffect } from 'react';
import BRAND_ICONS, { getIconById } from '../lib/brandIcons.js';
import { IconSearch } from '@tabler/icons-react';
/**
* Выбор иконки бренда с поиском и предпросмотром.
* value — id иконки (например IconBrandGoogle), onChange(id) — при выборе.
*/
export default function IconPicker({ value, onChange, disabled = false, label = 'Иконка бренда' }) {
const [search, setSearch] = useState('');
const [open, setOpen] = useState(false);
const selected = getIconById(value);
const filtered = useMemo(() => {
const q = (search || '').trim().toLowerCase();
if (!q) return BRAND_ICONS;
return BRAND_ICONS.filter(
(item) =>
item.label.toLowerCase().includes(q) || item.id.toLowerCase().includes(q)
);
}, [search]);
const handleSelect = (item) => {
onChange?.(item.id);
setOpen(false);
setSearch('');
};
const panelRef = useRef(null);
useEffect(() => {
if (!open) return;
const handleClickOutside = (e) => {
if (panelRef.current && !panelRef.current.contains(e.target) && !e.target.closest('button[aria-expanded]')) {
setOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [open]);
return (
<div className="mb-2">
<label className="form-label small">{label}</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"
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>
<span>{selected.label}</span>
</button>
{open && (
<div className="border rounded p-2 shadow-sm bg-body" style={{ minWidth: 280, maxWidth: 360 }}>
<div className="input-group input-group-sm mb-2">
<span className="input-group-text">
<IconSearch size={14} />
</span>
<input
type="text"
className="form-control"
placeholder="Поиск по названию..."
value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
/>
</div>
<div
className="d-flex flex-wrap gap-1 overflow-auto"
style={{ maxHeight: 200 }}
role="listbox"
>
{filtered.map((item) => (
<button
key={item.id}
type="button"
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 }}
onClick={() => handleSelect(item)}
title={item.label}
role="option"
aria-selected={value === item.id}
>
<item.Icon size={22} stroke={1.5} />
</button>
))}
</div>
{filtered.length === 0 && (
<div className="small text-muted py-2">Ничего не найдено</div>
)}
</div>
)}
</div>
</div>
);
}
export { getIconById };
+40 -21
View File
@@ -121,31 +121,50 @@ function iconByType(type) {
function NotifyViewport({ items, onClose }) {
return (
<div className="position-fixed top-0 end-0 p-3" style={{ zIndex: 1080, pointerEvents: 'none' }}>
<div className="d-flex flex-column gap-2 align-items-end">
{items.map((n) => (
<div key={n.id} className={`alert alert-${colorByType(n.type)} alert-dismissible shadow-sm`} role="alert" style={{ minWidth: 320, pointerEvents: 'auto' }}>
<div className="d-flex align-items-start">
<div className="me-1 mt-1">{iconByType(n.type)}</div>
<div className="flex-grow-1">
<div className="toast-container toast-container-end notify-viewport" style={{ zIndex: 1079 }}>
{items.map((n) => (
<div
key={n.id}
className="toast show toast-alert"
role="alert"
aria-live="assertive"
aria-atomic="true"
style={{
minWidth: '320px',
maxWidth: '420px',
overflow: 'hidden',
borderRadius: 'var(--tblr-border-radius, 0.25rem)',
animation: 'slideInRight 0.3s ease-out forwards',
}}
>
<div className={`alert alert-${colorByType(n.type)} toast-alert-inner mb-0`}>
<div className="toast-header toast-alert-header">
<span className="toast-alert-icon me-2">{iconByType(n.type)}</span>
<strong className="me-auto">
{n.type === 'success' && 'Успех'}
{n.type === 'error' && 'Ошибка'}
{n.type === 'warning' && 'Внимание'}
{n.type === 'info' && 'Информация'}
</strong>
<button type="button" className="btn-close" aria-label="Закрыть" onClick={() => onClose(n.id)} />
</div>
<div className="toast-body">
<div className="alert-description">
{n.message}
{n.details && (
<details className="small mt-1">
<summary>Подробнее</summary>
<pre className="mb-0 mt-1" style={{ whiteSpace: 'pre-wrap' }}>{typeof n.details === 'string' ? n.details : JSON.stringify(n.details, null, 2)}</pre>
</details>
)}
{n.count > 1 && (
<span className="badge bg-white text-body border ms-2">×{n.count}</span>
)}
{n.count > 1 && <span className="badge bg-white text-body border ms-2">×{n.count}</span>}
</div>
<button type="button" className="btn-close" aria-label="Close" onClick={() => onClose(n.id)}>
<IconX size={16} />
</button>
{n.details && (
<details className="small mt-1">
<summary>Подробнее</summary>
<pre className="mb-0 mt-1" style={{ whiteSpace: 'pre-wrap' }}>
{typeof n.details === 'string' ? n.details : JSON.stringify(n.details, null, 2)}
</pre>
</details>
)}
</div>
</div>
))}
</div>
</div>
))}
</div>
);
}
+20 -7
View File
@@ -1,24 +1,37 @@
import React from 'react'
function PageHeader({ title, pretitle, actions, meta }) {
/**
* Единый заголовок страницы в стиле Tabler UI.
* Используется на всех страницах для одинакового UX.
* Кнопки (actions) отображаются под названием страницы.
* @param {string} title - Заголовок страницы
* @param {React.ReactNode} [icon] - Иконка слева от заголовка (например, <IconServer />)
* @param {string} [pretitle] - Небольшая надпись над заголовком (page-pretitle)
* @param {React.ReactNode} [meta] - Подзаголовок или мета-информация под заголовком
* @param {React.ReactNode} [actions] - Кнопки/действия под названием страницы (btn-list, btn-group)
*/
function PageHeader({ title, icon, pretitle, actions, meta }) {
return (
<div className="page-header d-print-none mb-4">
{/* Верхняя строка: только заголовок */}
<div className="row align-items-center">
<div className="col">
<h2 className="page-title">{title}</h2>
{pretitle && (
<div className="page-pretitle mt-1">{pretitle}</div>
<div className="page-pretitle">{pretitle}</div>
)}
<h1 className="page-title d-flex align-items-center">
{icon && <span className="me-2 d-flex align-items-center">{icon}</span>}
{title}
</h1>
{meta && (
<div className="text-muted small mt-1">{meta}</div>
)}
</div>
</div>
{/* Отдельная строка под заголовком: действия страницы */}
{actions && (
<div className="mt-4 d-print-none">
{actions}
<div className="row mt-2">
<div className="col d-print-none">
{actions}
</div>
</div>
)}
</div>
+36 -28
View File
@@ -59,9 +59,13 @@ function PageHeaderActions({
{(onImport || onExport) && placeholderBtn(160)}
{onClear && placeholderBtn(130)}
{onClearCommunities && placeholderBtn(180)}
{onSave && placeholderBtn(140)}
{onHistory && placeholderBtn(110)}
</div>
{(onSave || onHistory) && (
<div className="header-actions-right ms-auto d-flex align-items-center gap-2">
{onSave && placeholderBtn(140)}
{onHistory && placeholderBtn(110)}
</div>
)}
</div>
);
}
@@ -140,33 +144,37 @@ function PageHeaderActions({
<IconEraser className="me-1" /> Очистить community
</button>
)}
{onSave && (
<button className="btn btn-primary" type="button" onClick={onSave} disabled={disableSave} title="Сохранить">
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
Сохранение...
</>
) : (
<>
<IconDeviceFloppy className="me-1" /> Сохранить
</>
)}
</button>
)}
{onHistory && (
<div className="btn-group">
<button className="btn btn-outline-secondary" type="button" onClick={onHistory} title="История версий">История</button>
{lastModified && (
<button className="btn btn-outline-secondary" type="button" onClick={onHistory} title={`Последние изменения: ${formatDateTimeWithRelative(lastModified)}`}>
<IconClock className="me-1" />
<span className="d-none d-sm-inline">{formatDateTimeWithRelative(lastModified)}</span>
<span className="d-inline d-sm-none">{formatDateTimeWithRelative(lastModified)}</span>
</button>
)}
</div>
)}
</div>
{(onSave || onHistory) && (
<div className="header-actions-right ms-auto d-flex align-items-center gap-2">
{onSave && (
<button className="btn btn-primary" type="button" onClick={onSave} disabled={disableSave} title="Сохранить">
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
Сохранение...
</>
) : (
<>
<IconDeviceFloppy className="me-1" /> Сохранить
</>
)}
</button>
)}
{onHistory && (
<div className="btn-group d-none d-sm-inline-flex">
<button className="btn btn-outline-secondary" type="button" onClick={onHistory} title="История версий">История</button>
{lastModified && (
<button className="btn btn-outline-secondary" type="button" onClick={onHistory} title={`Последние изменения: ${formatDateTimeWithRelative(lastModified)}`}>
<IconClock className="me-1" />
<span className="d-none d-sm-inline">{formatDateTimeWithRelative(lastModified)}</span>
<span className="d-inline d-sm-none">{formatDateTimeWithRelative(lastModified)}</span>
</button>
)}
</div>
)}
</div>
)}
{/* Компактное меню для маленьких экранов */}
{secondaryButtons && (
<div className="btn-group d-inline-flex d-sm-none">
+10 -4
View File
@@ -7,19 +7,25 @@ function S3MetaBar({ keys = [], className = '' }) {
const [meta, setMeta] = useState({});
const [loading, setLoading] = useState(false);
const fetchMeta = async () => {
const fetchMeta = async (abortSignal) => {
setLoading(true);
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const res = await api.get('/s3/last-modified');
const res = await api.get('/s3/last-modified', opts);
setMeta(res.data || {});
} catch {
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
setMeta({});
} finally {
setLoading(false);
}
};
useEffect(() => { fetchMeta(); }, []);
useEffect(() => {
const controller = new AbortController();
fetchMeta(controller.signal);
return () => controller.abort();
}, []);
const copyMeta = async (k) => {
try {
+297 -10
View File
@@ -1,8 +1,9 @@
import FormField from './FormField';
import { useEffect, useState } from 'react';
import { useEffect, useState, useMemo } from 'react';
import api from '../lib/api.js';
import { IconSettings, IconDeviceFloppy } from '@tabler/icons-react';
import ErrorAlert from './ErrorAlert';
import ServerAutocompleteInput from './ServerAutocompleteInput.jsx';
/**
* SettingsModal - модальное окно настроек интерфейса
@@ -14,25 +15,87 @@ export default function SettingsModal({ open, onClose }) {
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [etag, setEtag] = useState('');
const [rawSettings, setRawSettings] = useState({});
const [dohServer, setDohServer] = useState('');
const [wsUrl, setWsUrl] = useState('');
const [baseAS, setBaseAS] = useState('65001');
const [pingDomain, setPingDomain] = useState('');
const [pingCacheMinutes, setPingCacheMinutes] = useState('');
const [ptrZoneReplaceFrom, setPtrZoneReplaceFrom] = useState('');
const [ptrZoneReplaceTo, setPtrZoneReplaceTo] = useState('');
const [pingServicesSource, setPingServicesSource] = useState('web');
const [pingServicesServerId, setPingServicesServerId] = useState('');
const [pingServicesGatewayIp, setPingServicesGatewayIp] = useState('');
const [pingServicesCacheSeconds, setPingServicesCacheSeconds] = useState('');
const [pingServicesSchedulerEnabled, setPingServicesSchedulerEnabled] = useState(true);
const [pingServicesSchedulerIntervalMinutes, setPingServicesSchedulerIntervalMinutes] = useState('2');
const [serversList, setServersList] = useState([]);
const [interfaceSpeedTestProtocol, setInterfaceSpeedTestProtocol] = useState('tcp');
const [interfaceSpeedTestDurationSeconds, setInterfaceSpeedTestDurationSeconds] = useState('10');
const [interfaceSpeedTestCacheMinutes, setInterfaceSpeedTestCacheMinutes] = useState('');
const routerServersForPing = useMemo(() => {
return (serversList || []).filter(
(s) => s && (String(s.type || '').toLowerCase() === 'jumphost' || String(s.type || '').toLowerCase() === 'home')
);
}, [serversList]);
useEffect(() => {
if (!open) return;
setError('');
setSuccess('');
setLoading(true);
(async () => {
try {
const res = await api.get('/ui-settings');
const data = res?.data || {};
const [settingsRes, serversRes] = await Promise.all([
api.get('/ui-settings'),
api.get('/servers').catch(() => ({ data: [] })),
]);
const data = settingsRes?.data || {};
setRawSettings(data);
setDohServer(String(data?.dohServer || ''));
setWsUrl(String(data?.wsUpdateUrl || ''));
setBaseAS(String(data?.baseAS || '65001'));
const e = res?.headers?.etag || res?.headers?.ETag || '';
setPingDomain(String(data?.pingDomain || '').trim());
setPingCacheMinutes(
data?.pingCacheMinutes != null ? String(data.pingCacheMinutes) : ''
);
setPtrZoneReplaceFrom(String(data?.ptrZoneReplaceFrom || ''));
setPtrZoneReplaceTo(String(data?.ptrZoneReplaceTo || ''));
setPingServicesSource(String(data?.pingServicesSource || 'web').toLowerCase() === 'router' ? 'router' : 'web');
setPingServicesServerId(String(data?.pingServicesServerId || '').trim());
setPingServicesGatewayIp(String(data?.pingServicesGatewayIp || '').trim());
setPingServicesCacheSeconds(
data?.pingServicesCacheSeconds != null
? String(data.pingServicesCacheSeconds)
: ''
);
setPingServicesSchedulerEnabled(data?.pingServicesSchedulerEnabled !== false);
setPingServicesSchedulerIntervalMinutes(
data?.pingServicesSchedulerIntervalMinutes != null
? String(data.pingServicesSchedulerIntervalMinutes)
: '2'
);
setInterfaceSpeedTestProtocol(
String(data?.interfaceSpeedTestProtocol || 'tcp').toLowerCase() ===
'udp'
? 'udp'
: 'tcp'
);
setInterfaceSpeedTestDurationSeconds(
data?.interfaceSpeedTestDurationSeconds != null
? String(data.interfaceSpeedTestDurationSeconds)
: '5'
);
setInterfaceSpeedTestCacheMinutes(
data?.interfaceSpeedTestCacheMinutes != null
? String(data.interfaceSpeedTestCacheMinutes)
: ''
);
const e = settingsRes?.headers?.etag || settingsRes?.headers?.ETag || '';
setEtag(e ? String(e) : '');
setServersList(Array.isArray(serversRes?.data) ? serversRes.data : []);
} catch (e) {
setError('Не удалось загрузить настройки');
} finally {
@@ -86,18 +149,47 @@ export default function SettingsModal({ open, onClose }) {
setSaving(true);
try {
const mergedSettings = {
...rawSettings,
dohServer: String(dohServer || '').trim(),
wsUpdateUrl: String(wsUrl || '').trim(),
baseAS: String(baseAS || '65001').trim(),
pingDomain: String(pingDomain || '').trim(),
pingCacheMinutes: Math.max(0, parseInt(pingCacheMinutes, 10) || 0),
ptrZoneReplaceFrom: String(ptrZoneReplaceFrom || '').trim(),
ptrZoneReplaceTo: String(ptrZoneReplaceTo || '').trim(),
pingServicesSource: pingServicesSource === 'router' ? 'router' : 'web',
pingServicesServerId: String(pingServicesServerId || '').trim(),
pingServicesGatewayIp: String(pingServicesGatewayIp || '').trim(),
pingServicesCacheSeconds: Math.max(
0,
parseInt(pingServicesCacheSeconds, 10) || 0
),
pingServicesSchedulerEnabled: Boolean(pingServicesSchedulerEnabled),
pingServicesSchedulerIntervalMinutes: Math.max(
1,
Math.min(1440, parseInt(pingServicesSchedulerIntervalMinutes, 10) || 2)
),
interfaceSpeedTestProtocol:
interfaceSpeedTestProtocol === 'udp' ? 'udp' : 'tcp',
interfaceSpeedTestDurationSeconds: Math.max(
1,
parseInt(interfaceSpeedTestDurationSeconds, 10) || 5
),
interfaceSpeedTestCacheMinutes: Math.max(
0,
parseInt(interfaceSpeedTestCacheMinutes, 10) || 0
),
};
const payload = {
settings: {
dohServer: String(dohServer || '').trim(),
wsUpdateUrl: String(wsUrl || '').trim(),
baseAS: String(baseAS || '65001').trim()
},
settings: mergedSettings,
etag
};
const res = await api.post('/ui-settings', payload);
const meta = res?.data || {};
setSuccess('Настройки успешно сохранены');
setEtag(String(meta?.etag || ''));
setRawSettings(mergedSettings);
setTimeout(() => setSuccess(''), 3000);
} catch (e) {
setError(e?.response?.data?.message || 'Ошибка при сохранении настроек');
@@ -186,6 +278,201 @@ export default function SettingsModal({ open, onClose }) {
helpText="AS по умолчанию для community (например, 65001). Используется при поиске и нормализации community."
disabled={loading || saving}
/>
<FormField
label="Домен для пинга"
name="pingDomain"
type="text"
value={pingDomain}
onChange={setPingDomain}
placeholder="8.8.8.8 или ya.ru"
helpText="Домен или IP для проверки пинга через MikroTik (например, 8.8.8.8, ya.ru). Если не задан, используется значение по умолчанию."
disabled={loading || saving}
/>
<FormField
label="Срок кеша пинга (мин)"
name="pingCacheMinutes"
type="number"
value={pingCacheMinutes}
onChange={setPingCacheMinutes}
placeholder="0"
helpText="0 — без кеша. При значении больше 0 результаты пинга кешируются в S3 на указанное количество минут."
disabled={loading || saving}
min={0}
/>
<div className="mt-3 pt-3 border-top">
<h6 className="mb-2">Измерение скорости (интерфейсы)</h6>
<div className="mb-2">
<label className="form-label small">Протокол измерения</label>
<select
className="form-select form-select-sm"
value={interfaceSpeedTestProtocol}
onChange={(e) =>
setInterfaceSpeedTestProtocol(e.target.value)
}
disabled={loading || saving}
>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
<div className="form-text small">
Используется как настройка по умолчанию для инструментов
измерения скорости RouterOS.
</div>
</div>
<div className="row g-2">
<div className="col-md-6">
<FormField
label="Время замера (сек)"
name="interfaceSpeedTestDurationSeconds"
type="number"
value={interfaceSpeedTestDurationSeconds}
onChange={setInterfaceSpeedTestDurationSeconds}
placeholder="5"
helpText="Интервал, за который измеряется средняя скорость по интерфейсу."
disabled={loading || saving}
min={1}
max={600}
/>
</div>
<div className="col-md-6">
<FormField
label="Кеш результата замера (мин)"
name="interfaceSpeedTestCacheMinutes"
type="number"
value={interfaceSpeedTestCacheMinutes}
onChange={setInterfaceSpeedTestCacheMinutes}
placeholder="0"
helpText="0 — без кеша. При значении больше 0 результаты замеров скорости по интерфейсу кешируются в S3."
disabled={loading || saving}
min={0}
/>
</div>
</div>
</div>
<div className="mt-3 pt-3 border-top">
<h6 className="mb-2">Пинг сервисов на главной</h6>
<div className="mb-2">
<label className="form-label small">Источник пинга</label>
<select
className="form-select form-select-sm"
value={pingServicesSource}
onChange={(e) => setPingServicesSource(e.target.value)}
disabled={loading || saving}
>
<option value="web">Веб (TCP с сервера приложения)</option>
<option value="router">Роутер (RouterOS API)</option>
</select>
<div className="form-text small">
«Веб» — задержка до 8.8.8.8, 1.1.1.1 и др. с сервера. «Роутер» — пинг через выбранный MikroTik (jumphost).
</div>
</div>
<div className="mb-2">
<FormField
label="Время кеширования пингов (сек)"
name="pingServicesCacheSeconds"
type="number"
value={pingServicesCacheSeconds}
onChange={setPingServicesCacheSeconds}
placeholder="0"
helpText="0 — без кеша. Результаты пинга до Google, Cloudflare и др. будут кешироваться на указанное число секунд."
disabled={loading || saving}
min={0}
/>
</div>
<div className="mb-2">
<div className="form-check form-switch mb-1">
<input
className="form-check-input"
type="checkbox"
id="modalPingServicesSchedulerEnabled"
checked={pingServicesSchedulerEnabled}
onChange={(e) => setPingServicesSchedulerEnabled(e.target.checked)}
disabled={loading || saving}
/>
<label className="form-check-label small" htmlFor="modalPingServicesSchedulerEnabled">
Планировщик: обновлять кеш пингов по расписанию
</label>
</div>
<FormField
label="Интервал обновления (мин)"
name="pingServicesSchedulerIntervalMinutes"
type="number"
value={pingServicesSchedulerIntervalMinutes}
onChange={setPingServicesSchedulerIntervalMinutes}
placeholder="2"
helpText="Как часто обновлять кеш (1–1440 мин). По умолчанию 2."
disabled={loading || saving}
min={1}
max={1440}
/>
</div>
{pingServicesSource === 'router' && (
<div className="row g-2">
<div className="col-md-6">
<label className="form-label small">Домашний роутер</label>
<div className={loading || saving ? 'opacity-75 pe-none' : ''}>
<ServerAutocompleteInput
value={pingServicesServerId}
onChange={(v) => setPingServicesServerId(String(v || '').trim())}
servers={routerServersForPing}
placeholder="Выберите роутер (jumphost или входной)"
className="form-control form-control-sm"
maxSuggestions={10}
/>
</div>
<div className="form-text small">
Выбор роутера с MikroTik API для пинга с главной. Пусто — первый jumphost из списка.
</div>
</div>
<div className="col-md-6">
<FormField
label="IP шлюза (не обязательно)"
name="pingServicesGatewayIp"
type="text"
value={pingServicesGatewayIp}
onChange={setPingServicesGatewayIp}
placeholder="IP шлюза"
helpText="Не обязательно. Пусто — используется первый шлюз выбранного роутера."
disabled={loading || saving}
/>
</div>
</div>
)}
</div>
<div className="mt-4 pt-3 border-top">
<h6 className="mb-3">Настройка PTR зоны</h6>
<div className="row g-3">
<div className="col-md-6">
<FormField
label="Заменить в DNS домене"
name="ptrZoneReplaceFrom"
type="text"
value={ptrZoneReplaceFrom}
onChange={setPtrZoneReplaceFrom}
placeholder="rt.shx"
helpText='Часть DNS домена, которую нужно заменить (например, "rt.shx")'
disabled={loading || saving}
/>
</div>
<div className="col-md-6">
<FormField
label="Заменить на"
name="ptrZoneReplaceTo"
type="text"
value={ptrZoneReplaceTo}
onChange={setPtrZoneReplaceTo}
placeholder="shrt"
helpText='На что заменить (например, "shrt"). Результат: DNS "selectel.msk.rt.shx.su" → PTR "selectel.msk.shrt.su"'
disabled={loading || saving}
/>
</div>
</div>
</div>
</div>
<div className="modal-footer">
+11 -4
View File
@@ -1,7 +1,11 @@
/**
* Skeleton loader для таблиц с анимацией pulse
* @param {boolean} flat - без карточной подложки (table-flat)
*/
export default function TableSkeleton({ rows = 10, cols = 3, hasCheckbox = false }) {
export default function TableSkeleton({ rows = 10, cols = 3, hasCheckbox = false, flat = false }) {
const tableClass = flat
? 'table table-flat table-vcenter table-nowrap mb-0'
: 'table card-table table-vcenter table-nowrap mb-0';
const Row = () => (
<tr>
{hasCheckbox && (
@@ -18,7 +22,7 @@ export default function TableSkeleton({ rows = 10, cols = 3, hasCheckbox = false
);
return (
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<table className={tableClass}>
<thead>
<tr>
{hasCheckbox && <th style={{ width: '40px' }}></th>}
@@ -35,10 +39,13 @@ export default function TableSkeleton({ rows = 10, cols = 3, hasCheckbox = false
);
}
export function TableEmpty({ cols = 3, children }) {
export function TableEmpty({ cols = 3, flat = false, children }) {
const tableClass = flat
? 'table table-flat table-vcenter table-nowrap mb-0'
: 'table card-table table-vcenter table-nowrap mb-0';
return (
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<table className={tableClass}>
<tbody>
<tr>
<td colSpan={cols} className="text-center text-muted py-5">
+104 -72
View File
@@ -3,12 +3,12 @@ import {
IconCheck,
IconX,
IconAlertTriangle,
IconInfoCircle,
IconRefresh
IconInfoCircle
} from '@tabler/icons-react'
/**
* Контекст для глобального управления toast уведомлениями
* Контекст для глобального управления toast уведомлениями.
* Тосты в стиле Tabler Alerts, всплывают справа как Tabler Toasts.
*/
const ToastContext = createContext({
success: () => {},
@@ -19,24 +19,29 @@ const ToastContext = createContext({
export const useToast = () => useContext(ToastContext)
const DEFAULT_TITLES = {
success: 'Выполнено успешно',
error: 'Произошла ошибка',
warning: 'Внимание',
info: 'Информация',
}
/**
* Toast уведомление (отдельный элемент)
* Один toast: разметка Tabler (toast + toast-header + toast-body), стиль Tabler Alerts.
*/
function Toast({ toast, onClose }) {
const [isLeaving, setIsLeaving] = useState(false)
useEffect(() => {
if (toast.duration && toast.duration > 0) {
const timer = setTimeout(() => {
handleClose()
}, toast.duration)
const timer = setTimeout(() => handleClose(), toast.duration)
return () => clearTimeout(timer)
}
}, [toast.duration])
const handleClose = () => {
setIsLeaving(true)
setTimeout(() => onClose(toast.id), 300) // анимация 300ms
setTimeout(() => onClose(toast.id), 300)
}
const getIcon = () => {
@@ -45,47 +50,53 @@ function Toast({ toast, onClose }) {
case 'error': return <IconX size={20} />
case 'warning': return <IconAlertTriangle size={20} />
case 'info': return <IconInfoCircle size={20} />
default: return null
default: return <IconInfoCircle size={20} />
}
}
const getColorClass = () => {
switch (toast.type) {
case 'success': return 'alert-success'
case 'error': return 'alert-danger'
case 'warning': return 'alert-warning'
case 'info': return 'alert-info'
default: return 'alert-info'
}
}
const alertClass = {
success: 'alert-success',
error: 'alert-danger',
warning: 'alert-warning',
info: 'alert-info',
}[toast.type] || 'alert-info'
const title = toast.title ?? DEFAULT_TITLES[toast.type]
return (
<div
className={`alert ${getColorClass()} alert-dismissible fade ${isLeaving ? '' : 'show'}`}
className={`toast show toast-alert ${isLeaving ? 'toast-leaving' : ''}`}
role="alert"
aria-live="assertive"
aria-atomic="true"
data-toast-type={toast.type}
style={{
minWidth: '300px',
maxWidth: '400px',
boxShadow: '0 0.5rem 1rem rgba(0,0,0,.15)',
animation: isLeaving ? 'slideOutRight 0.3s ease-out' : 'slideInRight 0.3s ease-out'
minWidth: '320px',
maxWidth: '420px',
overflow: 'hidden',
borderRadius: 'var(--tblr-border-radius, 0.25rem)',
animation: isLeaving ? 'slideOutRight 0.3s ease-out forwards' : 'slideInRight 0.3s ease-out forwards',
}}
>
<div className="d-flex align-items-start">
<div className="me-2 mt-1">
{getIcon()}
<div className={`alert ${alertClass} toast-alert-inner mb-0`}>
<div className="toast-header toast-alert-header">
<span className="toast-alert-icon me-2">{getIcon()}</span>
<strong className="me-auto">{title}</strong>
<button
type="button"
className="btn-close"
onClick={handleClose}
aria-label="Закрыть"
/>
</div>
<div className="flex-grow-1">
{toast.title && (
<h4 className="alert-title">{toast.title}</h4>
)}
<div className="text-secondary">{toast.message}</div>
{/* Действия */}
<div className="toast-body">
<div className="alert-description">{toast.message}</div>
{toast.actions && toast.actions.length > 0 && (
<div className="btn-list mt-2">
{toast.actions.map((action, idx) => (
<button
key={idx}
type="button"
className={`btn btn-sm ${action.variant || 'btn-outline-primary'}`}
onClick={() => {
action.onClick?.()
@@ -99,12 +110,6 @@ function Toast({ toast, onClose }) {
</div>
)}
</div>
<button
type="button"
className="btn-close"
onClick={handleClose}
aria-label="Close"
></button>
</div>
</div>
)
@@ -152,46 +157,73 @@ export function ToastContainer({ children }) {
return (
<ToastContext.Provider value={contextValue}>
{children}
{/* Контейнер с toast'ами */}
<div
style={{
position: 'fixed',
bottom: '20px',
right: '20px',
zIndex: 9999,
display: 'flex',
flexDirection: 'column',
gap: '10px'
}}
>
{toasts.map(toast => (
<Toast key={toast.id} toast={toast} onClose={removeToast} />
{/* Контейнер тостов справа (как в Tabler) */}
<div className="toast-container toast-container-end">
{toasts.map((t) => (
<Toast key={t.id} toast={t} onClose={removeToast} />
))}
</div>
{/* CSS анимации */}
{/* Стили: тосты в стиле Tabler Alerts, всплывают справа */}
<style>{`
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
.toast-container-end {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 0.5rem;
pointer-events: none;
}
.toast-container-end .toast {
pointer-events: auto;
}
/* NotifyProvider тосты ниже, чтобы не перекрывать useToast */
.toast-container-end.notify-viewport {
top: 5rem;
}
/* Toast в стиле Tabler Alert: колоночный layout, без дублирования отступов */
.toast-alert .toast-alert-inner {
display: block;
flex-direction: column;
padding: 0;
gap: 0;
border-radius: var(--tblr-border-radius, 0.25rem);
box-shadow: var(--tblr-shadow, 0 0.5rem 1rem rgba(0, 0, 0, 0.15));
}
.toast-alert .toast-alert-header {
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
background: transparent;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
font-weight: 500;
}
.toast-alert .toast-alert-icon {
color: inherit;
flex-shrink: 0;
}
.toast-alert .toast-body {
padding: 0.75rem;
}
.toast-alert .toast-body .alert-description {
color: var(--tblr-secondary);
}
.toast-alert.alert-danger .toast-alert-header { border-bottom-color: rgba(0, 0, 0, 0.1); }
.toast-alert .btn-close {
margin-left: auto;
}
@keyframes slideInRight {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOutRight {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
`}</style>
</ToastContext.Provider>
+28 -23
View File
@@ -168,29 +168,34 @@ export default function BillingCard({
{/* Правая часть: действия */}
<div className="d-flex align-items-center gap-2 px-3 border-start">
<button
className="btn btn-outline-success"
title="Записать платёж"
onClick={(e) => { e.stopPropagation(); onAddPayment?.(item); }}
>
<IconCreditCard size={16} className="me-1" />
Оплатить
</button>
<button
className="btn btn-outline-secondary"
title="Редактировать"
onClick={(e) => { e.stopPropagation(); onEdit?.(item); }}
>
<IconEdit size={16} className="me-1" />
Изменить
</button>
<button
className="btn btn-outline-danger"
title="Удалить"
onClick={(e) => { e.stopPropagation(); onDelete?.(item); }}
>
<IconTrash size={16} />
</button>
<div className="btn-group" role="group" aria-label="Действия с записью биллинга">
<button
className="btn btn-outline-success"
title="Записать платёж"
style={{ minWidth: '6.5rem' }}
onClick={(e) => { e.stopPropagation(); onAddPayment?.(item); }}
>
<IconCreditCard size={16} className="me-1" />
Оплатить
</button>
<button
className="btn btn-outline-secondary"
title="Редактировать"
style={{ minWidth: '6.5rem' }}
onClick={(e) => { e.stopPropagation(); onEdit?.(item); }}
>
<IconEdit size={16} className="me-1" />
Изменить
</button>
<button
className="btn btn-outline-danger d-flex align-items-center justify-content-center"
title="Удалить"
style={{ minWidth: '6.5rem' }}
onClick={(e) => { e.stopPropagation(); onDelete?.(item); }}
>
<IconTrash size={16} />
</button>
</div>
<button
className="btn btn-ghost-secondary btn-icon ms-1"
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}
@@ -1,12 +1,11 @@
import {
IconTrash,
IconEdit,
IconCopy,
IconX,
IconNetwork,
IconDownload,
IconCheck
} from '@tabler/icons-react';
import FloatingBulkActionsBar from '../FloatingBulkActionsBar.jsx';
export default function ServerBulkActions({
selectedCount,
@@ -15,169 +14,48 @@ export default function ServerBulkActions({
onBulkChangeTunnel,
onBulkExport,
onBulkCopyLinks,
tunnelTypes = ['GRE', 'IPSec', 'WireGuard', 'OpenVPN']
tunnelTypes = ['GRE', 'IPSec', 'WireGuard', 'OpenVPN'],
}) {
if (selectedCount === 0) return null;
const actions = [
{
type: 'dropdown',
label: 'Туннель',
icon: IconNetwork,
title: 'Сменить тип туннеля',
items: tunnelTypes.map((tunnel) => ({
label: tunnel,
onClick: () => onBulkChangeTunnel(tunnel),
})),
},
{
type: 'button',
label: 'Ссылки',
icon: IconCopy,
onClick: onBulkCopyLinks,
title: 'Скопировать ссылки',
},
{
type: 'button',
label: 'Экспорт',
icon: IconDownload,
onClick: onBulkExport,
title: 'Экспорт',
},
{
type: 'button',
label: 'Удалить',
icon: IconTrash,
onClick: onBulkDelete,
variant: 'danger',
title: 'Удалить',
},
];
return (
<div
className="server-bulk-actions position-sticky rounded-3 shadow-lg mx-auto px-3 py-2"
style={{
bottom: '20px',
zIndex: 1050,
maxWidth: '720px',
animation: 'slideUp 0.2s ease',
background: 'linear-gradient(135deg, #1e293b 0%, #334155 100%)',
border: '1px solid rgba(255,255,255,0.1)'
}}
>
<div className="d-flex align-items-center justify-content-between gap-3">
{/* Счётчик выбранных */}
<div className="d-flex align-items-center gap-2">
<span
className="badge fs-6"
style={{
background: '#3b82f6',
color: '#fff',
padding: '6px 10px'
}}
>
{selectedCount}
</span>
<span style={{ color: '#e2e8f0', fontWeight: 500 }}>выбрано</span>
</div>
{/* Действия */}
<div className="d-flex align-items-center gap-2">
{/* Смена типа туннеля */}
<div className="dropdown">
<button
className="bulk-btn dropdown-toggle"
type="button"
data-bs-toggle="dropdown"
>
<IconNetwork size={15} />
<span>Туннель</span>
</button>
<div className="dropdown-menu dropdown-menu-dark">
<div className="dropdown-header">Сменить тип туннеля</div>
{tunnelTypes.map(tunnel => (
<button
key={tunnel}
className="dropdown-item"
onClick={() => onBulkChangeTunnel(tunnel)}
>
{tunnel}
</button>
))}
</div>
</div>
{/* Копировать ссылки */}
<button
className="bulk-btn"
onClick={onBulkCopyLinks}
title="Скопировать ссылки"
>
<IconCopy size={15} />
<span>Ссылки</span>
</button>
{/* Экспорт */}
<button
className="bulk-btn"
onClick={onBulkExport}
title="Экспорт"
>
<IconDownload size={15} />
<span>Экспорт</span>
</button>
<div style={{ width: '1px', height: '20px', background: 'rgba(255,255,255,0.2)', margin: '0 4px' }} />
{/* Удалить */}
<button
className="bulk-btn bulk-btn-danger"
onClick={onBulkDelete}
title="Удалить"
>
<IconTrash size={15} />
<span>Удалить</span>
</button>
{/* Отменить выделение */}
<button
className="bulk-btn-close"
onClick={onClearSelection}
title="Снять выделение"
>
<IconX size={16} />
</button>
</div>
</div>
<style>{`
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.bulk-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.1);
color: #e2e8f0;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.bulk-btn:hover {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
.bulk-btn-danger {
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
}
.bulk-btn-danger:hover {
background: rgba(239, 68, 68, 0.4);
color: #fef2f2;
}
.bulk-btn-close {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.05);
color: #94a3b8;
cursor: pointer;
transition: all 0.15s ease;
}
.bulk-btn-close:hover {
background: rgba(255, 255, 255, 0.15);
color: #fff;
}
`}</style>
</div>
<FloatingBulkActionsBar
selectedCount={selectedCount}
onClearSelection={onClearSelection}
actions={actions}
/>
);
}
+36 -44
View File
@@ -57,6 +57,7 @@ export default function ServerCard({
exit: { label: 'EXIT', color: 'red', icon: IconArrowsRightLeft },
bgp: { label: 'BGP', color: 'purple', icon: IconRoute },
dns: { label: 'DNS', color: 'cyan', icon: IconWorldWww },
home: { label: 'HOME', color: 'green', icon: IconRouter },
};
const currentTypeConfig = typeConfig[serverType] || typeConfig.jumphost;
@@ -168,55 +169,46 @@ export default function ServerCard({
</div>
</div>
{/* Расширенная информация */}
{/* Расширенная информация в стиле Tabler datagrid */}
{expanded && (
<div className="border-top bg-light" style={{ borderBottomLeftRadius: '7px', borderBottomRightRadius: '7px' }}>
<div className="row g-0">
{/* IP адреса */}
<div className="col-md-6 p-3 border-end">
<div className="text-muted small mb-2 fw-medium">
<IconWorld size={14} className="me-1" />
Сетевые адреса
</div>
<div className="row g-2">
<div className="col-4">
<div className="text-muted" style={{ fontSize: '0.65rem', textTransform: 'uppercase' }}>IP</div>
<code className="small">{server.ip}</code>
</div>
<div className="col-4">
<div className="text-muted" style={{ fontSize: '0.65rem', textTransform: 'uppercase' }}>Внешний</div>
<code className="small">{server.extIp || '—'}</code>
</div>
<div className="col-4">
<div className="text-muted" style={{ fontSize: '0.65rem', textTransform: 'uppercase' }}>Внутренний</div>
<code className="small">{server.internalIp || '—'}</code>
</div>
</div>
<div className="border-top bg-light p-3" style={{ borderBottomLeftRadius: '7px', borderBottomRightRadius: '7px' }}>
<div className="datagrid">
<div className="datagrid-item">
<div className="datagrid-title">IP</div>
<div className="datagrid-content"><code className="small">{server.ip}</code></div>
</div>
{/* Шлюзы */}
<div className="col-md-6 p-3">
<div className="text-muted small mb-2 fw-medium">
<div className="datagrid-item">
<div className="datagrid-title">Внешний</div>
<div className="datagrid-content"><code className="small">{server.extIp || '—'}</code></div>
</div>
<div className="datagrid-item">
<div className="datagrid-title">Внутренний</div>
<div className="datagrid-content"><code className="small">{server.internalIp || '—'}</code></div>
</div>
<div className="datagrid-item">
<div className="datagrid-title">
<IconServer size={14} className="me-1" />
Шлюзы ({server.gateways?.length || 0})
</div>
{server.gateways?.length > 0 ? (
<div className="d-flex flex-wrap gap-2">
{server.gateways.map((gw, idx) => (
<span
key={gw.id || idx}
className={`badge ${gw.primary ? 'bg-blue-lt text-blue' : 'bg-secondary-lt text-secondary'}`}
title={gw.comment || (gw.primary ? 'Основной' : 'Резервный')}
>
{gw.primary && <IconCheck size={10} className="me-1" />}
{gw.name}
{gw.ip && <span className="ms-1 opacity-75">({gw.ip})</span>}
</span>
))}
</div>
) : (
<span className="text-muted small">Нет шлюзов</span>
)}
<div className="datagrid-content">
{server.gateways?.length > 0 ? (
<div className="d-flex flex-wrap gap-2">
{server.gateways.map((gw, idx) => (
<span
key={gw.id || idx}
className={`badge ${gw.primary ? 'bg-blue-lt text-blue' : 'bg-secondary-lt text-secondary'}`}
title={gw.comment || (gw.primary ? 'Основной' : 'Резервный')}
>
{gw.primary && <IconCheck size={10} className="me-1" />}
{gw.name}
{gw.ip && <span className="ms-1 opacity-75">({gw.ip})</span>}
</span>
))}
</div>
) : (
<span className="text-muted small">Нет шлюзов</span>
)}
</div>
</div>
</div>
</div>
@@ -0,0 +1,243 @@
import {
IconEdit,
IconTrash,
IconChevronDown,
IconChevronUp,
IconRouter,
IconCircleCheck,
IconNetwork,
IconCloud
} from '@tabler/icons-react';
import { useState } from 'react';
function countryToFlag(isoCode) {
if (!isoCode) return '';
const codeMap = {
'SWE': 'SE', 'RUS': 'RU', 'USA': 'US', 'GER': 'DE', 'FIN': 'FI', 'NLD': 'NL'
};
const code = codeMap[isoCode?.toUpperCase()] || isoCode?.toUpperCase();
if (!code || code.length !== 2) return isoCode;
return code.replace(/./g, char =>
String.fromCodePoint(127397 + char.charCodeAt())
);
}
/**
* Карточка группы серверов в том же формате, что и одиночная ServerCard:
* слева бейдж HOME, центр — флаг, название, провайдер и DNS с тегами, справа кнопки.
*/
export default function ServerGroupCard({
groupKey,
servers,
selectedSet,
onSelect,
onSelectGroup,
onEdit,
onDelete
}) {
const [expanded, setExpanded] = useState(false);
const first = servers[0];
const title = groupKey || (first?.dns?.split('.')[0] || 'Группа');
const tunnel = first?.tunnel;
const wanCount = servers.length;
const anySelected = servers.some((s) => selectedSet.has(s.ip));
const allSelected = servers.length > 0 && servers.every((s) => selectedSet.has(s.ip));
const handleGroupSelect = () => {
if (onSelectGroup) {
onSelectGroup(servers, !allSelected);
} else {
servers.forEach((s) => {
const selected = selectedSet.has(s.ip);
if (allSelected && selected) onSelect?.(s);
if (!allSelected && !selected) onSelect?.(s);
});
}
};
return (
<div
className={`server-group-card card mb-2 ${anySelected ? 'border-primary' : ''}`}
style={{
borderRadius: '12px',
borderWidth: anySelected ? '2px' : '1px',
transition: 'all 0.15s ease',
boxShadow: anySelected ? '0 0 0 3px rgba(32, 107, 196, 0.15)' : 'none'
}}
>
<div className="card-body p-0">
{/* Основная строка — как у ServerCard */}
<div className="d-flex align-items-stretch">
{/* Левая часть: бейдж HOME */}
<div
className="d-flex align-items-center justify-content-center px-3 bg-green-lt"
style={{
minWidth: '80px',
borderTopLeftRadius: anySelected ? '10px' : '11px',
borderBottomLeftRadius: expanded ? '0' : (anySelected ? '10px' : '11px'),
cursor: 'pointer'
}}
onClick={handleGroupSelect}
>
<div className="text-center">
<div className="mb-1">
{allSelected ? (
<IconCircleCheck size={20} className="text-green" />
) : (
<IconRouter size={20} className="text-green" />
)}
</div>
<div className="fw-bold text-green" style={{ fontSize: '0.65rem', letterSpacing: '0.5px' }}>
HOME
</div>
</div>
</div>
{/* Центр: в свёрнутом виде — все провайдеры и домены, чтобы было однозначно */}
<div
className="flex-grow-1 py-2 px-3"
style={{ cursor: 'pointer' }}
onClick={() => setExpanded(!expanded)}
>
<div className="d-flex align-items-center mb-1">
<span className="me-2" style={{ fontSize: '1.25rem' }}>{countryToFlag(first?.country)}</span>
<div>
<span className="fw-semibold">{title}</span>
<span className="text-muted ms-2">· мультиван</span>
</div>
<span className="badge bg-success-lt text-success ms-auto">
{wanCount} WAN
</span>
</div>
<div className="d-flex flex-wrap align-items-center gap-2">
<span className="text-muted small">
{servers.map((s) => s.dns?.split('.')[0] || s.ip).join(', ')}
</span>
<span className="text-muted small">—</span>
{servers.map((s, i) => (
<code key={s.id || s.ip} className="text-muted" style={{ fontSize: '0.75rem' }}>
{s.dns || s.ip}{i < servers.length - 1 ? ' · ' : ''}
</code>
))}
{tunnel && (
<span className="badge bg-secondary-lt text-secondary">
<IconNetwork size={12} className="me-1" />
{tunnel}
</span>
)}
<span className="badge bg-green-lt text-green">
<IconCloud size={12} className="me-1" />
{wanCount}
</span>
</div>
</div>
{/* Правая часть: кнопки как у необъединённой карточки */}
<div className="d-flex align-items-center gap-2 px-3 border-start">
<button
className="btn btn-outline-secondary"
title="Редактировать"
onClick={(e) => { e.stopPropagation(); onEdit?.(first); }}
>
<IconEdit size={16} className="me-1" />
Изменить
</button>
<button
className="btn btn-outline-danger"
title="Удалить"
onClick={(e) => { e.stopPropagation(); onDelete?.(first); }}
>
<IconTrash size={16} className="me-1" />
Удалить
</button>
<button
className="btn btn-ghost-secondary btn-icon ms-1"
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}
title={expanded ? 'Свернуть' : 'Подробнее'}
>
{expanded ? <IconChevronUp size={18} /> : <IconChevronDown size={18} />}
</button>
</div>
</div>
{/* Развёрнутый список WAN: визуально отделённый блок, у каждой строки — бейдж HOME */}
{expanded && (
<div
className="border-top border-start border-3 border-green bg-green-lt"
style={{
borderBottomLeftRadius: '7px',
borderBottomRightRadius: '7px',
borderLeftColor: 'var(--tblr-green)',
backgroundColor: 'rgba(34, 197, 94, 0.06)'
}}
>
<div className="small text-muted px-3 py-1 border-bottom bg-green-lt" style={{ fontSize: '0.7rem' }}>
WAN-подключения группы
</div>
<div className="p-2">
{servers.map((server) => {
const isSelected = selectedSet.has(server.ip);
const serverName = server.dns?.split('.')[0] || server.ip;
return (
<div
key={server.id || server.ip}
className={`d-flex align-items-stretch rounded mb-1 overflow-hidden ${isSelected ? 'bg-primary-lt' : 'bg-white'}`}
style={{ boxShadow: '0 0 0 1px rgba(0,0,0,0.06)' }}
>
{/* Бейдж HOME — клик как у обычной карточки, без явной галочки */}
<div
className="d-flex align-items-center justify-content-center bg-green-lt text-green px-2"
style={{
minWidth: '52px',
fontSize: '0.6rem',
fontWeight: 700,
letterSpacing: '0.5px',
cursor: 'pointer'
}}
onClick={() => onSelect?.(server)}
title={isSelected ? 'Снять выбор' : 'Выбрать'}
>
{isSelected ? (
<IconCircleCheck size={14} className="me-1" />
) : (
<IconRouter size={14} className="me-1" />
)}
HOME
</div>
<div className="d-flex align-items-center flex-grow-1 py-2 px-2 min-w-0">
<span className="me-2 flex-shrink-0" style={{ fontSize: '1.1rem' }}>{countryToFlag(server.country)}</span>
<div className="min-w-0">
<span className="fw-medium">{serverName}</span>
<span className="text-muted ms-2 small">{server.dns || server.ip}</span>
</div>
</div>
<div className="d-flex align-items-center gap-2 px-3 border-start">
<button
type="button"
className="btn btn-outline-secondary"
title="Редактировать"
onClick={(e) => { e.stopPropagation(); onEdit?.(server); }}
>
<IconEdit size={16} className="me-1" />
Изменить
</button>
<button
type="button"
className="btn btn-outline-danger"
title="Удалить"
onClick={(e) => { e.stopPropagation(); onDelete?.(server); }}
>
<IconTrash size={16} className="me-1" />
Удалить
</button>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
);
}
+127 -177
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { IconPlus, IconEdit, IconTrash, IconServer, IconWorld, IconMapPin } from '@tabler/icons-react';
import { IconPlus, IconEdit, IconTrash, IconServer, IconWorld, IconMapPin, IconRouter, IconPlug } from '@tabler/icons-react';
import api from '../../lib/api.js';
import {
makeGateway,
normalizeGateways,
@@ -10,20 +11,8 @@ import {
} from '../../utils/serverUtils.js';
/**
* Списки стран и провайдеров
* Список провайдеров
*/
const COUNTRY_OPTIONS = [
{ value: '', label: 'Выберите страну', disabled: true },
{ value: 'RU', label: 'Россия (RU)' },
{ value: 'SWE', label: 'Швеция (SWE)' },
{ value: 'FI', label: 'Финляндия (FI)' },
{ value: 'US', label: 'США (US)' },
{ value: 'DE', label: 'Германия (DE)' },
{ value: 'NL', label: 'Нидерланды (NL)' },
{ value: 'SE', label: 'Швеция (SE)' },
{ value: 'SG', label: 'Сингапур (SG)' },
];
const PROVIDER_OPTIONS = [
{ value: '', label: 'Выберите провайдера', disabled: true },
{ value: 'Yandex', label: 'Yandex Cloud' },
@@ -76,6 +65,8 @@ function ServerModal({ show, mode = 'add', server, onSave, onClose, error: exter
const [localServer, setLocalServer] = useState(getEmptyServer());
const [customProvider, setCustomProvider] = useState('');
const [internalError, setInternalError] = useState('');
const [mikrotikTesting, setMikrotikTesting] = useState(false);
const [mikrotikTestResult, setMikrotikTestResult] = useState(null);
const isEditMode = mode === 'edit';
const error = externalError || internalError;
@@ -119,6 +110,7 @@ function ServerModal({ show, mode = 'add', server, onSave, onClose, error: exter
}
if (!show) {
initializedRef.current = false;
setMikrotikTestResult(null);
}
}, [show, server, isEditMode]);
@@ -150,53 +142,6 @@ function ServerModal({ show, mode = 'add', server, onSave, onClose, error: exter
setLocalServer(prev => ({ ...prev, [field]: value }));
};
/**
* Обработчики для gateways
*/
const handleGatewayChange = (idx, field, value) => {
setLocalServer(prev => {
const currentGateways = Array.isArray(prev.gateways) ? prev.gateways : [];
const updatedGateways = currentGateways.map((gw, i) =>
i === idx ? { ...gw, [field]: value } : gw
);
return { ...prev, gateways: updatedGateways };
});
};
const handlePrimaryChange = (idx, checked) => {
setLocalServer(prev => {
const currentGateways = Array.isArray(prev.gateways) ? prev.gateways : [];
const updatedGateways = currentGateways.map((gw, i) => ({
...gw,
primary: i === idx ? checked : false
}));
if (!updatedGateways.some(g => g.primary) && updatedGateways.length > 0) {
updatedGateways[idx].primary = true;
}
return { ...prev, gateways: updatedGateways };
});
};
const handleAddGateway = () => {
setLocalServer(prev => {
const currentGateways = Array.isArray(prev.gateways) ? prev.gateways : [];
const newGw = makeGateway({ primary: currentGateways.length === 0 });
return { ...prev, gateways: [...currentGateways, newGw] };
});
};
const handleRemoveGateway = (idx) => {
setLocalServer(prev => {
const currentGateways = Array.isArray(prev.gateways) ? prev.gateways : [];
if (currentGateways.length <= 1) return prev;
const updatedGateways = currentGateways.filter((_, i) => i !== idx);
if (!updatedGateways.some(g => g.primary) && updatedGateways.length > 0) {
updatedGateways[0].primary = true;
}
return { ...prev, gateways: updatedGateways };
});
};
/**
* Валидация и сохранение
*/
@@ -232,14 +177,13 @@ function ServerModal({ show, mode = 'add', server, onSave, onClose, error: exter
return;
}
// Валидация gateways для jumphost/exit
// Шлюзы для jumphost/exit задаются в другом месте (network-config); при сохранении передаём существующие или один по умолчанию
const needsGws = NEEDS_GATEWAYS(localServer.type);
let finalGateways = [];
if (needsGws) {
finalGateways = normalizeGateways(localServer.gateways, localServer.gateway || localServer.dns || localServer.ip);
if (finalGateways.length === 0) {
setInternalError('Добавьте хотя бы один шлюз');
return;
finalGateways = [makeGateway({ primary: true, name: localServer.dns?.split('.')[0] || 'main' })];
}
}
@@ -252,9 +196,6 @@ function ServerModal({ show, mode = 'add', server, onSave, onClose, error: exter
onSave && onSave(serverToSave);
};
const currentGateways = Array.isArray(localServer.gateways) ? localServer.gateways : [];
const showGateways = NEEDS_GATEWAYS(localServer.type);
return (
<div className="modal" tabIndex="-1" ref={modalRef}>
<div className="modal-dialog modal-lg">
@@ -363,26 +304,24 @@ function ServerModal({ show, mode = 'add', server, onSave, onClose, error: exter
/>
</div>
{/* Страна */}
{/* Страна — 2-буквенный код как в интерфейсах */}
<div className="col-md-6">
<label className="form-label required">Страна</label>
<div className="input-icon">
<span className="input-icon-addon">
<IconMapPin size={16} />
</span>
<select
className="form-select ps-8"
<input
type="text"
className="form-control ps-8"
value={localServer.country || ''}
onChange={(e) => handleFieldChange('country', e.target.value)}
onChange={(e) => handleFieldChange('country', e.target.value.toUpperCase().slice(0, 2))}
placeholder="RU, SE, DE…"
maxLength={2}
required
>
{COUNTRY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value} disabled={opt.disabled}>
{opt.label}
</option>
))}
</select>
/>
</div>
<div className="form-text">2-буквенный код страны (ISO 3166-1 alpha-2)</div>
</div>
{/* Провайдер */}
@@ -451,112 +390,123 @@ function ServerModal({ show, mode = 'add', server, onSave, onClose, error: exter
</div>
)}
{/* Основной шлюз (текстовое поле для совместимости) */}
<div className="col-12">
<label className="form-label">
Основной шлюз <span className="text-muted">(устаревшее поле)</span>
</label>
<input
type="text"
className="form-control"
value={localServer.gateway || ''}
onChange={(e) => handleFieldChange('gateway', e.target.value)}
placeholder="Cloudflare, Hetzner и т.д."
/>
<div className="form-text">
Используйте секцию "Шлюзы" ниже для более детальной настройки
{/* Группа объединения — для домашнего роутера с мультиваном: один ключ у нескольких записей = одна карточка в списке */}
{localServer.type === 'home' && (
<div className="col-12">
<label className="form-label">
Группа (объединение в списке)
</label>
<input
type="text"
className="form-control"
value={localServer.groupKey || ''}
onChange={(e) => {
const v = e.target.value.trim();
handleFieldChange('groupKey', v || '');
}}
placeholder="Например: home-multivan — две записи с одним ключом отобразятся как один роутер"
/>
<div className="form-text">
Укажите одинаковый ключ у обоих WAN-записей одного роутера, чтобы в разделе «Серверы» они отображались одной карточкой. ID и привязки (интерфейсы, фильтры) не меняются.
</div>
</div>
</div>
)}
</div>
{/* Секция шлюзов */}
{showGateways && (
<div className="mt-4">
<div className="d-flex align-items-center justify-content-between mb-2">
<label className="form-label mb-0">
Шлюзы ({currentGateways.length})
</label>
{/* MikroTik RouterOS API — для jumphost и домашних (входных) роутеров */}
{(localServer.type === 'jumphost' || localServer.type === 'home') && (
<div className="mt-4 pt-3 border-top">
<label className="form-label d-flex align-items-center gap-2">
<IconRouter size={18} />
MikroTik RouterOS API
</label>
<div className="row g-3">
<div className="col-md-4">
<label className="form-label small">Хост</label>
<input
type="text"
className="form-control form-control-sm"
value={localServer.mikrotikHost || ''}
onChange={(e) => handleFieldChange('mikrotikHost', e.target.value)}
placeholder={localServer.ip || 'IP или DNS'}
/>
</div>
<div className="col-md-2">
<label className="form-label small">Порт</label>
<input
type="number"
className="form-control form-control-sm"
value={localServer.mikrotikPort || '80'}
onChange={(e) => handleFieldChange('mikrotikPort', e.target.value)}
placeholder="80"
/>
</div>
<div className="col-md-3">
<label className="form-label small">Пользователь</label>
<input
type="text"
className="form-control form-control-sm"
value={localServer.mikrotikUser || 'admin'}
onChange={(e) => handleFieldChange('mikrotikUser', e.target.value)}
placeholder="admin"
/>
</div>
<div className="col-md-3">
<label className="form-label small">Пароль</label>
<input
type="password"
className="form-control form-control-sm"
value={localServer.mikrotikPassword ?? ''}
onChange={(e) => handleFieldChange('mikrotikPassword', e.target.value)}
placeholder={localServer.hasMikrotikPassword ? '••••••' : 'Введите пароль'}
/>
</div>
</div>
<div className="mt-2 d-flex align-items-center gap-2">
<button
type="button"
className="btn btn-outline-primary btn-sm"
onClick={handleAddGateway}
className="btn btn-outline-primary btn-sm d-flex align-items-center gap-1"
disabled={mikrotikTesting || !(localServer.mikrotikHost || localServer.ip)}
onClick={async () => {
setMikrotikTestResult(null);
setMikrotikTesting(true);
try {
const hasPassword = localServer.mikrotikPassword && String(localServer.mikrotikPassword).trim() !== '';
const payload = hasPassword
? {
host: localServer.mikrotikHost || localServer.ip,
port: localServer.mikrotikPort || 80,
user: localServer.mikrotikUser || 'admin',
password: localServer.mikrotikPassword,
}
: (localServer.hasMikrotikPassword && (localServer.id || localServer.dns || localServer.ip))
? { serverId: localServer.id || localServer.dns || localServer.ip }
: null;
if (!payload) {
setMikrotikTestResult({ ok: false, message: 'Введите пароль или сохраните сервер с паролем' });
return;
}
const { data } = await api.post('/mikrotik/test-connection', payload);
setMikrotikTestResult(data);
} catch (err) {
setMikrotikTestResult({
ok: false,
message: err.response?.data?.message || err.message || 'Ошибка соединения',
});
} finally {
setMikrotikTesting(false);
}
}}
>
<IconPlus size={14} className="me-1" />
Добавить шлюз
<IconPlug size={14} />
{mikrotikTesting ? 'Проверка...' : 'Проверить соединение'}
</button>
</div>
<div className="list-group list-group-flush border rounded">
{currentGateways.map((gw, idx) => (
<div key={gw.id || `gw-${idx}`} className="list-group-item">
<div className="row g-2 align-items-end">
<div className="col-12 col-md-3">
<label className="form-label small mb-1">Имя *</label>
<input
type="text"
className="form-control form-control-sm"
value={gw.name || ''}
onChange={(e) => handleGatewayChange(idx, 'name', e.target.value)}
placeholder="GW-MSK"
required
/>
</div>
<div className="col-12 col-md-3">
<label className="form-label small mb-1">IP</label>
<input
type="text"
className="form-control form-control-sm"
value={gw.ip || ''}
onChange={(e) => handleGatewayChange(idx, 'ip', e.target.value)}
placeholder="10.0.0.1"
/>
</div>
<div className="col-12 col-md-3">
<label className="form-label small mb-1">Комментарий</label>
<input
type="text"
className="form-control form-control-sm"
value={gw.comment || ''}
onChange={(e) => handleGatewayChange(idx, 'comment', e.target.value)}
placeholder="Описание"
/>
</div>
<div className="col-6 col-md-2">
<div className="form-check form-switch">
<input
className="form-check-input"
type="checkbox"
checked={!!gw.primary}
onChange={(e) => handlePrimaryChange(idx, e.target.checked)}
id={`primary-${gw.id || idx}`}
/>
<label className="form-check-label small" htmlFor={`primary-${gw.id || idx}`}>
Основной
</label>
</div>
</div>
<div className="col-6 col-md-1 text-end">
{currentGateways.length > 1 && (
<button
type="button"
className="btn btn-ghost-danger btn-icon btn-sm"
onClick={() => handleRemoveGateway(idx)}
title="Удалить шлюз"
>
<IconTrash size={14} />
</button>
)}
</div>
</div>
<div className="text-muted small mt-1">
{gw.primary ? 'Основной шлюз' : 'Резервный шлюз'}
</div>
</div>
))}
</div>
<div className="form-text mt-2">
Для типов jumphost/exit должен быть минимум один шлюз с признаком "Основной"
{mikrotikTestResult && (
<span className={mikrotikTestResult.ok ? 'text-success' : 'text-danger'}>
{mikrotikTestResult.ok ? '✓ ' + (mikrotikTestResult.message || 'OK') : '✗ ' + mikrotikTestResult.message}
</span>
)}
</div>
</div>
)}
+192 -338
View File
@@ -6,29 +6,63 @@ import {
IconNetwork,
IconChevronDown,
IconChevronRight,
IconX,
IconSearch,
IconFilter,
IconDatabase
IconSearch,
IconDatabase,
IconX
} from '@tabler/icons-react';
// Преобразование кода страны в emoji-флаг
function countryToFlag(isoCode) {
if (!isoCode) return '';
const codeMap = {
'SWE': 'SE',
'RUS': 'RU',
'USA': 'US',
'GER': 'DE',
'FIN': 'FI',
'NLD': 'NL'
SWE: 'SE',
RUS: 'RU',
USA: 'US',
GER: 'DE',
FIN: 'FI',
NLD: 'NL'
};
const code = codeMap[isoCode?.toUpperCase()] || isoCode?.toUpperCase();
if (!code || code.length !== 2) return isoCode;
return code
.replace(/./g, char =>
String.fromCodePoint(127397 + char.charCodeAt())
);
return code.replace(/./g, char => String.fromCodePoint(127397 + char.charCodeAt()));
}
function FilterSection({ title, icon: Icon, iconColor, expanded, onToggle, children }) {
return (
<div className="filter-section border-bottom">
<button
type="button"
className="list-group-header d-flex align-items-center justify-content-between w-100 border-0 bg-transparent py-2 px-3 text-start"
onClick={onToggle}
aria-expanded={expanded}
>
<span className="d-flex align-items-center">
<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" />}
</button>
{expanded && <div className="list-group list-group-flush list-group-transparent">{children}</div>}
</div>
);
}
function FilterItem({ label, count, active, onClick, leftAddon }) {
return (
<button
type="button"
className={`list-group-item list-group-item-action d-flex align-items-center justify-content-between py-2 px-3 text-start ${active ? 'active' : ''}`}
onClick={onClick}
>
<span className="d-flex align-items-center min-w-0 flex-grow-1" style={{ overflow: 'hidden' }}>
{leftAddon}
<span className="text-break" style={{ wordBreak: 'break-word' }}>{label}</span>
</span>
<span className={`badge ms-2 flex-shrink-0 ${active ? 'bg-white text-primary' : 'bg-secondary-lt text-secondary'}`}>
{count}
</span>
</button>
);
}
export default function ServerSidebar({
@@ -37,7 +71,9 @@ export default function ServerSidebar({
onFiltersChange,
onSearch,
searchTerm = '',
onReset
onReset,
/** В режиме true рендерится только контент без обёртки card (для использования внутри offcanvas) */
embedded = false
}) {
const [expandedSections, setExpandedSections] = useState({
country: true,
@@ -46,380 +82,198 @@ export default function ServerSidebar({
tunnel: false
});
// Подсчёт серверов по категориям
const stats = useMemo(() => {
const byCountry = {};
const byProvider = {};
const byType = {};
const byTunnel = {};
servers.forEach(srv => {
byCountry[srv.country] = (byCountry[srv.country] || 0) + 1;
byProvider[srv.provider] = (byProvider[srv.provider] || 0) + 1;
byType[srv.type] = (byType[srv.type] || 0) + 1;
byTunnel[srv.tunnel] = (byTunnel[srv.tunnel] || 0) + 1;
});
return { byCountry, byProvider, byType, byTunnel };
}, [servers]);
const toggleSection = (section) => {
setExpandedSections(prev => ({
...prev,
[section]: !prev[section]
}));
const toggleSection = section => {
setExpandedSections(prev => ({ ...prev, [section]: !prev[section] }));
};
const handleFilterClick = (category, value) => {
const currentValue = filters[category];
const newValue = currentValue === value ? '' : value;
onFiltersChange({ ...filters, [category]: newValue });
const current = filters[category];
onFiltersChange({ ...filters, [category]: current === value ? '' : value });
};
const activeFiltersCount = Object.values(filters).filter(Boolean).length + (searchTerm ? 1 : 0);
const typeLabels = { jumphost: 'Jumphost', exit: 'Выходная нода' };
const typeLabels = {
'jumphost': 'Jumphost',
'exit': 'Выходная нода'
};
return (
<div
className="server-sidebar d-flex flex-column"
style={{
width: '260px',
minWidth: '260px',
background: '#fff',
borderRadius: '12px',
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
height: 'fit-content',
maxHeight: 'calc(100vh - 200px)',
overflow: 'hidden'
}}
>
{/* Заголовок */}
<div
className="d-flex align-items-center justify-content-between px-3 py-3"
style={{ borderBottom: '1px solid rgba(0,0,0,0.06)' }}
>
<div className="d-flex align-items-center">
<IconFilter size={18} className="text-primary me-2" />
<span className="fw-semibold">Фильтры</span>
const content = (
<>
{!embedded && (
<div className="card-header py-3 d-flex align-items-center justify-content-between">
<div className="d-flex align-items-center">
<IconFilter size={18} className="text-primary me-2" />
<span className="fw-semibold">Фильтры</span>
{activeFiltersCount > 0 && (
<span className="badge bg-primary-lt text-primary ms-2">{activeFiltersCount}</span>
)}
</div>
{activeFiltersCount > 0 && (
<span
className="badge bg-primary ms-2"
style={{ fontSize: '0.7rem', padding: '3px 6px' }}
<button
type="button"
className="btn btn-ghost-secondary btn-icon btn-sm"
onClick={onReset}
title="Сбросить фильтры"
aria-label="Сбросить фильтры"
>
{activeFiltersCount}
</span>
<IconX size={16} />
</button>
)}
</div>
{activeFiltersCount > 0 && (
<button
className="btn btn-ghost-secondary btn-icon btn-sm"
onClick={onReset}
title="Сбросить"
style={{ width: 28, height: 28 }}
>
<IconX size={14} />
</button>
)}
</div>
)}
{/* Поиск */}
<div className="px-3 py-2" style={{ borderBottom: '1px solid rgba(0,0,0,0.06)' }}>
<div className={embedded ? 'py-2' : 'card-body py-2 border-bottom'}>
<div className="input-icon">
<span className="input-icon-addon">
<IconSearch size={14} className="text-muted" />
<IconSearch size={16} className="text-muted" />
</span>
<input
type="text"
className="form-control form-control-sm"
placeholder="Поиск..."
value={searchTerm}
onChange={(e) => onSearch(e.target.value)}
style={{
background: 'rgba(0,0,0,0.02)',
border: '1px solid rgba(0,0,0,0.06)',
borderRadius: '8px',
fontSize: '0.85rem'
}}
onChange={e => onSearch(e.target.value)}
aria-label="Поиск серверов"
/>
</div>
</div>
{/* Скроллируемый контент */}
<div style={{ overflowY: 'auto', flex: 1 }}>
{/* Секция: Страны */}
<div className="filter-section">
<div
className="d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer"
onClick={() => toggleSection('country')}
style={{
background: 'rgba(0,0,0,0.02)',
borderBottom: '1px solid rgba(0,0,0,0.04)'
}}
>
<div className="d-flex align-items-center">
<IconWorld size={15} className="me-2" style={{ color: '#3b82f6' }} />
<span className="fw-medium small">Страны</span>
</div>
{expandedSections.country ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
</div>
{expandedSections.country && (
<div className="p-2">
{Object.entries(stats.byCountry)
.sort((a, b) => b[1] - a[1])
.map(([country, count]) => (
<button
key={country}
className={`btn btn-sm w-100 text-start mb-1 d-flex align-items-center justify-content-between ${
filters.country === country ? 'btn-primary' : ''
}`}
onClick={() => handleFilterClick('country', country)}
style={{
background: filters.country === country ? undefined : 'transparent',
border: 'none',
borderRadius: '8px',
padding: '8px 10px',
transition: 'background 0.15s'
}}
onMouseEnter={(e) => {
if (filters.country !== country) {
e.currentTarget.style.background = 'rgba(0,0,0,0.04)';
}
}}
onMouseLeave={(e) => {
if (filters.country !== country) {
e.currentTarget.style.background = 'transparent';
}
}}
>
<span className="d-flex align-items-center">
<span className="me-2" style={{ fontSize: '1.1rem' }}>{countryToFlag(country)}</span>
<span className={filters.country === country ? 'text-white' : ''}>{country}</span>
</span>
<span
className={`badge ${filters.country === country ? 'bg-white text-primary' : 'bg-secondary-lt text-secondary'}`}
style={{ fontSize: '0.7rem', padding: '3px 7px' }}
>
{count}
</span>
</button>
))}
</div>
)}
</div>
<div className={embedded ? 'overflow-auto flex-grow-1 min-h-0' : 'server-sidebar-body overflow-auto'}>
<FilterSection
title="Страны"
icon={IconWorld}
iconColor="text-blue"
expanded={expandedSections.country}
onToggle={() => toggleSection('country')}
>
{Object.entries(stats.byCountry)
.sort((a, b) => b[1] - a[1])
.map(([country, count]) => (
<FilterItem
key={country}
label={country}
count={count}
active={filters.country === country}
onClick={() => handleFilterClick('country', country)}
leftAddon={
<span className="me-2" style={{ fontSize: '1rem' }} aria-hidden>
{countryToFlag(country)}
</span>
}
/>
))}
</FilterSection>
{/* Секция: Провайдеры */}
<div className="filter-section">
<div
className="d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer"
onClick={() => toggleSection('provider')}
style={{
background: 'rgba(0,0,0,0.02)',
borderBottom: '1px solid rgba(0,0,0,0.04)'
}}
>
<div className="d-flex align-items-center">
<IconBuildingSkyscraper size={15} className="me-2" style={{ color: '#8b5cf6' }} />
<span className="fw-medium small">Провайдеры</span>
</div>
{expandedSections.provider ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
</div>
{expandedSections.provider && (
<div className="p-2">
{Object.entries(stats.byProvider)
.sort((a, b) => b[1] - a[1])
.map(([provider, count]) => (
<button
key={provider}
className={`btn btn-sm w-100 text-start mb-1 d-flex align-items-center justify-content-between ${
filters.provider === provider ? 'btn-primary' : ''
}`}
onClick={() => handleFilterClick('provider', provider)}
style={{
background: filters.provider === provider ? undefined : 'transparent',
border: 'none',
borderRadius: '8px',
padding: '8px 10px'
}}
onMouseEnter={(e) => {
if (filters.provider !== provider) {
e.currentTarget.style.background = 'rgba(0,0,0,0.04)';
}
}}
onMouseLeave={(e) => {
if (filters.provider !== provider) {
e.currentTarget.style.background = 'transparent';
}
}}
>
<span className={filters.provider === provider ? 'text-white' : ''}>{provider}</span>
<span
className={`badge ${filters.provider === provider ? 'bg-white text-primary' : 'bg-secondary-lt text-secondary'}`}
style={{ fontSize: '0.7rem', padding: '3px 7px' }}
>
{count}
</span>
</button>
))}
</div>
)}
</div>
<FilterSection
title="Провайдеры"
icon={IconBuildingSkyscraper}
iconColor="text-purple"
expanded={expandedSections.provider}
onToggle={() => toggleSection('provider')}
>
{Object.entries(stats.byProvider)
.sort((a, b) => b[1] - a[1])
.map(([provider, count]) => (
<FilterItem
key={provider}
label={provider}
count={count}
active={filters.provider === provider}
onClick={() => handleFilterClick('provider', provider)}
/>
))}
</FilterSection>
{/* Секция: Типы */}
<div className="filter-section">
<div
className="d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer"
onClick={() => toggleSection('type')}
style={{
background: 'rgba(0,0,0,0.02)',
borderBottom: '1px solid rgba(0,0,0,0.04)'
}}
>
<div className="d-flex align-items-center">
<IconServer size={15} className="me-2" style={{ color: '#10b981' }} />
<span className="fw-medium small">Тип сервера</span>
</div>
{expandedSections.type ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
</div>
{expandedSections.type && (
<div className="p-2">
{Object.entries(stats.byType)
.sort((a, b) => b[1] - a[1])
.map(([type, count]) => (
<button
key={type}
className={`btn btn-sm w-100 text-start mb-1 d-flex align-items-center justify-content-between ${
filters.type === type ? 'btn-primary' : ''
}`}
onClick={() => handleFilterClick('type', type)}
<FilterSection
title="Тип сервера"
icon={IconServer}
iconColor="text-green"
expanded={expandedSections.type}
onToggle={() => toggleSection('type')}
>
{Object.entries(stats.byType)
.sort((a, b) => b[1] - a[1])
.map(([type, count]) => (
<FilterItem
key={type}
label={typeLabels[type] || type}
count={count}
active={filters.type === type}
onClick={() => handleFilterClick('type', type)}
leftAddon={
<span
className="rounded-circle me-2 flex-shrink-0"
style={{
background: filters.type === type ? undefined : 'transparent',
border: 'none',
borderRadius: '8px',
padding: '8px 10px'
width: 8,
height: 8,
background: type === 'exit' ? 'var(--tblr-red)' : 'var(--tblr-primary)'
}}
onMouseEnter={(e) => {
if (filters.type !== type) {
e.currentTarget.style.background = 'rgba(0,0,0,0.04)';
}
}}
onMouseLeave={(e) => {
if (filters.type !== type) {
e.currentTarget.style.background = 'transparent';
}
}}
>
<span className="d-flex align-items-center">
<span
className="rounded-circle me-2"
style={{
width: 8,
height: 8,
background: type === 'exit' ? '#ef4444' : '#3b82f6'
}}
/>
<span className={filters.type === type ? 'text-white' : ''}>
{typeLabels[type] || type}
</span>
</span>
<span
className={`badge ${filters.type === type ? 'bg-white text-primary' : 'bg-secondary-lt text-secondary'}`}
style={{ fontSize: '0.7rem', padding: '3px 7px' }}
>
{count}
</span>
</button>
))}
</div>
)}
</div>
aria-hidden
/>
}
/>
))}
</FilterSection>
{/* Секция: Туннели */}
<div className="filter-section">
<div
className="d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer"
onClick={() => toggleSection('tunnel')}
style={{
background: 'rgba(0,0,0,0.02)',
borderBottom: '1px solid rgba(0,0,0,0.04)'
}}
>
<div className="d-flex align-items-center">
<IconNetwork size={15} className="me-2" style={{ color: '#f59e0b' }} />
<span className="fw-medium small">Туннель</span>
</div>
{expandedSections.tunnel ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
</div>
{expandedSections.tunnel && (
<div className="p-2">
{Object.entries(stats.byTunnel)
.sort((a, b) => b[1] - a[1])
.map(([tunnel, count]) => (
<button
key={tunnel}
className={`btn btn-sm w-100 text-start mb-1 d-flex align-items-center justify-content-between ${
filters.tunnel === tunnel ? 'btn-primary' : ''
}`}
onClick={() => handleFilterClick('tunnel', tunnel)}
style={{
background: filters.tunnel === tunnel ? undefined : 'transparent',
border: 'none',
borderRadius: '8px',
padding: '8px 10px'
}}
onMouseEnter={(e) => {
if (filters.tunnel !== tunnel) {
e.currentTarget.style.background = 'rgba(0,0,0,0.04)';
}
}}
onMouseLeave={(e) => {
if (filters.tunnel !== tunnel) {
e.currentTarget.style.background = 'transparent';
}
}}
>
<span className={filters.tunnel === tunnel ? 'text-white' : ''}>{tunnel}</span>
<span
className={`badge ${filters.tunnel === tunnel ? 'bg-white text-primary' : 'bg-secondary-lt text-secondary'}`}
style={{ fontSize: '0.7rem', padding: '3px 7px' }}
>
{count}
</span>
</button>
))}
</div>
)}
</div>
<FilterSection
title="Туннель"
icon={IconNetwork}
iconColor="text-orange"
expanded={expandedSections.tunnel}
onToggle={() => toggleSection('tunnel')}
>
{Object.entries(stats.byTunnel)
.sort((a, b) => b[1] - a[1])
.map(([tunnel, count]) => (
<FilterItem
key={tunnel}
label={tunnel}
count={count}
active={filters.tunnel === tunnel}
onClick={() => handleFilterClick('tunnel', tunnel)}
/>
))}
</FilterSection>
</div>
{/* Статистика внизу */}
<div
className="px-3 py-3"
style={{
borderTop: '1px solid rgba(0,0,0,0.06)',
background: 'rgba(0,0,0,0.01)'
}}
>
<div className={embedded ? 'py-3 mt-2 pt-3 border-top' : 'card-footer py-3 bg-secondary-lt'}>
<div className="d-flex align-items-center mb-2">
<IconDatabase size={14} className="text-muted me-2" />
<span className="text-muted small">Статистика</span>
<IconDatabase size={16} className="text-muted me-2" />
<span className="text-muted small fw-medium">Статистика</span>
</div>
<div className="d-flex justify-content-between mb-1">
<span className="small text-muted">Всего серверов</span>
<span className="small fw-bold">{servers.length}</span>
<div className="d-flex justify-content-between small mb-1">
<span className="text-muted">Всего серверов</span>
<span className="fw-semibold">{servers.length}</span>
</div>
<div className="d-flex justify-content-between mb-1">
<span className="small text-muted">Выходных нод</span>
<span className="small fw-bold" style={{ color: '#ef4444' }}>{stats.byType['exit'] || 0}</span>
<div className="d-flex justify-content-between small mb-1">
<span className="text-muted">Выходных нод</span>
<span className="fw-semibold text-danger">{stats.byType['exit'] || 0}</span>
</div>
<div className="d-flex justify-content-between">
<span className="small text-muted">Jumphosts</span>
<span className="small fw-bold" style={{ color: '#3b82f6' }}>{stats.byType['jumphost'] || 0}</span>
<div className="d-flex justify-content-between small">
<span className="text-muted">Jumphosts</span>
<span className="fw-semibold text-primary">{stats.byType['jumphost'] || 0}</span>
</div>
</div>
</>
);
if (embedded) {
return <div className="d-flex flex-column h-100">{content}</div>;
}
return (
<div className="server-sidebar card flex-shrink-0 mb-0">
{content}
</div>
);
}
+1
View File
@@ -5,5 +5,6 @@ export { default as LinkGeneratorModal } from './LinkGeneratorModal.jsx';
export { default as AddServerModal } from './AddServerModal.jsx';
export { default as ServerSidebar } from './ServerSidebar.jsx';
export { default as ServerCard } from './ServerCard.jsx';
export { default as ServerGroupCard } from './ServerGroupCard.jsx';
export { default as ServerBulkActions } from './ServerBulkActions.jsx';
+105
View File
@@ -0,0 +1,105 @@
import { createContext, useContext, useState, useCallback, useRef } from 'react';
import api from '../lib/api.js';
/**
* Единая точка входа для пингов до шлюзов (MikroTik).
* Кеш: ключ `${routerId}:${gatewayIp}` → number | null (мс или ошибка).
* TTL: после истечения записи перезапрашиваются при переходе на раздел.
* Используется и на Easy Switch, и на странице фильтров (карточки gateway).
*/
const PingContext = createContext(null);
const PING_COUNT = 5;
/** Время жизни кеша пинга (мс). После истечения при открытии раздела данные перезапрашиваются. */
const PING_CACHE_TTL_MS = 2 * 60 * 1000; // 2 минуты
export function PingProvider({ children }) {
const [pingMap, setPingMap] = useState({});
const inFlightRef = useRef(new Set());
const pingMapRef = useRef(pingMap);
const timestampsRef = useRef({}); // key → Date.now() когда запись установлена
pingMapRef.current = pingMap;
const getKey = (routerId, gatewayIp) => {
if (!routerId || !gatewayIp) return null;
return `${routerId}:${gatewayIp}`;
};
const isCacheValid = useCallback((key) => {
const cache = pingMapRef.current;
if (!Object.prototype.hasOwnProperty.call(cache, key)) return false;
const ts = timestampsRef.current[key];
if (ts == null) return false;
return (Date.now() - ts) < PING_CACHE_TTL_MS;
}, []);
const getPing = useCallback((routerId, gatewayIp) => {
const key = getKey(routerId, gatewayIp);
if (!key) return undefined;
return Object.prototype.hasOwnProperty.call(pingMap, key) ? pingMap[key] : undefined;
}, [pingMap]);
/**
* Запросить пинги для списка пар (routerId, gatewayIp).
* Пропускаются ключи с валидным кешем (не истёк TTL) и уже запрашиваемые.
* @param tasks Array<{ routerId, gatewayIp }>
*/
const requestPings = useCallback((tasks) => {
if (!Array.isArray(tasks) || tasks.length === 0) return;
const toRequest = [];
tasks.forEach(({ routerId, gatewayIp }) => {
const ip = gatewayIp || '';
if (!routerId || !ip) return;
const key = getKey(routerId, ip);
if (!key) return;
if (isCacheValid(key)) return;
if (inFlightRef.current.has(key)) return;
inFlightRef.current.add(key);
toRequest.push({ routerId, ip, key });
});
toRequest.forEach(({ routerId, ip, key }) => {
api
.post('/mikrotik/ping', {
serverId: routerId,
gatewayIp: ip,
count: PING_COUNT,
})
.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) => {
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);
});
});
}, [isCacheValid]);
const value = {
pingMap,
getPing,
requestPings,
getKey,
};
return (
<PingContext.Provider value={value}>
{children}
</PingContext.Provider>
);
}
export function usePing() {
const ctx = useContext(PingContext);
if (!ctx) {
throw new Error('usePing must be used within PingProvider');
}
return ctx;
}
+87 -1
View File
@@ -296,7 +296,7 @@ button:focus-visible,
z-index: 2;
}
/* ===== Table Improvements ===== */
/* ===== Table Improvements (Tabler + UX) ===== */
.table thead th {
font-weight: 600;
text-transform: uppercase;
@@ -318,6 +318,92 @@ button:focus-visible,
background-color: rgba(32, 107, 196, 0.1);
}
/* Колонка «Действия» — фиксированная ширина, не сжимается (лаконичный UX) */
.table .td-actions {
min-width: 140px;
white-space: nowrap;
}
/* Группы кнопок в таблицах: «слитые» Tabler-style action buttons */
.table .btn-group.btn-group-sm {
gap: 0;
}
.table .btn-group.btn-group-sm .btn {
border-radius: 0;
}
.table .btn-group.btn-group-sm .btn + .btn {
margin-left: -1px; /* убираем двойную границу между кнопками */
}
.table .btn-group.btn-group-sm .btn:first-child {
border-top-left-radius: .375rem;
border-bottom-left-radius: .375rem;
}
.table .btn-group.btn-group-sm .btn:last-child {
border-top-right-radius: .375rem;
border-bottom-right-radius: .375rem;
}
/* Выбранная строка поверх zebra (table-striped) */
.table-striped tbody tr.table-selected > * {
--tblr-table-bg-type: rgba(32, 107, 196, 0.08);
}
.table-striped tbody tr.table-selected:hover > * {
--tblr-table-bg-type: rgba(32, 107, 196, 0.12);
}
/* ===== Table flat (без карточной подложки, как UniFi) ===== */
/* Только горизонтальные разделители, thead темнее, без вертикальных границ */
.table-flat {
--tblr-table-border-color: var(--tblr-border-color);
background: transparent;
}
.table-flat thead th {
font-weight: 600;
border-top: none;
border-left: none;
border-right: none;
border-bottom: 1px solid var(--tblr-table-border-color);
background: var(--tblr-bg-surface-secondary, rgba(0, 0, 0, 0.03));
color: var(--tblr-body-color);
padding: 0.75rem 1rem;
white-space: nowrap;
}
.table-flat tbody td {
border-top: none;
border-left: none;
border-right: none;
border-bottom: 1px solid var(--tblr-table-border-color);
background: transparent;
padding: 0.75rem 1rem;
vertical-align: middle;
}
.table-flat tbody tr:hover td {
background: var(--tblr-bg-surface-tertiary, rgba(0, 0, 0, 0.02));
}
.table-flat tbody tr.table-selected td,
.table-flat tbody tr.table-active td {
background: rgba(32, 107, 196, 0.08);
}
.table-flat tbody tr.table-selected:hover td,
.table-flat tbody tr.table-active:hover td {
background: rgba(32, 107, 196, 0.12);
}
/* В тёмной теме — чуть другие оттенки для thead */
[data-bs-theme="dark"] .table-flat thead th {
background: rgba(255, 255, 255, 0.06);
}
[data-bs-theme="dark"] .table-flat tbody tr:hover td {
background: rgba(255, 255, 255, 0.04);
}
[data-bs-theme="dark"] .table-flat tbody tr.table-selected td,
[data-bs-theme="dark"] .table-flat tbody tr.table-active td {
background: rgba(32, 107, 196, 0.15);
}
.user-select-none {
user-select: none;
-webkit-user-select: none;
+1 -1
View File
@@ -15,7 +15,7 @@ import {
// Базовый axios-клиент для всего приложения
const api = axios.create({
baseURL: '/api',
timeout: 30000, // Увеличен до 30 секунд для больших запросов
timeout: 180000, // 3 минуты для долгих запросов (address-lists и др.)
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
+85
View File
@@ -0,0 +1,85 @@
/**
* Реестр брендовых иконок (Tabler Icons) для выбора в настройках пинг-сервисов.
* id — имя компонента (например IconBrandGoogle), label — для поиска и отображения.
*/
import {
IconBrandGoogle,
IconBrandCloudflare,
IconBrandYandex,
IconBrandInstagram,
IconBrandGithub,
IconBrandTelegram,
IconBrandWhatsapp,
IconBrandFacebook,
IconBrandX,
IconBrandYoutube,
IconBrandTiktok,
IconBrandAmazon,
IconBrandApple,
IconBrandDiscord,
IconBrandSpotify,
IconBrandVk,
IconBrandReddit,
IconBrandLinkedin,
IconBrandPinterest,
IconBrandTwitch,
IconBrandSteam,
IconBrandDocker,
IconBrandChrome,
IconBrandFirefox,
IconBrandSafari,
IconBrandEdge,
IconBrandSlack,
IconBrandZoom,
IconBrandSkype,
IconBrandPaypal,
IconBrandStripe,
IconBrandVisa,
IconBrandMastercard,
IconWorld,
} from '@tabler/icons-react';
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 },
];
const ICON_BY_ID = Object.fromEntries(BRAND_ICONS.map((item) => [item.id, item]));
export function getIconById(id) {
return ICON_BY_ID[id] || BRAND_ICONS[0];
}
export default BRAND_ICONS;
+2
View File
@@ -24,6 +24,8 @@ export interface Server {
type: ServerType;
gateway?: string;
gateways: Gateway[];
/** Объединение в UI: серверы с одинаковым groupKey отображаются как один роутер (например мультиван). Зависимости (интерфейсы, gateways) по id не меняются. */
groupKey?: string;
}
export type ServerType = 'jumphost' | 'exit';
+1
View File
@@ -93,6 +93,7 @@ export const SERVER_TYPE_OPTIONS = [
{ value: 'exit', label: 'Выходная нода' },
{ value: 'bgp', label: 'BGP роутер' },
{ value: 'dns', label: 'DNS сервер' },
{ value: 'home', label: 'Входной роутер (домашний)' },
];
/**