Compare commits

...
65 Commits
Author SHA1 Message Date
Denozordec d1095081bc Implement bad connections baseline reset functionality
Publish telemt-api gateway Docker image / test (push) Successful in 9s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m1s
- Added a new field `badConnBaseline` in the `Handler` struct to store the baseline for bad connections.
- Updated the `BuildIncidents` function to utilize the effective bad connections count for incident generation.
- Introduced a new API endpoint `/api/agg/bad-connections/reset` to reset the bad connections baseline.
- Enhanced the frontend with a button to trigger the reset action, providing user feedback through toast notifications.
- Updated Svelte components to handle the reset functionality and display appropriate messages based on the operation's success or failure.
2026-04-12 13:30:56 +07:00
Denozordec ea2ecb44d2 Implement radar-telemt-dcs aggregation endpoint and UI integration
Publish telemt-api gateway Docker image / test (push) Successful in 9s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 1m53s
- Added new API route `/api/agg/radar-telemt-dcs` to aggregate DC status data from multiple upstreams, including metrics like coverage percentage and RTT.
- Implemented handler logic in `handlers.go` and corresponding tests in `handlers_test.go` to ensure correct data retrieval and response formatting.
- Updated the frontend to fetch and display radar DC data, enhancing the user interface with a new section for Telemt ME snapshots.
- Enhanced documentation in `AGGREGATE.md` and `README.md` to reflect the new functionality and usage details.
2026-04-12 13:07:14 +07:00
Denozordec d021e4b1d7 Add Radar DC Telegram functionality and configuration
Publish telemt-api gateway Docker image / test (push) Successful in 23s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m9s
- Introduced new `RadarConfig` structure in `config.go` to manage radar settings, including `statuses_url`, `http_timeout_ms`, and `ping_from`.
- Implemented validation for radar configuration in `config_test.go` to ensure correct URL schemes and timeout limits.
- Added new API routes for radar statuses and ping functionality in the gateway, enhancing the service's capabilities.
- Updated documentation in `GATEWAY_RUN.md` to include details about the new radar features and their usage.
- Enhanced the user interface to include navigation and display options for the Radar DC section in the sidebar and page titles.
- Added client-side API functions for fetching radar statuses and ping responses, improving integration with the frontend.
2026-04-12 12:53:20 +07:00
Denozordec 6b2e38d051 Fix navigation state handling in layout component
Publish telemt-api gateway Docker image / test (push) Successful in 23s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m18s
- Updated the `isNavigating` derived variable to use a boolean conversion instead of a strict null check, preventing the navigation indicator from hanging when `navigating` is undefined.
- Added a comment to clarify the reason for this change, enhancing code readability and maintainability.
2026-04-11 15:25:26 +07:00
Denozordec 7c6892ce0e Enhance DataTableCard component with accent and icon support
Publish telemt-api gateway Docker image / test (push) Successful in 26s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m24s
- Added accent and icon properties to the DataTableCard component for improved visual distinction and context.
- Updated multiple pages to utilize the new accent feature, enhancing the user interface with color-coded cards.
- Incorporated icons in DataTableCard instances across various routes for better representation of content.
2026-04-10 17:19:49 +07:00
Denozordec bfe79ca601 Update layout and component structure in Svelte pages
Publish telemt-api gateway Docker image / test (push) Successful in 28s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m34s
- Adjusted the layout of several Svelte components to ensure consistent spacing and alignment, specifically changing `gap-3` to `gap-4` in multiple instances.
- Replaced Card components with DataTableCard in the Mihomo page for improved data presentation.
- Enhanced text formatting for better readability across various pages, ensuring consistent use of class attributes.
2026-04-10 17:04:45 +07:00
Denozordec c51482e04e Improve alias filter change handling in Svelte components
Publish telemt-api gateway Docker image / test (push) Successful in 28s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m26s
- Added a check to prevent unnecessary updates when the selected alias remains the same, enhancing performance.
- Updated the alias filter select component to utilize the `untrack` function for more efficient value change handling.
- Streamlined the alias filter change logic across main and users pages to ensure consistent behavior.
2026-04-10 16:47:15 +07:00
Denozordec 4ee9c6805c Refactor alias filter handling in Svelte components for improved clarity and performance
Publish telemt-api gateway Docker image / test (push) Successful in 26s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m20s
- Consolidated alias filter change logic into a single function to streamline updates and reduce redundancy.
- Removed the previous `applyAliasIfChanged` function, simplifying the alias filter handling process.
- Ensured immediate data reload upon alias filter change to maintain up-to-date information display.
2026-04-10 16:35:15 +07:00
Denozordec b68ca985d3 Refactor alias filter handling in Svelte components to prevent unnecessary updates
Publish telemt-api gateway Docker image / test (push) Successful in 26s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m22s
- Added a check to prevent alias filter changes from triggering updates if the selected alias remains the same.
- Introduced a reactive effect to apply alias changes only when the component is rendered in the browser, improving performance and clarity in data handling.
2026-04-10 16:17:54 +07:00
Denozordec 12539a22ae Remove redundant alias filter effect from Svelte components to streamline data handling
Publish telemt-api gateway Docker image / test (push) Successful in 26s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m24s
- Eliminated the `$effect` that applied alias changes in both the main and users pages, improving performance and reducing unnecessary checks.
- This change enhances the clarity of the alias filter logic and maintains the focus on essential data updates.
2026-04-10 16:02:22 +07:00
Denozordec af37331284 Enhance alias filter functionality and improve data handling in Svelte components
Publish telemt-api gateway Docker image / test (push) Successful in 26s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m13s
- Updated the alias filter logic to prevent selected aliases from disappearing during partial or empty responses.
- Refactored the alias filter change handling to a dedicated function for better readability and maintainability.
- Removed redundant alias filter validation in the users page to streamline the alias selection process.
2026-04-10 15:51:15 +07:00
Denozordec 68b74a861d Enhance alias filter handling and data loading in Svelte components
Publish telemt-api gateway Docker image / test (push) Successful in 25s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m26s
- Updated the alias filter select component to include an `onValueChange` handler for improved value management.
- Implemented immediate data reload upon filter change to ensure the displayed information is current.
- Removed redundant alias filter check in the users page to streamline the alias change process.
2026-04-10 15:29:35 +07:00
Denozordec 8b996c6e25 Refactor select component to streamline value handling
Publish telemt-api gateway Docker image / test (push) Successful in 28s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m20s
- Removed the `onValueChange` prop from the select component to simplify its interface.
- Updated the alias filter select component to ensure the selected value defaults to "all" if not present in available aliases, enhancing user experience.
2026-04-10 15:22:19 +07:00
Denozordec 3ce37b6067 Refactor alias filter select component to use native select element
Publish telemt-api gateway Docker image / test (push) Successful in 29s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m17s
- Replaced the custom select component with a native HTML select element for improved performance and accessibility.
- Simplified value change handling by directly binding the select's value and updating the onChange event.
- Ensured the selected value defaults to "all" if not present in available aliases, enhancing user experience.
2026-04-10 15:08:39 +07:00
Denozordec 7ae3c5a806 Enhance alias filter select component and integrate value change handling
Publish telemt-api gateway Docker image / test (push) Successful in 25s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m20s
- Added an `onValueChange` callback to the alias filter select component for improved event handling.
- Updated the component's logic to normalize the selected value and trigger the callback on changes.
- Implemented the alias filter change handling in the IPs and users pages to apply changes effectively.
2026-04-10 14:57:35 +07:00
Denozordec 9db5a1e97c Add user creation button and improve data loading logic
Publish telemt-api gateway Docker image / test (push) Successful in 25s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m11s
- Introduced a button for creating users, which prompts the user to select a specific node before proceeding.
- Enhanced data loading logic to handle scenarios where the URL changes without immediate data refresh, ensuring up-to-date information is displayed.
- Added Russian translations for new UI elements to maintain localization consistency.
2026-04-10 14:13:42 +07:00
Denozordec 2373ed388b Replace KpiTrendCard with KpiStatCard for incident metrics display, enhancing visual consistency and user experience. Update empty state messages in the data table and adjust layout properties for improved responsiveness.
Publish telemt-api gateway Docker image / test (push) Successful in 30s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m28s
2026-04-10 01:02:05 +07:00
Denozordec 8ad627cb9b Translate UI text to Russian for improved localization across various components and pages, including sidebar, pagination, and server details. Update labels, tooltips, and headers to enhance user experience for Russian-speaking users.
Publish telemt-api gateway Docker image / test (push) Successful in 30s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m29s
2026-04-10 00:17:52 +07:00
Denozordec 297fb79f11 Unify overview and users pages with GT1 visual style.
Publish telemt-api gateway Docker image / test (push) Successful in 26s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m15s
Align headers and card rhythm, add accent KPI cards to users, and tune shared table/trend card styling for consistent UI language.

Made-with: Cursor
2026-04-09 23:46:05 +07:00
Denozordec ba9d2fc374 Remove KPI trend subtitles on server dashboard.
Publish telemt-api gateway Docker image / test (push) Successful in 27s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m19s
Drop per-card trend footer text and related previous-value calculation from the server overview KPI block.

Made-with: Cursor
2026-04-09 23:30:40 +07:00
Denozordec 7c731b93c1 Standardize server dashboard KPI style to match Mihomo.
Publish telemt-api gateway Docker image / test (push) Successful in 26s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m16s
Switch server overview KPI block to KpiStatCard with accent icons, align header copy/layout, and sync skeleton grid density with the Mihomo pattern.

Made-with: Cursor
2026-04-09 23:20:37 +07:00
Denozordec 5564ab8a62 Refactor server dashboard pages with reusable UI blocks.
Publish telemt-api gateway Docker image / test (push) Successful in 27s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m28s
Extract shared header, query-state, and JSON panel components, then apply them across server runtime/security/upstreams/update/config views for consistent Telemt panel UX.

Made-with: Cursor
2026-04-09 23:00:01 +07:00
Denozordec e1f16799ba Refactor select component to enhance value binding and event handling
Publish telemt-api gateway Docker image / test (push) Successful in 25s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m19s
- Updated the alias filter select component to bind the selected value directly, simplifying the value change logic.
- Enhanced the select component to support an optional `onValueChange` callback, improving flexibility in handling value changes.
2026-03-31 22:02:12 +07:00
Denozordec 4eb53c91c9 Refactor alias filter select component to improve value handling
Publish telemt-api gateway Docker image / test (push) Successful in 30s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m50s
- Updated the alias filter select component to ensure the selected value is correctly set on value change, defaulting to "all" only when no value is provided.
- Enhanced the component's logic for better state management during user interactions.
2026-03-31 21:42:38 +07:00
Denozordec dade8253c4 Refactor alias filter select component to prevent automatic reset of selected alias
Publish telemt-api gateway Docker image / test (push) Successful in 29s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m31s
- Updated logic in the alias filter select component to avoid resetting the selected alias to "all" during loading when the available aliases may be temporarily empty.
- Ensured that the selected value defaults to "all" only if no value is provided.
2026-03-31 21:33:34 +07:00
Denozordec 81331dd406 Enhance chart components with improved hover effects and color consistency
Publish telemt-api gateway Docker image / test (push) Successful in 28s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m16s
- Updated background and hover colors for doughnut and bar charts in both fleet and mihomo components to use defined color variables for better maintainability.
- Added hover background and border colors for data points in line charts to enhance user interaction.
- Improved overall visual consistency across chart components by standardizing color usage.
2026-03-31 21:11:39 +07:00
Denozordec edd0d7c25e Update card component class for improved styling consistency
Publish telemt-api gateway Docker image / test (push) Successful in 26s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m19s
- Modified the Card component class in the mihomo page to enhance visual alignment and maintain a cohesive appearance throughout the application.
2026-03-31 20:58:50 +07:00
Denozordec d6e3b4561b Refactor IPs table layout for improved styling and structure
Publish telemt-api gateway Docker image / test (push) Successful in 26s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m24s
- Adjusted the ScrollArea and surrounding div structure to enhance layout consistency.
- Updated class properties for better visual alignment and responsiveness in the IPs table component.
2026-03-31 20:49:37 +07:00
Denozordec dbbbf41a1a Add pagination to IPs table for improved navigation
Publish telemt-api gateway Docker image / test (push) Successful in 26s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m40s
2026-03-31 20:37:14 +07:00
Denozordec 6f5f123aa8 Refactor card component styles for improved layout consistency
Publish telemt-api gateway Docker image / test (push) Successful in 25s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m25s
- Standardized Card component classes across multiple files by removing unnecessary padding and gaps, enhancing visual alignment and creating a more cohesive appearance throughout the application.
2026-03-31 20:27:09 +07:00
Denozordec c8724268e9 Refactor card components to standardize density and layout
Publish telemt-api gateway Docker image / test (push) Successful in 24s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m17s
- Updated Card component styles across multiple files to introduce a `density` prop, allowing for compact layouts and improved visual consistency.
- Removed unnecessary padding from card components to enhance alignment and create a more cohesive appearance throughout the application.
2026-03-31 20:15:30 +07:00
Denozordec 31d0db57f1 Refactor card components for enhanced layout consistency
Publish telemt-api gateway Docker image / test (push) Successful in 26s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m22s
- Updated Card component styles across multiple files to standardize padding and gap settings, improving visual alignment and creating a cohesive appearance throughout the application.
2026-03-31 20:08:22 +07:00
Denozordec 4f80a8c0d1 Refactor card components for improved layout consistency
Publish telemt-api gateway Docker image / test (push) Successful in 26s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m21s
- Updated padding and gap settings in the Card component across multiple files to enhance visual alignment and maintain a cohesive appearance.
2026-03-31 19:58:48 +07:00
Denozordec 1c919a636e Update card component styles for improved layout and consistency
Publish telemt-api gateway Docker image / test (push) Successful in 26s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m22s
- Adjusted padding and gap settings in the Card component for a more cohesive appearance.
- Enhanced the DataTableCard component by refining its class properties to ensure better alignment and visual consistency.
2026-03-31 19:52:04 +07:00
Denozordec d5d0326bf3 Refactor card and user page components for improved styling and layout
Publish telemt-api gateway Docker image / test (push) Successful in 25s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m25s
- Adjusted padding and gap settings in the card component for a cleaner appearance.
- Updated the user page layout to enhance the ScrollArea and table styling, ensuring better alignment and visual consistency.
- Improved the overall structure of the user page for a more cohesive user experience.
2026-03-31 19:45:07 +07:00
Denozordec e4e164aee9 Refactor data table components for improved styling consistency
Publish telemt-api gateway Docker image / test (push) Successful in 27s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m25s
- Removed unnecessary background color from the data table toolbar for a cleaner look.
- Updated text color in sortable table headers to enhance readability and maintain a cohesive theme.
- Adjusted padding and border styles in user page components for better alignment and visual consistency.
2026-03-31 19:27:23 +07:00
Denozordec 9533241624 Refactor data table components for improved layout and functionality
Publish telemt-api gateway Docker image / test (push) Successful in 27s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m22s
- Made the `title` prop optional in `DataTableCard`, allowing for more flexible usage without a title.
- Updated the rendering logic in `DataTableCard` to conditionally display the title and description.
- Enhanced the styling of table headers across various pages for a more consistent appearance, removing unnecessary backdrop blur effects.
- Replaced button elements with native button tags in `SortableTh` for better accessibility and semantic HTML.
2026-03-31 19:07:04 +07:00
Denozordec 8e616cd1c4 Enhance chart theming and functionality across fleet and Mihomo components
Publish telemt-api gateway Docker image / test (push) Successful in 26s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m33s
- Introduced `chartColorWithAlpha` function to manage color transparency, improving visual consistency in charts.
- Updated chart configurations in fleet and Mihomo components to utilize dynamic theming for background and border colors, enhancing user experience.
- Implemented `chartLineAreaFillCallback` for better area fill effects in line charts, providing a more polished visual representation.
- Refactored chart initialization logic to ensure proper handling of color and gradient settings, improving performance and responsiveness.
2026-03-31 18:54:22 +07:00
Denozordec 89b6cc5185 Enhance Mihomo overview charts component with improved theming and chart management
Publish telemt-api gateway Docker image / test (push) Successful in 25s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m25s
- Integrated dynamic theming for chart colors using a centralized theme file, enhancing visual consistency across charts.
- Refactored chart initialization logic to ensure proper destruction and recreation of charts on data updates, improving performance and responsiveness.
- Updated chart options to utilize theme colors for grid, ticks, and legends, providing a more cohesive user experience.
2026-03-31 18:37:03 +07:00
Denozordec 054f58b071 Refactor Mihomo proxy groups component to optimize data handling and prevent rendering issues
Publish telemt-api gateway Docker image / test (push) Successful in 25s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m55s
- Utilized `untrack` from Svelte to avoid infinite rendering loops during asynchronous operations.
- Improved loading state management by ensuring notifications are handled correctly without causing UI flickering.
- Enhanced data fetching logic for proxy groups, ensuring stable access to test URLs and reducing potential delays in updates.
2026-03-31 18:16:45 +07:00
Denozordec ed015bd4ee Refactor Mihomo proxy groups component for improved styling and accessibility
Publish telemt-api gateway Docker image / test (push) Successful in 26s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m13s
- Simplified the layout of compact mode in the MihomoProxyGroups component by removing unnecessary styling elements.
- Enhanced the accessibility of group elements by maintaining essential ARIA attributes while streamlining the overall structure.
- Improved readability and user experience through cleaner code and layout adjustments.
2026-03-31 18:04:42 +07:00
Denozordec 5cfbf7f6a1 Enhance Mihomo proxy groups component with new delay management features
Publish telemt-api gateway Docker image / test (push) Successful in 28s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m23s
- Added new props for managing delay checks across all groups, improving user control over delay monitoring.
- Implemented a loading state for delay checks to provide feedback during asynchronous operations.
- Updated UI elements to conditionally display delay buttons, enhancing the overall user experience and interaction with proxy groups.
2026-03-31 17:59:03 +07:00
Denozordec 56832e525d Refactor Mihomo proxy groups component to improve loading state management
Publish telemt-api gateway Docker image / test (push) Successful in 27s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m23s
- Introduced `untrack` from Svelte to prevent infinite rendering loops during loading state changes.
- Enhanced loading state handling by ensuring that loading notifications are managed correctly without causing UI flickering.
- Improved overall data fetching logic for better user experience during proxy updates.
2026-03-31 17:50:54 +07:00
Denozordec 2fd98f8c3d Enhance Mihomo proxy groups component with new features and improved UI
Publish telemt-api gateway Docker image / test (push) Successful in 25s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m12s
- Added new props to the MihomoProxyGroups component for better control over loading states and toolbar visibility.
- Implemented a refresh button in the Mihomo page to manually trigger proxy updates, enhancing user interaction.
- Updated the layout and styling of proxy groups for improved readability and user experience.
- Enhanced loading indicators to provide clearer feedback during data fetching operations.
2026-03-31 17:42:21 +07:00
Denozordec 86a5360f56 Update Mihomo page layout for improved responsiveness
Publish telemt-api gateway Docker image / test (push) Successful in 27s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m13s
- Adjusted grid classes in the Mihomo page to enhance layout flexibility, transitioning from a fixed column setup to a more responsive design.
- Improved the display of proxy groups by allowing for a greater number of columns on larger screens, optimizing user experience across devices.
2026-03-31 17:27:10 +07:00
Denozordec 55354d3270 Enhance Mihomo proxy groups component with improved data handling and UI updates
Publish telemt-api gateway Docker image / test (push) Successful in 28s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m14s
- Introduced a stable order for selectable proxy groups to minimize DOM changes during JSON updates.
- Added a refresh icon to the update button for better user feedback during loading states.
- Enhanced error handling to prevent UI flickering when fetching proxy data.
- Updated layout in the Mihomo page to utilize a grid system for better responsiveness and organization.
2026-03-31 17:18:23 +07:00
Denozordec 398c776238 Refactor Mihomo proxy groups component for improved layout and functionality
Publish telemt-api gateway Docker image / test (push) Successful in 28s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m31s
- Replaced the lazy loading mechanism with a compact mode for better user experience and performance.
- Enhanced the layout of proxy groups to support a more flexible display, accommodating both compact and standard views.
- Updated button styles and descriptions for clarity, ensuring a more intuitive interaction with proxy nodes.
- Adjusted the overall structure of the Mihomo page to streamline the presentation of proxy data and improve accessibility.
2026-03-31 17:10:50 +07:00
Denozordec 206fee9ff9 Refactor Mihomo page layout and remove unused components
Publish telemt-api gateway Docker image / test (push) Successful in 29s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m34s
- Removed the Tabs component and associated state variable to simplify the Mihomo page structure.
- Updated the card descriptions for clarity and improved user guidance on proxy settings.
- Enhanced the layout for displaying proxies by introducing a new section with clearer instructions and a more organized presentation of nodes.
2026-03-31 16:59:53 +07:00
Denozordec e2d89f2d11 Add Mihomo menu item and refactor page component
Publish telemt-api gateway Docker image / test (push) Successful in 28s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m35s
- Introduced a new menu item for Mihomo in the app sidebar for easier navigation.
- Refactored the Mihomo page component by removing unused imports and state variables, streamlining the code for better performance and readability.
- Enhanced the overall structure of the Mihomo page to improve maintainability and user experience.
2026-03-31 16:51:26 +07:00
Denozordec 8bf4bb7f35 Enhance error handling in gateway and configuration
Publish telemt-api gateway Docker image / test (push) Successful in 24s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m6s
- Added `expose_upstream_errors` option to the configuration, allowing detailed error messages in JSON responses for 502 errors.
- Implemented `writeBadGatewayJSON` function to streamline JSON error responses, including upstream error details when enabled.
- Updated documentation to reflect changes in configuration and error handling behavior for improved diagnostics.
2026-03-31 14:30:15 +07:00
Denozordec deb12e72c0 Refactor Mihomo overview charts and statistics display
Publish telemt-api gateway Docker image / test (push) Successful in 31s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m15s
- Replaced Card components with KpiPanelCard and KpiStatCard for a more consistent and visually appealing layout.
- Updated chart and statistics sections to include descriptive footers and icons, enhancing user experience and clarity.
- Improved grid layout for better responsiveness and spacing in the overview section.
2026-03-31 13:31:55 +07:00
Denozordec 8da50bd254 Refactor memory handling in Mihomo to use KiB
- Updated memory variable names and comments to reflect usage in KiB instead of KB for clarity.
- Introduced a function to convert memory values from bytes to KiB, ensuring consistent data representation across components.
- Adjusted chart data to display memory in KiB, enhancing accuracy in visualizations.
2026-03-31 13:26:20 +07:00
Denozordec c3a0a771a3 Enhance Mihomo WebSocket functionality and testing for connections
Publish telemt-api gateway Docker image / test (push) Successful in 25s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m18s
- Added support for WebSocket connections to the `/connections` endpoint, allowing for improved handling of WebSocket requests.
- Updated the `useMihomoWebSocketTunnel` function to accept `Sec-WebSocket-Key` for `/connections`, enhancing compatibility with various proxies.
- Introduced new test cases in `mihomo_test.go` to validate WebSocket tunnel behavior for connections, ensuring comprehensive coverage.
- Enhanced the `applyMihomoWSDialAuth` function to manage token handling for WebSocket connections, improving security and functionality.
- Updated Svelte components to implement native WebSocket loops for connections, providing real-time data updates and improved user experience.
2026-03-31 11:54:18 +07:00
Denozordec 49f36d2b8b Enhance Mihomo WebSocket functionality and testing
Publish telemt-api gateway Docker image / test (push) Successful in 24s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 1m59s
- Added support for the Gorilla WebSocket library to improve WebSocket handling in Mihomo.
- Refactored the WebSocket upgrade detection logic to utilize `websocket.IsWebSocketUpgrade`, enhancing reliability.
- Updated the `NewMihomoForward` function to streamline request handling and improve path normalization.
- Introduced new test cases for WebSocket tunnel scenarios, ensuring comprehensive coverage for traffic and memory endpoints.
- Improved error handling and connection management for WebSocket upgrades, ensuring robust communication.
2026-03-31 11:41:56 +07:00
Denozordec 7e88cfcb3e Refactor Mihomo WebSocket handling and enhance tests
Publish telemt-api gateway Docker image / test (push) Successful in 25s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 1m59s
- Updated the WebSocket upgrade detection logic in `isWebSocketUpgrade` to improve header handling and added a fallback for the "Sec-WebSocket-Key" header.
- Refactored the WebSocket proxy logic to use a new `newMihomoWSTunnel` function, enhancing the connection handling process.
- Introduced comprehensive test cases in `TestIsWebSocketUpgrade` to validate various WebSocket upgrade scenarios, ensuring robust functionality.
- Improved error handling and request building for WebSocket upgrades, ensuring secure and efficient communication.
2026-03-31 11:19:40 +07:00
Denozordec 65d153df99 Implement WebSocket proxy handling in Mihomo
Publish telemt-api gateway Docker image / test (push) Successful in 25s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m8s
- Added a new function to handle WebSocket connections, including hijacking the client connection and establishing a connection to the upstream WebSocket server.
- Enhanced error handling for connection issues and improved request cloning for WebSocket upgrades.
- Introduced utility functions for dialing WebSocket upstream and writing raw HTTP errors, ensuring robust communication and error reporting.
- Refactored the existing proxy logic to accommodate the new WebSocket handling, improving overall functionality and reliability.
2026-03-31 10:52:16 +07:00
Denozordec 77a6c1e31e Enhance WebSocket upgrade detection in Mihomo
Publish telemt-api gateway Docker image / test (push) Successful in 26s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 1m57s
- Updated the `isWebSocketUpgrade` function to improve handling of WebSocket upgrade requests by considering the presence of the "Sec-WebSocket-Key" header as a strong indicator.
- Added a new test case in `TestIsWebSocketUpgrade` to validate the detection logic for requests with the "Sec-WebSocket-Key" header, ensuring comprehensive test coverage.
2026-03-31 10:28:06 +07:00
Denozordec 1686840b0e Add WebSocket upgrade handling and tests in Mihomo
Publish telemt-api gateway Docker image / test (push) Successful in 26s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m0s
- Implemented enhanced logic in `isWebSocketUpgrade` to accurately determine WebSocket upgrade requests by checking both "Connection" and "Upgrade" headers.
- Added a new test function `TestIsWebSocketUpgrade` to validate the WebSocket upgrade detection logic, ensuring correct behavior for various header configurations.
2026-03-31 10:22:37 +07:00
Denozordec 6c1f8a8a37 Add WebSocket support methods to statusWriter in gateway.go
Publish telemt-api gateway Docker image / test (push) Successful in 28s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m3s
- Implemented Hijack method to preserve WebSocket upgrade support through middleware.
- Added ReadFrom method to maintain fast-path behavior for io.Copy when the underlying writer supports it.
- Introduced Unwrap method to allow access to the original ResponseWriter, enhancing flexibility in response handling.
2026-03-31 09:57:15 +07:00
Denozordec 309b4a0e64 Update Mihomo WebSocket handling to clarify native connection usage
Publish telemt-api gateway Docker image / test (push) Successful in 25s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m17s
- Modified the Svelte component to include a comment explaining the use of native WebSocket connections for real-time traffic and memory metrics, emphasizing the importance of direct connections for immediate issue detection during upgrades.
2026-03-31 09:41:26 +07:00
Denozordec cb6ecd46b1 Refactor Mihomo WebSocket URL handling and improve Svelte data processing
Publish telemt-api gateway Docker image / test (push) Successful in 32s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m28s
- Updated the `mihomoWsUrl` function to handle cases where the gateway base URL may be undefined, ensuring robust WebSocket URL generation.
- Enhanced the Svelte component to read and process memory metrics more reliably by implementing a mechanism to capture multiple lines of data before concluding the read operation, improving data accuracy.
2026-03-31 09:26:59 +07:00
Denozordec 8b7233159a Enhance Mihomo WebSocket handling and documentation updates
Publish telemt-api gateway Docker image / test (push) Successful in 29s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m26s
- Updated `GATEWAY_RUN.md` to clarify the use of native WebSocket connections for `/traffic` and `/memory`, detailing the authorization process and fallback HTTP GET requests.
- Modified the Svelte component to implement native WebSocket loops for real-time traffic and memory metrics, improving data retrieval and connection stability.
- Refactored the traffic and memory polling functions to integrate WebSocket handling, ensuring a more robust and responsive user experience.
2026-03-31 01:47:23 +07:00
Denozordec 82dd7dd1bf Implement exact active state matching for sidebar menu items
Publish telemt-api gateway Docker image / test (push) Successful in 29s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m20s
- Added a new function `activeExact` to ensure only exact matches highlight the sidebar menu items, improving navigation clarity for root nodes.
- Updated the `Sidebar.MenuButton` to utilize the new exact matching function for the Dashboard link, enhancing user experience by preventing incorrect highlighting of nested pages.
2026-03-31 01:39:55 +07:00
Denozordec 06141dbad0 Refactor Mihomo page to implement tab navigation for overview and proxies
Publish telemt-api gateway Docker image / test (push) Successful in 25s
Publish telemt-api gateway Docker image / build-and-push (push) Has been cancelled
- Replaced button-based navigation with a tab component for improved user experience and UI consistency.
- Updated state management for the active tab, enhancing the overall functionality of the Mihomo interface.
2026-03-31 01:37:05 +07:00
Denozordec f951e7ef8d Enhance Mihomo traffic and memory handling with health check support
Publish telemt-api gateway Docker image / test (push) Successful in 27s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m19s
- Updated `GATEWAY_RUN.md` to clarify the use of HTTP GET requests for traffic and memory metrics, ensuring compatibility with upstream services.
- Added `testUrl` property to `MihomoProxyEntry` type for health check configuration.
- Refactored Svelte component to implement a delay query function, improving error handling and stability during data retrieval.
- Renamed functions for clarity and improved the polling mechanism for traffic and memory data, enhancing overall performance.
2026-03-31 01:33:22 +07:00
88 changed files with 6050 additions and 874 deletions

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
# telemt-api # telemt-api
HTTP‑шлюз на Go для [Telemt Control API](docs/API.md): один порт, **белый список IP (CIDR)**, маршруты вида `/api/{alias}/…` → `{base_url}/v1/…`, опционально **Mihomo** — `/api/{alias}/mihomo/…` к external-controller (см. [docs/GATEWAY_RUN.md](docs/GATEWAY_RUN.md#mihomo-external-controller)), агрегация нескольких инстансов — [`/api/agg/…`](docs/AGGREGATE.md), live SSE поток — `/api/live/events`, метрики Prometheus на `/metrics`. **Web UI** (SvelteKit) встроен в тот же процесс/образ: статика на `/`, API на `/api/…` и `/health`, раздел **Mihomo** на `/servers/{alias}/mihomo`. HTTP‑шлюз на Go для [Telemt Control API](docs/API.md): один порт, **белый список IP (CIDR)**, маршруты вида `/api/{alias}/…` → `{base_url}/v1/…`, опционально **Mihomo** — `/api/{alias}/mihomo/…` к external-controller (см. [docs/GATEWAY_RUN.md](docs/GATEWAY_RUN.md#mihomo-external-controller)), агрегация нескольких инстансов — [`/api/agg/…`](docs/AGGREGATE.md) (в т.ч. `GET /api/agg/radar-telemt-dcs` — `stats/dcs` по всем нодам для радара), live SSE поток — `/api/live/events`, **радар DC Telegram** — `GET /api/radar/statuses` и `GET /api/radar/ping-dc` (см. [docs/GATEWAY_RUN.md](docs/GATEWAY_RUN.md#radar-dc-telegram)), метрики Prometheus на `/metrics`. **Web UI** (SvelteKit) встроен в тот же процесс/образ: статика на `/`, API на `/api/…` и `/health`, раздел **Mihomo** на `/servers/{alias}/mihomo`, **Радар DC** на `/radar`.
## Быстрый старт (Linux) ## Быстрый старт (Linux)
+10
View File
@@ -8,6 +8,10 @@
listen: ":8080" listen: ":8080"
# Диагностика: при 502 от прокси в JSON добавляется error.detail с текстом ошибки RoundTrip (TLS, DNS, таймаут).
# Включайте только во внутренней сети — строка может содержать хосты/порты upstream.
# expose_upstream_errors: true
# If true, IP whitelist is not enforced (development only). # If true, IP whitelist is not enforced (development only).
allow_all: false allow_all: false
@@ -39,6 +43,12 @@ trusted_proxies: []
# - gt2 # - gt2
# cache_ttl_ms: 2000 # cache_ttl_ms: 2000
# Радар DC Telegram: прокси к radar.telemt.top и TCP-пинг DC с хоста шлюза (см. README).
# radar:
# statuses_url: "https://radar.telemt.top/api/v1/endpoints/statuses"
# http_timeout_ms: 30000
# ping_from: "stats.example.com"
# Геолокация IP в /api/agg/unique-ips. См. docs/GEOIP.md (City + опционально ASN; Country-only не нужен) # Геолокация IP в /api/agg/unique-ips. См. docs/GEOIP.md (City + опционально ASN; Country-only не нужен)
# geoip: # geoip:
# enabled: true # enabled: true
+2
View File
@@ -4,6 +4,7 @@
- Большинство маршрутов агрегации используют **`GET /v1/stats/users`** на каждом сервере из конфигурации. - Большинство маршрутов агрегации используют **`GET /v1/stats/users`** на каждом сервере из конфигурации.
- **`GET /api/agg/fleet-status`** дополнительно вызывает на каждом upstream **`GET /v1/health`** и **`GET /v1/system/info`** (параллельно по серверам). - **`GET /api/agg/fleet-status`** дополнительно вызывает на каждом upstream **`GET /v1/health`** и **`GET /v1/system/info`** (параллельно по серверам).
- **`GET /api/agg/radar-telemt-dcs`** — на каждом upstream параллельно **`GET /v1/stats/dcs`** (снимок ME / DC для панели «Радар DC»); см. [API.md](API.md) про `minimal_runtime_enabled` и поля `DcStatusData`.
**Единицы трафика в агрегатах:** поля `*_megabytes` — это **двоичные мегабайты (MiB)**, 1 MiB = 1024² октетов (как у Telemt в ответе считаются октеты, шлюз делит на MiB для удобства). **Единицы трафика в агрегатах:** поля `*_megabytes` — это **двоичные мегабайты (MiB)**, 1 MiB = 1024² октетов (как у Telemt в ответе считаются октеты, шлюз делит на MiB для удобства).
@@ -33,6 +34,7 @@
| GET | `/api/agg/users` | Объединённый список пользователей с `by_server`, суммарным `total_megabytes` и **смерженными лимитами** (см. ниже). | | GET | `/api/agg/users` | Объединённый список пользователей с `by_server`, суммарным `total_megabytes` и **смерженными лимитами** (см. ниже). |
| GET | `/api/agg/user/{username}` | Один пользователь в том же формате, что элементы `/api/agg/users` (без списка всех). Имя в пути: `[A-Za-z0-9_.-]+`. Ответ **`404`**, если пользователь не найден ни на одном успешном upstream. | | GET | `/api/agg/user/{username}` | Один пользователь в том же формате, что элементы `/api/agg/users` (без списка всех). Имя в пути: `[A-Za-z0-9_.-]+`. Ответ **`404`**, если пользователь не найден ни на одном успешном upstream. |
| GET | `/api/agg/fleet-status` | По каждому алиасу: параллельно health + system/info; в `data.servers[]` — статусы подзапросов и тела `health` / `system_info` при успехе. См. [AGGREGATE_OPENAPI.yaml](AGGREGATE_OPENAPI.yaml). | | GET | `/api/agg/fleet-status` | По каждому алиасу: параллельно health + system/info; в `data.servers[]` — статусы подзапросов и тела `health` / `system_info` при успехе. См. [AGGREGATE_OPENAPI.yaml](AGGREGATE_OPENAPI.yaml). |
| GET | `/api/agg/radar-telemt-dcs` | По каждому алиасу: параллельно `GET /v1/stats/dcs`; в `data.servers[]` — `alias`, `ok`, при успехе объект `data` (поля `middle_proxy_enabled`, `reason`, `dcs[]` с `dc`, `coverage_pct`, `rtt_ms` и т.д.). |
| GET | `/api/agg/incidents` | Нормализованный snapshot инцидентов для triage-панели: `critical/warning/info`, `affected_aliases`, рекомендуемые `actions` (runbook/deep links), счётчики по severity. | | GET | `/api/agg/incidents` | Нормализованный snapshot инцидентов для triage-панели: `critical/warning/info`, `affected_aliases`, рекомендуемые `actions` (runbook/deep links), счётчики по severity. |
Все методы — **GET**; действует тот же whitelist, что и для остального API шлюза. Все методы — **GET**; действует тот же whitelist, что и для остального API шлюза.
+39 -1
View File
@@ -96,6 +96,18 @@ servers:
| `mihomo_base_url_env` | Имя переменной окружения; если задано и значение **непустое**, URL контроллера берётся из `os.Getenv` при старте (удобно в Docker без хардкода IP). Если env пустой, используется `mihomo_base_url`. | | `mihomo_base_url_env` | Имя переменной окружения; если задано и значение **непустое**, URL контроллера берётся из `os.Getenv` при старте (удобно в Docker без хардкода IP). Если env пустой, используется `mihomo_base_url`. |
| `mihomo_authorization_env` | Имя env: **полное** значение заголовка `Authorization` (например `Bearer <secret>`), как у `authorization_env` для Telemt. Должно совпадать с секретом на стороне Mihomo (`secret` / `CLASH_SECRET` в конфиге ядра). | | `mihomo_authorization_env` | Имя env: **полное** значение заголовка `Authorization` (например `Bearer <secret>`), как у `authorization_env` для Telemt. Должно совпадать с секретом на стороне Mihomo (`secret` / `CLASH_SECRET` в конфиге ядра). |
У **нескольких** записей `servers[]` можно задать **разные** имена переменных (`mihomo_authorization_env: TELEMT_MIHOMO_MTG` и `…_GT1`), если секреты контроллеров на нодах различаются. Одно и то же имя env для всех нод — нормально, если везде один и тот же токен.
#### Локальный Mihomo (HTTP в Docker) работает, удалённый (HTTPS) даёт 502 через шлюз
Прямой `curl` с хоста к `https://…:8443` может быть успешен, а шлюз при этом отдаёт `502` с `mihomo upstream unreachable`: исходящий запрос делает **процесс шлюза** (часто контейнер). Отличия от «рабочего» `curl`:
- другая сеть/DNS из контейнера;
- другой исходящий IP на стороне nginx Mihomo (whitelist `allow`);
- ошибка TLS при проверке сертификата из окружения процесса.
Включите в `config.yaml` **`expose_upstream_errors: true`**, перезапустите шлюз и повторите запрос: в JSON появится **`error.detail`** с текстом ошибки (`x509: …`, `dial tcp …`, `lookup …` и т.д.). После диагностики флаг отключите.
Правила: Правила:
- Если указан `mihomo_base_url` или `mihomo_base_url_env`, обязательно задайте `mihomo_authorization_env` и непустые значения в env при старте шлюза. - Если указан `mihomo_base_url` или `mihomo_base_url_env`, обязательно задайте `mihomo_authorization_env` и непустые значения в env при старте шлюза.
@@ -126,7 +138,7 @@ services:
|-----|--------| |-----|--------|
| **REST** (GET `/version`, `/proxies`, `/connections`, POST к API контроллера и т.д.) | Тот же путь, что и прокси к Telemt: `http.NewRequest` + `RoundTrip` (`internal/proxy/forward.go`, общая логика `newAliasForward`). Так совпадает с рабочим `curl` к upstream. | | **REST** (GET `/version`, `/proxies`, `/connections`, POST к API контроллера и т.д.) | Тот же путь, что и прокси к Telemt: `http.NewRequest` + `RoundTrip` (`internal/proxy/forward.go`, общая логика `newAliasForward`). Так совпадает с рабочим `curl` к upstream. |
| **Не** единый `httputil.ReverseProxy` на весь Mihomo | Для обычного HTTP `ReverseProxy` нередко даёт **400** у строгих upstream при том, что прямой запрос работает. | | **Не** единый `httputil.ReverseProxy` на весь Mihomo | Для обычного HTTP `ReverseProxy` нередко даёт **400** у строгих upstream при том, что прямой запрос работает. |
| **WebSocket** (`/traffic`, `/memory`, …) | На upstream — отдельно `httputil.ReverseProxy` + `Rewrite` (`internal/proxy/mihomo.go`). **Веб-панель** для графиков скорости и памяти использует **потоковый HTTP GET** к тем же путям (как в Mihomo без `Upgrade`): так же проходит через шлюз, что и `/connections`, без WebSocket за nginx. | | **WebSocket** (`/traffic`, `/memory`, …) | На upstream — отдельно `httputil.ReverseProxy` + `Rewrite` (`internal/proxy/mihomo.go`). **Веб-панель** использует нативный **WebSocket** к шлюзу для метрик и параллельно короткие **HTTP GET** (резерв); шлюз подставляет `Authorization` к Mihomo, токен в браузер не передаётся. |
| Заголовок **Host** | Не пересылается с клиента; на upstream уходит authority из `mihomo_base_url` (как для Telemt и `base_url`). | | Заголовок **Host** | Не пересылается с клиента; на upstream уходит authority из `mihomo_base_url` (как для Telemt и `base_url`). |
Проверки конфигурации: Проверки конфигурации:
@@ -134,6 +146,32 @@ services:
- `mihomo_authorization_env` в YAML — это **имя** переменной; **полная** строка `Authorization` (например `Bearer <secret>`) задаётся в **environment** контейнера gateway, не в YAML. - `mihomo_authorization_env` в YAML — это **имя** переменной; **полная** строка `Authorization` (например `Bearer <secret>`) задаётся в **environment** контейнера gateway, не в YAML.
- После изменений в этом месте пересоберите образ / задеплойте актуальный бинарь — иначе будет старое поведение. - После изменений в этом месте пересоберите образ / задеплойте актуальный бинарь — иначе будет старое поведение.
<a id="radar-dc-telegram"></a>
### Радар DC Telegram
Два встроенных маршрута (аналог `radar_proxy.php` и `ping_proxy.php` из [telemt-proxy-panel-bot-firewall](https://github.com/upe4d/telemt-proxy-panel-bot-firewall)):
| Маршрут | Назначение |
|---------|------------|
| `GET /api/radar/statuses` | Прокси JSON с `https://radar.telemt.top/api/v1/endpoints/statuses` (без CORS в браузере к внешнему хосту). При ошибке исходящего запроса — HTTP 500 и `{"error":"failed"}`. |
| `GET /api/radar/ping-dc` | TCP-подключение с **хоста шлюза** к каждому из пяти DC Telegram на порт **443**, таймаут **2 с**; JSON с полями `ok`, `results`, `from` (как в PHP-панели). |
В **Web UI** раздел **«Радар DC»**: путь `/radar`.
Опциональный блок в `config.yaml` (см. [config.example.yaml](../config.example.yaml)):
```yaml
radar:
statuses_url: "https://radar.telemt.top/api/v1/endpoints/statuses"
http_timeout_ms: 30000
ping_from: "stats.example.com"
```
Поле `ping_from` задаёт строку `from` в ответе `ping-dc`; если пусто — используется заголовок `Host` запроса (без порта) или значение `telemt-gateway`.
Зарезервированы **только** пути `/api/radar/statuses` и `/api/radar/ping-dc`. Остальные URL вида `/api/radar/…` по-прежнему относятся к серверу с **alias** `radar` в `servers[]`, если такой задан.
## Переменные окружения ## Переменные окружения
| Переменная | Описание | | Переменная | Описание |
+15
View File
@@ -3,7 +3,22 @@ module github.com/telemt/telemt-api
go 1.22 go 1.22
require ( require (
github.com/gorilla/websocket v1.5.3
github.com/oschwald/geoip2-golang v1.11.0 github.com/oschwald/geoip2-golang v1.11.0
github.com/prometheus/client_golang v1.20.5 github.com/prometheus/client_golang v1.20.5
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
golang.org/x/sys v0.22.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
)
+48
View File
@@ -0,0 +1,48 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/oschwald/geoip2-golang v1.11.0 h1:hNENhCn1Uyzhf9PTmquXENiWS6AlxAEnBII6r8krA3w=
github.com/oschwald/geoip2-golang v1.11.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU=
github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+69
View File
@@ -0,0 +1,69 @@
package aggregate
import (
"context"
"net/http"
"time"
)
// effectiveConnectionsBad возвращает значение для порогов инцидентов: прирост от последнего сброса.
func (h *Handler) effectiveConnectionsBad(alias string, current uint64) uint64 {
h.badConnBaselineMu.Lock()
defer h.badConnBaselineMu.Unlock()
b, ok := h.badConnBaseline[alias]
if !ok {
return current
}
if current < b {
// Счётчик на ноде уменьшился (перезапуск и т.п.) — старая отметка неприменима.
delete(h.badConnBaseline, alias)
return current
}
return current - b
}
func (h *Handler) setBadConnectionBaseline(alias string, snapshot uint64) {
h.badConnBaselineMu.Lock()
defer h.badConnBaselineMu.Unlock()
if h.badConnBaseline == nil {
h.badConnBaseline = make(map[string]uint64)
}
h.badConnBaseline[alias] = snapshot
}
// HandleResetBadConnectionsBaseline — POST /api/agg/bad-connections/reset
// Сохраняет текущий connections_bad_total по каждой выбранной ноде как нулевую отметку для алертов.
func (h *Handler) HandleResetBadConnectionsBaseline(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
aliases, err := h.resolveAliases(r)
if err != nil {
writeBadRequest(w, err)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
type row struct {
Alias string `json:"alias"`
OK bool `json:"ok"`
Baseline uint64 `json:"baseline,omitempty"`
Error string `json:"error,omitempty"`
}
rows := make([]row, 0, len(aliases))
partial := false
for _, alias := range aliases {
data, meta := FetchTelemtGET[statsSummaryLite](ctx, h.Client, h.Parsed, alias, "stats/summary")
if !meta.OK {
partial = true
rows = append(rows, row{Alias: alias, OK: false, Error: meta.Error})
continue
}
h.setBadConnectionBaseline(alias, data.ConnectionsBadTotal)
rows = append(rows, row{Alias: alias, OK: true, Baseline: data.ConnectionsBadTotal})
}
writeAggOK(w, partial, map[string]any{"servers": rows})
}
+24
View File
@@ -0,0 +1,24 @@
package aggregate
import "testing"
func TestEffectiveConnectionsBad(t *testing.T) {
h := &Handler{}
if got := h.effectiveConnectionsBad("a", 500); got != 500 {
t.Fatalf("without baseline want 500, got %d", got)
}
h.setBadConnectionBaseline("a", 400)
if got := h.effectiveConnectionsBad("a", 900); got != 500 {
t.Fatalf("with baseline 400 want delta 500, got %d", got)
}
h.setBadConnectionBaseline("b", 1000)
if got := h.effectiveConnectionsBad("b", 500); got != 500 {
t.Fatalf("after counter drop want raw value 500, got %d", got)
}
if _, ok := h.badConnBaseline["b"]; ok {
t.Fatal("baseline for b should be cleared when current < baseline")
}
}
+26
View File
@@ -33,6 +33,11 @@ type Handler struct {
cacheMu sync.Mutex cacheMu sync.Mutex
cache map[string]cacheEntry cache map[string]cacheEntry
// badConnBaseline хранит снимок connections_bad_total на момент сброса в UI;
// инциденты bad_connections_* считаются по приросту от этой отметки (в памяти процесса шлюза).
badConnBaselineMu sync.Mutex
badConnBaseline map[string]uint64
} }
// ResolveAliasesForLive resolves aliases for external endpoints (SSE/live). // ResolveAliasesForLive resolves aliases for external endpoints (SSE/live).
@@ -113,6 +118,8 @@ func (h *Handler) dispatch(w http.ResponseWriter, r *http.Request, sub string) {
h.handleUsers(w, r) h.handleUsers(w, r)
case sub == "fleet-status": case sub == "fleet-status":
h.handleFleetStatus(w, r) h.handleFleetStatus(w, r)
case sub == "radar-telemt-dcs":
h.handleRadarTelemtDcs(w, r)
case sub == "incidents": case sub == "incidents":
h.handleIncidents(w, r) h.handleIncidents(w, r)
case strings.HasPrefix(sub, "user/"): case strings.HasPrefix(sub, "user/"):
@@ -275,6 +282,25 @@ func (h *Handler) handleUsers(w http.ResponseWriter, r *http.Request) {
writeAggOK(w, partial, data) writeAggOK(w, partial, data)
} }
func (h *Handler) handleRadarTelemtDcs(w http.ResponseWriter, r *http.Request) {
aliases, err := h.resolveAliases(r)
if err != nil {
writeBadRequest(w, err)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
data := FetchRadarTelemtDcs(ctx, h.Client, h.Parsed, aliases)
partial := false
for _, s := range data.Servers {
if !s.OK {
partial = true
break
}
}
writeAggOK(w, partial, data)
}
func (h *Handler) handleFleetStatus(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleFleetStatus(w http.ResponseWriter, r *http.Request) {
aliases, err := h.resolveAliases(r) aliases, err := h.resolveAliases(r)
if err != nil { if err != nil {
+59
View File
@@ -113,6 +113,65 @@ func TestHandlerFleetStatus(t *testing.T) {
} }
} }
func TestHandlerRadarTelemtDcs(t *testing.T) {
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/stats/dcs" {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"data": map[string]any{
"middle_proxy_enabled": true,
"generated_at_epoch_secs": 1,
"dcs": []map[string]any{{
"dc": 1, "rtt_ms": 42.5, "coverage_pct": 100.0,
"alive_writers": 3, "required_writers": 3, "load": 0,
}},
},
"revision": "rd",
})
}))
defer up.Close()
cfg := &config.Config{
Servers: []config.Server{
{Alias: "test", BaseURL: up.URL, PathPrefix: "/v1"},
},
}
if err := cfg.Validate(); err != nil {
t.Fatal(err)
}
parsed, err := cfg.Parse()
if err != nil {
t.Fatal(err)
}
h := NewHandler(parsed, up.Client(), nil, 0)
req := httptest.NewRequest(http.MethodGet, "/api/agg/radar-telemt-dcs?aliases=test", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
var env struct {
OK bool `json:"ok"`
Data RadarTelemtDcsData `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
t.Fatal(err)
}
if !env.OK || len(env.Data.Servers) != 1 {
t.Fatalf("envelope: %+v", env)
}
row := env.Data.Servers[0]
if row.Alias != "test" || !row.OK || row.Data == nil || !row.Data.MiddleProxyEnabled {
t.Fatalf("row: %+v", row)
}
if len(row.Data.Dcs) != 1 || row.Data.Dcs[0].DC != 1 {
t.Fatalf("dcs: %+v", row.Data.Dcs)
}
}
func TestHandlerUserOne(t *testing.T) { func TestHandlerUserOne(t *testing.T) {
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/stats/users" { if r.URL.Path != "/v1/stats/users" {
+5 -4
View File
@@ -65,7 +65,8 @@ func BuildIncidents(ctx context.Context, h *Handler, aliases []string) (Incident
partial = true partial = true
continue continue
} }
if data.ConnectionsBadTotal >= 10000 { badEff := h.effectiveConnectionsBad(alias, data.ConnectionsBadTotal)
if badEff >= 10000 {
out.Items = append(out.Items, IncidentItem{ out.Items = append(out.Items, IncidentItem{
ID: fmt.Sprintf("bad_connections_high:%s", alias), ID: fmt.Sprintf("bad_connections_high:%s", alias),
Kind: "bad_connections_high", Kind: "bad_connections_high",
@@ -75,13 +76,13 @@ func BuildIncidents(ctx context.Context, h *Handler, aliases []string) (Incident
Summary: "Резкий рост ошибок клиентских соединений", Summary: "Резкий рост ошибок клиентских соединений",
AffectedAliases: []string{alias}, AffectedAliases: []string{alias},
MetricName: "connections_bad_total", MetricName: "connections_bad_total",
MetricValue: float64(data.ConnectionsBadTotal), MetricValue: float64(badEff),
MetricThreshold: 10000, MetricThreshold: 10000,
Actions: []IncidentAction{ Actions: []IncidentAction{
{Label: "Node dashboard", Href: "/servers/" + alias}, {Label: "Node dashboard", Href: "/servers/" + alias},
}, },
}) })
} else if data.ConnectionsBadTotal >= 1000 { } else if badEff >= 1000 {
out.Items = append(out.Items, IncidentItem{ out.Items = append(out.Items, IncidentItem{
ID: fmt.Sprintf("bad_connections_warn:%s", alias), ID: fmt.Sprintf("bad_connections_warn:%s", alias),
Kind: "bad_connections_warn", Kind: "bad_connections_warn",
@@ -91,7 +92,7 @@ func BuildIncidents(ctx context.Context, h *Handler, aliases []string) (Incident
Summary: "Наблюдается рост ошибок клиентских соединений", Summary: "Наблюдается рост ошибок клиентских соединений",
AffectedAliases: []string{alias}, AffectedAliases: []string{alias},
MetricName: "connections_bad_total", MetricName: "connections_bad_total",
MetricValue: float64(data.ConnectionsBadTotal), MetricValue: float64(badEff),
MetricThreshold: 1000, MetricThreshold: 1000,
Actions: []IncidentAction{ Actions: []IncidentAction{
{Label: "Node dashboard", Href: "/servers/" + alias}, {Label: "Node dashboard", Href: "/servers/" + alias},
+44
View File
@@ -0,0 +1,44 @@
package aggregate
import (
"context"
"net/http"
"sort"
"sync"
"github.com/telemt/telemt-api/internal/config"
)
const statsDcsPath = "stats/dcs"
// FetchRadarTelemtDcs calls GET /v1/stats/dcs on each Telemt in parallel.
func FetchRadarTelemtDcs(ctx context.Context, client *http.Client, parsed *config.Parsed, aliases []string) RadarTelemtDcsData {
if len(aliases) == 0 {
return RadarTelemtDcsData{}
}
rows := make([]RadarTelemtDcsServer, len(aliases))
var wg sync.WaitGroup
for i, alias := range aliases {
i, alias := i, alias
wg.Add(1)
go func() {
defer wg.Done()
data, meta := FetchTelemtGET[DcStatusPayload](ctx, client, parsed, alias, statsDcsPath)
row := RadarTelemtDcsServer{
Alias: alias,
OK: meta.OK,
HTTPStatus: meta.HTTPStatus,
LatencyMs: meta.LatencyMs,
Error: meta.Error,
Revision: meta.Revision,
}
if meta.OK {
row.Data = &data
}
rows[i] = row
}()
}
wg.Wait()
sort.Slice(rows, func(i, j int) bool { return rows[i].Alias < rows[j].Alias })
return RadarTelemtDcsData{Servers: rows}
}
+34
View File
@@ -206,3 +206,37 @@ type TopUserByUniqueIPs struct {
Username string `json:"username"` Username string `json:"username"`
UniqueIPs uint64 `json:"unique_ips"` UniqueIPs uint64 `json:"unique_ips"`
} }
// DcStatusRow mirrors Telemt GET /v1/stats/dcs data.dcs[] (subset for radar UI).
type DcStatusRow struct {
DC int `json:"dc"`
RttMs *float64 `json:"rtt_ms"`
CoveragePct float64 `json:"coverage_pct"`
AliveWriters int `json:"alive_writers"`
RequiredWriters int `json:"required_writers"`
Load int `json:"load"`
}
// DcStatusPayload mirrors Telemt GET /v1/stats/dcs data object.
type DcStatusPayload struct {
MiddleProxyEnabled bool `json:"middle_proxy_enabled"`
Reason *string `json:"reason"`
GeneratedAtEpochSecs uint64 `json:"generated_at_epoch_secs"`
Dcs []DcStatusRow `json:"dcs"`
}
// RadarTelemtDcsServer is one upstream stats/dcs outcome for /api/agg/radar-telemt-dcs.
type RadarTelemtDcsServer struct {
Alias string `json:"alias"`
OK bool `json:"ok"`
HTTPStatus int `json:"http_status,omitempty"`
LatencyMs int64 `json:"latency_ms,omitempty"`
Error string `json:"error,omitempty"`
Revision string `json:"revision,omitempty"`
Data *DcStatusPayload `json:"data,omitempty"`
}
// RadarTelemtDcsData is aggregate payload for /api/agg/radar-telemt-dcs.
type RadarTelemtDcsData struct {
Servers []RadarTelemtDcsServer `json:"servers"`
}
+24
View File
@@ -16,6 +16,8 @@ var aliasRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
// Config is the gateway YAML configuration. // Config is the gateway YAML configuration.
type Config struct { type Config struct {
Listen string `yaml:"listen"` Listen string `yaml:"listen"`
// ExposeUpstreamErrors puts RoundTrip error text in JSON 502 bodies (error.detail). For private ops only.
ExposeUpstreamErrors bool `yaml:"expose_upstream_errors"`
AllowAll bool `yaml:"allow_all"` AllowAll bool `yaml:"allow_all"`
WhitelistCIDRs []string `yaml:"whitelist_cidrs"` WhitelistCIDRs []string `yaml:"whitelist_cidrs"`
TrustedProxies []string `yaml:"trusted_proxies"` TrustedProxies []string `yaml:"trusted_proxies"`
@@ -23,6 +25,17 @@ type Config struct {
Servers []Server `yaml:"servers"` Servers []Server `yaml:"servers"`
Aggregate *AggregateConfig `yaml:"aggregate"` Aggregate *AggregateConfig `yaml:"aggregate"`
GeoIP *GeoIPConfig `yaml:"geoip"` GeoIP *GeoIPConfig `yaml:"geoip"`
Radar *RadarConfig `yaml:"radar"`
}
// RadarConfig controls GET /api/radar/statuses and /api/radar/ping-dc (Telegram DC radar).
type RadarConfig struct {
// StatusesURL defaults to https://radar.telemt.top/api/v1/endpoints/statuses when empty.
StatusesURL string `yaml:"statuses_url"`
// HTTPTimeoutMs is outbound GET timeout for statuses; 0 means 30000.
HTTPTimeoutMs uint64 `yaml:"http_timeout_ms"`
// PingFrom is copied into JSON field "from" in ping-dc; empty uses request Host (no port) or "telemt-gateway".
PingFrom string `yaml:"ping_from"`
} }
// GeoIPConfig enables GeoLite2 lookups for /api/agg/unique-ips (optional). // GeoIPConfig enables GeoLite2 lookups for /api/agg/unique-ips (optional).
@@ -160,6 +173,17 @@ func (c *Config) Validate() error {
return fmt.Errorf("aggregate.cache_ttl_ms must be within [0, 60000]") return fmt.Errorf("aggregate.cache_ttl_ms must be within [0, 60000]")
} }
} }
if c.Radar != nil {
if c.Radar.HTTPTimeoutMs > 600000 {
return fmt.Errorf("radar.http_timeout_ms must be within [0, 600000]")
}
if u := strings.TrimSpace(c.Radar.StatusesURL); u != "" {
pu, err := url.Parse(u)
if err != nil || (pu.Scheme != "http" && pu.Scheme != "https") || pu.Host == "" {
return fmt.Errorf("radar.statuses_url: invalid URL %q", c.Radar.StatusesURL)
}
}
}
return nil return nil
} }
+24
View File
@@ -114,3 +114,27 @@ func TestValidateAggregateCacheTTL(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
} }
func TestValidateRadarStatusesURL(t *testing.T) {
c := &Config{
Servers: []Server{{Alias: "a", BaseURL: "http://x:1"}},
Radar: &RadarConfig{StatusesURL: "ftp://bad"},
}
if err := c.Validate(); err == nil {
t.Fatal("expected error for invalid radar.statuses_url scheme")
}
c.Radar.StatusesURL = "https://radar.example/api"
if err := c.Validate(); err != nil {
t.Fatal(err)
}
}
func TestValidateRadarHTTPTimeoutMs(t *testing.T) {
c := &Config{
Servers: []Server{{Alias: "a", BaseURL: "http://x:1"}},
Radar: &RadarConfig{HTTPTimeoutMs: 600001},
}
if err := c.Validate(); err == nil {
t.Fatal("expected error for radar.http_timeout_ms > 600000")
}
}
+145 -37
View File
@@ -1,19 +1,21 @@
package proxy package proxy
import ( import (
"crypto/tls"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httputil"
"net/url" "net/url"
"strings" "strings"
"time"
"github.com/gorilla/websocket"
) )
// NewMihomoForward proxies /api/{alias}/mihomo/... to Mihomo external-controller. // NewMihomoForward proxies /api/{alias}/mihomo/... to Mihomo external-controller.
// //
// REST и обычные GET/POST идут тем же путём, что и NewAliasForward (http.NewRequest + RoundTrip): // REST: same path as NewAliasForward (http.NewRequest + RoundTrip).
// httputil.ReverseProxy для всего Mihomo часто давал 400 на upstream при том, что curl к контроллеру работал. // WebSocket (traffic, memory, …): gorilla/websocket Dial to upstream, then Upgrader on client, then frame relay.
// Только WebSocket (traffic, memory, …) остаётся на ReverseProxy + Rewrite.
// Чеклист при повторении проблемы: docs/GATEWAY_RUN.md#mihomo-debug-400
func NewMihomoForward( func NewMihomoForward(
target *url.URL, target *url.URL,
stripPrefix string, stripPrefix string,
@@ -22,9 +24,16 @@ func NewMihomoForward(
errHandler func(http.ResponseWriter, *http.Request, error), errHandler func(http.ResponseWriter, *http.Request, error),
) http.Handler { ) http.Handler {
httpH := newAliasForward(target, stripPrefix, "", auth, rt, errHandler, false) httpH := newAliasForward(target, stripPrefix, "", auth, rt, errHandler, false)
wsH := newMihomoWebSocketReverseProxy(target, stripPrefix, auth, rt, errHandler) wsH := newMihomoWSTunnel(target, stripPrefix, auth, errHandler)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isWebSocketUpgrade(r) { NormalizeRequestURLPath(r)
p := r.URL.Path
if !strings.HasPrefix(p, stripPrefix) {
httpH.ServeHTTP(w, r)
return
}
rest := strings.TrimPrefix(strings.TrimPrefix(p, stripPrefix), "/")
if useMihomoWebSocketTunnel(rest, r) {
wsH.ServeHTTP(w, r) wsH.ServeHTTP(w, r)
return return
} }
@@ -32,57 +41,156 @@ func NewMihomoForward(
}) })
} }
func isWebSocketUpgrade(r *http.Request) bool { // useMihomoWebSocketTunnel is true when the client is doing a WS handshake.
// For /traffic, /memory, /connections we also accept Sec-WebSocket-Key alone: some hops strip
// Connection/Upgrade but leave Sec-WebSocket-Key; plain streaming GET has no key.
func useMihomoWebSocketTunnel(rest string, r *http.Request) bool {
if r == nil { if r == nil {
return false return false
} }
return strings.EqualFold(r.Header.Get("Upgrade"), "websocket") if websocket.IsWebSocketUpgrade(r) {
return true
}
if r.Method != http.MethodGet {
return false
}
switch rest {
case "traffic", "memory", "connections":
return strings.TrimSpace(r.Header.Get("Sec-WebSocket-Key")) != ""
default:
return false
}
} }
func newMihomoWebSocketReverseProxy( // applyMihomoWSDialAuth prepares upstream WS URL and Dial headers.
// Mihomo: /connections WebSocket expects ?token=<secret> (raw secret, no "Bearer " prefix);
// /traffic, /memory use Authorization: Bearer … like REST.
func applyMihomoWSDialAuth(rest string, dest *url.URL, auth string) http.Header {
hdr := make(http.Header)
a := strings.TrimSpace(auth)
if a == "" || dest == nil {
return hdr
}
if rest == "connections" {
token := a
if len(token) > 7 && strings.EqualFold(token[:7], "Bearer ") {
token = strings.TrimSpace(token[7:])
}
q := dest.Query()
q.Set("token", token)
dest.RawQuery = q.Encode()
return hdr
}
hdr.Set("Authorization", a)
return hdr
}
func mihomoWebSocketURL(dest *url.URL) (string, error) {
if dest == nil {
return "", fmt.Errorf("nil destination URL")
}
u := *dest
switch strings.ToLower(u.Scheme) {
case "http":
u.Scheme = "ws"
case "https":
u.Scheme = "wss"
default:
return "", fmt.Errorf("unsupported mihomo scheme %q", dest.Scheme)
}
return u.String(), nil
}
func tlsConfigForMihomoWS(dest *url.URL) *tls.Config {
if dest == nil || strings.ToLower(dest.Scheme) != "https" {
return nil
}
return &tls.Config{
ServerName: dest.Hostname(),
MinVersion: tls.VersionTLS12,
}
}
// newMihomoWSTunnel: dial upstream WebSocket first (with Authorization), then upgrade the client, then relay frames.
func newMihomoWSTunnel(
target *url.URL, target *url.URL,
stripPrefix string, stripPrefix string,
auth string, auth string,
rt http.RoundTripper,
errHandler func(http.ResponseWriter, *http.Request, error), errHandler func(http.ResponseWriter, *http.Request, error),
) http.Handler { ) http.Handler {
if errHandler == nil { if errHandler == nil {
errHandler = defaultForwardErrorHandler errHandler = defaultForwardErrorHandler
} }
if rt == nil { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rt = http.DefaultTransport NormalizeRequestURLPath(r)
} p := r.URL.Path
rp := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
NormalizeRequestURLPath(pr.Out)
p := pr.Out.URL.Path
if !strings.HasPrefix(p, stripPrefix) { if !strings.HasPrefix(p, stripPrefix) {
errHandler(w, r, fmt.Errorf("path %q: missing strip prefix %q", p, stripPrefix))
return return
} }
rest := strings.TrimPrefix(strings.TrimPrefix(p, stripPrefix), "/") rest := strings.TrimPrefix(strings.TrimPrefix(p, stripPrefix), "/")
dest := JoinPathPrefix(target, "/", rest) dest := JoinPathPrefix(target, "/", rest)
du := *dest du := *dest
du.RawQuery = pr.Out.URL.RawQuery du.RawQuery = r.URL.RawQuery
out := pr.Out dest = &du
out.URL = &du
out.Header.Del("Host") dialHdr := applyMihomoWSDialAuth(rest, dest, auth)
out.Host = du.Host wsURL, err := mihomoWebSocketURL(dest)
out.RequestURI = "" if err != nil {
out.Proto = "HTTP/1.1"
out.ProtoMajor = 1
out.ProtoMinor = 1
out.Header.Del("Authorization")
if auth != "" {
out.Header.Set("Authorization", auth)
}
},
Transport: rt,
FlushInterval: -1,
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
errHandler(w, r, err) errHandler(w, r, err)
}, return
}
d := websocket.Dialer{
HandshakeTimeout: 15 * time.Second,
Proxy: func(*http.Request) (*url.URL, error) { return nil, nil },
TLSClientConfig: tlsConfigForMihomoWS(dest),
}
upConn, _, err := d.Dial(wsURL, dialHdr)
if err != nil {
errHandler(w, r, fmt.Errorf("mihomo ws dial: %w", err))
return
}
defer upConn.Close()
// Upgrader requires Connection+Upgrade; some proxies strip them but leave Sec-WebSocket-Key.
if !websocket.IsWebSocketUpgrade(r) {
r.Header.Set("Connection", "Upgrade")
r.Header.Set("Upgrade", "websocket")
if strings.TrimSpace(r.Header.Get("Sec-WebSocket-Version")) == "" {
r.Header.Set("Sec-WebSocket-Version", "13")
}
}
upgrader := websocket.Upgrader{
HandshakeTimeout: 15 * time.Second,
CheckOrigin: func(*http.Request) bool { return true },
}
clientConn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer clientConn.Close()
errCh := make(chan error, 2)
go relayWSFrames(upConn, clientConn, errCh)
go relayWSFrames(clientConn, upConn, errCh)
<-errCh
})
}
func relayWSFrames(dst, src *websocket.Conn, errCh chan<- error) {
for {
mt, data, err := src.ReadMessage()
if err != nil {
errCh <- err
return
}
if err := dst.WriteMessage(mt, data); err != nil {
errCh <- err
return
}
} }
return rp
} }
// MihomoMetaJSON returns a JSON body for GET .../mihomo/meta (display URL without credentials). // MihomoMetaJSON returns a JSON body for GET .../mihomo/meta (display URL without credentials).
+110
View File
@@ -39,3 +39,113 @@ func TestMihomoForwardRewritesPathAndAuth(t *testing.T) {
} }
assertSameURL(t, cap.got.URL, want) assertSameURL(t, cap.got.URL, want)
} }
func TestUseMihomoWebSocketTunnel(t *testing.T) {
t.Run("gorilla IsWebSocketUpgrade", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/api/x/mihomo/traffic", nil)
r.Header.Set("Connection", "Upgrade")
r.Header.Set("Upgrade", "websocket")
r.Header.Set("Sec-WebSocket-Version", "13")
r.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
if !useMihomoWebSocketTunnel("traffic", r) {
t.Fatal("expected tunnel for full WS handshake")
}
})
t.Run("traffic with key only (no Connection token)", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/api/x/mihomo/traffic", nil)
r.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
if !useMihomoWebSocketTunnel("traffic", r) {
t.Fatal("expected tunnel for traffic+Sec-WebSocket-Key")
}
})
t.Run("memory with key only", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/api/x/mihomo/memory", nil)
r.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
if !useMihomoWebSocketTunnel("memory", r) {
t.Fatal("expected tunnel for memory+Sec-WebSocket-Key")
}
})
t.Run("traffic streaming GET without key", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/api/x/mihomo/traffic", nil)
if useMihomoWebSocketTunnel("traffic", r) {
t.Fatal("plain GET /traffic must use HTTP forwarder")
}
})
t.Run("proxies path never tunnel", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/api/x/mihomo/proxies", nil)
r.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
if useMihomoWebSocketTunnel("proxies", r) {
t.Fatal("proxies must not use WS tunnel")
}
})
t.Run("connections with key only", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/api/x/mihomo/connections", nil)
r.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
if !useMihomoWebSocketTunnel("connections", r) {
t.Fatal("expected tunnel for connections+Sec-WebSocket-Key")
}
})
t.Run("connections REST GET without key", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/api/x/mihomo/connections", nil)
if useMihomoWebSocketTunnel("connections", r) {
t.Fatal("plain GET /connections must use HTTP forwarder")
}
})
}
func TestApplyMihomoWSDialAuth(t *testing.T) {
t.Run("connections strips Bearer for token query", func(t *testing.T) {
u, _ := url.Parse("http://127.0.0.1:9090/connections")
hdr := applyMihomoWSDialAuth("connections", u, "Bearer mysecret")
if hdr.Get("Authorization") != "" {
t.Fatal("connections WS must not set Authorization")
}
if u.RawQuery != "token=mysecret" {
t.Fatalf("query: %q", u.RawQuery)
}
})
t.Run("connections raw token", func(t *testing.T) {
u, _ := url.Parse("http://127.0.0.1:9090/connections")
_ = applyMihomoWSDialAuth("connections", u, "rawonly")
if u.RawQuery != "token=rawonly" {
t.Fatalf("query: %q", u.RawQuery)
}
})
t.Run("traffic uses Authorization", func(t *testing.T) {
u, _ := url.Parse("http://127.0.0.1:9090/traffic")
hdr := applyMihomoWSDialAuth("traffic", u, "Bearer t")
if hdr.Get("Authorization") != "Bearer t" {
t.Fatalf("Authorization: %q", hdr.Get("Authorization"))
}
})
}
func TestMihomoWebSocketURL(t *testing.T) {
u, err := url.Parse("http://172.20.0.2:9090/traffic?q=1")
if err != nil {
t.Fatal(err)
}
s, err := mihomoWebSocketURL(u)
if err != nil {
t.Fatal(err)
}
if want := "ws://172.20.0.2:9090/traffic?q=1"; s != want {
t.Fatalf("got %q want %q", s, want)
}
u2, _ := url.Parse("https://example.com/mem")
s2, err := mihomoWebSocketURL(u2)
if err != nil {
t.Fatal(err)
}
if want := "wss://example.com/mem"; s2 != want {
t.Fatalf("got %q want %q", s2, want)
}
}
+72 -12
View File
@@ -1,9 +1,11 @@
package server package server
import ( import (
"bufio"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net" "net"
"net/http" "net/http"
@@ -32,6 +34,22 @@ type Gateway struct {
promHandler http.Handler promHandler http.Handler
corsAllowed []string corsAllowed []string
webUI http.Handler webUI http.Handler
radarStatusesURL string
radarPingFrom string
radarHTTPClient *http.Client
}
func writeBadGatewayJSON(w http.ResponseWriter, expose bool, code, message string, upstreamErr error) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusBadGateway)
errObj := map[string]any{"code": code, "message": message}
if expose && upstreamErr != nil {
errObj["detail"] = upstreamErr.Error()
}
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": false,
"error": errObj,
})
} }
// NewGateway builds handlers and reverse proxies from parsed config. // NewGateway builds handlers and reverse proxies from parsed config.
@@ -52,6 +70,7 @@ func NewGateway(p *config.Parsed, log *slog.Logger, geo *geoip.Service) (*Gatewa
transport: t, transport: t,
promHandler: promhttp.Handler(), promHandler: promhttp.Handler(),
} }
exposeErr := p.Config.ExposeUpstreamErrors
for i := range p.Config.Servers { for i := range p.Config.Servers {
s := &p.Config.Servers[i] s := &p.Config.Servers[i]
u, err := url.Parse(s.BaseURL) u, err := url.Parse(s.BaseURL)
@@ -62,12 +81,7 @@ func NewGateway(p *config.Parsed, log *slog.Logger, geo *geoip.Service) (*Gatewa
strip := "/api/" + s.Alias strip := "/api/" + s.Alias
g.proxies[s.Alias] = proxy.NewAliasForward(u, strip, s.PathPrefix, auth, t, func(w http.ResponseWriter, r *http.Request, err error) { g.proxies[s.Alias] = proxy.NewAliasForward(u, strip, s.PathPrefix, auth, t, func(w http.ResponseWriter, r *http.Request, err error) {
log.Error("upstream error", "alias", s.Alias, "err", err) log.Error("upstream error", "alias", s.Alias, "err", err)
w.Header().Set("Content-Type", "application/json; charset=utf-8") writeBadGatewayJSON(w, exposeErr, "bad_gateway", "upstream unreachable", err)
w.WriteHeader(http.StatusBadGateway)
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": false,
"error": map[string]string{"code": "bad_gateway", "message": "upstream unreachable"},
})
}) })
} }
for alias, m := range p.MihomoByAlias { for alias, m := range p.MihomoByAlias {
@@ -75,12 +89,7 @@ func NewGateway(p *config.Parsed, log *slog.Logger, geo *geoip.Service) (*Gatewa
strip := "/api/" + a + "/mihomo" strip := "/api/" + a + "/mihomo"
g.mihomo[a] = proxy.NewMihomoForward(m.Base, strip, m.Auth, t, func(w http.ResponseWriter, r *http.Request, err error) { g.mihomo[a] = proxy.NewMihomoForward(m.Base, strip, m.Auth, t, func(w http.ResponseWriter, r *http.Request, err error) {
log.Error("mihomo upstream error", "alias", a, "err", err) log.Error("mihomo upstream error", "alias", a, "err", err)
w.Header().Set("Content-Type", "application/json; charset=utf-8") writeBadGatewayJSON(w, exposeErr, "bad_gateway", "mihomo upstream unreachable", err)
w.WriteHeader(http.StatusBadGateway)
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": false,
"error": map[string]string{"code": "bad_gateway", "message": "mihomo upstream unreachable"},
})
}) })
} }
var aggCacheTTL time.Duration var aggCacheTTL time.Duration
@@ -90,6 +99,21 @@ func NewGateway(p *config.Parsed, log *slog.Logger, geo *geoip.Service) (*Gatewa
g.corsAllowed = append([]string(nil), p.Config.CorsAllowedOrigins...) g.corsAllowed = append([]string(nil), p.Config.CorsAllowedOrigins...)
g.agg = aggregate.NewHandler(p, &http.Client{Transport: t}, geo, aggCacheTTL) g.agg = aggregate.NewHandler(p, &http.Client{Transport: t}, geo, aggCacheTTL)
g.webUI = webui.Handler() g.webUI = webui.Handler()
statusesURL := defaultRadarStatusesURL
radarTo := 30 * time.Second
if p.Config.Radar != nil {
if u := strings.TrimSpace(p.Config.Radar.StatusesURL); u != "" {
statusesURL = u
}
if p.Config.Radar.HTTPTimeoutMs > 0 {
radarTo = time.Duration(p.Config.Radar.HTTPTimeoutMs) * time.Millisecond
}
g.radarPingFrom = strings.TrimSpace(p.Config.Radar.PingFrom)
}
g.radarStatusesURL = statusesURL
g.radarHTTPClient = &http.Client{Transport: t, Timeout: radarTo}
return g, nil return g, nil
} }
@@ -236,6 +260,13 @@ func routeEndpoint(path string) string {
if path == "/api/agg" { if path == "/api/agg" {
return "agg" return "agg"
} }
if strings.HasPrefix(path, "/api/radar/") {
rest := strings.TrimPrefix(path, "/api/radar/")
if rest == "" {
return "radar"
}
return "radar_" + strings.ReplaceAll(rest, "/", "_")
}
if strings.HasPrefix(path, "/api/live/events") { if strings.HasPrefix(path, "/api/live/events") {
return "live_events" return "live_events"
} }
@@ -277,6 +308,10 @@ func (g *Gateway) serve(w http.ResponseWriter, r *http.Request) {
proxy.NormalizeRequestURLPath(r) proxy.NormalizeRequestURLPath(r)
} }
const prefix = "/api/" const prefix = "/api/"
if r.Method == http.MethodPost && r.URL.Path == "/api/agg/bad-connections/reset" {
g.agg.HandleResetBadConnectionsBaseline(w, r)
return
}
if r.URL.Path == "/api/agg" || strings.HasPrefix(r.URL.Path, "/api/agg/") { if r.URL.Path == "/api/agg" || strings.HasPrefix(r.URL.Path, "/api/agg/") {
g.agg.ServeHTTP(w, r) g.agg.ServeHTTP(w, r)
return return
@@ -285,6 +320,10 @@ func (g *Gateway) serve(w http.ResponseWriter, r *http.Request) {
g.serveLiveEvents(w, r) g.serveLiveEvents(w, r)
return return
} }
if r.URL.Path == "/api/radar/statuses" || r.URL.Path == "/api/radar/ping-dc" {
g.serveRadar(w, r)
return
}
if !strings.HasPrefix(r.URL.Path, prefix) { if !strings.HasPrefix(r.URL.Path, prefix) {
g.webUI.ServeHTTP(w, r) g.webUI.ServeHTTP(w, r)
return return
@@ -403,6 +442,27 @@ func (s *statusWriter) Flush() {
} }
} }
// Hijack preserves WebSocket upgrade support through middleware wrappers.
func (s *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if h, ok := s.ResponseWriter.(http.Hijacker); ok {
return h.Hijack()
}
return nil, nil, http.ErrNotSupported
}
// ReadFrom keeps io.Copy fast-path behavior when the underlying writer supports it.
func (s *statusWriter) ReadFrom(r io.Reader) (int64, error) {
if rf, ok := s.ResponseWriter.(io.ReaderFrom); ok {
return rf.ReadFrom(r)
}
return io.Copy(s.ResponseWriter, r)
}
// Unwrap lets net/http.ResponseController reach the original writer.
func (s *statusWriter) Unwrap() http.ResponseWriter {
return s.ResponseWriter
}
// Shutdown idle connections on the shared transport. // Shutdown idle connections on the shared transport.
func (g *Gateway) Shutdown(ctx context.Context) error { func (g *Gateway) Shutdown(ctx context.Context) error {
g.transport.CloseIdleConnections() g.transport.CloseIdleConnections()
+124
View File
@@ -0,0 +1,124 @@
package server
import (
"encoding/json"
"io"
"net"
"net/http"
"strconv"
"strings"
"time"
)
const defaultRadarStatusesURL = "https://radar.telemt.top/api/v1/endpoints/statuses"
// telegramDC443 — IPv4 DC Telegram для MTProto (как в ping_proxy.php исходной панели).
var telegramDC443 = map[int]string{
1: "149.154.175.50",
2: "149.154.167.51",
3: "149.154.175.100",
4: "149.154.167.91",
5: "149.154.171.5",
}
func (g *Gateway) serveRadar(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/radar/statuses":
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
g.serveRadarStatuses(w, r)
case "/api/radar/ping-dc":
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
g.serveRadarPingDC(w, r)
default:
http.NotFound(w, r)
}
}
// serveRadarStatuses проксирует JSON radar.telemt.top (аналог radar_proxy.php).
func (g *Gateway) serveRadarStatuses(w http.ResponseWriter, r *http.Request) {
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, g.radarStatusesURL, nil)
if err != nil {
writeRadarFailed(w)
return
}
resp, err := g.radarHTTPClient.Do(req)
if err != nil {
writeRadarFailed(w)
return
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
writeRadarFailed(w)
return
}
ct := resp.Header.Get("Content-Type")
if ct == "" {
ct = "application/json"
}
w.Header().Set("Content-Type", ct)
w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, resp.Body)
}
func writeRadarFailed(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "failed"})
}
// serveRadarPingDC — TCP :443 до каждого DC с таймаутом 2 с (аналог ping_proxy.php).
func (g *Gateway) serveRadarPingDC(w http.ResponseWriter, r *http.Request) {
from := strings.TrimSpace(g.radarPingFrom)
if from == "" {
from = hostOnly(r.Host)
if from == "" {
from = "telemt-gateway"
}
}
results := make(map[string]map[string]any, len(telegramDC443))
for i := 1; i <= 5; i++ {
ip := telegramDC443[i]
addr := net.JoinHostPort(ip, "443")
t0 := time.Now()
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
ms := int(time.Since(t0).Milliseconds())
key := strconv.Itoa(i)
if err != nil {
results[key] = map[string]any{"ok": false, "ms": nil}
continue
}
_ = conn.Close()
results[key] = map[string]any{"ok": true, "ms": ms}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"results": results,
"from": from,
})
}
func hostOnly(hostPort string) string {
hostPort = strings.TrimSpace(hostPort)
if hostPort == "" {
return ""
}
// IPv6 в квадратных скобках: [::1]:8080
if strings.HasPrefix(hostPort, "[") {
if i := strings.IndexByte(hostPort, ']'); i > 0 {
return hostPort[1:i]
}
}
h, _, err := net.SplitHostPort(hostPort)
if err != nil {
return hostPort
}
return h
}
+19
View File
@@ -0,0 +1,19 @@
package server
import "testing"
func TestHostOnly(t *testing.T) {
tests := []struct {
in, want string
}{
{"example.com:8080", "example.com"},
{"[::1]:443", "::1"},
{"127.0.0.1", "127.0.0.1"},
{"", ""},
}
for _, tt := range tests {
if got := hostOnly(tt.in); got != tt.want {
t.Errorf("hostOnly(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
+130 -4
View File
@@ -21,13 +21,17 @@ export function mihomoUrl(alias: string, path: string): string {
} }
/** /**
* WebSocket к тому же origin (ws / wss). * WebSocket к шлюзу: `ws(s)://…/api/{alias}/mihomo/{path}`.
* Для /traffic и /memory во фронте предпочтительнее потоковый GET через `mihomoUrl` — тот же прокси, что и для REST; WS из браузера часто рвётся за nginx. * Authorization к Mihomo подставляет шлюз; браузеру токен не нужен.
* Обзор Mihomo: WS для /traffic, /memory, /connections (токен на upstream подставляет шлюз; для /connections — ?token=…).
*/ */
export function mihomoWsUrl(alias: string, path: string): string { export function mihomoWsUrl(alias: string, path: string): string {
const p = path.replace(/^\/+/, ''); const p = path.replace(/^\/+/, '');
const base = gatewayBase(); const base =
const wsBase = base.replace(/^http/, 'ws'); gatewayBase() || (typeof window !== 'undefined' ? window.location.origin.replace(/\/$/, '') : '');
let wsBase = base;
if (wsBase.startsWith('https://')) wsBase = `wss://${wsBase.slice('https://'.length)}`;
else if (wsBase.startsWith('http://')) wsBase = `ws://${wsBase.slice('http://'.length)}`;
return `${wsBase}/api/${encodeURIComponent(alias)}/mihomo/${p}`; return `${wsBase}/api/${encodeURIComponent(alias)}/mihomo/${p}`;
} }
@@ -176,6 +180,50 @@ export async function fetchAggFleetStatus(params?: { aliases?: string }): Promis
return body as AggEnvelope<components['schemas']['FleetStatusData']>; return body as AggEnvelope<components['schemas']['FleetStatusData']>;
} }
/** Payload GET /api/agg/radar-telemt-dcs (снимок GET /v1/stats/dcs с каждой ноды). */
export type AggRadarTelemtDcsRow = {
alias: string;
ok: boolean;
http_status?: number;
latency_ms?: number;
error?: string;
revision?: string;
data?: {
middle_proxy_enabled: boolean;
reason?: string | null;
generated_at_epoch_secs?: number;
dcs?: {
dc: number;
rtt_ms?: number | null;
coverage_pct: number;
alive_writers: number;
required_writers: number;
load: number;
}[];
};
};
export type AggRadarTelemtDcsData = {
servers: AggRadarTelemtDcsRow[];
};
export async function fetchAggRadarTelemtDcs(params?: {
aliases?: string;
}): Promise<AggEnvelope<AggRadarTelemtDcsData>> {
const q = new URLSearchParams();
if (params?.aliases) q.set('aliases', params.aliases);
const url = `${gatewayBase()}/api/agg/radar-telemt-dcs${q.toString() ? `?${q}` : ''}`;
const res = await fetch(url);
const body = (await parseJson(res)) as Record<string, unknown> | null;
if (!res.ok) {
throw new ApiError(`radar-telemt-dcs HTTP ${res.status}`, res.status, body);
}
if (!body || body.ok !== true) {
throw new ApiError('radar-telemt-dcs: ok !== true', res.status, body);
}
return body as AggEnvelope<AggRadarTelemtDcsData>;
}
export async function fetchAggUniqueIps(params?: { export async function fetchAggUniqueIps(params?: {
aliases?: string; aliases?: string;
geo?: boolean; geo?: boolean;
@@ -234,6 +282,35 @@ export async function fetchAggIncidents(params?: { aliases?: string }): Promise<
return body as AggEnvelope<IncidentsData>; return body as AggEnvelope<IncidentsData>;
} }
export type ResetBadConnectionsRow = {
alias: string;
ok: boolean;
baseline?: number;
error?: string;
};
export type ResetBadConnectionsData = {
servers: ResetBadConnectionsRow[];
};
/** POST: зафиксировать текущий connections_bad_total как «ноль» для алертов bad connections на шлюзе. */
export async function postAggResetBadConnectionsBaseline(params?: {
aliases?: string;
}): Promise<AggEnvelope<ResetBadConnectionsData>> {
const q = new URLSearchParams();
if (params?.aliases) q.set('aliases', params.aliases);
const url = `${gatewayBase()}/api/agg/bad-connections/reset${q.toString() ? `?${q}` : ''}`;
const res = await fetch(url, { method: 'POST' });
const body = (await parseJson(res)) as Record<string, unknown> | null;
if (!res.ok) {
throw new ApiError(`bad-connections/reset HTTP ${res.status}`, res.status, body);
}
if (!body || body.ok !== true) {
throw new ApiError('bad-connections/reset: ok !== true', res.status, body);
}
return body as AggEnvelope<ResetBadConnectionsData>;
}
export function liveEventsUrl(params?: { aliases?: string }): string { export function liveEventsUrl(params?: { aliases?: string }): string {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (params?.aliases) q.set('aliases', params.aliases); if (params?.aliases) q.set('aliases', params.aliases);
@@ -353,3 +430,52 @@ export async function deleteUser(
} }
return parsed as TelemtSuccess<string>; return parsed as TelemtSuccess<string>;
} }
/** Ответ GET /api/radar/ping-dc (как ping_proxy.php). */
export type RadarPingRow = { ok: boolean; ms: number | null };
export type RadarPingResponse = { ok: boolean; results: Record<string, RadarPingRow>; from: string };
/** Прокси к radar.telemt.top — массив эндпоинтов (как radar_proxy.php). */
export async function fetchRadarStatuses(): Promise<unknown[]> {
const res = await fetch(`${gatewayBase()}/api/radar/statuses`);
const text = await res.text();
if (!res.ok) {
let msg = `HTTP ${res.status}`;
try {
const j = JSON.parse(text) as { error?: string };
if (j?.error) msg = j.error;
} catch {
/* ignore */
}
throw new ApiError(msg, res.status, text);
}
let data: unknown;
try {
data = text ? JSON.parse(text) : null;
} catch {
throw new ApiError(`Ответ не JSON (HTTP ${res.status})`, res.status, text);
}
if (!Array.isArray(data)) {
throw new ApiError('radar: ожидался JSON-массив', res.status, data);
}
return data;
}
export async function fetchRadarPingDC(): Promise<RadarPingResponse> {
const res = await fetch(`${gatewayBase()}/api/radar/ping-dc`);
const text = await res.text();
let parsed: unknown;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
throw new ApiError(`Ответ не JSON (HTTP ${res.status})`, res.status, text);
}
if (!res.ok) {
throw new ApiError(`ping-dc HTTP ${res.status}`, res.status, parsed);
}
const body = parsed as RadarPingResponse;
if (!body || body.ok !== true || !body.results) {
throw new ApiError('ping-dc: неверный ответ', res.status, parsed);
}
return body;
}
+6
View File
@@ -8,6 +8,8 @@ export type MihomoProxyEntry = {
name?: string; name?: string;
now?: string; now?: string;
all?: string[]; all?: string[];
/** URL для healthcheck / GET …/delay (если задан в конфиге группы). */
testUrl?: string;
history?: { time: string; delay: number }[]; history?: { time: string; delay: number }[];
udp?: boolean; udp?: boolean;
[key: string]: unknown; [key: string]: unknown;
@@ -16,6 +18,10 @@ export type MihomoProxyEntry = {
export type MihomoConnectionsResponse = { export type MihomoConnectionsResponse = {
total?: number; total?: number;
connections?: MihomoConnection[]; connections?: MihomoConnection[];
/** Снимок по WebSocket /connections (Mihomo). */
downloadTotal?: number;
uploadTotal?: number;
memory?: number;
}; };
export type MihomoConnection = { export type MihomoConnection = {
+41
View File
@@ -0,0 +1,41 @@
export type BreadcrumbItem = { href: string; label: string; current?: boolean };
const SEG_LABELS: Record<string, string> = {
users: 'Пользователи',
ips: 'Уникальные IP',
incidents: 'Инциденты',
live: 'Поток',
radar: 'Радар DC',
mihomo: 'Mihomo',
servers: 'Серверы',
runtime: 'Состояние',
security: 'Безопасность',
upstreams: 'Апстримы и ДЦ',
config: 'Конфигурация',
update: 'Обновление'
};
function segLabel(seg: string): string {
return SEG_LABELS[seg] ?? decodeURIComponent(seg);
}
export function breadcrumbsFromPath(pathname: string): BreadcrumbItem[] {
const p = pathname || '/';
if (p === '/') {
return [{ href: '/', label: 'Обзор', current: true }];
}
const parts = p.split('/').filter(Boolean);
const items: BreadcrumbItem[] = [{ href: '/', label: 'Обзор' }];
let acc = '';
for (let i = 0; i < parts.length; i++) {
const seg = parts[i];
acc += `/${seg}`;
items.push({
href: acc,
label: segLabel(seg),
current: i === parts.length - 1
});
}
return items;
}
+94
View File
@@ -0,0 +1,94 @@
/** Цвета для Chart.js из CSS-переменных темы (светлая/тёмная). */
export function readCssVar(name: string): string {
if (typeof document === 'undefined') return '';
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
export function chartTickColor(): string {
return readCssVar('--muted-foreground') || 'oklch(0.552 0.016 285.938)';
}
export function chartGridColor(): string {
const b = readCssVar('--border');
return b ? `color-mix(in oklch, ${b} 45%, transparent)` : 'oklch(0.5 0.02 285 / 0.12)';
}
export function chartSeriesColors(): string[] {
return [
readCssVar('--chart-1') || '#6366f1',
readCssVar('--chart-2') || '#22c55e',
readCssVar('--chart-3') || '#eab308',
readCssVar('--chart-4') || '#f97316',
readCssVar('--chart-5') || '#ec4899'
];
}
export function chartLegendColor(): string {
return chartTickColor();
}
/**
* Добавляет альфа-канал к цвету из темы (oklch/hex/rgb).
* Нельзя склеивать `oklch(...) + "33"` — в canvas это даёт невалидный цвет и чёрную заливку.
*/
export function chartColorWithAlpha(base: string, alpha: number): string {
const t = base.trim();
const indigo = `rgba(99, 102, 241, ${alpha})`;
if (!t) return indigo;
const oklch = /^oklch\(\s*(.+)\s*\)$/i.exec(t);
if (oklch) {
let inner = oklch[1].trim().replace(/\s*\/\s*[\d.]+%?\s*$/, '').trim();
return `oklch(${inner} / ${alpha})`;
}
if (t.startsWith('#')) {
let h = t.slice(1);
if (h.length === 3) {
h = [...h].map((c) => c + c).join('');
}
if (h.length === 6 || h.length === 8) {
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
}
if (/^rgba?\(/i.test(t)) {
const m = t.match(/rgba?\(\s*([^)]+)\s*\)/i);
if (m) {
const parts = m[1].split(',').map((s) => s.trim());
if (parts.length >= 3) {
return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${alpha})`;
}
}
}
return `color-mix(in srgb, ${t} ${Math.round(alpha * 100)}%, transparent)`;
}
/** Лёгкая заливка под линией: сверху чуть насыщеннее, к низу к прозрачному. */
export function chartLineAreaGradient(
ctx: CanvasRenderingContext2D,
chartArea: { top: number; bottom: number },
lineColor: string
): CanvasGradient {
const g = ctx.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
const c = lineColor.trim() || '#6366f1';
g.addColorStop(0, chartColorWithAlpha(c, 0.16));
g.addColorStop(0.45, chartColorWithAlpha(c, 0.045));
g.addColorStop(1, chartColorWithAlpha(c, 0));
return g;
}
/** Callback для Chart.js `dataset.backgroundColor` у линейных графиков с заливкой. */
export function chartLineAreaFillCallback(lineColor: string) {
return (context: { chart: { ctx: CanvasRenderingContext2D; chartArea?: { top: number; bottom: number } } }) => {
const { chart } = context;
const area = chart.chartArea;
if (!area) return 'transparent';
return chartLineAreaGradient(chart.ctx, area, lineColor);
};
}
@@ -1,41 +1,43 @@
<script lang="ts"> <script lang="ts">
import * as Select from "$lib/components/ui/select/index.js"; import { untrack } from 'svelte';
let { let {
availableAliases = [], availableAliases = [],
value = $bindable("all"), value = $bindable("all"),
label = "Alias", label = "Alias",
allLabel = "All nodes" allLabel = "All nodes",
onValueChange
}: { }: {
availableAliases: string[]; availableAliases: string[];
value?: string; value?: string;
label?: string; label?: string;
allLabel?: string; allLabel?: string;
onValueChange?: (next: string) => void;
} = $props(); } = $props();
let prev = value || "all";
$effect(() => { $effect(() => {
if ((value ?? "all") !== "all" && !availableAliases.includes(value ?? "all")) {
value = "all";
}
if (!value) { if (!value) {
value = "all"; value = "all";
return;
}
if (value !== prev) {
prev = value;
untrack(() => onValueChange?.(value));
} }
}); });
let selectedLabel = $derived((value ?? "all") === "all" ? allLabel : (value ?? "all"));
</script> </script>
<label class="text-xs text-muted-foreground"> <label class="text-xs text-muted-foreground">
{label} {label}
<Select.Root type="single" bind:value> <select
<Select.Trigger class="mt-1 w-[220px]"> class="border-input bg-background mt-1 h-8 w-[220px] rounded-lg border px-2.5 text-sm outline-none focus-visible:border-ring"
{selectedLabel} bind:value
</Select.Trigger> >
<Select.Content class="z-[1200]"> <option value="all">{allLabel}</option>
<Select.Item value="all">{allLabel}</Select.Item>
{#each availableAliases as alias (alias)} {#each availableAliases as alias (alias)}
<Select.Item value={alias}>{alias}</Select.Item> <option value={alias}>{alias}</option>
{/each} {/each}
</Select.Content> </select>
</Select.Root>
</label> </label>
+41 -15
View File
@@ -12,6 +12,7 @@
import ZapIcon from '@lucide/svelte/icons/zap'; import ZapIcon from '@lucide/svelte/icons/zap';
import SirenIcon from '@lucide/svelte/icons/siren'; import SirenIcon from '@lucide/svelte/icons/siren';
import RadioIcon from '@lucide/svelte/icons/radio'; import RadioIcon from '@lucide/svelte/icons/radio';
import RadarIcon from '@lucide/svelte/icons/radar';
import * as Sidebar from '$lib/components/ui/sidebar/index.js'; import * as Sidebar from '$lib/components/ui/sidebar/index.js';
let { let {
@@ -27,6 +28,11 @@
if (href === '/') return p === '/'; if (href === '/') return p === '/';
return p === href || p.startsWith(href + '/'); return p === href || p.startsWith(href + '/');
} }
/** Только точное совпадение (для корня ноды `/servers/{alias}` — иначе вложенные страницы вроде …/mihomo подсвечивали бы и Dashboard). */
function activeExact(href: string): boolean {
return page.url.pathname === href;
}
</script> </script>
<Sidebar.Root collapsible="icon"> <Sidebar.Root collapsible="icon">
@@ -80,7 +86,7 @@
</Sidebar.MenuButton> </Sidebar.MenuButton>
</Sidebar.MenuItem> </Sidebar.MenuItem>
<Sidebar.MenuItem> <Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active('/incidents')} tooltipContent="Incidents"> <Sidebar.MenuButton isActive={active('/incidents')} tooltipContent="Инциденты">
{#snippet child({ props })} {#snippet child({ props })}
<a href="/incidents" {...props}> <a href="/incidents" {...props}>
<SirenIcon /> <SirenIcon />
@@ -99,6 +105,26 @@
{/snippet} {/snippet}
</Sidebar.MenuButton> </Sidebar.MenuButton>
</Sidebar.MenuItem> </Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active('/radar')} tooltipContent="Радар DC Telegram">
{#snippet child({ props })}
<a href="/radar" {...props}>
<RadarIcon />
<span>Радар DC</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active('/mihomo')} tooltipContent="Mihomo (флот)">
{#snippet child({ props })}
<a href="/mihomo" {...props}>
<ZapIcon />
<span>Mihomo</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu> </Sidebar.Menu>
</Sidebar.Group> </Sidebar.Group>
{#if serverAliases.length > 0} {#if serverAliases.length > 0}
@@ -128,11 +154,11 @@
<Sidebar.GroupLabel>Нода</Sidebar.GroupLabel> <Sidebar.GroupLabel>Нода</Sidebar.GroupLabel>
<Sidebar.Menu> <Sidebar.Menu>
<Sidebar.MenuItem> <Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active(serverPath)} tooltipContent="Dashboard"> <Sidebar.MenuButton isActive={activeExact(serverPath)} tooltipContent="Обзор">
{#snippet child({ props })} {#snippet child({ props })}
<a href={serverPath} {...props}> <a href={serverPath} {...props}>
<LayoutDashboardIcon /> <LayoutDashboardIcon />
<span>Dashboard</span> <span>Обзор</span>
</a> </a>
{/snippet} {/snippet}
</Sidebar.MenuButton> </Sidebar.MenuButton>
@@ -148,61 +174,61 @@
</Sidebar.MenuButton> </Sidebar.MenuButton>
</Sidebar.MenuItem> </Sidebar.MenuItem>
<Sidebar.MenuItem> <Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active(serverPath + '/runtime')} tooltipContent="Runtime"> <Sidebar.MenuButton isActive={active(serverPath + '/runtime')} tooltipContent="Состояние">
{#snippet child({ props })} {#snippet child({ props })}
<a href="{serverPath}/runtime" {...props}> <a href="{serverPath}/runtime" {...props}>
<ActivityIcon /> <ActivityIcon />
<span>Runtime</span> <span>Состояние</span>
</a> </a>
{/snippet} {/snippet}
</Sidebar.MenuButton> </Sidebar.MenuButton>
</Sidebar.MenuItem> </Sidebar.MenuItem>
<Sidebar.MenuItem> <Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active(serverPath + '/users')} tooltipContent="Users"> <Sidebar.MenuButton isActive={active(serverPath + '/users')} tooltipContent="Пользователи">
{#snippet child({ props })} {#snippet child({ props })}
<a href="{serverPath}/users" {...props}> <a href="{serverPath}/users" {...props}>
<UsersIcon /> <UsersIcon />
<span>Users</span> <span>Пользователи</span>
</a> </a>
{/snippet} {/snippet}
</Sidebar.MenuButton> </Sidebar.MenuButton>
</Sidebar.MenuItem> </Sidebar.MenuItem>
<Sidebar.MenuItem> <Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active(serverPath + '/security')} tooltipContent="Security"> <Sidebar.MenuButton isActive={active(serverPath + '/security')} tooltipContent="Безопасность">
{#snippet child({ props })} {#snippet child({ props })}
<a href="{serverPath}/security" {...props}> <a href="{serverPath}/security" {...props}>
<ShieldIcon /> <ShieldIcon />
<span>Security</span> <span>Безопасность</span>
</a> </a>
{/snippet} {/snippet}
</Sidebar.MenuButton> </Sidebar.MenuButton>
</Sidebar.MenuItem> </Sidebar.MenuItem>
<Sidebar.MenuItem> <Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active(serverPath + '/upstreams')} tooltipContent="Upstreams"> <Sidebar.MenuButton isActive={active(serverPath + '/upstreams')} tooltipContent="Апстримы и ДЦ">
{#snippet child({ props })} {#snippet child({ props })}
<a href="{serverPath}/upstreams" {...props}> <a href="{serverPath}/upstreams" {...props}>
<NetworkIcon /> <NetworkIcon />
<span>Upstreams & DCs</span> <span>Апстримы и ДЦ</span>
</a> </a>
{/snippet} {/snippet}
</Sidebar.MenuButton> </Sidebar.MenuButton>
</Sidebar.MenuItem> </Sidebar.MenuItem>
<Sidebar.MenuItem> <Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active(serverPath + '/config')} tooltipContent="Configuration"> <Sidebar.MenuButton isActive={active(serverPath + '/config')} tooltipContent="Конфигурация">
{#snippet child({ props })} {#snippet child({ props })}
<a href="{serverPath}/config" {...props}> <a href="{serverPath}/config" {...props}>
<SettingsIcon /> <SettingsIcon />
<span>Configuration</span> <span>Конфигурация</span>
</a> </a>
{/snippet} {/snippet}
</Sidebar.MenuButton> </Sidebar.MenuButton>
</Sidebar.MenuItem> </Sidebar.MenuItem>
<Sidebar.MenuItem> <Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active(serverPath + '/update')} tooltipContent="Update"> <Sidebar.MenuButton isActive={active(serverPath + '/update')} tooltipContent="Обновление">
{#snippet child({ props })} {#snippet child({ props })}
<a href="{serverPath}/update" {...props}> <a href="{serverPath}/update" {...props}>
<RefreshCwIcon /> <RefreshCwIcon />
<span>Update</span> <span>Обновление</span>
</a> </a>
{/snippet} {/snippet}
</Sidebar.MenuButton> </Sidebar.MenuButton>
@@ -0,0 +1,63 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card/index.js';
import { cn } from '$lib/utils.js';
import { kpiAccent, type KpiAccentKey } from '$lib/kpi-accents.js';
import type { Snippet } from 'svelte';
let {
title,
description,
accent,
icon,
class: className,
toolbar,
children
}: {
title?: string;
description?: string;
accent?: KpiAccentKey;
icon?: Snippet;
toolbar?: Snippet;
children: Snippet;
class?: string;
} = $props();
</script>
<Card.Root
density="compact"
class={cn(
'overflow-hidden rounded-lg border border-border shadow-sm',
accent && 'border-l-4',
accent && kpiAccent[accent].border,
className
)}
>
{#if title}
<Card.Header class="border-b border-border bg-muted/30 px-4 py-3.5">
<div class="flex gap-3">
{#if icon && accent}
<div
class={cn(
'flex size-10 shrink-0 items-center justify-center rounded-lg',
kpiAccent[accent].iconWrap
)}
>
{@render icon()}
</div>
{/if}
<div class="min-w-0 flex-1 space-y-0.5">
<Card.Title class="text-base font-semibold">{title}</Card.Title>
{#if description}
<Card.Description class="text-xs sm:text-sm">{description}</Card.Description>
{/if}
</div>
</div>
</Card.Header>
{/if}
{#if toolbar}
{@render toolbar()}
{/if}
<Card.Content class="p-0">
{@render children()}
</Card.Content>
</Card.Root>
@@ -0,0 +1,21 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { Snippet } from 'svelte';
let {
class: className,
children
}: {
class?: string;
children: Snippet;
} = $props();
</script>
<div
class={cn(
'flex flex-wrap items-center justify-between gap-2 border-b border-border px-3 py-2',
className
)}
>
{@render children()}
</div>
@@ -0,0 +1,55 @@
<script lang="ts">
import * as Table from '$lib/components/ui/table/index.js';
import { cn } from '$lib/utils.js';
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
import type { Snippet } from 'svelte';
import type { SortDir } from '$lib/table-sort.js';
let {
active,
dir,
onToggle,
class: className,
children,
numeric
}: {
active: boolean;
dir: SortDir | null;
onToggle: () => void;
class?: string;
children: Snippet;
numeric?: boolean;
} = $props();
</script>
<Table.Head
class={cn(
'px-3 py-2 text-left align-middle text-sm font-medium text-muted-foreground',
numeric && 'text-right',
className
)}
>
<button
type="button"
class={cn(
'inline-flex max-w-full items-center gap-1.5 rounded-md',
'text-muted-foreground transition-colors',
'hover:bg-accent/60 hover:text-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/45',
active && 'text-foreground',
numeric && 'ms-auto'
)}
onclick={onToggle}
>
{@render children()}
{#if active && dir === 'asc'}
<ArrowUpIcon class="size-3 shrink-0 opacity-70" aria-hidden="true" />
{:else if active && dir === 'desc'}
<ArrowDownIcon class="size-3 shrink-0 opacity-70" aria-hidden="true" />
{:else}
<ArrowUpDownIcon class="size-3 shrink-0 opacity-40" aria-hidden="true" />
{/if}
</button>
</Table.Head>
@@ -0,0 +1,26 @@
<script lang="ts">
import * as Empty from '$lib/components/ui/empty/index.js';
import InboxIcon from '@lucide/svelte/icons/inbox';
let {
title,
description
}: {
title: string;
description?: string;
} = $props();
</script>
<div class="flex justify-center py-10">
<Empty.Root class="max-w-sm border-0">
<Empty.Header>
<Empty.Media variant="icon">
<InboxIcon />
</Empty.Media>
<Empty.Title>{title}</Empty.Title>
{#if description}
<Empty.Description>{description}</Empty.Description>
{/if}
</Empty.Header>
</Empty.Root>
</div>
@@ -0,0 +1,178 @@
<script lang="ts">
import { onDestroy, onMount, tick } from 'svelte';
import Chart from 'chart.js/auto';
import * as Card from '$lib/components/ui/card/index.js';
import {
chartColorWithAlpha,
chartGridColor,
chartLegendColor,
chartSeriesColors,
chartTickColor,
readCssVar
} from '$lib/chart-theme.js';
import ServerIcon from '@lucide/svelte/icons/server';
import ChartNoAxesCombinedIcon from '@lucide/svelte/icons/chart-no-axes-combined';
let {
nodesOk,
nodesDegraded,
topUsers
}: {
nodesOk: number;
nodesDegraded: number;
topUsers: { username: string; total_megabytes: number }[];
} = $props();
let elDonut = $state<HTMLCanvasElement | null>(null);
let elBar = $state<HTMLCanvasElement | null>(null);
let chartDonut: Chart | null = null;
let chartBar: Chart | null = null;
function buildDonut() {
if (!elDonut) return;
const colors = chartSeriesColors();
const legend = chartLegendColor();
chartDonut?.destroy();
const hole = readCssVar('--card') || readCssVar('--background') || '#18181b';
const okColor = chartColorWithAlpha(colors[1] ?? '#22c55e', 0.88);
const degradedColor = chartColorWithAlpha(colors[4] ?? '#ef4444', 0.88);
chartDonut = new Chart(elDonut, {
type: 'doughnut',
data: {
labels: ['OK', 'Degraded'],
datasets: [
{
data: [Math.max(nodesOk, 0), Math.max(nodesDegraded, 0)],
backgroundColor: [okColor, degradedColor],
hoverBackgroundColor: [okColor, degradedColor],
borderWidth: 2,
borderColor: hole,
hoverBorderWidth: 2,
hoverBorderColor: hole,
hoverOffset: 4
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
cutout: '58%',
plugins: { legend: { labels: { color: legend } } }
}
});
}
function buildBar() {
if (!elBar) return;
const colors = chartSeriesColors();
const tickC = chartTickColor();
const grid = chartGridColor();
const slice = topUsers.slice(0, 8);
const barColor = chartColorWithAlpha(colors[0] ?? '#6366f1', 0.52);
chartBar?.destroy();
chartBar = new Chart(elBar, {
type: 'bar',
data: {
labels: slice.map((x) => x.username),
datasets: [
{
label: 'MiB',
data: slice.map((x) => x.total_megabytes),
backgroundColor: barColor,
hoverBackgroundColor: barColor,
borderRadius: 4,
borderSkipped: false
}
]
},
options: {
indexAxis: 'y' as const,
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: tickC }, grid: { color: grid } },
y: { ticks: { color: tickC }, grid: { display: false } }
}
}
});
}
function rebuildAll() {
buildDonut();
buildBar();
}
onMount(() => {
let cancelled = false;
void tick().then(() => {
if (!cancelled) rebuildAll();
});
const obs = new MutationObserver(() => {
rebuildAll();
});
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => {
cancelled = true;
obs.disconnect();
};
});
$effect(() => {
nodesOk;
nodesDegraded;
topUsers;
if (chartDonut) {
chartDonut.data.datasets[0].data = [Math.max(nodesOk, 0), Math.max(nodesDegraded, 0)];
chartDonut.update('none');
}
if (chartBar) {
const slice = topUsers.slice(0, 8);
chartBar.data.labels = slice.map((x) => x.username);
chartBar.data.datasets[0].data = slice.map((x) => x.total_megabytes);
chartBar.update('none');
}
});
onDestroy(() => {
chartDonut?.destroy();
chartBar?.destroy();
});
</script>
<div class="mb-6 grid gap-4 lg:grid-cols-2">
<Card.Root density="compact" class="overflow-hidden rounded-lg border border-border shadow-sm">
<Card.Header class="border-b border-border bg-muted/40 px-4 py-3">
<div class="flex items-center gap-2">
<div class="flex size-9 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<ServerIcon class="size-5" />
</div>
<div>
<Card.Title class="text-base">Статус нод</Card.Title>
<Card.Description class="text-xs">OK vs degraded по fleet-status</Card.Description>
</div>
</div>
</Card.Header>
<Card.Content class="h-56 p-4">
<canvas bind:this={elDonut} class="max-h-full w-full"></canvas>
</Card.Content>
</Card.Root>
<Card.Root density="compact" class="overflow-hidden rounded-lg border border-border shadow-sm">
<Card.Header class="border-b border-border bg-muted/40 px-4 py-3">
<div class="flex items-center gap-2">
<div class="flex size-9 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<ChartNoAxesCombinedIcon class="size-5" />
</div>
<div>
<Card.Title class="text-base">Топ трафика</Card.Title>
<Card.Description class="text-xs">До 8 пользователей, MiB</Card.Description>
</div>
</div>
</Card.Header>
<Card.Content class="h-56 p-4">
<canvas bind:this={elBar} class="max-h-full w-full"></canvas>
</Card.Content>
</Card.Root>
</div>
@@ -0,0 +1,98 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import { cn } from '$lib/utils.js';
import TrendingUpIcon from '@lucide/svelte/icons/trending-up';
import TrendingDownIcon from '@lucide/svelte/icons/trending-down';
import MinusIcon from '@lucide/svelte/icons/minus';
import { formatSignedPercent, percentChange, trendFromZero } from '$lib/kpi-trend-math.js';
let {
label,
insight,
valueDisplay,
prevValue,
currentValue,
invertTrend = false,
class: className
}: {
label: string;
/** Короткий текст под разделителем */
insight: string;
/** Отображаемое основное значение */
valueDisplay: string;
prevValue: number | null;
currentValue: number | null;
/** true: рост значения = хуже (красный «вверх») */
invertTrend?: boolean;
class?: string;
} = $props();
type TrendOut =
| null
| { raw: 'flat'; label: string; good: boolean }
| { raw: 'up' | 'down'; label: string; good: boolean };
const trend = $derived.by((): TrendOut => {
if (prevValue == null || currentValue == null) return null;
if (!Number.isFinite(prevValue) || !Number.isFinite(currentValue)) return null;
const dir = trendFromZero(prevValue, currentValue);
if (dir === null || dir === 'flat') {
if (dir === 'flat' && prevValue === currentValue)
return {
raw: 'flat' as const,
label: '0%',
good: true
};
return null;
}
const raw: 'up' | 'down' = dir === 'up' ? 'up' : 'down';
const good = invertTrend ? raw === 'down' : raw === 'up';
const pct = percentChange(prevValue, currentValue);
if (pct != null) {
return { raw, label: formatSignedPercent(pct), good };
}
if (prevValue === 0 && currentValue !== 0) {
return { raw, label: 'от нуля', good };
}
return null;
});
</script>
<Card.Root
class={cn(
'border-l-4 border-l-muted-foreground/35 bg-card shadow-sm transition-colors dark:bg-card/80',
className
)}
>
<Card.Header class="space-y-0 pb-2">
<div class="flex items-start justify-between gap-2">
<Card.Description class="text-xs sm:text-sm">{label}</Card.Description>
{#if trend}
<Badge
variant="secondary"
class={cn(
'gap-0.5 border-0 font-medium tabular-nums',
trend.good &&
'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
!trend.good && trend.raw !== 'flat' && 'bg-red-500/15 text-red-700 dark:text-red-400',
trend.raw === 'flat' && 'text-muted-foreground'
)}
>
{#if trend.raw === 'up'}
<TrendingUpIcon class="size-3.5" aria-hidden="true" />
{:else if trend.raw === 'down'}
<TrendingDownIcon class="size-3.5" aria-hidden="true" />
{:else}
<MinusIcon class="size-3.5" aria-hidden="true" />
{/if}
{trend.label}
</Badge>
{/if}
</div>
<Card.Title class="pt-1 text-2xl font-semibold tabular-nums tracking-tight">{valueDisplay}</Card.Title>
</Card.Header>
<Separator />
<Card.Content class="pt-3 text-sm text-muted-foreground">{insight}</Card.Content>
</Card.Root>
@@ -1,10 +1,22 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount, tick } from 'svelte';
import Chart from 'chart.js/auto'; import Chart from 'chart.js/auto';
import * as Card from '$lib/components/ui/card/index.js'; import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import ActivityIcon from '@lucide/svelte/icons/activity'; import ActivityIcon from '@lucide/svelte/icons/activity';
import ChartPieIcon from '@lucide/svelte/icons/chart-pie';
import MemoryStickIcon from '@lucide/svelte/icons/memory-stick';
import PlugIcon from '@lucide/svelte/icons/plug';
import NetworkIcon from '@lucide/svelte/icons/network'; import NetworkIcon from '@lucide/svelte/icons/network';
import GaugeIcon from '@lucide/svelte/icons/gauge'; import GaugeIcon from '@lucide/svelte/icons/gauge';
import {
chartColorWithAlpha,
chartGridColor,
chartLegendColor,
chartLineAreaFillCallback,
chartSeriesColors,
chartTickColor,
readCssVar
} from '$lib/chart-theme.js';
let { let {
histUp, histUp,
@@ -42,72 +54,205 @@
let chartNet: Chart | null = null; let chartNet: Chart | null = null;
let chartTop: Chart | null = null; let chartTop: Chart | null = null;
const grid = '#64748b';
function lineOpts() { function lineOpts() {
const tick = chartTickColor();
const grid = chartGridColor();
const legend = chartLegendColor();
return { return {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
animation: false as const, animation: false as const,
plugins: { legend: { labels: { color: grid } } }, plugins: { legend: { labels: { color: legend } } },
scales: { scales: {
x: { ticks: { color: grid, maxTicksLimit: 8 }, grid: { color: '#33415555' } }, x: { ticks: { color: tick, maxTicksLimit: 8 }, grid: { color: grid } },
y: { ticks: { color: grid }, grid: { color: '#33415555' } } y: { ticks: { color: tick }, grid: { color: grid } }
} }
}; };
} }
onMount(() => { function rebuildAll() {
if (!elTraffic || !elFlow || !elMem || !elConn || !elNet || !elTop) return; const colors = chartSeriesColors();
const legend = chartLegendColor();
const grid = chartGridColor();
const tick = chartTickColor();
const lab = histDown.map((_, i) => String(i)); const lab = histDown.map((_, i) => String(i));
if (elTraffic) {
chartTraffic?.destroy();
const c0 = colors[0] ?? '#38bdf8';
const c4 = colors[4] ?? '#f472b6';
chartTraffic = new Chart(elTraffic, { chartTraffic = new Chart(elTraffic, {
type: 'line', type: 'line',
data: { data: {
labels: lab, labels: lab,
datasets: [ datasets: [
{ label: 'Скачивание', data: [...histDown], borderColor: '#38bdf8', tension: 0.2, fill: true }, {
{ label: 'Загрузка', data: [...histUp], borderColor: '#f472b6', tension: 0.2, fill: true } label: 'Скачивание',
data: [...histDown],
borderColor: c0,
backgroundColor: chartLineAreaFillCallback(c0),
borderWidth: 1.5,
pointRadius: 0,
pointHoverRadius: 3,
pointHoverBackgroundColor: c0,
pointHoverBorderColor: c0,
tension: 0.2,
fill: true
},
{
label: 'Загрузка',
data: [...histUp],
borderColor: c4,
backgroundColor: chartLineAreaFillCallback(c4),
borderWidth: 1.5,
pointRadius: 0,
pointHoverRadius: 3,
pointHoverBackgroundColor: c4,
pointHoverBorderColor: c4,
tension: 0.2,
fill: true
}
] ]
}, },
options: lineOpts() options: lineOpts()
}); });
}
if (elFlow) {
chartFlow?.destroy();
const hole = readCssVar('--card') || readCssVar('--background') || '#18181b';
const downColor = chartColorWithAlpha(colors[0] ?? '#38bdf8', 0.88);
const upColor = chartColorWithAlpha(colors[4] ?? '#f472b6', 0.88);
chartFlow = new Chart(elFlow, { chartFlow = new Chart(elFlow, {
type: 'doughnut', type: 'doughnut',
data: { data: {
labels: ['Скачано', 'Загружено'], labels: ['Скачано', 'Загружено'],
datasets: [{ data: [Math.max(totalDown, 0), Math.max(totalUp, 0)], backgroundColor: ['#38bdf8', '#f472b6'] }] datasets: [
{
data: [Math.max(totalDown, 0), Math.max(totalUp, 0)],
backgroundColor: [downColor, upColor],
hoverBackgroundColor: [downColor, upColor],
borderWidth: 2,
borderColor: hole,
hoverBorderWidth: 2,
hoverBorderColor: hole,
hoverOffset: 4
}
]
}, },
options: { responsive: true, maintainAspectRatio: false, animation: false, plugins: { legend: { labels: { color: grid } } } } options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
cutout: '58%',
plugins: { legend: { labels: { color: legend } } }
}
}); });
}
if (elMem) {
chartMem?.destroy();
const c2 = colors[2] ?? '#a78bfa';
chartMem = new Chart(elMem, { chartMem = new Chart(elMem, {
type: 'line', type: 'line',
data: { data: {
labels: histMem.map((_, i) => String(i)), labels: histMem.map((_, i) => String(i)),
datasets: [{ label: 'KB', data: [...histMem], borderColor: '#a78bfa', tension: 0.2, fill: true }] datasets: [
{
label: 'KiB',
data: [...histMem],
borderColor: c2,
backgroundColor: chartLineAreaFillCallback(c2),
borderWidth: 1.5,
pointRadius: 0,
pointHoverRadius: 3,
pointHoverBackgroundColor: c2,
pointHoverBorderColor: c2,
tension: 0.2,
fill: true
}
]
}, },
options: lineOpts() options: lineOpts()
}); });
}
if (elConn) {
chartConn?.destroy();
const c1 = colors[1] ?? '#34d399';
chartConn = new Chart(elConn, { chartConn = new Chart(elConn, {
type: 'line', type: 'line',
data: { data: {
labels: histConn.map((_, i) => String(i)), labels: histConn.map((_, i) => String(i)),
datasets: [{ label: 'Соединения', data: [...histConn], borderColor: '#34d399', tension: 0.2, fill: true }] datasets: [
{
label: 'Соединения',
data: [...histConn],
borderColor: c1,
backgroundColor: chartLineAreaFillCallback(c1),
borderWidth: 1.5,
pointRadius: 0,
pointHoverRadius: 3,
pointHoverBackgroundColor: c1,
pointHoverBorderColor: c1,
tension: 0.2,
fill: true
}
]
}, },
options: lineOpts() options: lineOpts()
}); });
}
if (elNet) {
chartNet?.destroy();
const holeNet = readCssVar('--card') || readCssVar('--background') || '#18181b';
const tcpColor = chartColorWithAlpha(colors[1] ?? '#22c55e', 0.88);
const udpColor = chartColorWithAlpha(colors[3] ?? '#eab308', 0.88);
chartNet = new Chart(elNet, { chartNet = new Chart(elNet, {
type: 'doughnut', type: 'doughnut',
data: { data: {
labels: ['TCP', 'UDP'], labels: ['TCP', 'UDP'],
datasets: [{ data: [tcpN, udpN], backgroundColor: ['#22c55e', '#eab308'] }] datasets: [
{
data: [tcpN, udpN],
backgroundColor: [tcpColor, udpColor],
hoverBackgroundColor: [tcpColor, udpColor],
borderWidth: 2,
borderColor: holeNet,
hoverBorderWidth: 2,
hoverBorderColor: holeNet,
hoverOffset: 4
}
]
}, },
options: { responsive: true, maintainAspectRatio: false, animation: false, plugins: { legend: { labels: { color: grid } } } } options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
cutout: '58%',
plugins: { legend: { labels: { color: legend } } }
}
}); });
}
if (elTop) {
chartTop?.destroy();
const topBarColor = chartColorWithAlpha(colors[0] ?? '#818cf8', 0.52);
chartTop = new Chart(elTop, { chartTop = new Chart(elTop, {
type: 'bar', type: 'bar',
data: { data: {
labels: topList.map((x) => x.name), labels: topList.map((x) => x.name),
datasets: [{ label: 'Сессии', data: topList.map((x) => x.n), backgroundColor: '#818cf8' }] datasets: [
{
label: 'Сессии',
data: topList.map((x) => x.n),
backgroundColor: topBarColor,
hoverBackgroundColor: topBarColor,
borderRadius: 4,
borderSkipped: false
}
]
}, },
options: { options: {
indexAxis: 'y' as const, indexAxis: 'y' as const,
@@ -116,11 +261,25 @@
animation: false, animation: false,
plugins: { legend: { display: false } }, plugins: { legend: { display: false } },
scales: { scales: {
x: { ticks: { color: grid }, grid: { color: '#33415555' } }, x: { ticks: { color: tick }, grid: { color: grid } },
y: { ticks: { color: grid }, grid: { display: false } } y: { ticks: { color: tick }, grid: { display: false } }
} }
} }
}); });
}
}
onMount(() => {
let cancelled = false;
void tick().then(() => {
if (!cancelled) rebuildAll();
});
const obs = new MutationObserver(() => rebuildAll());
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => {
cancelled = true;
obs.disconnect();
};
}); });
$effect(() => { $effect(() => {
@@ -167,58 +326,76 @@
</script> </script>
<div class="grid gap-4 lg:grid-cols-2"> <div class="grid gap-4 lg:grid-cols-2">
<Card.Root> <KpiPanelCard
<Card.Header> accent="neutral"
<Card.Title class="flex items-center gap-2 text-base"> title="Трафик"
<ActivityIcon class="size-4" /> Трафик description="Скорость загрузки и скачивания (история из /traffic)"
</Card.Title> headerClass="pb-2"
</Card.Header> contentClass="h-56 px-4 pb-4 pt-0"
<Card.Content class="h-56"> >
{#snippet icon()}
<ActivityIcon class="size-5" aria-hidden="true" />
{/snippet}
<canvas bind:this={elTraffic} class="max-h-full w-full"></canvas> <canvas bind:this={elTraffic} class="max-h-full w-full"></canvas>
</Card.Content> </KpiPanelCard>
</Card.Root> <KpiPanelCard
<Card.Root> accent="neutral"
<Card.Header> title="Поток (накоплено)"
<Card.Title class="text-base">Поток (накоплено)</Card.Title> description="Соотношение накопленного down / up"
</Card.Header> headerClass="pb-2"
<Card.Content class="h-56"> contentClass="h-56 px-4 pb-4 pt-0"
>
{#snippet icon()}
<ChartPieIcon class="size-5" aria-hidden="true" />
{/snippet}
<canvas bind:this={elFlow} class="max-h-full w-full"></canvas> <canvas bind:this={elFlow} class="max-h-full w-full"></canvas>
</Card.Content> </KpiPanelCard>
</Card.Root> <KpiPanelCard
<Card.Root> accent="neutral"
<Card.Header> title="Память (KiB)"
<Card.Title class="text-base">Память (KB)</Card.Title> description="Использование по снимкам (ось — KiB)"
</Card.Header> headerClass="pb-2"
<Card.Content class="h-56"> contentClass="h-56 px-4 pb-4 pt-0"
>
{#snippet icon()}
<MemoryStickIcon class="size-5" aria-hidden="true" />
{/snippet}
<canvas bind:this={elMem} class="max-h-full w-full"></canvas> <canvas bind:this={elMem} class="max-h-full w-full"></canvas>
</Card.Content> </KpiPanelCard>
</Card.Root> <KpiPanelCard
<Card.Root> accent="neutral"
<Card.Header> title="Подключения"
<Card.Title class="text-base">Подключения</Card.Title> description="Число активных соединений во времени"
</Card.Header> headerClass="pb-2"
<Card.Content class="h-56"> contentClass="h-56 px-4 pb-4 pt-0"
>
{#snippet icon()}
<PlugIcon class="size-5" aria-hidden="true" />
{/snippet}
<canvas bind:this={elConn} class="max-h-full w-full"></canvas> <canvas bind:this={elConn} class="max-h-full w-full"></canvas>
</Card.Content> </KpiPanelCard>
</Card.Root> <KpiPanelCard
<Card.Root> accent="neutral"
<Card.Header> title="Типы сети"
<Card.Title class="flex items-center gap-2 text-base"> description="TCP и UDP по текущему снимку"
<NetworkIcon class="size-4" /> Типы сети headerClass="pb-2"
</Card.Title> contentClass="h-56 px-4 pb-4 pt-0"
</Card.Header> >
<Card.Content class="h-56"> {#snippet icon()}
<NetworkIcon class="size-5" aria-hidden="true" />
{/snippet}
<canvas bind:this={elNet} class="max-h-full w-full"></canvas> <canvas bind:this={elNet} class="max-h-full w-full"></canvas>
</Card.Content> </KpiPanelCard>
</Card.Root> <KpiPanelCard
<Card.Root> accent="neutral"
<Card.Header> title="Топ прокси (по сессиям)"
<Card.Title class="flex items-center gap-2 text-base"> description="Распределение по имени прокси"
<GaugeIcon class="size-4" /> Топ прокси (по сессиям) headerClass="pb-2"
</Card.Title> contentClass="h-56 px-4 pb-4 pt-0"
</Card.Header> >
<Card.Content class="h-56"> {#snippet icon()}
<GaugeIcon class="size-5" aria-hidden="true" />
{/snippet}
<canvas bind:this={elTop} class="max-h-full w-full"></canvas> <canvas bind:this={elTop} class="max-h-full w-full"></canvas>
</Card.Content> </KpiPanelCard>
</Card.Root>
</div> </div>
@@ -0,0 +1,386 @@
<script lang="ts">
import { untrack } from 'svelte';
import { ApiError, fetchMihomoJson, mihomoPut } from '$lib/api/client.js';
import type { MihomoProxiesResponse, MihomoProxyEntry } from '$lib/api/mihomo-types.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import { cn } from '$lib/utils.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import ActivityIcon from '@lucide/svelte/icons/activity';
let {
alias,
/** Плотная сетка мелких ячеек — для флота и списков из многих нод. */
compact = false,
/** Скрыть верхнюю панель «Обновить» (кнопка в Card.Action у родителя). */
hideToolbar = false,
/** Увеличьте, чтобы принудительно перезагрузить список снаружи. */
refreshNonce = 0,
/** Состояние загрузки для индикатора у родителя. */
onLoadingChange,
/** Увеличьте — проверить задержку для всех групп (флот: кнопка в шапке карточки). */
delayAllNonce = 0,
/** Не показывать «Все задержки» внутри группы (кнопка на родителе). */
hideCompactPerGroupDelayButton = false,
/** Идёт проверка задержек по всем группам. */
onDelayAllChange
}: {
alias: string;
compact?: boolean;
hideToolbar?: boolean;
refreshNonce?: number;
onLoadingChange?: (loading: boolean) => void;
delayAllNonce?: number;
hideCompactPerGroupDelayButton?: boolean;
onDelayAllChange?: (busy: boolean) => void;
} = $props();
const MIHOMO_DELAY_DEFAULT_URL = 'https://www.gstatic.com/generate_204';
let proxiesLoading = $state(false);
let proxiesErr = $state<string | null>(null);
let proxiesData = $state<MihomoProxiesResponse | null>(null);
let switching = $state<string | null>(null);
let testing = $state<string | null>(null);
let testingGroup = $state<string | null>(null);
function delayQuery(testUrl?: string) {
const u =
testUrl && String(testUrl).trim() !== '' ? String(testUrl).trim() : MIHOMO_DELAY_DEFAULT_URL;
return `timeout=5000&url=${encodeURIComponent(u)}`;
}
async function loadProxies() {
const a = untrack(() => alias);
if (!a) return;
const notify = untrack(() => onLoadingChange);
proxiesLoading = true;
// Иначе $effect подпишется на проп-колбэк: новая fn каждый рендер родителя → бесконечный цикл /proxies
untrack(() => notify?.(true));
proxiesErr = null;
try {
proxiesData = await fetchMihomoJson<MihomoProxiesResponse>(a, 'proxies');
} catch (e) {
proxiesErr = e instanceof ApiError ? e.message : String(e);
// не затираем успешный снимок при сбое обновления — без мерцания UI
} finally {
proxiesLoading = false;
untrack(() => notify?.(false));
}
}
$effect(() => {
const a = alias;
void refreshNonce;
if (!a) return;
void loadProxies();
});
function isSelectableGroup(p: MihomoProxyEntry) {
const t = (p.type ?? '').toLowerCase();
if (t === 'selector' || t === 'urltest' || t === 'fallback') return true;
if (t === 'loadbalance' && Array.isArray(p.all) && p.all.length > 0) return true;
return false;
}
/** Стабильный порядок групп — меньше скачков DOM при обновлении JSON. */
let selectableGroups = $derived.by((): [string, MihomoProxyEntry][] => {
const p = proxiesData?.proxies;
if (!p) return [];
return Object.entries(p)
.filter(([_, v]) => isSelectableGroup(v))
.sort(([a], [b]) => a.localeCompare(b));
});
function lastDelay(p: MihomoProxyEntry): number | null {
const h = p.history;
if (!h?.length) return null;
return h[h.length - 1]?.delay ?? null;
}
async function selectProxy(groupName: string, nodeName: string) {
const a = alias;
if (!a) return;
switching = groupName;
try {
await mihomoPut(a, `proxies/${encodeURIComponent(groupName)}`, { name: nodeName });
await loadProxies();
} catch (e) {
proxiesErr = e instanceof ApiError ? e.message : String(e);
} finally {
switching = null;
}
}
async function pingOne(name: string) {
const a = untrack(() => alias);
if (!a) return;
testing = name;
try {
const testUrl = untrack(() => proxiesData?.proxies?.[name]?.testUrl);
await fetchMihomoJson(
a,
`proxies/${encodeURIComponent(name)}/delay?${delayQuery(testUrl)}`
);
await loadProxies();
} catch (e) {
proxiesErr = e instanceof ApiError ? e.message : String(e);
} finally {
testing = null;
}
}
async function pingGroup(groupName: string) {
const a = untrack(() => alias);
if (!a) return;
// Снимок до await: иначе $effect(delayAllNonce) подпишется на proxiesData и
// после loadProxies() перезапустится при том же nonce → бесконечные delay/ping.
const snap = untrack(() => {
const p = proxiesData?.proxies;
const g = p?.[groupName];
const names = g?.all ?? [];
return {
groupTestUrl: g?.testUrl,
names,
nodeTestUrls: names.map((n) => p?.[n]?.testUrl)
};
});
testingGroup = groupName;
try {
await fetchMihomoJson(
a,
`group/${encodeURIComponent(groupName)}/delay?${delayQuery(snap.groupTestUrl)}`
);
await loadProxies();
} catch {
try {
for (let i = 0; i < snap.names.length; i++) {
const n = snap.names[i]!;
await fetchMihomoJson(
a,
`proxies/${encodeURIComponent(n)}/delay?${delayQuery(snap.nodeTestUrls[i])}`
);
}
await loadProxies();
} catch (e) {
proxiesErr = e instanceof ApiError ? e.message : String(e);
}
} finally {
testingGroup = null;
}
}
$effect(() => {
const a = alias;
const n = delayAllNonce;
if (!a || n <= 0) return;
const groupsSnapshot = untrack(() => [...selectableGroups]);
void (async () => {
untrack(() => onDelayAllChange?.(true));
try {
for (const [gName] of groupsSnapshot) {
await pingGroup(gName);
}
} finally {
untrack(() => onDelayAllChange?.(false));
}
})();
});
</script>
{#if !hideToolbar}
<div
class={cn(
'flex flex-wrap items-center justify-end gap-1.5',
compact ? 'mb-2' : 'mb-4'
)}
>
<Button
variant="outline"
size="sm"
class={cn(compact ? 'h-7 gap-1.5 px-2 text-xs' : 'gap-2')}
onclick={() => loadProxies()}
disabled={proxiesLoading || !alias}
>
<RefreshCwIcon
class={cn('size-3.5 shrink-0', proxiesLoading && 'animate-spin')}
aria-hidden="true"
/>
Обновить
</Button>
</div>
{/if}
<DataQueryState
err={proxiesErr}
hasStaleData={proxiesData != null && proxiesErr != null}
showSkeleton={proxiesLoading && proxiesData === null}
isEmpty={false}
>
{#snippet skeleton()}
{#if compact}
<div class="flex flex-wrap gap-1.5">
{#each Array.from({ length: 10 }) as _, i (i)}
<Skeleton class="h-8 w-[5.5rem] rounded-md" />
{/each}
</div>
{:else}
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each Array.from({ length: 6 }) as _, i (i)}
<Skeleton class="h-32 rounded-xl" />
{/each}
</div>
{/if}
{/snippet}
{#snippet children()}
{#if proxiesData?.proxies}
<div class={cn('flex flex-col', compact ? 'gap-2' : 'gap-8')}>
{#each selectableGroups as [gName, group] (gName)}
{#if compact}
<div class="space-y-2" role="group" aria-label="Группа {gName}">
<div class="flex flex-wrap items-center gap-2">
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">
<Badge variant="outline" class="h-5 px-1.5 font-mono text-[10px] uppercase tracking-wide">
{gName}
</Badge>
<span class="text-muted-foreground text-xs">
<span class="text-foreground/90 font-medium">{group.now ?? '—'}</span>
</span>
</div>
{#if !hideCompactPerGroupDelayButton}
<Button
variant="outline"
size="sm"
class="h-7 shrink-0 px-2 text-xs"
onclick={() => pingGroup(gName)}
disabled={testingGroup === gName}
>
{testingGroup === gName ? '…' : 'Все задержки'}
</Button>
{/if}
</div>
<div class="flex flex-wrap gap-1.5" role="list">
{#each group.all ?? [] as nodeName (nodeName)}
{@const node = proxiesData.proxies?.[nodeName]}
{@const d = node ? lastDelay(node) : null}
{@const active = group.now === nodeName}
<div
class={cn(
'bg-background flex min-w-0 max-w-full items-stretch overflow-hidden rounded-md border text-left text-xs transition-colors',
active
? 'border-primary/60 bg-muted/30'
: 'border-border/60 hover:bg-muted/25'
)}
role="listitem"
>
<button
type="button"
class="hover:bg-muted/50 min-w-0 flex-1 px-2 py-1.5 text-left transition-colors disabled:opacity-50"
onclick={() => selectProxy(gName, nodeName)}
disabled={switching === gName}
title="Выбрать {nodeName}"
>
<div class="truncate font-medium leading-none">{nodeName}</div>
<div
class="text-muted-foreground mt-1 flex items-center justify-between gap-2 text-[10px] leading-none"
>
<span class="truncate opacity-90">{node?.type ?? '—'}</span>
<span
class={cn(
'tabular-nums',
d != null && d < 500 ? 'text-emerald-600 dark:text-emerald-400' : ''
)}
>
{d != null ? `${d}` : '—'}<span class="text-muted-foreground/70">ms</span>
</span>
</div>
</button>
<Button
variant="ghost"
size="icon"
class="text-muted-foreground hover:text-foreground size-8 shrink-0 rounded-none border-l px-0"
onclick={(ev) => {
ev.stopPropagation();
void pingOne(nodeName);
}}
disabled={testing === nodeName}
aria-label="Задержка для {nodeName}"
title="Проверить задержку"
>
{#if testing === nodeName}
<span class="text-[10px]">…</span>
{:else}
<ActivityIcon class="size-3.5 opacity-70" aria-hidden="true" />
{/if}
</Button>
</div>
{/each}
</div>
</div>
{:else}
<div class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 class="text-lg font-semibold">{gName}</h2>
<p class="text-muted-foreground text-sm">
Сейчас: <span class="text-foreground">{group.now ?? '—'}</span>
</p>
</div>
<Button
variant="secondary"
size="sm"
onclick={() => pingGroup(gName)}
disabled={testingGroup === gName}
>
{testingGroup === gName ? 'Проверка…' : 'Все задержки'}
</Button>
</div>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each group.all ?? [] as nodeName (nodeName)}
{@const node = proxiesData.proxies?.[nodeName]}
{@const d = node ? lastDelay(node) : null}
{@const active = group.now === nodeName}
<div
class="bg-card text-card-foreground rounded-xl border p-4 transition-shadow {active
? 'ring-primary ring-2'
: ''}"
>
<button
type="button"
class="w-full text-left"
onclick={() => selectProxy(gName, nodeName)}
disabled={switching === gName}
>
<div class="flex items-start justify-between gap-2">
<span class="font-medium">{nodeName}</span>
<Badge variant="outline">{node?.type ?? '—'}</Badge>
</div>
<div
class="mt-2 text-sm {d != null && d < 500 ? 'text-emerald-400' : 'text-muted-foreground'}"
>
{d != null ? `${d} ms` : '—'}
</div>
</button>
<div class="mt-2 flex justify-end">
<Button
variant="ghost"
size="sm"
class="h-7 px-2"
onclick={() => pingOne(nodeName)}
disabled={testing === nodeName}
>
Ping
</Button>
</div>
</div>
{/each}
</div>
</div>
{/if}
{/each}
</div>
{/if}
{/snippet}
</DataQueryState>
@@ -0,0 +1,40 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { KpiAccentKey } from '$lib/kpi-accents.js';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import BracesIcon from '@lucide/svelte/icons/braces';
let {
title,
description,
accent = 'info',
payload,
class: className,
preClass = 'max-h-[70vh]',
iconSnippet
}: {
title: string;
description?: string;
accent?: KpiAccentKey;
payload: unknown;
class?: string;
preClass?: string;
iconSnippet?: Snippet;
} = $props();
</script>
<KpiPanelCard {accent} {title} {description} class={className} contentClass="p-0">
{#snippet icon()}
{#if iconSnippet}
{@render iconSnippet()}
{:else}
<BracesIcon class="size-5" aria-hidden="true" />
{/if}
{/snippet}
<pre
class={[
preClass,
'overflow-auto rounded-md border border-border bg-muted/40 p-4 text-xs leading-relaxed'
]}
>{JSON.stringify(payload, null, 2)}</pre>
</KpiPanelCard>
@@ -0,0 +1,39 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { Button } from '$lib/components/ui/button/index.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
let {
title,
subtitle,
loading = false,
showRefresh = true,
onRefresh,
actions
}: {
title: string;
subtitle?: string;
loading?: boolean;
showRefresh?: boolean;
onRefresh?: () => void | Promise<void>;
actions?: Snippet;
} = $props();
</script>
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div class="space-y-1">
<h1 class="text-2xl font-semibold tracking-tight">{title}</h1>
{#if subtitle}
<p class="text-sm text-muted-foreground">{subtitle}</p>
{/if}
</div>
<div class="flex items-center gap-2">
{@render actions?.()}
{#if showRefresh}
<Button variant="outline" size="sm" onclick={onRefresh} disabled={loading || !onRefresh}>
<RefreshCwIcon class={['mr-1 size-4', loading && 'animate-spin']} />
Обновить
</Button>
{/if}
</div>
</div>
@@ -0,0 +1,62 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle';
import InboxIcon from '@lucide/svelte/icons/inbox';
let {
loading = false,
err = null,
isEmpty = false,
loadingTitle = 'Загрузка данных',
loadingDescription = 'Пожалуйста, подождите...',
emptyTitle = 'Нет данных',
emptyDescription = 'По этому endpoint пока нет содержимого.',
children
}: {
loading?: boolean;
err?: string | null;
isEmpty?: boolean;
loadingTitle?: string;
loadingDescription?: string;
emptyTitle?: string;
emptyDescription?: string;
children?: Snippet;
} = $props();
</script>
{#if err}
<Alert variant="destructive" class="mb-4">
<AlertTitle>Ошибка</AlertTitle>
<AlertDescription>{err}</AlertDescription>
</Alert>
{/if}
{#if loading}
<KpiPanelCard
accent="neutral"
title={loadingTitle}
description={loadingDescription}
contentClass="pt-0 text-sm text-muted-foreground"
>
{#snippet icon()}
<LoaderCircleIcon class="size-5 animate-spin" aria-hidden="true" />
{/snippet}
Идет запрос к ноде, обновляем показатели dashboard.
</KpiPanelCard>
{:else if !err && isEmpty}
<KpiPanelCard
accent="neutral"
title={emptyTitle}
description={emptyDescription}
contentClass="pt-0 text-sm text-muted-foreground"
>
{#snippet icon()}
<InboxIcon class="size-5" aria-hidden="true" />
{/snippet}
Проверьте параметры запроса или состояние сервера.
</KpiPanelCard>
{:else if !loading && !isEmpty}
{@render children?.()}
{/if}
+94
View File
@@ -0,0 +1,94 @@
<script lang="ts">
import { page } from '$app/state';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { breadcrumbsFromPath } from '$lib/breadcrumb-trail.js';
import { setMode, resetMode, userPrefersMode } from 'mode-watcher';
import MonitorIcon from '@lucide/svelte/icons/monitor';
import SunIcon from '@lucide/svelte/icons/sun';
import MoonIcon from '@lucide/svelte/icons/moon';
const items = $derived(breadcrumbsFromPath(page.url.pathname));
</script>
<div class="flex min-w-0 flex-1 items-center justify-between gap-3">
<div class="flex min-w-0 flex-1 items-center gap-2">
<Sidebar.Trigger class="-ml-1" />
<Separator orientation="vertical" class="me-1 h-4" />
<Breadcrumb.Root class="min-w-0">
<Breadcrumb.List class="flex-wrap">
{#each items as it, i (it.href + String(i))}
<Breadcrumb.Item class="min-w-0 max-w-[140px] sm:max-w-[220px]">
{#if it.current}
<Breadcrumb.Page class="truncate">{it.label}</Breadcrumb.Page>
{:else}
<Breadcrumb.Link href={it.href} class="truncate">{it.label}</Breadcrumb.Link>
{/if}
</Breadcrumb.Item>
{#if i < items.length - 1}
<Breadcrumb.Separator />
{/if}
{/each}
</Breadcrumb.List>
</Breadcrumb.Root>
</div>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
size="icon-sm"
class="relative shrink-0"
aria-label="Тема оформления"
>
<SunIcon
class="size-4 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90"
/>
<MoonIcon
class="absolute size-4 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0"
/>
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-44">
<DropdownMenu.Label class="text-xs text-muted-foreground">Тема</DropdownMenu.Label>
<DropdownMenu.Item
class="gap-2"
onclick={() => {
setMode('light');
}}
>
<SunIcon class="size-4" /> Светлая
{#if userPrefersMode.current === 'light'}
<span class="ms-auto text-xs text-muted-foreground">✓</span>
{/if}
</DropdownMenu.Item>
<DropdownMenu.Item
class="gap-2"
onclick={() => {
setMode('dark');
}}
>
<MoonIcon class="size-4" /> Тёмная
{#if userPrefersMode.current === 'dark'}
<span class="ms-auto text-xs text-muted-foreground">✓</span>
{/if}
</DropdownMenu.Item>
<DropdownMenu.Item
class="gap-2"
onclick={() => {
resetMode();
}}
>
<MonitorIcon class="size-4" /> Системная
{#if userPrefersMode.current === 'system'}
<span class="ms-auto text-xs text-muted-foreground">✓</span>
{/if}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
@@ -0,0 +1,39 @@
<script lang="ts">
import { Badge } from '$lib/components/ui/badge/index.js';
import { cn } from '$lib/utils.js';
import CheckIcon from '@lucide/svelte/icons/check';
import Loader2Icon from '@lucide/svelte/icons/loader-circle';
import AlertCircleIcon from '@lucide/svelte/icons/circle-alert';
let {
variant
}: {
variant: 'ok' | 'degraded' | 'pending' | 'error';
} = $props();
</script>
{#if variant === 'ok'}
<Badge
class={cn(
'gap-1 border-0 bg-emerald-500/15 font-medium text-emerald-700 dark:text-emerald-400'
)}
>
<CheckIcon class="size-3.5 shrink-0" aria-hidden="true" />
OK
</Badge>
{:else if variant === 'degraded'}
<Badge variant="destructive" class="gap-1 font-medium">
<AlertCircleIcon class="size-3.5 shrink-0" aria-hidden="true" />
degraded
</Badge>
{:else if variant === 'pending'}
<Badge variant="secondary" class="gap-1 font-medium text-muted-foreground">
<Loader2Icon class="size-3.5 shrink-0 animate-spin" aria-hidden="true" />
ожидание
</Badge>
{:else}
<Badge variant="destructive" class="gap-1 font-medium">
<AlertCircleIcon class="size-3.5 shrink-0" aria-hidden="true" />
ошибка
</Badge>
{/if}
@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLSpanElement>>> = $props();
</script>
<span
bind:this={ref}
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
class={cn("size-5 [&>svg]:size-4 flex items-center justify-center", className)}
{...restProps}
>
<MoreHorizontalIcon />
<span class="sr-only">More</span>
</span>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLLiAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLLiAttributes> = $props();
</script>
<li
bind:this={ref}
data-slot="breadcrumb-item"
class={cn("gap-1 inline-flex items-center", className)}
{...restProps}
>
{@render children?.()}
</li>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import type { Snippet } from "svelte";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
href = undefined,
child,
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
child?: Snippet<[{ props: HTMLAnchorAttributes }]>;
} = $props();
const attrs = $derived({
"data-slot": "breadcrumb-link",
class: cn("hover:text-foreground transition-colors", className),
href,
...restProps,
});
</script>
{#if child}
{@render child({ props: attrs })}
{:else}
<a bind:this={ref} {...attrs}>
{@render children?.()}
</a>
{/if}
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLOlAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLOlAttributes> = $props();
</script>
<ol
bind:this={ref}
data-slot="breadcrumb-list"
class={cn("text-muted-foreground gap-1.5 text-sm flex flex-wrap items-center wrap-break-word", className)}
{...restProps}
>
{@render children?.()}
</ol>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
class={cn("text-foreground font-normal", className)}
{...restProps}
>
{@render children?.()}
</span>
@@ -0,0 +1,27 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLLiAttributes } from "svelte/elements";
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLLiAttributes> = $props();
</script>
<li
bind:this={ref}
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
class={cn("[&>svg]:size-3.5", className)}
{...restProps}
>
{#if children}
{@render children?.()}
{:else}
<ChevronRightIcon />
{/if}
</li>
@@ -0,0 +1,22 @@
<script lang="ts">
import type { WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<nav
bind:this={ref}
data-slot="breadcrumb"
aria-label="breadcrumb"
class={cn("cn-breadcrumb", className)}
{...restProps}
>
{@render children?.()}
</nav>
@@ -0,0 +1,25 @@
import Root from "./breadcrumb.svelte";
import Ellipsis from "./breadcrumb-ellipsis.svelte";
import Item from "./breadcrumb-item.svelte";
import Separator from "./breadcrumb-separator.svelte";
import Link from "./breadcrumb-link.svelte";
import List from "./breadcrumb-list.svelte";
import Page from "./breadcrumb-page.svelte";
export {
Root,
Ellipsis,
Item,
Separator,
Link,
List,
Page,
//
Root as Breadcrumb,
Ellipsis as BreadcrumbEllipsis,
Item as BreadcrumbItem,
Separator as BreadcrumbSeparator,
Link as BreadcrumbLink,
List as BreadcrumbList,
Page as BreadcrumbPage,
};
+12 -2
View File
@@ -7,15 +7,25 @@
class: className, class: className,
children, children,
size = "default", size = "default",
density = "default",
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: "default" | "sm" } = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
size?: "default" | "sm";
density?: "default" | "compact";
} = $props();
</script> </script>
<div <div
bind:this={ref} bind:this={ref}
data-slot="card" data-slot="card"
data-size={size} data-size={size}
class={cn("ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)} data-density={density}
class={cn(
"ring-foreground/10 bg-card text-card-foreground overflow-hidden rounded-xl text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col",
"data-[density=default]:gap-4 data-[density=default]:py-4 data-[density=default]:data-[size=sm]:gap-3 data-[density=default]:data-[size=sm]:py-3",
"data-[density=compact]:gap-0 data-[density=compact]:py-0",
className
)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
@@ -0,0 +1,39 @@
<script lang="ts">
import { Checkbox as CheckboxPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import CheckIcon from '@lucide/svelte/icons/check';
import MinusIcon from '@lucide/svelte/icons/minus';
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
...restProps
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
</script>
<CheckboxPrimitive.Root
bind:ref
data-slot="checkbox"
class={cn(
"border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-colors group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
bind:checked
bind:indeterminate
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<div
data-slot="checkbox-indicator"
class="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
>
{#if checked}
<CheckIcon />
{:else if indeterminate}
<MinusIcon />
{/if}
</div>
{/snippet}
</CheckboxPrimitive.Root>
@@ -0,0 +1,6 @@
import Root from "./checkbox.svelte";
export {
Root,
//
Root as Checkbox,
};
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="empty-content"
class={cn(
"gap-2.5 text-sm flex w-full max-w-sm min-w-0 flex-col items-center text-balance",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="empty-description"
class={cn(
"text-sm/relaxed text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="empty-header"
class={cn("gap-2 flex max-w-sm flex-col items-center", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,41 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const emptyMediaVariants = tv({
base: "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-8 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
},
});
export type EmptyMediaVariant = VariantProps<typeof emptyMediaVariants>["variant"];
</script>
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
variant = "default",
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { variant?: EmptyMediaVariant } = $props();
</script>
<div
bind:this={ref}
data-slot="empty-icon"
data-variant={variant}
class={cn(emptyMediaVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,15 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} data-slot="empty-title" class={cn("text-sm font-medium tracking-tight", className)} {...restProps}>
{@render children?.()}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="empty"
class={cn(
"gap-4 rounded-xl border-dashed p-6 flex w-full min-w-0 flex-1 flex-col items-center justify-center text-center text-balance",
className
)}
{...restProps}
>
{@render children?.()}
</div>
+22
View File
@@ -0,0 +1,22 @@
import Root from "./empty.svelte";
import Header from "./empty-header.svelte";
import Media from "./empty-media.svelte";
import Title from "./empty-title.svelte";
import Description from "./empty-description.svelte";
import Content from "./empty-content.svelte";
export {
Root,
Header,
Media,
Title,
Description,
Content,
//
Root as Empty,
Header as EmptyHeader,
Media as EmptyMedia,
Title as EmptyTitle,
Description as EmptyDescription,
Content as EmptyContent,
};
@@ -13,13 +13,13 @@
</script> </script>
{#snippet Fallback()} {#snippet Fallback()}
<span>Next</span> <span>Следующая</span>
<ChevronRightIcon class="size-4" /> <ChevronRightIcon class="size-4" />
{/snippet} {/snippet}
<PaginationPrimitive.NextButton <PaginationPrimitive.NextButton
bind:ref bind:ref
aria-label="Go to next page" aria-label="Перейти на следующую страницу"
class={cn(buttonVariants({ variant: "ghost" }), "pr-1.5!", className)} class={cn(buttonVariants({ variant: "ghost" }), "pr-1.5!", className)}
{...restProps} {...restProps}
> >
@@ -10,11 +10,11 @@
</script> </script>
<PaginationLink <PaginationLink
aria-label="Go to next page" aria-label="Перейти на следующую страницу"
size="default" size="default"
class={cn("pr-1.5!", className)} class={cn("pr-1.5!", className)}
{...restProps} {...restProps}
> >
<span class="cn-pagination-next-text hidden sm:block">Next</span> <span class="cn-pagination-next-text hidden sm:block">Следующая</span>
<ChevronRightIcon data-icon="inline-end" /> <ChevronRightIcon data-icon="inline-end" />
</PaginationLink> </PaginationLink>
@@ -14,12 +14,12 @@
{#snippet Fallback()} {#snippet Fallback()}
<ChevronLeftIcon class="size-4" /> <ChevronLeftIcon class="size-4" />
<span>Previous</span> <span>Предыдущая</span>
{/snippet} {/snippet}
<PaginationPrimitive.PrevButton <PaginationPrimitive.PrevButton
bind:ref bind:ref
aria-label="Go to previous page" aria-label="Перейти на предыдущую страницу"
class={cn(buttonVariants({ variant: "ghost" }), "pl-1.5!", className)} class={cn(buttonVariants({ variant: "ghost" }), "pl-1.5!", className)}
{...restProps} {...restProps}
> >
@@ -10,11 +10,11 @@
</script> </script>
<PaginationLink <PaginationLink
aria-label="Go to previous page" aria-label="Перейти на предыдущую страницу"
size="default" size="default"
class={cn("pl-1.5!", className)} class={cn("pl-1.5!", className)}
{...restProps} {...restProps}
> >
<ChevronLeftIcon data-icon="inline-start" /> <ChevronLeftIcon data-icon="inline-start" />
<span class="cn-pagination-previous-text hidden sm:block">Previous</span> <span class="cn-pagination-previous-text hidden sm:block">Предыдущая</span>
</PaginationLink> </PaginationLink>
@@ -18,7 +18,7 @@
bind:ref bind:ref
bind:page bind:page
role="navigation" role="navigation"
aria-label="pagination" aria-label="Пагинация"
data-slot="pagination" data-slot="pagination"
{count} {count}
{perPage} {perPage}
@@ -51,8 +51,8 @@
{side} {side}
> >
<Sheet.Header class="sr-only"> <Sheet.Header class="sr-only">
<Sheet.Title>Sidebar</Sheet.Title> <Sheet.Title>Боковая панель</Sheet.Title>
<Sheet.Description>Displays the mobile sidebar.</Sheet.Description> <Sheet.Description>Отображает мобильную боковую панель.</Sheet.Description>
</Sheet.Header> </Sheet.Header>
<div class="flex h-full w-full flex-col"> <div class="flex h-full w-full flex-col">
{@render children?.()} {@render children?.()}
+18
View File
@@ -0,0 +1,18 @@
import Root from "./tabs.svelte";
import Content from "./tabs-content.svelte";
import List, { tabsListVariants, type TabsListVariant } from "./tabs-list.svelte";
import Trigger from "./tabs-trigger.svelte";
export {
Root,
Content,
List,
Trigger,
tabsListVariants,
type TabsListVariant,
//
Root as Tabs,
Content as TabsContent,
List as TabsList,
Trigger as TabsTrigger,
};
@@ -0,0 +1,17 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.ContentProps = $props();
</script>
<TabsPrimitive.Content
bind:ref
data-slot="tabs-content"
class={cn("text-sm flex-1 outline-none", className)}
{...restProps}
/>
@@ -0,0 +1,40 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const tabsListVariants = tv({
base: "rounded-lg p-[3px] group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
variants: {
variant: {
default: "cn-tabs-list-variant-default bg-muted",
line: "cn-tabs-list-variant-line gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
});
export type TabsListVariant = VariantProps<typeof tabsListVariants>["variant"];
</script>
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
variant = "default",
class: className,
...restProps
}: TabsPrimitive.ListProps & {
variant?: TabsListVariant;
} = $props();
</script>
<TabsPrimitive.List
bind:ref
data-slot="tabs-list"
data-variant={variant}
class={cn(tabsListVariants({ variant }), className)}
{...restProps}
/>
@@ -0,0 +1,23 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.TriggerProps = $props();
</script>
<TabsPrimitive.Trigger
bind:ref
data-slot="tabs-trigger"
class={cn(
"gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background dark:data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 data-active:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...restProps}
/>
@@ -0,0 +1,19 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
value = $bindable(""),
class: className,
...restProps
}: TabsPrimitive.RootProps = $props();
</script>
<TabsPrimitive.Root
bind:ref
bind:value
data-slot="tabs"
class={cn("gap-2 group/tabs flex data-[orientation=horizontal]:flex-col", className)}
{...restProps}
/>
+23
View File
@@ -0,0 +1,23 @@
/** Доля изменения для тренд-бейджа; null если сравнивать не с чем. */
export function percentChange(prev: number, cur: number): number | null {
if (!Number.isFinite(prev) || !Number.isFinite(cur)) return null;
if (prev === 0 && cur === 0) return null;
if (prev === 0) return cur === 0 ? null : null; // избегаем бесконечного %; покажем только направление отдельно
const p = ((cur - prev) / Math.abs(prev)) * 100;
return Number.isFinite(p) ? p : null;
}
/** Для prev===0 и cur>0 возвращает «рост от нуля». */
export function trendFromZero(prev: number, cur: number): 'up' | 'down' | 'flat' | null {
if (!Number.isFinite(prev) || !Number.isFinite(cur)) return null;
if (prev === 0 && cur === 0) return null;
if (prev === 0) return cur > 0 ? 'up' : cur < 0 ? 'down' : 'flat';
if (cur > prev) return 'up';
if (cur < prev) return 'down';
return 'flat';
}
export function formatSignedPercent(p: number): string {
const sign = p > 0 ? '+' : '';
return `${sign}${p.toFixed(1)}%`;
}
+39
View File
@@ -0,0 +1,39 @@
const SEG_LABELS: Record<string, string> = {
users: 'Пользователи',
ips: 'Уникальные IP',
incidents: 'Инциденты',
live: 'Поток',
radar: 'Радар DC',
mihomo: 'Mihomo',
servers: 'Серверы',
runtime: 'Состояние',
security: 'Безопасность',
upstreams: 'Апстримы и ДЦ',
config: 'Конфигурация',
update: 'Обновление'
};
export function panelTitleFromPath(pathname: string): string {
const p = pathname === '' ? '/' : pathname;
if (p === '/') return 'Обзор флота';
const parts = p.split('/').filter(Boolean);
if (parts.length === 0) return 'Telemt Panel';
if (parts[0] === 'servers' && parts.length >= 2) {
const alias = decodeURIComponent(parts[1]);
if (parts.length === 2) return `Нода ${alias}`;
const sub = parts.slice(2).map((s) => SEG_LABELS[s] ?? s).join(' · ');
return `${alias} · ${sub}`;
}
if (parts.length === 1) {
return SEG_LABELS[parts[0]] ?? parts[0];
}
if (parts[0] === 'users' && parts[1]) {
return `Пользователь ${decodeURIComponent(parts[1])}`;
}
return parts.map((s) => SEG_LABELS[s] ?? decodeURIComponent(s)).join(' · ');
}
+20
View File
@@ -0,0 +1,20 @@
export type SortDir = 'asc' | 'desc';
export function nextSortDir(
current: SortDir | null,
key: string,
activeKey: string | null
): SortDir | null {
if (activeKey !== key) return 'asc';
if (current === 'asc') return 'desc';
return null;
}
export function compareStrings(a: string, b: string, dir: SortDir): number {
const c = a.localeCompare(b, undefined, { sensitivity: 'base' });
return dir === 'asc' ? c : -c;
}
export function compareNum(a: number, b: number, dir: SortDir): number {
return dir === 'asc' ? a - b : b - a;
}
+15 -6
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import './layout.css'; import './layout.css';
import { page } from '$app/state'; import { page, navigating } from '$app/state';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { ModeWatcher } from 'mode-watcher'; import { ModeWatcher } from 'mode-watcher';
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
@@ -9,9 +9,15 @@
import * as Sidebar from '$lib/components/ui/sidebar/index.js'; import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js'; import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { Toaster } from '$lib/components/ui/sonner/index.js'; import { Toaster } from '$lib/components/ui/sonner/index.js';
import SiteHeader from '$lib/components/site-header.svelte';
import { panelTitleFromPath } from '$lib/page-title.js';
let { children } = $props(); let { children } = $props();
const docTitle = $derived(`${panelTitleFromPath(page.url.pathname)} · Telemt Panel`);
/** Нельзя использовать `!== null`: при `navigating === undefined` выражение истинно и полоска «зависает». */
const isNavigating = $derived(!!navigating);
let serverAliases = $state<string[]>([]); let serverAliases = $state<string[]>([]);
const serverMatch = $derived.by(() => { const serverMatch = $derived.by(() => {
@@ -42,7 +48,7 @@
<svelte:head> <svelte:head>
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
<title>Telemt Panel</title> <title>{docTitle}</title>
</svelte:head> </svelte:head>
<ModeWatcher /> <ModeWatcher />
@@ -50,10 +56,13 @@
<Sidebar.Provider> <Sidebar.Provider>
<AppSidebar {serverAliases} {serverPath} /> <AppSidebar {serverAliases} {serverPath} />
<Sidebar.Inset> <Sidebar.Inset>
<header {#if isNavigating}
class="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4" <div class="h-0.5 w-full overflow-hidden bg-transparent">
> <div class="h-full w-1/3 animate-pulse rounded-full bg-primary"></div>
<Sidebar.Trigger class="-ml-1" /> </div>
{/if}
<header class="flex h-14 w-full shrink-0 items-center gap-2 border-b border-border px-4">
<SiteHeader />
</header> </header>
<div class="flex flex-1 flex-col p-4"> <div class="flex flex-1 flex-col p-4">
{@render children()} {@render children()}
+481 -115
View File
@@ -9,35 +9,44 @@
fetchAggUniqueIps, fetchAggUniqueIps,
fetchStatsSummary fetchStatsSummary
} from '$lib/api/client.js'; } from '$lib/api/client.js';
import type { SummaryData } from '$lib/api/summary-types.js'; import type { SummaryData, TopUser } from '$lib/api/summary-types.js';
import type { components } from '$lib/api/aggregate.gen.js'; import type { components } from '$lib/api/aggregate.gen.js';
import { formatDurationSeconds, formatMiB, flagEmoji } from '$lib/format.js'; import { formatDurationSeconds, formatMiB, flagEmoji } from '$lib/format.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js'; import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import IpServerPresence from '$lib/components/ip-server-presence.svelte'; import IpServerPresence from '$lib/components/ip-server-presence.svelte';
import * as Card from '$lib/components/ui/card/index.js'; import * as Card from '$lib/components/ui/card/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js'; import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import * as Table from '$lib/components/ui/table/index.js'; import * as Table from '$lib/components/ui/table/index.js';
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import AliasFilterSelect from '$lib/components/alias-filter-select.svelte'; import AliasFilterSelect from '$lib/components/alias-filter-select.svelte';
import DataQueryState from '$lib/components/fleet/data-query-state.svelte'; import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
import FleetLiveStaleBadge from '$lib/components/fleet/fleet-live-stale-badge.svelte'; import FleetLiveStaleBadge from '$lib/components/fleet/fleet-live-stale-badge.svelte';
import FleetRefreshInput from '$lib/components/fleet/fleet-refresh-input.svelte'; import FleetRefreshInput from '$lib/components/fleet/fleet-refresh-input.svelte';
import FleetOverviewCharts from '$lib/components/fleet/fleet-overview-charts.svelte';
import { Skeleton } from '$lib/components/ui/skeleton/index.js'; import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import { cn } from '$lib/utils.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'; import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import AlertTriangleIcon from '@lucide/svelte/icons/triangle-alert'; import AlertTriangleIcon from '@lucide/svelte/icons/triangle-alert';
import ServerIcon from '@lucide/svelte/icons/server';
import UsersIcon from '@lucide/svelte/icons/users';
import UnplugIcon from '@lucide/svelte/icons/unplug';
import GlobeIcon from '@lucide/svelte/icons/globe';
import ActivityIcon from '@lucide/svelte/icons/activity';
import LockKeyholeIcon from '@lucide/svelte/icons/lock-keyhole';
import InfoIcon from '@lucide/svelte/icons/info'; import InfoIcon from '@lucide/svelte/icons/info';
import type { KpiAccentKey } from '$lib/kpi-accents.js';
import KpiStatCard from '$lib/components/kpi-stat-card.svelte'; import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte'; import DataTableCard from '$lib/components/data-table/data-table-card.svelte';
import ChartNoAxesCombinedIcon from '@lucide/svelte/icons/chart-no-axes-combined'; import DataTableToolbar from '$lib/components/data-table/data-table-toolbar.svelte';
import SortableTh from '$lib/components/data-table/sortable-th.svelte';
import TableEmpty from '$lib/components/data-table/table-empty.svelte';
import StatusBadge from '$lib/components/status-badge.svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import MoreVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import SearchIcon from '@lucide/svelte/icons/search';
import { Badge } from '$lib/components/ui/badge/index.js';
import ServerIcon from '@lucide/svelte/icons/server';
import PlugIcon from '@lucide/svelte/icons/plug';
import ShieldAlertIcon from '@lucide/svelte/icons/shield-alert';
import GlobeIcon from '@lucide/svelte/icons/globe';
import CloudUploadIcon from '@lucide/svelte/icons/cloud-upload';
import LockIcon from '@lucide/svelte/icons/lock';
import UsersIcon from '@lucide/svelte/icons/users';
import { nextSortDir, compareStrings, compareNum, type SortDir } from '$lib/table-sort.js';
let loading = $state(true); let loading = $state(true);
let err = $state<string | null>(null); let err = $state<string | null>(null);
@@ -61,6 +70,46 @@
> >
>({}); >({});
function sumBad(stats: typeof nodeStats): number {
let n = 0;
for (const v of Object.values(stats)) {
if (v && 'ok' in v && v.ok && typeof v.bad === 'number') n += v.bad;
}
return n;
}
function countUniqueIps(rows: components['schemas']['UniqueIPsRow'][] | null): number {
const set = new Set<string>();
for (const row of rows ?? []) {
for (const ip of row.ips ?? []) {
if (ip.ip) set.add(ip.ip);
}
}
return set.size;
}
function countReadOnlyNodes(f: components['schemas']['FleetStatusData'] | null): number {
let n = 0;
for (const s of f?.servers ?? []) {
if (s.health?.read_only) n++;
}
return n;
}
/** Таблицы: сортировка и поиск */
let ipFilter = $state('');
let ipSortKey = $state<'ip' | 'user' | null>(null);
let ipSortDir = $state<SortDir | null>(null);
let nodeFilter = $state('');
let nodeStatusFilter = $state<'all' | 'ok' | 'degraded'>('all');
let nodeSortKey = $state<'alias' | 'latency' | 'bad' | null>(null);
let nodeSortDir = $state<SortDir | null>(null);
let topFilter = $state('');
let topSortKey = $state<'user' | 'traffic' | 'ips' | null>(null);
let topSortDir = $state<SortDir | null>(null);
function parseAliasFilter(raw: string | null): string { function parseAliasFilter(raw: string | null): string {
const value = raw?.trim() ?? ''; const value = raw?.trim() ?? '';
return !value || value.includes(',') ? 'all' : value; return !value || value.includes(',') ? 'all' : value;
@@ -90,10 +139,15 @@
summary = s.data; summary = s.data;
fleet = f.data ?? null; fleet = f.data ?? null;
uniqueIps = u.data ?? null; uniqueIps = u.data ?? null;
availableAliases = collectAliases( const nextAliases = collectAliases(
(fleet?.servers ?? []).map((x) => x.alias), (fleet?.servers ?? []).map((x) => x.alias),
(summary?.servers ?? []).map((x) => x.alias) (summary?.servers ?? []).map((x) => x.alias)
); );
// Не сужаем список до текущего фильтра: иначе выбранный alias
// может исчезать из селекта при частичных/пустых ответах.
availableAliases = Array.from(new Set([...availableAliases, ...nextAliases])).sort((a, b) =>
a.localeCompare(b)
);
lastSuccessAtMs = Date.now(); lastSuccessAtMs = Date.now();
// Как бейдж «OK» в таблице: только health + system/info (без x.ok — в JSON поле ok опционально). // Как бейдж «OK» в таблице: только health + system/info (без x.ok — в JSON поле ok опционально).
@@ -166,9 +220,10 @@
}); });
} }
async function applyAliasIfChanged(nextAlias: string) { async function handleAliasFilterChange(nextAlias: string) {
const currentAlias = parseAliasFilter(page.url.searchParams.get('aliases')); aliasFilter = nextAlias;
if (nextAlias === currentAlias) return; const currentUrlAlias = parseAliasFilter(page.url.searchParams.get('aliases'));
if (nextAlias === currentUrlAlias) return;
const q = new URLSearchParams(page.url.searchParams); const q = new URLSearchParams(page.url.searchParams);
if (nextAlias === 'all') q.delete('aliases'); if (nextAlias === 'all') q.delete('aliases');
else q.set('aliases', nextAlias); else q.set('aliases', nextAlias);
@@ -178,6 +233,7 @@
noScroll: true, noScroll: true,
keepFocus: true keepFocus: true
}); });
await load();
} }
let queryKey = $derived(page.url.searchParams.toString()); let queryKey = $derived(page.url.searchParams.toString());
@@ -191,11 +247,6 @@
void load(); void load();
}); });
$effect(() => {
if (typeof window === 'undefined') return;
void applyAliasIfChanged(aliasFilter);
});
let staleSeconds = $derived.by(() => let staleSeconds = $derived.by(() =>
lastSuccessAtMs == null ? null : Math.max(0, Math.floor((nowMs - lastSuccessAtMs) / 1000)) lastSuccessAtMs == null ? null : Math.max(0, Math.floor((nowMs - lastSuccessAtMs) / 1000))
); );
@@ -266,26 +317,15 @@
return n; return n;
}); });
let nodesHealthAccent = $derived.by((): KpiAccentKey => { let nodesOkChart = $derived.by(() => {
const t = (fleet?.servers_total ?? summary?.servers_total) ?? 0; const list = fleet?.servers ?? [];
const ok = (fleet?.servers_all_ok ?? summary?.servers_ok) ?? 0; return list.filter((s) => s.health_ok && s.system_info_ok).length;
if (t === 0) return 'neutral';
if (ok === t) return 'success';
if (ok === 0) return 'danger';
return 'warning';
}); });
let nodesDegradedChart = $derived.by(() => {
/** Пороги условные: 0 — норма; дальше — внимание и «сильное» внимание к метрике bad. */ const list = fleet?.servers ?? [];
let badConnectionsAccent = $derived.by((): KpiAccentKey => { return list.filter((s) => !s.health_ok || !s.system_info_ok).length;
if (totalBad === 0) return 'success';
if (totalBad < 10_000) return 'warning';
return 'danger';
}); });
let readOnlyAccent = $derived.by((): KpiAccentKey =>
readOnlyNodes === 0 ? 'success' : 'warning'
);
function chipLabel(ip: components['schemas']['IPAssignments']): string { function chipLabel(ip: components['schemas']['IPAssignments']): string {
const cc = ip.country_code?.trim(); const cc = ip.country_code?.trim();
const flag = flagEmoji(cc ?? undefined); const flag = flagEmoji(cc ?? undefined);
@@ -373,19 +413,106 @@
out.sort((a, b) => a.ip.localeCompare(b.ip) || a.username.localeCompare(b.username)); out.sort((a, b) => a.ip.localeCompare(b.ip) || a.username.localeCompare(b.username));
return out; return out;
}); });
let filteredSortedIpRows = $derived.by(() => {
let rows = activeIpServerRows;
const q = ipFilter.trim().toLowerCase();
if (q) {
rows = rows.filter(
(r) =>
r.username.toLowerCase().includes(q) ||
r.ip.toLowerCase().includes(q) ||
r.activeLabel.toLowerCase().includes(q)
);
}
const k = ipSortKey;
const d = ipSortDir;
if (!k || !d) return rows;
const copy = [...rows];
if (k === 'ip') copy.sort((a, b) => compareStrings(a.ip, b.ip, d));
else copy.sort((a, b) => compareStrings(a.username, b.username, d));
return copy;
});
type NodeRow = components['schemas']['FleetServerStatus'];
let filteredSortedNodeRows = $derived.by((): NodeRow[] => {
let rows = [...(fleet?.servers ?? [])];
const q = nodeFilter.trim().toLowerCase();
if (q) rows = rows.filter((s) => (s.alias ?? '').toLowerCase().includes(q));
if (nodeStatusFilter === 'ok') rows = rows.filter((s) => s.health_ok && s.system_info_ok);
if (nodeStatusFilter === 'degraded')
rows = rows.filter((s) => !s.health_ok || !s.system_info_ok);
const k = nodeSortKey;
const d = nodeSortDir;
if (k && d) {
rows.sort((a, b) => {
if (k === 'alias') return compareStrings(a.alias ?? '', b.alias ?? '', d);
if (k === 'latency') {
const la = a.health_latency_ms ?? -1;
const lb = b.health_latency_ms ?? -1;
return compareNum(la, lb, d);
}
if (k === 'bad') {
const ba =
a.alias && nodeStats[a.alias] && 'bad' in nodeStats[a.alias]!
? (nodeStats[a.alias] as { bad?: number }).bad ?? -1
: -1;
const bb =
b.alias && nodeStats[b.alias] && 'bad' in nodeStats[b.alias]!
? (nodeStats[b.alias] as { bad?: number }).bad ?? -1
: -1;
return compareNum(ba, bb, d);
}
return 0;
});
}
return rows;
});
let filteredSortedTopUsers = $derived.by((): TopUser[] => {
if (!summary) return [];
let rows = [...summary.top_users.slice(0, 10)];
const q = topFilter.trim().toLowerCase();
if (q) rows = rows.filter((t) => t.username.toLowerCase().includes(q));
const k = topSortKey;
const d = topSortDir;
if (k && d) {
rows.sort((a, b) => {
if (k === 'user') return compareStrings(a.username, b.username, d);
if (k === 'traffic') return compareNum(a.total_megabytes, b.total_megabytes, d);
return compareNum(uniqueIpCountByUser[a.username] ?? 0, uniqueIpCountByUser[b.username] ?? 0, d);
});
}
return rows;
});
function toggleIpSort(key: 'ip' | 'user') {
const next = nextSortDir(ipSortDir, key, ipSortKey);
ipSortKey = next === null ? null : key;
ipSortDir = next;
}
function toggleNodeSort(key: 'alias' | 'latency' | 'bad') {
const next = nextSortDir(nodeSortDir, key, nodeSortKey);
nodeSortKey = next === null ? null : key;
nodeSortDir = next;
}
function toggleTopSort(key: 'user' | 'traffic' | 'ips') {
const next = nextSortDir(topSortDir, key, topSortKey);
topSortKey = next === null ? null : key;
topSortDir = next;
}
</script> </script>
<div class="mb-6 flex flex-wrap items-center justify-between gap-4"> <div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div> <div>
<p class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Telemt multi-server overview
</p>
<h1 class="text-2xl font-semibold tracking-tight">Обзор флота</h1> <h1 class="text-2xl font-semibold tracking-tight">Обзор флота</h1>
{#if generatedAt}
<p class="mt-1 text-sm text-muted-foreground"> <p class="mt-1 text-sm text-muted-foreground">
Снимок: {new Date(generatedAt).toLocaleString()} Состояние нод и агрегированные метрики Telemt
</p> {#if generatedAt}
<span class="hidden sm:inline"> · Снимок: {new Date(generatedAt).toLocaleString()}</span>
{/if} {/if}
</p>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<FleetLiveStaleBadge {isStale} {staleSeconds} refreshSeconds={refreshSeconds} /> <FleetLiveStaleBadge {isStale} {staleSeconds} refreshSeconds={refreshSeconds} />
@@ -397,7 +524,11 @@
</div> </div>
<div class="mb-4 flex flex-wrap items-end gap-2"> <div class="mb-4 flex flex-wrap items-end gap-2">
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} /> <AliasFilterSelect
availableAliases={availableAliases}
bind:value={aliasFilter}
onValueChange={(next) => void handleAliasFilterChange(next)}
/>
<FleetRefreshInput bind:value={refreshInput} onValueChange={() => void applyControls()} /> <FleetRefreshInput bind:value={refreshInput} onValueChange={() => void applyControls()} />
</div> </div>
@@ -420,80 +551,116 @@
{#snippet skeleton()} {#snippet skeleton()}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6"> <div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{#each Array.from({ length: 6 }) as _, i (i)} {#each Array.from({ length: 6 }) as _, i (i)}
<Card.Root class="border-l-4 border-l-muted-foreground/30 shadow-sm"> <Skeleton class="h-28 rounded-xl" />
<Card.Header class="pb-2">
<div class="flex gap-3">
<Skeleton class="size-10 shrink-0 rounded-lg" />
<div class="min-w-0 flex-1 space-y-2">
<Skeleton class="h-3 w-20" />
<Skeleton class="h-8 w-14" />
</div>
</div>
</Card.Header>
<Card.Content class="pt-0">
<Skeleton class="h-3 w-28" />
</Card.Content>
</Card.Root>
{/each} {/each}
</div> </div>
<Skeleton class="mb-4 h-32 w-full rounded-lg" /> <Skeleton class="mb-4 h-56 w-full rounded-lg" />
<Skeleton class="mb-4 h-56 w-full rounded-lg" />
{/snippet} {/snippet}
{#snippet children()} {#snippet children()}
{#if summary} {#if summary}
{@const okN = fleet?.servers_all_ok ?? summary.servers_ok}
{@const totN = fleet?.servers_total ?? summary.servers_total}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6"> <div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<KpiStatCard accent={nodesHealthAccent} label="Ноды" footer="успешный опрос"> <KpiStatCard accent="violet" label="Ноды OK / всего" footer="health + system/info" valueClass="text-xl">
{#snippet icon()} {#snippet icon()}
<ServerIcon class="size-5" aria-hidden="true" /> <ServerIcon class="size-5" aria-hidden="true" />
{/snippet} {/snippet}
{fleet?.servers_all_ok ?? summary.servers_ok}/{fleet?.servers_total ?? summary.servers_total} {okN}/{totN}
</KpiStatCard> </KpiStatCard>
<KpiStatCard accent="info" label="Соединения (флот)" footer="текущие по stats/users"> <KpiStatCard
accent="info"
label="Соединения (флот)"
footer="агрегат stats/users"
valueClass="text-xl"
>
{#snippet icon()} {#snippet icon()}
<UsersIcon class="size-5" aria-hidden="true" /> <PlugIcon class="size-5" aria-hidden="true" />
{/snippet} {/snippet}
{summary.fleet_total_connections} {summary.fleet_total_connections}
</KpiStatCard> </KpiStatCard>
<KpiStatCard accent={badConnectionsAccent} label="Bad connections" footer="сумма по нодам (stats/summary)"> <KpiStatCard accent="danger" label="Плохие соединения" footer="сумма по нодам" valueClass="text-xl">
{#snippet icon()} {#snippet icon()}
<UnplugIcon class="size-5" aria-hidden="true" /> <ShieldAlertIcon class="size-5" aria-hidden="true" />
{/snippet} {/snippet}
{totalBad} {totalBad}
</KpiStatCard> </KpiStatCard>
<KpiStatCard accent="violet" label="Уникальные IP" footer="по снимку unique-ips"> <KpiStatCard accent="teal" label="Уникальные IP" footer="по снимку unique-ips" valueClass="text-xl">
{#snippet icon()} {#snippet icon()}
<GlobeIcon class="size-5" aria-hidden="true" /> <GlobeIcon class="size-5" aria-hidden="true" />
{/snippet} {/snippet}
{uniqueIpCount} {uniqueIpCount}
</KpiStatCard> </KpiStatCard>
<KpiStatCard accent="teal" label="Трафик флота" footer="сумма MiB"> <KpiStatCard
accent="warning"
label="Трафик флота (MiB)"
footer="суммарный трафик summary"
valueClass="text-lg"
>
{#snippet icon()} {#snippet icon()}
<ActivityIcon class="size-5" aria-hidden="true" /> <CloudUploadIcon class="size-5" aria-hidden="true" />
{/snippet} {/snippet}
{formatMiB(summary.fleet_total_megabytes)} {formatMiB(summary.fleet_total_megabytes)}
</KpiStatCard> </KpiStatCard>
<KpiStatCard accent={readOnlyAccent} label="Read-only API" footer="нод с read_only"> <KpiStatCard accent="neutral" label="Read-only API (нод)" footer="health.read_only" valueClass="text-xl">
{#snippet icon()} {#snippet icon()}
<LockKeyholeIcon class="size-5" aria-hidden="true" /> <LockIcon class="size-5" aria-hidden="true" />
{/snippet} {/snippet}
{readOnlyNodes} {readOnlyNodes}
</KpiStatCard> </KpiStatCard>
</div> </div>
<KpiPanelCard <FleetOverviewCharts
nodesOk={nodesOkChart}
nodesDegraded={nodesDegradedChart}
topUsers={summary.top_users.slice(0, 8)}
/>
<DataTableCard
class="mb-6" class="mb-6"
accent="violet"
title="Активные IP (агрегат)" title="Активные IP (агрегат)"
description="С GeoIP при включённой базе на шлюзе" description="С GeoIP при включённой базе на шлюзе"
accent="teal"
> >
{#snippet icon()} {#snippet icon()}<GlobeIcon class="size-5" aria-hidden="true" />{/snippet}
<GlobeIcon class="size-5" aria-hidden="true" /> {#snippet toolbar()}
<DataTableToolbar>
<div class="relative max-w-xs flex-1">
<SearchIcon
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
class="h-8 ps-8"
placeholder="Поиск IP, пользователя…"
bind:value={ipFilter}
/>
</div>
<Button
variant="outline"
size="sm"
class="h-8"
onclick={() => {
ipFilter = '';
ipSortKey = null;
ipSortDir = null;
}}
>Сброс</Button>
</DataTableToolbar>
{/snippet} {/snippet}
<div class="overflow-x-auto"> <ScrollArea class="max-h-[min(70vh,520px)] w-full" orientation="both">
<Table.Root> <Table.Root>
<Table.Header> <Table.Header class="sticky top-0 z-10 bg-muted">
<Table.Row> <Table.Row class="border-b border-border/80 hover:bg-transparent">
<Table.Head class="text-xs sm:text-sm">IP со страной</Table.Head> <SortableTh
<Table.Head class="text-xs sm:text-sm">Клиент</Table.Head> active={ipSortKey === 'ip'}
dir={ipSortDir}
onToggle={() => toggleIpSort('ip')}
>IP / страна</SortableTh>
<SortableTh
active={ipSortKey === 'user'}
dir={ipSortDir}
onToggle={() => toggleIpSort('user')}
>Пользователь</SortableTh>
<Table.Head class="text-xs sm:text-sm"> <Table.Head class="text-xs sm:text-sm">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
Серверы Серверы
@@ -506,27 +673,39 @@
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content side="bottom" class="max-w-xs"> <Tooltip.Content side="bottom" class="max-w-xs">
<p class="text-xs"> <p class="text-xs">
Активные и недавние — списки на момент снимка шлюза; в ячейке пояснены Активные и недавние — на момент снимка шлюза.
группы.
</p> </p>
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
</div> </div>
</Table.Head> </Table.Head>
<Table.Head class="w-10"></Table.Head>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{#if (activeIpServerRows?.length ?? 0) === 0} {#if activeIpServerRows.length === 0}
<Table.Row> <Table.Row>
<Table.Cell colspan={3} class="p-4 text-center text-muted-foreground"> <Table.Cell colspan={4} class="p-0">
Нет активных подключений <TableEmpty
title="Нет активных подключений"
description="В снимке unique-ips нет IP с активными серверами."
/>
</Table.Cell>
</Table.Row>
{:else if filteredSortedIpRows.length === 0}
<Table.Row>
<Table.Cell colspan={4} class="p-0">
<TableEmpty
title="Нет строк по фильтру"
description="Измените поиск или сбросьте фильтры."
/>
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
{:else} {:else}
{#each activeIpServerRows as r (r.username + '|' + r.ip)} {#each filteredSortedIpRows as r (r.username + '|' + r.ip)}
<Table.Row> <Table.Row>
<Table.Cell class="font-mono text-xs sm:text-sm whitespace-normal break-all"> <Table.Cell class="font-mono text-xs sm:text-sm whitespace-normal break-all">
{r.activeLabel} <Badge variant="secondary" class="font-mono font-normal">{r.activeLabel}</Badge>
</Table.Cell> </Table.Cell>
<Table.Cell class="text-xs sm:text-sm"> <Table.Cell class="text-xs sm:text-sm">
<a <a
@@ -543,49 +722,145 @@
primary_server={r.primary_server} primary_server={r.primary_server}
/> />
</Table.Cell> </Table.Cell>
<Table.Cell class="w-10 text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon-xs" aria-label="Действия">
<MoreVerticalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
onclick={() =>
window.open(
`/users/${encodeURIComponent(r.username)}`,
'_blank'
)}
>Открыть пользователя</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row> </Table.Row>
{/each} {/each}
{/if} {/if}
</Table.Body> </Table.Body>
</Table.Root> </Table.Root>
</div> </ScrollArea>
</KpiPanelCard> </DataTableCard>
<KpiPanelCard accent="info" title="По нодам" description="Health, версия, аптайм, трафик ноды" contentClass="p-0"> <DataTableCard
{#snippet icon()} class="mb-6"
<ServerIcon class="size-5" aria-hidden="true" /> title="По нодам"
description="Health, версия, аптайм, bad connections"
accent="violet"
>
{#snippet icon()}<ServerIcon class="size-5" aria-hidden="true" />{/snippet}
{#snippet toolbar()}
<DataTableToolbar>
<div class="relative max-w-xs flex-1">
<SearchIcon
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
class="h-8 ps-8"
placeholder="Поиск по alias…"
bind:value={nodeFilter}
/>
</div>
<div class="flex flex-wrap gap-1">
<Button
size="sm"
variant={nodeStatusFilter === 'all' ? 'default' : 'outline'}
class="h-8"
onclick={() => (nodeStatusFilter = 'all')}
>Все</Button>
<Button
size="sm"
variant={nodeStatusFilter === 'ok' ? 'default' : 'outline'}
class="h-8"
onclick={() => (nodeStatusFilter = 'ok')}
>OK</Button>
<Button
size="sm"
variant={nodeStatusFilter === 'degraded' ? 'default' : 'outline'}
class="h-8"
onclick={() => (nodeStatusFilter = 'degraded')}
>Degraded</Button>
</div>
<Button
variant="outline"
size="sm"
class="h-8"
onclick={() => {
nodeFilter = '';
nodeStatusFilter = 'all';
nodeSortKey = null;
nodeSortDir = null;
}}
>Сброс</Button>
</DataTableToolbar>
{/snippet} {/snippet}
<ScrollArea class="max-h-[min(70vh,480px)] w-full" orientation="both">
<Table.Root> <Table.Root>
<Table.Header> <Table.Header class="sticky top-0 z-10 bg-muted">
<Table.Row> <Table.Row class="border-b border-border/80 hover:bg-transparent">
<Table.Head>Сервер</Table.Head> <SortableTh
active={nodeSortKey === 'alias'}
dir={nodeSortDir}
onToggle={() => toggleNodeSort('alias')}
>Сервер</SortableTh>
<Table.Head>Статус</Table.Head> <Table.Head>Статус</Table.Head>
<Table.Head class="text-right">Latency</Table.Head> <SortableTh
numeric
active={nodeSortKey === 'latency'}
dir={nodeSortDir}
onToggle={() => toggleNodeSort('latency')}
class="text-right"
>Latency</SortableTh>
<Table.Head class="text-right">Версия</Table.Head> <Table.Head class="text-right">Версия</Table.Head>
<Table.Head class="text-right">Аптайм</Table.Head> <Table.Head class="text-right">Аптайм</Table.Head>
<Table.Head class="text-right">Bad conn.</Table.Head> <SortableTh
numeric
active={nodeSortKey === 'bad'}
dir={nodeSortDir}
onToggle={() => toggleNodeSort('bad')}
class="text-right"
>Bad conn.</SortableTh>
<Table.Head class="w-10"></Table.Head>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{#each fleet?.servers ?? [] as srv (srv.alias)} {#if filteredSortedNodeRows.length === 0}
<Table.Row>
<Table.Cell colspan={7} class="p-0">
<TableEmpty title="Нет нод по фильтру" />
</Table.Cell>
</Table.Row>
{:else}
{#each filteredSortedNodeRows as srv (srv.alias)}
<Table.Row> <Table.Row>
<Table.Cell class="font-medium"> <Table.Cell class="font-medium">
<a class="text-primary hover:underline" href="/servers/{encodeURIComponent(srv.alias ?? '')}"> <a
class="text-primary hover:underline"
href="/servers/{encodeURIComponent(srv.alias ?? '')}"
>
{srv.alias} {srv.alias}
</a> </a>
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
{#if srv.health_ok && srv.system_info_ok} {#if srv.health_ok && srv.system_info_ok}
<Badge class="bg-emerald-600/20 text-emerald-400">OK</Badge> <StatusBadge variant="ok" />
{:else} {:else}
<Badge variant="destructive">degraded</Badge> <StatusBadge variant="degraded" />
{/if} {/if}
</Table.Cell> </Table.Cell>
<Table.Cell class="text-right text-muted-foreground tabular-nums"> <Table.Cell class="text-right text-muted-foreground tabular-nums">
{srv.health_latency_ms ?? '—'} ms {srv.health_latency_ms ?? '—'} ms
</Table.Cell> </Table.Cell>
<Table.Cell class="text-right text-muted-foreground"> <Table.Cell class="text-right text-muted-foreground">
{srv.system_info?.version ?? '—'} <Badge variant="secondary">{srv.system_info?.version ?? '—'}</Badge>
</Table.Cell> </Table.Cell>
<Table.Cell class="text-right text-muted-foreground"> <Table.Cell class="text-right text-muted-foreground">
{srv.system_info && srv.system_info.uptime_seconds != null {srv.system_info && srv.system_info.uptime_seconds != null
@@ -596,7 +871,7 @@
{#if srv.alias} {#if srv.alias}
{@const st = nodeStats[srv.alias]} {@const st = nodeStats[srv.alias]}
{#if !st} {#if !st}
<span title="Нет ответа stats/summary для этой ноды">—</span> <StatusBadge variant="pending" />
{:else if st && 'ok' in st && !st.ok && 'error' in st} {:else if st && 'ok' in st && !st.ok && 'error' in st}
<span class="text-xs text-muted-foreground" title={st.error}>—</span> <span class="text-xs text-muted-foreground" title={st.error}>—</span>
{:else if st && 'bad' in st && st.ok} {:else if st && 'bad' in st && st.ok}
@@ -608,41 +883,132 @@
— —
{/if} {/if}
</Table.Cell> </Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon-xs" aria-label="Действия">
<MoreVerticalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
onclick={() =>
window.open(
`/servers/${encodeURIComponent(srv.alias ?? '')}`,
'_blank'
)}
>Открыть ноду</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row> </Table.Row>
{/each} {/each}
{/if}
</Table.Body> </Table.Body>
</Table.Root> </Table.Root>
</KpiPanelCard> </ScrollArea>
</DataTableCard>
<KpiPanelCard class="mt-6" accent="teal" title="Топ пользователей по трафику" contentClass=""> <DataTableCard
{#snippet icon()} class="mt-6"
<ChartNoAxesCombinedIcon class="size-5" aria-hidden="true" /> title="Топ пользователей по трафику"
description="До 10 записей из summary"
accent="info"
>
{#snippet icon()}<UsersIcon class="size-5" aria-hidden="true" />{/snippet}
{#snippet toolbar()}
<DataTableToolbar>
<div class="relative max-w-xs flex-1">
<SearchIcon
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input class="h-8 ps-8" placeholder="Поиск пользователя…" bind:value={topFilter} />
</div>
<Button
variant="outline"
size="sm"
class="h-8"
onclick={() => {
topFilter = '';
topSortKey = null;
topSortDir = null;
}}
>Сброс</Button>
</DataTableToolbar>
{/snippet} {/snippet}
<ScrollArea class="max-h-[min(60vh,400px)] w-full" orientation="vertical">
<Table.Root> <Table.Root>
<Table.Header> <Table.Header class="sticky top-0 z-10 bg-muted">
<Table.Row> <Table.Row class="border-b border-border/80 hover:bg-transparent">
<Table.Head>Пользователь</Table.Head> <SortableTh
<Table.Head class="text-right">Трафик</Table.Head> active={topSortKey === 'user'}
<Table.Head class="text-right">Замеченные IP</Table.Head> dir={topSortDir}
onToggle={() => toggleTopSort('user')}
>Пользователь</SortableTh>
<SortableTh
numeric
active={topSortKey === 'traffic'}
dir={topSortDir}
onToggle={() => toggleTopSort('traffic')}
class="text-right"
>Трафик</SortableTh>
<SortableTh
numeric
active={topSortKey === 'ips'}
dir={topSortDir}
onToggle={() => toggleTopSort('ips')}
class="text-right"
>IP</SortableTh>
<Table.Head class="w-10"></Table.Head>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{#each summary.top_users.slice(0, 10) as t (t.username)} {#if filteredSortedTopUsers.length === 0}
<Table.Row>
<Table.Cell colspan={4} class="p-0">
<TableEmpty title="Нет данных" />
</Table.Cell>
</Table.Row>
{:else}
{#each filteredSortedTopUsers as t (t.username)}
<Table.Row> <Table.Row>
<Table.Cell class="font-medium"> <Table.Cell class="font-medium">
<a href="/users/{encodeURIComponent(t.username)}" class="text-primary hover:underline"> <a href="/users/{encodeURIComponent(t.username)}" class="text-primary hover:underline">
{t.username} {t.username}
</a> </a>
</Table.Cell> </Table.Cell>
<Table.Cell class="text-right text-muted-foreground tabular-nums">{formatMiB(t.total_megabytes)}</Table.Cell> <Table.Cell class="text-right text-muted-foreground tabular-nums">
{formatMiB(t.total_megabytes)}
</Table.Cell>
<Table.Cell class="text-right text-muted-foreground tabular-nums"> <Table.Cell class="text-right text-muted-foreground tabular-nums">
{uniqueIpCountByUser[t.username] ?? 0} {uniqueIpCountByUser[t.username] ?? 0}
</Table.Cell> </Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon-xs" aria-label="Действия">
<MoreVerticalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
onclick={() =>
window.open(`/users/${encodeURIComponent(t.username)}`, '_blank')}
>Открыть</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row> </Table.Row>
{/each} {/each}
{/if}
</Table.Body> </Table.Body>
</Table.Root> </Table.Root>
</KpiPanelCard> </ScrollArea>
</DataTableCard>
{/if} {/if}
{/snippet} {/snippet}
</DataQueryState> </DataQueryState>
+163 -28
View File
@@ -6,16 +6,19 @@
ApiError, ApiError,
fetchAggIncidents, fetchAggIncidents,
fetchAggSummary, fetchAggSummary,
postAggResetBadConnectionsBaseline,
type IncidentItem, type IncidentItem,
type IncidentsData type IncidentsData
} from '$lib/api/client.js'; } from '$lib/api/client.js';
import { toast } from 'svelte-sonner';
import * as Card from '$lib/components/ui/card/index.js'; import * as Card from '$lib/components/ui/card/index.js';
import KpiStatCard from '$lib/components/kpi-stat-card.svelte'; import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte'; import DataTableCard from '$lib/components/data-table/data-table-card.svelte';
import CircleAlertIcon from '@lucide/svelte/icons/circle-alert'; import DataTableToolbar from '$lib/components/data-table/data-table-toolbar.svelte';
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert'; import SortableTh from '$lib/components/data-table/sortable-th.svelte';
import InfoSmallIcon from '@lucide/svelte/icons/info'; import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import ClipboardListIcon from '@lucide/svelte/icons/clipboard-list'; import SearchIcon from '@lucide/svelte/icons/search';
import { nextSortDir, compareStrings, compareNum, type SortDir } from '$lib/table-sort.js';
import * as Table from '$lib/components/ui/table/index.js'; import * as Table from '$lib/components/ui/table/index.js';
import { Badge } from '$lib/components/ui/badge/index.js'; import { Badge } from '$lib/components/ui/badge/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js'; import * as Tooltip from '$lib/components/ui/tooltip/index.js';
@@ -29,6 +32,10 @@
import FleetRefreshInput from '$lib/components/fleet/fleet-refresh-input.svelte'; import FleetRefreshInput from '$lib/components/fleet/fleet-refresh-input.svelte';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'; import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import InfoIcon from '@lucide/svelte/icons/info'; import InfoIcon from '@lucide/svelte/icons/info';
import SirenIcon from '@lucide/svelte/icons/siren';
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
import InfoIconBadge from '@lucide/svelte/icons/info';
import EraserIcon from '@lucide/svelte/icons/eraser';
type TriageState = { type TriageState = {
ack?: boolean; ack?: boolean;
@@ -54,6 +61,11 @@
let staleTimer: ReturnType<typeof setInterval> | null = null; let staleTimer: ReturnType<typeof setInterval> | null = null;
let triageById = $state<Record<string, TriageState>>({}); let triageById = $state<Record<string, TriageState>>({});
let incSearch = $state('');
let incSortKey = $state<'sev' | 'title' | null>(null);
let incSortDir = $state<SortDir | null>(null);
let resetBadBusy = $state(false);
function parseRefresh(raw: string | null): number { function parseRefresh(raw: string | null): number {
if (raw == null || raw.trim() === '') return 30; if (raw == null || raw.trim() === '') return 30;
const n = Number(raw); const n = Number(raw);
@@ -115,6 +127,12 @@
return 'outline'; return 'outline';
} }
function severityLabel(sev: IncidentItem['severity']): string {
if (sev === 'critical') return 'критично';
if (sev === 'warning') return 'предупреждение';
return 'инфо';
}
function restartPolling() { function restartPolling() {
if (pollTimer) { if (pollTimer) {
clearInterval(pollTimer); clearInterval(pollTimer);
@@ -127,6 +145,12 @@
} }
} }
function sevRank(s: IncidentItem['severity']): number {
if (s === 'critical') return 3;
if (s === 'warning') return 2;
return 1;
}
async function load(aliasOverride?: string) { async function load(aliasOverride?: string) {
loading = true; loading = true;
err = null; err = null;
@@ -225,18 +249,91 @@
let hasStaleData = $derived(err != null && data != null); let hasStaleData = $derived(err != null && data != null);
let showSkeleton = $derived(loading && data === null && !err); let showSkeleton = $derived(loading && data === null && !err);
let filteredIncidents = $derived.by((): IncidentItem[] => {
const items = data?.items ?? [];
const q = incSearch.trim().toLowerCase();
let out = items;
if (q) {
out = items.filter(
(it) =>
(it.title ?? '').toLowerCase().includes(q) ||
(it.summary ?? '').toLowerCase().includes(q) ||
(it.severity ?? '').toLowerCase().includes(q)
);
}
const k = incSortKey;
const d = incSortDir;
if (k && d) {
out = [...out].sort((a, b) => {
if (k === 'sev') return compareNum(sevRank(a.severity), sevRank(b.severity), d);
return compareStrings(a.title ?? '', b.title ?? '', d);
});
}
return out;
});
function toggleIncSort(key: 'sev' | 'title') {
const next = nextSortDir(incSortDir, key, incSortKey);
incSortKey = next === null ? null : key;
incSortDir = next;
}
async function resetBadConnectionsBaseline() {
if (resetBadBusy) return;
resetBadBusy = true;
try {
const env = await postAggResetBadConnectionsBaseline({
aliases: aliasFilter === 'all' ? undefined : aliasFilter
});
const failed = env.data.servers.filter((s) => !s.ok);
if (failed.length > 0) {
toast.warning(
`Не на всех нодах удалось прочитать stats/summary: ${failed.map((f) => `${f.alias}: ${f.error ?? 'ошибка'}`).join('; ')}`
);
} else {
toast.success(
'Для алертов bad connections зафиксированы текущие значения счётчиков (отметка на шлюзе).'
);
}
await load();
} catch (e) {
toast.error(e instanceof ApiError ? e.message : String(e));
} finally {
resetBadBusy = false;
}
}
</script> </script>
<div class="mb-6 flex flex-wrap items-center justify-between gap-4"> <div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div> <div>
<h1 class="text-2xl font-semibold tracking-tight">Инциденты</h1> <h1 class="text-2xl font-semibold tracking-tight">Инциденты</h1>
<p class="text-sm text-muted-foreground">Сводка и triage по всему флоту.</p> <p class="text-sm text-muted-foreground">Сводка и разбор инцидентов по всему флоту.</p>
{#if generatedAt} {#if generatedAt}
<p class="mt-1 text-xs text-muted-foreground">Снимок: {new Date(generatedAt).toLocaleString()}</p> <p class="mt-1 text-xs text-muted-foreground">Снимок: {new Date(generatedAt).toLocaleString()}</p>
{/if} {/if}
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<FleetLiveStaleBadge {isStale} {staleSeconds} refreshSeconds={refreshSeconds} /> <FleetLiveStaleBadge {isStale} {staleSeconds} refreshSeconds={refreshSeconds} />
<Tooltip.Root>
<Tooltip.Trigger class="inline-flex">
<Button
variant="outline"
size="sm"
disabled={resetBadBusy || loading}
onclick={() => void resetBadConnectionsBaseline()}
>
<EraserIcon class="mr-1 size-4 {resetBadBusy ? 'animate-pulse' : ''}" />
Сброс bad connections
</Button>
</Tooltip.Trigger>
<Tooltip.Content class="max-w-xs">
<p class="text-xs">
Запомнить текущий <span class="font-mono">connections_bad_total</span> на выбранных нодах как ноль для
инцидентов на этом шлюзе (память процесса). Сырые счётчики на нодах Telemt не меняются.
</p>
</Tooltip.Content>
</Tooltip.Root>
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}> <Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" /> <RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить Обновить
@@ -277,35 +374,62 @@
{/snippet} {/snippet}
{#snippet children()} {#snippet children()}
<div class="mb-4 grid gap-4 sm:grid-cols-3"> <div class="mb-4 grid gap-4 sm:grid-cols-3">
<KpiStatCard accent="danger" label="Critical" footer="открытые critical"> <KpiStatCard accent="danger" label="Критичные" footer="Открытые инциденты" valueClass="text-xl">
{#snippet icon()} {#snippet icon()}
<CircleAlertIcon class="size-5" aria-hidden="true" /> <SirenIcon class="size-5" aria-hidden="true" />
{/snippet} {/snippet}
{data?.critical_total ?? 0} {data?.critical_total ?? 0}
</KpiStatCard> </KpiStatCard>
<KpiStatCard accent="warning" label="Warning" footer="открытые warning"> <KpiStatCard accent="warning" label="Предупреждения" footer="Открытые инциденты" valueClass="text-xl">
{#snippet icon()} {#snippet icon()}
<TriangleAlertIcon class="size-5" aria-hidden="true" /> <TriangleAlertIcon class="size-5" aria-hidden="true" />
{/snippet} {/snippet}
{data?.warning_total ?? 0} {data?.warning_total ?? 0}
</KpiStatCard> </KpiStatCard>
<KpiStatCard accent="info" label="Info" footer="открытые info"> <KpiStatCard accent="info" label="Инфо" footer="Открытые инциденты" valueClass="text-xl">
{#snippet icon()} {#snippet icon()}
<InfoSmallIcon class="size-5" aria-hidden="true" /> <InfoIconBadge class="size-5" aria-hidden="true" />
{/snippet} {/snippet}
{data?.info_total ?? 0} {data?.info_total ?? 0}
</KpiStatCard> </KpiStatCard>
</div> </div>
<KpiPanelCard accent="neutral" title="Инциденты" description="Список и triage" contentClass="p-0"> <DataTableCard title="Инциденты" description="Список и разбор" accent="danger">
{#snippet icon()} {#snippet icon()}<SirenIcon class="size-5" aria-hidden="true" />{/snippet}
<ClipboardListIcon class="size-5" aria-hidden="true" /> {#snippet toolbar()}
<DataTableToolbar>
<div class="relative max-w-md flex-1">
<SearchIcon
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input class="h-8 ps-8" placeholder="Поиск по заголовку, описанию, критичности…" bind:value={incSearch} />
</div>
<Button
variant="outline"
size="sm"
class="h-8"
onclick={() => {
incSearch = '';
incSortKey = null;
incSortDir = null;
}}
>Сброс</Button>
</DataTableToolbar>
{/snippet} {/snippet}
<ScrollArea class="max-h-[min(70vh,560px)] w-full" orientation="both">
<Table.Root> <Table.Root>
<Table.Header> <Table.Header class="sticky top-0 z-10 bg-muted">
<Table.Row> <Table.Row class="border-b border-border/80 hover:bg-transparent">
<Table.Head>Severity</Table.Head> <SortableTh
<Table.Head>Инцидент</Table.Head> active={incSortKey === 'sev'}
dir={incSortDir}
onToggle={() => toggleIncSort('sev')}
>Критичность</SortableTh>
<SortableTh
active={incSortKey === 'title'}
dir={incSortDir}
onToggle={() => toggleIncSort('title')}
>Инцидент</SortableTh>
<Table.Head> <Table.Head>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
Серверы Серверы
@@ -322,8 +446,8 @@
</Tooltip.Root> </Tooltip.Root>
</div> </div>
</Table.Head> </Table.Head>
<Table.Head>Runbook</Table.Head> <Table.Head>Инструкции</Table.Head>
<Table.Head>Triage</Table.Head> <Table.Head>Разбор</Table.Head>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
@@ -333,11 +457,17 @@
Инцидентов нет Инцидентов нет
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
{:else if filteredIncidents.length === 0}
<Table.Row>
<Table.Cell colspan={5} class="p-4 text-center text-muted-foreground">
Нет строк по фильтру
</Table.Cell>
</Table.Row>
{:else} {:else}
{#each data?.items ?? [] as item (item.id)} {#each filteredIncidents as item (item.id)}
<Table.Row> <Table.Row>
<Table.Cell> <Table.Cell>
<Badge variant={severityVariant(item.severity)}>{item.severity}</Badge> <Badge variant={severityVariant(item.severity)}>{severityLabel(item.severity)}</Badge>
</Table.Cell> </Table.Cell>
<Table.Cell class="max-w-[360px]"> <Table.Cell class="max-w-[360px]">
<div class="font-medium">{item.title}</div> <div class="font-medium">{item.title}</div>
@@ -360,29 +490,33 @@
{/each} {/each}
</div> </div>
</Table.Cell> </Table.Cell>
<Table.Cell class="min-w-[280px]"> <Table.Cell class="min-w-[240px]">
{@const triage = triageFor(item.id)} {@const triage = triageFor(item.id)}
<div class="mb-2 flex flex-wrap gap-1"> <div class="mb-2 flex flex-wrap gap-1">
<Button <Button
size="xs" size="xs"
class="h-7 px-2 text-xs"
variant={triage.ack ? 'default' : 'outline'} variant={triage.ack ? 'default' : 'outline'}
onclick={() => toggleAck(item.id)} onclick={() => toggleAck(item.id)}
title={triage.ack ? 'Снять подтверждение' : 'Подтвердить'}
> >
{triage.ack ? 'unack' : 'ack'} {triage.ack ? 'Снять' : 'Подтв.'}
</Button> </Button>
<Button <Button
size="xs" size="xs"
class="h-7 px-2 text-xs"
variant={triage.resolved ? 'default' : 'outline'} variant={triage.resolved ? 'default' : 'outline'}
onclick={() => toggleResolved(item.id)} onclick={() => toggleResolved(item.id)}
title={triage.resolved ? 'Вернуть в работу' : 'Решить'}
> >
{triage.resolved ? 'unresolve' : 'resolve'} {triage.resolved ? 'В работу' : 'Решить'}
</Button> </Button>
</div> </div>
<div class="mb-1"> <div class="mb-1">
<Input <Input
type="text" type="text"
class="h-8 w-full text-xs" class="h-8 w-full text-xs"
placeholder="owner" placeholder="Ответственный"
value={triage.owner ?? ''} value={triage.owner ?? ''}
oninput={(e) => oninput={(e) =>
patchTriage(item.id, { patchTriage(item.id, {
@@ -394,7 +528,7 @@
<Input <Input
type="text" type="text"
class="h-8 w-full text-xs" class="h-8 w-full text-xs"
placeholder="note" placeholder="Примечание"
value={triage.note ?? ''} value={triage.note ?? ''}
oninput={(e) => oninput={(e) =>
patchTriage(item.id, { patchTriage(item.id, {
@@ -408,6 +542,7 @@
{/if} {/if}
</Table.Body> </Table.Body>
</Table.Root> </Table.Root>
</KpiPanelCard> </ScrollArea>
</DataTableCard>
{/snippet} {/snippet}
</DataQueryState> </DataQueryState>
+175 -16
View File
@@ -9,6 +9,13 @@
import { flagEmoji } from '$lib/format.js'; import { flagEmoji } from '$lib/format.js';
import * as Card from '$lib/components/ui/card/index.js'; import * as Card from '$lib/components/ui/card/index.js';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte'; import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import DataTableCard from '$lib/components/data-table/data-table-card.svelte';
import DataTableToolbar from '$lib/components/data-table/data-table-toolbar.svelte';
import SortableTh from '$lib/components/data-table/sortable-th.svelte';
import { Input } from '$lib/components/ui/input/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import SearchIcon from '@lucide/svelte/icons/search';
import { nextSortDir, compareStrings, type SortDir } from '$lib/table-sort.js';
import MapIcon from '@lucide/svelte/icons/map'; import MapIcon from '@lucide/svelte/icons/map';
import GlobeIcon from '@lucide/svelte/icons/globe'; import GlobeIcon from '@lucide/svelte/icons/globe';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js'; import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
@@ -49,6 +56,77 @@
let mapStatus = $state<string | null>(null); let mapStatus = $state<string | null>(null);
let renderAttempts = $state(0); let renderAttempts = $state(0);
type IpFlatRow = {
username: string;
ip: components['schemas']['IPAssignments'];
};
let ipSearch = $state('');
let ipSortKey = $state<'user' | 'ip' | 'geo' | null>(null);
let ipSortDir = $state<SortDir | null>(null);
const ipsPerPage = 25;
let ipsPage = $state(1);
let flatIpRows = $derived.by((): IpFlatRow[] => {
const out: IpFlatRow[] = [];
for (const ur of rows) {
const u = ur.username ?? '';
for (const ipa of ur.ips ?? []) {
if (ipa?.ip) out.push({ username: u, ip: ipa });
}
}
return out;
});
let displayedIpRows = $derived.by(() => {
let r = flatIpRows;
const q = ipSearch.trim().toLowerCase();
if (q) {
r = r.filter(
({ username, ip }) =>
username.toLowerCase().includes(q) ||
(ip.ip ?? '').toLowerCase().includes(q) ||
(ip.country_code ?? '').toLowerCase().includes(q) ||
(ip.city_name ?? '').toLowerCase().includes(q) ||
String(ip.asn ?? '').includes(q)
);
}
const k = ipSortKey;
const d = ipSortDir;
if (k && d) {
r = [...r].sort((a, b) => {
if (k === 'user') return compareStrings(a.username, b.username, d);
if (k === 'ip') return compareStrings(a.ip.ip ?? '', b.ip.ip ?? '', d);
const ga = `${a.ip.country_code ?? ''} ${a.ip.city_name ?? ''}`;
const gb = `${b.ip.country_code ?? ''} ${b.ip.city_name ?? ''}`;
return compareStrings(ga, gb, d);
});
}
return r;
});
let ipsPageCount = $derived(Math.max(1, Math.ceil(displayedIpRows.length / ipsPerPage)));
let pagedIpRows = $derived.by(() => {
const start = (ipsPage - 1) * ipsPerPage;
return displayedIpRows.slice(start, start + ipsPerPage);
});
$effect(() => {
ipSearch;
aliasFilter;
ipsPage = 1;
});
$effect(() => {
if (ipsPage > ipsPageCount) ipsPage = ipsPageCount;
});
function toggleIpSort(key: 'user' | 'ip' | 'geo') {
const next = nextSortDir(ipSortDir, key, ipSortKey);
ipSortKey = next === null ? null : key;
ipSortDir = next;
}
function parseAliasFilter(raw: string | null): string { function parseAliasFilter(raw: string | null): string {
const value = raw?.trim() ?? ''; const value = raw?.trim() ?? '';
return !value || value.includes(',') ? 'all' : value; return !value || value.includes(',') ? 'all' : value;
@@ -373,6 +451,14 @@
noScroll: true, noScroll: true,
keepFocus: true keepFocus: true
}); });
// Страхуемся от кейсов, когда URL уже сменился, а перезапрос не стартовал.
await load();
}
async function handleAliasFilterChange(nextAlias: string) {
if (nextAlias === aliasFilter) return;
aliasFilter = nextAlias;
await applyAliasIfChanged(nextAlias);
} }
let queryKey = $derived(page.url.searchParams.toString()); let queryKey = $derived(page.url.searchParams.toString());
@@ -431,7 +517,7 @@
let isDataEmpty = $derived(!loading && totalIpRows === 0 && !err); let isDataEmpty = $derived(!loading && totalIpRows === 0 && !err);
</script> </script>
<div class="mb-6 flex flex-wrap items-end justify-between gap-4"> <div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div> <div>
<h1 class="text-2xl font-semibold tracking-tight">Уникальные IP</h1> <h1 class="text-2xl font-semibold tracking-tight">Уникальные IP</h1>
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
@@ -456,7 +542,11 @@
</div> </div>
<div class="mb-4 flex flex-wrap items-end gap-2"> <div class="mb-4 flex flex-wrap items-end gap-2">
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} /> <AliasFilterSelect
availableAliases={availableAliases}
bind:value={aliasFilter}
onValueChange={(next) => void handleAliasFilterChange(next)}
/>
<FleetRefreshInput bind:value={refreshInput} onValueChange={() => void applyControls()} /> <FleetRefreshInput bind:value={refreshInput} onValueChange={() => void applyControls()} />
</div> </div>
@@ -528,16 +618,52 @@
</Alert> </Alert>
{/if} {/if}
<KpiPanelCard accent="violet" title="Уникальные IP" description="Снимок шлюза: активные и недавние списки" contentClass="p-0"> <DataTableCard title="Уникальные IP" description="Снимок шлюза: активные и недавние списки" accent="teal">
{#snippet icon()} {#snippet icon()}<GlobeIcon class="size-5" aria-hidden="true" />{/snippet}
<GlobeIcon class="size-5" aria-hidden="true" /> {#snippet toolbar()}
<DataTableToolbar>
<div class="relative max-w-md flex-1">
<SearchIcon
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input class="h-8 ps-8" placeholder="Поиск: пользователь, IP, страна, город…" bind:value={ipSearch} />
</div>
<Button
variant="outline"
size="sm"
class="h-8"
onclick={() => {
ipSearch = '';
ipSortKey = null;
ipSortDir = null;
ipsPage = 1;
}}
>Сброс</Button>
</DataTableToolbar>
{/snippet} {/snippet}
<div class="flex w-full flex-col">
<ScrollArea
class="h-[min(75vh,560px)] w-full shrink-0"
orientation="both"
>
<Table.Root> <Table.Root>
<Table.Header> <Table.Header class="sticky top-0 z-10 bg-muted">
<Table.Row> <Table.Row class="border-b border-border/80 hover:bg-transparent">
<Table.Head>Пользователь</Table.Head> <SortableTh
<Table.Head>IP</Table.Head> active={ipSortKey === 'user'}
<Table.Head>Geo</Table.Head> dir={ipSortDir}
onToggle={() => toggleIpSort('user')}
>Пользователь</SortableTh>
<SortableTh
active={ipSortKey === 'ip'}
dir={ipSortDir}
onToggle={() => toggleIpSort('ip')}
>IP</SortableTh>
<SortableTh
active={ipSortKey === 'geo'}
dir={ipSortDir}
onToggle={() => toggleIpSort('geo')}
>Geo</SortableTh>
<Table.Head> <Table.Head>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
Серверы Серверы
@@ -559,12 +685,18 @@
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{#each rows as ur (ur.username)} {#if displayedIpRows.length === 0}
{#each ur.ips ?? [] as ip (ur.username + (ip.ip ?? ''))} <Table.Row>
<Table.Cell colspan={4} class="p-4 text-center text-muted-foreground">
Нет строк по фильтру или снимок пуст.
</Table.Cell>
</Table.Row>
{:else}
{#each pagedIpRows as { username, ip } (username + (ip.ip ?? ''))}
<Table.Row> <Table.Row>
<Table.Cell class="font-medium"> <Table.Cell class="font-medium">
<a href="/users/{encodeURIComponent(ur.username ?? '')}" class="text-primary hover:underline"> <a href="/users/{encodeURIComponent(username)}" class="text-primary hover:underline">
{ur.username} {username}
</a> </a>
</Table.Cell> </Table.Cell>
<Table.Cell class="font-mono text-sm">{ip.ip}</Table.Cell> <Table.Cell class="font-mono text-sm">{ip.ip}</Table.Cell>
@@ -587,10 +719,37 @@
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
{/each} {/each}
{/each} {/if}
</Table.Body> </Table.Body>
</Table.Root> </Table.Root>
</KpiPanelCard> </ScrollArea>
{#if displayedIpRows.length > ipsPerPage}
<div
class="relative z-10 flex shrink-0 flex-wrap items-center justify-between gap-2 border-t border-border bg-card px-4 py-3 text-sm text-muted-foreground"
>
<span>
{displayedIpRows.length === 0
? '0'
: `${(ipsPage - 1) * ipsPerPage + 1}–${Math.min(ipsPage * ipsPerPage, displayedIpRows.length)}`} из {displayedIpRows.length}
</span>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={ipsPage <= 1}
onclick={() => (ipsPage = Math.max(1, ipsPage - 1))}>Назад</Button
>
<Button
variant="outline"
size="sm"
disabled={ipsPage >= ipsPageCount}
onclick={() => (ipsPage = Math.min(ipsPageCount, ipsPage + 1))}>Вперёд</Button
>
</div>
</div>
{/if}
</div>
</DataTableCard>
{/snippet} {/snippet}
</DataQueryState> </DataQueryState>
+163 -27
View File
@@ -5,16 +5,24 @@
import { import {
liveEventsUrl, liveEventsUrl,
fetchAggSummary, fetchAggSummary,
postAggResetBadConnectionsBaseline,
ApiError,
type IncidentItem, type IncidentItem,
type IncidentSeverity, type IncidentSeverity,
type IncidentStatus type IncidentStatus
} from '$lib/api/client.js'; } from '$lib/api/client.js';
import { toast } from 'svelte-sonner';
import KpiStatCard from '$lib/components/kpi-stat-card.svelte'; import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte'; import DataTableCard from '$lib/components/data-table/data-table-card.svelte';
import DataTableToolbar from '$lib/components/data-table/data-table-toolbar.svelte';
import SortableTh from '$lib/components/data-table/sortable-th.svelte';
import { Input } from '$lib/components/ui/input/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import SearchIcon from '@lucide/svelte/icons/search';
import { nextSortDir, compareStrings, compareNum, type SortDir } from '$lib/table-sort.js';
import ActivityIcon from '@lucide/svelte/icons/activity'; import ActivityIcon from '@lucide/svelte/icons/activity';
import LayersIcon from '@lucide/svelte/icons/layers'; import LayersIcon from '@lucide/svelte/icons/layers';
import ClockIcon from '@lucide/svelte/icons/clock'; import ClockIcon from '@lucide/svelte/icons/clock';
import BellIcon from '@lucide/svelte/icons/bell';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js'; import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Badge } from '$lib/components/ui/badge/index.js'; import { Badge } from '$lib/components/ui/badge/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js'; import * as Tooltip from '$lib/components/ui/tooltip/index.js';
@@ -25,6 +33,7 @@
import FleetLiveStaleBadge from '$lib/components/fleet/fleet-live-stale-badge.svelte'; import FleetLiveStaleBadge from '$lib/components/fleet/fleet-live-stale-badge.svelte';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'; import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import InfoIcon from '@lucide/svelte/icons/info'; import InfoIcon from '@lucide/svelte/icons/info';
import EraserIcon from '@lucide/svelte/icons/eraser';
type LiveStatus = 'healthy' | 'degraded' | 'critical'; type LiveStatus = 'healthy' | 'degraded' | 'critical';
@@ -56,18 +65,95 @@
let staleTimer: ReturnType<typeof setInterval> | null = null; let staleTimer: ReturnType<typeof setInterval> | null = null;
let es: EventSource | null = null; let es: EventSource | null = null;
let liveIncSearch = $state('');
let liveSortKey = $state<'sev' | 'title' | null>(null);
let liveSortDir = $state<SortDir | null>(null);
let resetBadBusy = $state(false);
function liveSevRank(s: IncidentSeverity): number {
if (s === 'critical') return 3;
if (s === 'warning') return 2;
return 1;
}
let filteredLiveIncidents = $derived.by((): IncidentItem[] => {
const items = snapshot?.incidents ?? [];
const q = liveIncSearch.trim().toLowerCase();
let out = items;
if (q) {
out = items.filter(
(it) =>
(it.title ?? '').toLowerCase().includes(q) ||
(it.summary ?? '').toLowerCase().includes(q) ||
(it.severity ?? '').toLowerCase().includes(q)
);
}
const k = liveSortKey;
const d = liveSortDir;
if (k && d) {
out = [...out].sort((a, b) => {
if (k === 'sev') return compareNum(liveSevRank(a.severity), liveSevRank(b.severity), d);
return compareStrings(a.title, b.title, d);
});
}
return out;
});
async function resetBadConnectionsBaseline() {
if (resetBadBusy) return;
resetBadBusy = true;
try {
const env = await postAggResetBadConnectionsBaseline({
aliases: aliasFilter === 'all' ? undefined : aliasFilter
});
const failed = env.data.servers.filter((s) => !s.ok);
if (failed.length > 0) {
toast.warning(
`Не на всех нодах удалось прочитать stats/summary: ${failed.map((f) => `${f.alias}: ${f.error ?? 'ошибка'}`).join('; ')}`
);
} else {
toast.success(
'Для алертов bad connections зафиксированы текущие значения счётчиков (отметка на шлюзе). Следующий снимок потока обновит список.'
);
}
connectStream();
} catch (e) {
toast.error(e instanceof ApiError ? e.message : String(e));
} finally {
resetBadBusy = false;
}
}
function toggleLiveSort(key: 'sev' | 'title') {
const next = nextSortDir(liveSortDir, key, liveSortKey);
liveSortKey = next === null ? null : key;
liveSortDir = next;
}
function severityVariant(sev: IncidentSeverity): 'default' | 'secondary' | 'destructive' | 'outline' { function severityVariant(sev: IncidentSeverity): 'default' | 'secondary' | 'destructive' | 'outline' {
if (sev === 'critical') return 'destructive'; if (sev === 'critical') return 'destructive';
if (sev === 'warning') return 'secondary'; if (sev === 'warning') return 'secondary';
return 'outline'; return 'outline';
} }
function severityLabel(sev: IncidentSeverity): string {
if (sev === 'critical') return 'критично';
if (sev === 'warning') return 'предупреждение';
return 'инфо';
}
function statusVariant(st: LiveStatus | undefined): 'default' | 'secondary' | 'destructive' { function statusVariant(st: LiveStatus | undefined): 'default' | 'secondary' | 'destructive' {
if (st === 'critical') return 'destructive'; if (st === 'critical') return 'destructive';
if (st === 'degraded') return 'secondary'; if (st === 'degraded') return 'secondary';
return 'default'; return 'default';
} }
function statusLabel(st: LiveStatus | undefined): string {
if (st === 'critical') return 'критично';
if (st === 'degraded') return 'деградация';
return 'норма';
}
function parseAliasFilter(raw: string | null): string { function parseAliasFilter(raw: string | null): string {
const value = raw?.trim() ?? ''; const value = raw?.trim() ?? '';
return !value || value.includes(',') ? 'all' : value; return !value || value.includes(',') ? 'all' : value;
@@ -268,12 +354,12 @@
<div class="mb-6 flex flex-wrap items-center justify-between gap-4"> <div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div> <div>
<h1 class="text-2xl font-semibold tracking-tight">Live</h1> <h1 class="text-2xl font-semibold tracking-tight">Поток</h1>
<p class="text-sm text-muted-foreground">SSE поток инцидентов и статуса флота.</p> <p class="text-sm text-muted-foreground">SSE поток инцидентов и статуса флота.</p>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Badge variant={connected ? 'default' : 'secondary'}> <Badge variant={connected ? 'default' : 'secondary'}>
{connected ? 'connected' : 'disconnected'} {connected ? 'подключено' : 'отключено'}
</Badge> </Badge>
<FleetLiveStaleBadge <FleetLiveStaleBadge
{isStale} {isStale}
@@ -283,9 +369,28 @@
? 'Нет событий SSE дольше 12 с — снимок считается устаревшим.' ? 'Нет событий SSE дольше 12 с — снимок считается устаревшим.'
: `Последнее событие потока: ${staleSeconds ?? 0} с назад.`} : `Последнее событие потока: ${staleSeconds ?? 0} с назад.`}
/> />
<Tooltip.Root>
<Tooltip.Trigger class="inline-flex">
<Button
variant="outline"
size="sm"
disabled={resetBadBusy}
onclick={() => void resetBadConnectionsBaseline()}
>
<EraserIcon class="mr-1 size-4 {resetBadBusy ? 'animate-pulse' : ''}" />
Сброс bad connections
</Button>
</Tooltip.Trigger>
<Tooltip.Content class="max-w-xs">
<p class="text-xs">
Запомнить текущий <span class="font-mono">connections_bad_total</span> на выбранных нодах как ноль для
инцидентов на этом шлюзе (память процесса). Сырые счётчики на нодах Telemt не меняются.
</p>
</Tooltip.Content>
</Tooltip.Root>
<Button variant="outline" size="sm" onclick={() => connectStream()}> <Button variant="outline" size="sm" onclick={() => connectStream()}>
<RefreshCwIcon class="mr-1 size-4" /> <RefreshCwIcon class="mr-1 size-4" />
Reconnect Переподключить
</Button> </Button>
</div> </div>
</div> </div>
@@ -296,11 +401,11 @@
{#if err} {#if err}
<Alert class="mb-4"> <Alert class="mb-4">
<AlertTitle>Live stream</AlertTitle> <AlertTitle>Поток событий</AlertTitle>
<AlertDescription> <AlertDescription>
{err} {err}
{#if reconnectInSec != null} {#if reconnectInSec != null}
Переподключение через {reconnectInSec}s. Переподключение через {reconnectInSec} с.
{/if} {/if}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@@ -311,19 +416,17 @@
{#snippet icon()} {#snippet icon()}
<ActivityIcon class="size-5" aria-hidden="true" /> <ActivityIcon class="size-5" aria-hidden="true" />
{/snippet} {/snippet}
<Badge variant={statusVariant(snapshot?.status)}> <Badge variant={statusVariant(snapshot?.status)}>{statusLabel(snapshot?.status)}</Badge>
{snapshot?.status ?? 'healthy'}
</Badge>
</KpiStatCard> </KpiStatCard>
<KpiStatCard accent="warning" label="Partial" footer="неполный снимок"> <KpiStatCard accent="warning" label="Частичность" footer="неполный снимок">
{#snippet icon()} {#snippet icon()}
<LayersIcon class="size-5" aria-hidden="true" /> <LayersIcon class="size-5" aria-hidden="true" />
{/snippet} {/snippet}
{snapshot?.partial ? 'yes' : 'no'} {snapshot?.partial ? 'да' : 'нет'}
</KpiStatCard> </KpiStatCard>
<KpiStatCard <KpiStatCard
accent="info" accent="info"
label="Timestamp" label="Временная метка"
footer="время snapshot" footer="время snapshot"
valueClass="text-base font-normal tabular-nums leading-tight" valueClass="text-base font-normal tabular-nums leading-tight"
> >
@@ -334,21 +437,47 @@
</KpiStatCard> </KpiStatCard>
</div> </div>
<KpiPanelCard <DataTableCard
accent="danger" title="Последние инциденты из снимка"
title="Последние incidents из snapshot"
description="Всего: {snapshot?.counts?.total ?? snapshot?.incidents?.length ?? 0}" description="Всего: {snapshot?.counts?.total ?? snapshot?.incidents?.length ?? 0}"
contentClass="p-0" accent="warning"
> >
{#snippet icon()} {#snippet icon()}<ActivityIcon class="size-5" aria-hidden="true" />{/snippet}
<BellIcon class="size-5" aria-hidden="true" /> {#snippet toolbar()}
<DataTableToolbar>
<div class="relative max-w-md flex-1">
<SearchIcon
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input class="h-8 ps-8" placeholder="Поиск по критичности, заголовку, описанию…" bind:value={liveIncSearch} />
</div>
<Button
variant="outline"
size="sm"
class="h-8"
onclick={() => {
liveIncSearch = '';
liveSortKey = null;
liveSortDir = null;
}}
>Сброс</Button>
</DataTableToolbar>
{/snippet} {/snippet}
<ScrollArea class="max-h-[min(70vh,520px)] w-full" orientation="both">
<Table.Root> <Table.Root>
<Table.Header> <Table.Header class="sticky top-0 z-10 bg-muted">
<Table.Row> <Table.Row class="border-b border-border/80 hover:bg-transparent">
<Table.Head>Severity</Table.Head> <SortableTh
<Table.Head>Title</Table.Head> active={liveSortKey === 'sev'}
<Table.Head>Summary</Table.Head> dir={liveSortDir}
onToggle={() => toggleLiveSort('sev')}
>Критичность</SortableTh>
<SortableTh
active={liveSortKey === 'title'}
dir={liveSortDir}
onToggle={() => toggleLiveSort('title')}
>Заголовок</SortableTh>
<Table.Head>Описание</Table.Head>
<Table.Head> <Table.Head>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
Серверы Серверы
@@ -374,11 +503,17 @@
Инцидентов нет Инцидентов нет
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
{:else if filteredLiveIncidents.length === 0}
<Table.Row>
<Table.Cell colspan={4} class="p-4 text-center text-muted-foreground">
Нет строк по фильтру
</Table.Cell>
</Table.Row>
{:else} {:else}
{#each snapshot?.incidents ?? [] as item (item.id)} {#each filteredLiveIncidents as item (item.id)}
<Table.Row> <Table.Row>
<Table.Cell> <Table.Cell>
<Badge variant={severityVariant(item.severity)}>{item.severity}</Badge> <Badge variant={severityVariant(item.severity)}>{severityLabel(item.severity)}</Badge>
</Table.Cell> </Table.Cell>
<Table.Cell class="font-medium">{item.title}</Table.Cell> <Table.Cell class="font-medium">{item.title}</Table.Cell>
<Table.Cell class="max-w-[380px] text-sm text-muted-foreground">{item.summary}</Table.Cell> <Table.Cell class="max-w-[380px] text-sm text-muted-foreground">{item.summary}</Table.Cell>
@@ -390,4 +525,5 @@
{/if} {/if}
</Table.Body> </Table.Body>
</Table.Root> </Table.Root>
</KpiPanelCard> </ScrollArea>
</DataTableCard>
+425
View File
@@ -0,0 +1,425 @@
<script lang="ts">
import { browser } from '$app/environment';
import { onMount } from 'svelte';
import {
ApiError,
fetchAggSummary,
fetchMihomoJson,
fetchMihomoMeta
} from '$lib/api/client.js';
import type { MihomoConnectionsResponse, MihomoMetaResponse } from '$lib/api/mihomo-types.js';
import { formatBytes } from '$lib/format.js';
import * as Table from '$lib/components/ui/table/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
import DataTableCard from '$lib/components/data-table/data-table-card.svelte';
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
import MihomoProxyGroups from '$lib/components/mihomo/mihomo-proxy-groups.svelte';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import { cn } from '$lib/utils.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import ServerIcon from '@lucide/svelte/icons/server';
import PlugIcon from '@lucide/svelte/icons/plug';
import MemoryStickIcon from '@lucide/svelte/icons/memory-stick';
import CloudUploadIcon from '@lucide/svelte/icons/cloud-upload';
import CheckCircleIcon from '@lucide/svelte/icons/circle-check';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
type RowStatus = 'ok' | 'no_mihomo' | 'error';
type FleetRow = {
alias: string;
metaStatus: RowStatus;
meta: MihomoMetaResponse | null;
metaMessage?: string;
alive?: boolean;
connections?: number;
memory?: number;
uploadTotal?: number;
downloadTotal?: number;
pollErr?: string | null;
};
const POLL_MS = 8000;
let loading = $state(true);
let err = $state<string | null>(null);
let rows = $state<FleetRow[]>([]);
/** Список alias с рабочим meta; обновляется только при полной перезагрузке. */
let mihomoOkAliases: string[] = [];
let pollTimer: ReturnType<typeof setInterval> | null = null;
/** Принудительная перезагрузка /proxies для карточки ноды. */
let proxyRefreshNonce = $state<Record<string, number>>({});
let proxyDelayAllNonce = $state<Record<string, number>>({});
let nodeProxyLoading = $state<Record<string, boolean>>({});
let nodeDelayAllBusy = $state<Record<string, boolean>>({});
function bumpProxyRefresh(a: string) {
proxyRefreshNonce = { ...proxyRefreshNonce, [a]: (proxyRefreshNonce[a] ?? 0) + 1 };
}
function bumpProxyDelayAll(a: string) {
proxyDelayAllNonce = { ...proxyDelayAllNonce, [a]: (proxyDelayAllNonce[a] ?? 0) + 1 };
}
let withMihomo = $derived(rows.filter((r) => r.metaStatus === 'ok').length);
let aliveCount = $derived(rows.filter((r) => r.metaStatus === 'ok' && r.alive).length);
let sumConn = $derived(
rows.reduce((acc, r) => acc + (r.metaStatus === 'ok' && typeof r.connections === 'number' ? r.connections : 0), 0)
);
let sumUpload = $derived(
rows.reduce((acc, r) => acc + (typeof r.uploadTotal === 'number' ? r.uploadTotal : 0), 0)
);
let sumDownload = $derived(
rows.reduce((acc, r) => acc + (typeof r.downloadTotal === 'number' ? r.downloadTotal : 0), 0)
);
let sumMemory = $derived(
rows.reduce((acc, r) => acc + (typeof r.memory === 'number' && r.memory > 0 ? r.memory : 0), 0)
);
async function probeMeta(alias: string): Promise<FleetRow> {
try {
const meta = await fetchMihomoMeta(alias);
return {
alias,
metaStatus: 'ok',
meta,
alive: false,
pollErr: null
};
} catch (e) {
const ae = e instanceof ApiError ? e : null;
if (ae?.status === 404) {
return {
alias,
metaStatus: 'no_mihomo',
meta: null,
metaMessage: ae.message
};
}
return {
alias,
metaStatus: 'error',
meta: null,
metaMessage: ae?.message ?? String(e)
};
}
}
async function pollMetrics() {
if (mihomoOkAliases.length === 0) return;
await Promise.all(
mihomoOkAliases.map(async (alias) => {
try {
const j = await fetchMihomoJson<MihomoConnectionsResponse>(alias, 'connections');
const list = j.connections ?? [];
const connTotal = typeof j.total === 'number' ? j.total : list.length;
rows = rows.map((r) =>
r.alias !== alias
? r
: {
...r,
alive: true,
connections: connTotal,
memory: typeof j.memory === 'number' ? j.memory : undefined,
uploadTotal: typeof j.uploadTotal === 'number' ? j.uploadTotal : undefined,
downloadTotal: typeof j.downloadTotal === 'number' ? j.downloadTotal : undefined,
pollErr: null
}
);
} catch (e) {
const msg = e instanceof ApiError ? e.message : String(e);
rows = rows.map((r) =>
r.alias !== alias ? r : { ...r, alive: false, pollErr: msg }
);
}
})
);
}
async function reloadAll() {
if (!browser) return;
loading = true;
err = null;
try {
const env = await fetchAggSummary({ top_n: 1 });
const set = new Set<string>();
for (const s of env.data.servers ?? []) {
if (s.alias) set.add(s.alias);
}
const aliases = [...set].sort((a, b) => a.localeCompare(b));
if (aliases.length === 0) {
rows = [];
mihomoOkAliases = [];
return;
}
const probed = await Promise.all(aliases.map((a) => probeMeta(a)));
probed.sort((a, b) => a.alias.localeCompare(b.alias));
rows = probed;
mihomoOkAliases = probed.filter((r) => r.metaStatus === 'ok').map((r) => r.alias);
await pollMetrics();
} catch (e) {
err = e instanceof ApiError ? e.message : String(e);
rows = [];
mihomoOkAliases = [];
} finally {
loading = false;
}
}
onMount(() => {
if (!browser) return;
void reloadAll();
pollTimer = setInterval(() => void pollMetrics(), POLL_MS);
return () => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
};
});
function statusBadge(r: FleetRow) {
if (r.metaStatus === 'no_mihomo') return { label: 'Нет Mihomo', variant: 'secondary' as const };
if (r.metaStatus === 'error') return { label: 'Meta ошибка', variant: 'destructive' as const };
if (r.alive === false && r.pollErr) return { label: 'Недоступен', variant: 'destructive' as const };
if (r.alive) return { label: 'Онлайн', variant: 'default' as const };
return { label: 'Ожидание', variant: 'outline' as const };
}
</script>
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Mihomo (флот)</h1>
<p class="text-sm text-muted-foreground">
Сводка по нодам и управление прокси. Опрос соединений каждые {POLL_MS / 1000} с.
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onclick={() => void reloadAll()} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить
</Button>
</div>
</div>
<DataQueryState {err} showSkeleton={loading} isEmpty={!loading && rows.length === 0 && !err}>
{#snippet empty()}
<p class="text-sm text-muted-foreground">
В снимке агрегата нет серверов. Проверьте шлюз и конфигурацию <code class="text-xs">servers</code>.
</p>
{/snippet}
{#snippet skeleton()}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
{#each Array.from({ length: 5 }) as _, i (i)}
<Skeleton class="h-28 rounded-xl" />
{/each}
</div>
<Skeleton class="h-64 w-full rounded-lg" />
{/snippet}
{#snippet children()}
{#if rows.length > 0}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<KpiStatCard accent="violet" label="Нод с Mihomo" footer="meta OK" valueClass="text-xl">
{#snippet icon()}
<ServerIcon class="size-5" aria-hidden="true" />
{/snippet}
{withMihomo}
</KpiStatCard>
<KpiStatCard accent="success" label="Онлайн (опрос)" footer="успешный /connections" valueClass="text-xl">
{#snippet icon()}
<CheckCircleIcon class="size-5" aria-hidden="true" />
{/snippet}
{aliveCount}/{withMihomo}
</KpiStatCard>
<KpiStatCard accent="info" label="Соединения ∑" footer="по снимку" valueClass="text-xl">
{#snippet icon()}
<PlugIcon class="size-5" aria-hidden="true" />
{/snippet}
{sumConn}
</KpiStatCard>
<KpiStatCard accent="neutral" label="Память ∑" footer="поле memory" valueClass="text-xl">
{#snippet icon()}
<MemoryStickIcon class="size-5" aria-hidden="true" />
{/snippet}
{sumMemory > 0 ? formatBytes(sumMemory) : '—'}
</KpiStatCard>
<KpiStatCard accent="warning" label="Трафик ∑" footer="upload / download" valueClass="text-lg">
{#snippet icon()}
<CloudUploadIcon class="size-5" aria-hidden="true" />
{/snippet}
<span class="block truncate" title="{formatBytes(sumUpload)} ↑ · {formatBytes(sumDownload)} ↓">
{formatBytes(sumUpload)} ↑ · {formatBytes(sumDownload)} ↓
</span>
</KpiStatCard>
</div>
<DataTableCard
title="Ноды"
description="Контроллер и метрики. Ниже — компактные группы прокси (автозагрузка). «Детально» — обзор с графиками."
accent="violet"
>
{#snippet icon()}<ServerIcon class="size-5" aria-hidden="true" />{/snippet}
<ScrollArea class="w-full" orientation="both">
<Table.Root>
<Table.Header class="sticky top-0 z-10 bg-muted">
<Table.Row class="border-b border-border/80 hover:bg-transparent">
<Table.Head class="text-xs sm:text-sm">Alias</Table.Head>
<Table.Head class="text-xs sm:text-sm">Контроллер</Table.Head>
<Table.Head class="text-xs sm:text-sm">Статус</Table.Head>
<Table.Head class="text-xs sm:text-sm text-right">Conn</Table.Head>
<Table.Head class="text-xs sm:text-sm text-right">Память</Table.Head>
<Table.Head class="text-xs sm:text-sm text-right">Трафик</Table.Head>
<Table.Head class="text-xs sm:text-sm w-[1%]">Прокси</Table.Head>
<Table.Head class="text-xs sm:text-sm w-[1%]"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each rows as r (r.alias)}
{@const b = statusBadge(r)}
<Table.Row>
<Table.Cell class="font-medium text-sm">{r.alias}</Table.Cell>
<Table.Cell class="max-w-[200px] truncate font-mono text-xs sm:text-sm">
{r.meta?.controller_base ?? '—'}
</Table.Cell>
<Table.Cell>
<Badge variant={b.variant}>{b.label}</Badge>
{#if r.metaStatus !== 'ok' && r.metaMessage}
<p class="text-muted-foreground mt-1 max-w-xs text-xs">{r.metaMessage}</p>
{/if}
{#if r.pollErr}
<p class="text-destructive mt-1 max-w-xs text-xs">{r.pollErr}</p>
{/if}
</Table.Cell>
<Table.Cell class="text-right text-sm">
{r.metaStatus === 'ok' ? (r.connections ?? '—') : '—'}
</Table.Cell>
<Table.Cell class="text-right text-sm">
{r.memory != null && r.memory > 0 ? formatBytes(r.memory) : '—'}
</Table.Cell>
<Table.Cell class="text-right font-mono text-xs sm:text-sm">
{#if r.metaStatus === 'ok' && (r.uploadTotal != null || r.downloadTotal != null)}
{formatBytes(r.uploadTotal ?? 0)} ↑<br />
{formatBytes(r.downloadTotal ?? 0)} ↓
{:else}
—
{/if}
</Table.Cell>
<Table.Cell class="text-right">
{#if r.metaStatus === 'ok'}
<a
href="#mihomo-proxies-{encodeURIComponent(r.alias)}"
class="text-primary text-sm hover:underline"
>
К настройке
</a>
{/if}
</Table.Cell>
<Table.Cell class="text-right">
{#if r.metaStatus === 'ok'}
<a
href="/servers/{encodeURIComponent(r.alias)}/mihomo"
class="text-primary inline-flex items-center gap-1 text-sm hover:underline"
>
Детально
<ExternalLinkIcon class="size-3.5 opacity-70" aria-hidden="true" />
</a>
{/if}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</ScrollArea>
</DataTableCard>
<div class="mt-8 space-y-3">
<div>
<h2 class="text-base font-semibold tracking-tight">Прокси по нодам</h2>
<p class="text-muted-foreground mt-0.5 max-w-2xl text-xs leading-relaxed">
Автозагрузка групп. В шапке: «Все задержки» и обновление списка. Клик по узлу — выбор; справа —
проверка одного узла.
</p>
</div>
<div
class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4"
>
{#each rows.filter((r) => r.metaStatus === 'ok') as r (r.alias)}
<Card.Root
id="mihomo-proxies-{encodeURIComponent(r.alias)}"
size="sm"
density="compact"
class="min-h-0 min-w-0 scroll-mt-20 overflow-hidden shadow-sm"
>
<Card.Header class="border-border/80 border-b px-3 pb-3 pt-3 sm:px-4">
<Card.Title class="font-mono text-sm font-semibold leading-none tracking-tight">
{r.alias}
</Card.Title>
{#if r.meta?.controller_base}
<Card.Description
class="text-muted-foreground line-clamp-2 pt-1 font-mono text-[10px] leading-snug"
>
{r.meta.controller_base}
</Card.Description>
{/if}
<Card.Action class="flex flex-wrap items-center justify-end gap-1">
<Button
variant="outline"
size="sm"
class="h-8 px-2 text-xs"
disabled={!!nodeProxyLoading[r.alias] || !!nodeDelayAllBusy[r.alias]}
onclick={() => bumpProxyDelayAll(r.alias)}
>
{nodeDelayAllBusy[r.alias] ? 'Проверка…' : 'Все задержки'}
</Button>
<Button
variant="outline"
size="icon"
class="size-8 shrink-0"
aria-label="Обновить прокси для {r.alias}"
disabled={!!nodeProxyLoading[r.alias] || !!nodeDelayAllBusy[r.alias]}
onclick={() => bumpProxyRefresh(r.alias)}
>
<RefreshCwIcon
class={cn(
'size-3.5',
nodeProxyLoading[r.alias] ? 'animate-spin' : ''
)}
aria-hidden="true"
/>
</Button>
</Card.Action>
</Card.Header>
<Card.Content class="px-0 pb-0 pt-0">
<div class="px-2 py-2 sm:px-3 sm:py-2.5">
<MihomoProxyGroups
alias={r.alias}
compact={true}
hideToolbar={true}
hideCompactPerGroupDelayButton={true}
refreshNonce={proxyRefreshNonce[r.alias] ?? 0}
delayAllNonce={proxyDelayAllNonce[r.alias] ?? 0}
onLoadingChange={(v) => {
nodeProxyLoading = { ...nodeProxyLoading, [r.alias]: v };
}}
onDelayAllChange={(v) => {
nodeDelayAllBusy = { ...nodeDelayAllBusy, [r.alias]: v };
}}
/>
</div>
</Card.Content>
</Card.Root>
{/each}
{#if rows.filter((r) => r.metaStatus === 'ok').length === 0}
<p class="text-sm text-muted-foreground">
Нет нод с настроенным Mihomo — переключать прокси негде. Проверьте конфиг шлюза.
</p>
{/if}
</div>
</div>
{/if}
{/snippet}
</DataQueryState>
+492
View File
@@ -0,0 +1,492 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
fetchAggRadarTelemtDcs,
fetchRadarPingDC,
fetchRadarStatuses,
type AggRadarTelemtDcsRow,
type RadarPingRow
} from '$lib/api/client.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import RadarIcon from '@lucide/svelte/icons/radar';
import ActivityIcon from '@lucide/svelte/icons/activity';
import InfoIcon from '@lucide/svelte/icons/info';
type RadarEp = {
group?: string;
name?: string;
results?: { success?: boolean }[];
};
const dcs = ['DC1', 'DC2', 'DC3', 'DC4', 'DC5'] as const;
const locs: Record<(typeof dcs)[number], string> = {
DC1: '🇺🇸 США',
DC2: '🇳🇱 NL',
DC3: '🇺🇸 США',
DC4: '🇳🇱 NL',
DC5: '🇸🇬 SG'
};
const legendRows = [
{ dc: 'DC1', loc: '🇺🇸 США, Майами', desc: 'Основной для пользователей Америки. Сообщения, звонки.' },
{ dc: 'DC2', loc: '🇳🇱 Нидерланды', desc: 'Основной для большинства RU/EU. Сообщения, файлы, медиа.' },
{ dc: 'DC3', loc: '🇺🇸 США, Майами', desc: 'Резервный для DC1. Медиафайлы и CDN.' },
{ dc: 'DC4', loc: '🇳🇱 Нидерланды', desc: 'Резервный для DC2. Медиа и файлы EU пользователей.' },
{ dc: 'DC5', loc: '🇸🇬 Сингапур', desc: 'Основной для пользователей Азии и Океании.' },
{ dc: 'DC203', loc: '🌐 CDN', desc: 'Специальный CDN узел для ускорения загрузки медиа.' }
];
let radarLoading = $state(false);
let radarErr = $state<string | null>(null);
let radarUpdated = $state<string | null>(null);
let summaryHtml = $state<string>('');
let matrixHtml = $state<string>('');
let diagLoading = $state(false);
let pingByDc = $state<Record<number, RadarPingRow | null>>({
1: null,
2: null,
3: null,
4: null,
5: null
});
let pingFrom = $state<string | null>(null);
let telemtLoading = $state(false);
let telemtErr = $state<string | null>(null);
let telemtPartial = $state(false);
let telemtUpdated = $state<string | null>(null);
let telemtServers = $state<AggRadarTelemtDcsRow[]>([]);
const telemtDcNums = [1, 2, 3, 4, 5] as const;
function dcRowFind(row: AggRadarTelemtDcsRow, dc: number) {
return row.data?.dcs?.find((d) => d.dc === dc);
}
function telemtCoverageClass(cov: number | undefined): string {
if (cov === undefined) return 'text-muted-foreground';
if (cov >= 90) return 'text-emerald-600 font-medium';
if (cov >= 50) return 'text-amber-600 font-medium';
return 'text-red-600 font-medium';
}
function buildMatrix(data: unknown[]) {
const regions: Record<string, { ok: number; total: number }> = {
SE: { ok: 0, total: 0 },
DE: { ok: 0, total: 0 },
RU: { ok: 0, total: 0 }
};
const matrix: Record<string, Record<string, boolean | null>> = {
SE: {},
DE: {},
RU: {}
};
const ruSeen: Record<string, boolean> = {};
for (const raw of data) {
const ep = raw as RadarEp;
const g = ep.group ?? '';
const name = ep.name ?? '';
const results = ep.results;
const last =
results && results.length > 0 ? results[results.length - 1] : null;
const ok = last ? last.success : null;
if (!g.includes('TCP 443 IPv4') || name.includes('IPv6')) continue;
const dcM = name.match(/DC(\d+)/);
if (!dcM) continue;
const dcNum = 'DC' + dcM[1];
let region: string | null = null;
if (g.includes('from SE')) region = 'SE';
else if (g.includes('from DE')) region = 'DE';
else if (g.includes('from RU')) region = 'RU';
if (!region) continue;
if (region === 'RU') {
if (ruSeen[dcNum]) continue;
ruSeen[dcNum] = true;
}
matrix[region][dcNum] = ok ?? null;
regions[region].total++;
if (ok) regions[region].ok++;
}
const info: Record<string, { flag: string; name: string; note: string }> = {
SE: {
flag: '🇸🇪',
name: 'Швеция',
note: 'Референс из мониторинга radar.telemt.top (from SE)'
},
DE: { flag: '🇩🇪', name: 'Германия', note: 'Для сравнения (from DE)' },
RU: { flag: '🇷🇺', name: 'Россия', note: 'Напрямую (from RU)' }
};
summaryHtml = (['SE', 'DE', 'RU'] as const)
.map((code) => {
const i = info[code];
const ok = regions[code].ok || 0;
const total = regions[code].total || 5;
const color = ok === total ? 'text-emerald-600' : ok === 0 ? 'text-red-600' : 'text-amber-600';
return `<div class="rounded-lg border border-border bg-card p-3 text-sm">
<div class="font-medium">${i.flag} ${i.name}</div>
<div class="mt-1 text-2xl font-semibold ${color}">${ok}/${total}</div>
<div class="text-muted-foreground mt-1 text-xs">${i.note}</div>
</div>`;
})
.join('');
let tbl = `<div class="overflow-x-auto"><table class="w-full min-w-[520px] border-collapse text-sm">
<thead><tr class="border-b border-border">
<th class="p-2 text-start font-medium">Из страны</th>
${dcs.map((dc) => `<th class="p-2 text-center font-medium">${dc}<br/><span class="text-muted-foreground text-xs font-normal">${locs[dc]}</span></th>`).join('')}
</tr></thead><tbody>`;
for (const row of [
{ code: 'SE', flag: '🇸🇪', label: 'Швеция' },
{ code: 'DE', flag: '🇩🇪', label: 'Германия' },
{ code: 'RU', flag: '🇷🇺', label: 'Россия' }
] as const) {
tbl += `<tr class="border-b border-border"><td class="p-2 whitespace-nowrap">${row.flag} ${row.label}</td>`;
for (const dc of dcs) {
const v = matrix[row.code][dc];
const color =
v === true ? 'text-emerald-600' : v === false ? 'text-red-600' : 'text-muted-foreground';
const title = v === true ? 'доступен' : v === false ? 'заблокирован' : 'нет данных';
tbl += `<td class="p-2 text-center ${color}" title="${title}">●</td>`;
}
tbl += '</tr>';
}
tbl += '</tbody></table></div>';
matrixHtml = tbl;
}
async function loadRadar() {
radarLoading = true;
radarErr = null;
try {
const data = await fetchRadarStatuses();
buildMatrix(data);
radarUpdated = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
} catch (e) {
radarErr = e instanceof Error ? e.message : String(e);
summaryHtml = '';
matrixHtml = '';
} finally {
radarLoading = false;
}
}
async function runDiag() {
diagLoading = true;
for (let i = 1; i <= 5; i++) {
pingByDc = { ...pingByDc, [i]: null };
}
try {
const data = await fetchRadarPingDC();
pingFrom = data.from;
const next: Record<number, RadarPingRow | null> = { ...pingByDc };
for (let i = 1; i <= 5; i++) {
const r = data.results[String(i)];
next[i] = r ?? null;
}
pingByDc = next;
} catch {
for (let i = 1; i <= 5; i++) {
pingByDc = { ...pingByDc, [i]: { ok: false, ms: null } };
}
} finally {
diagLoading = false;
}
}
function pingStyle(ms: number | null, ok: boolean): { text: string; barPct: number; color: string } {
if (!ok || ms === null) {
return { text: 'блок.', barPct: 100, color: 'bg-red-500' };
}
const color = ms < 100 ? 'bg-emerald-500' : ms < 300 ? 'bg-amber-500' : 'bg-red-500';
return { text: `${ms} мс`, barPct: Math.min(ms / 3, 100), color };
}
async function loadTelemtDcs() {
telemtLoading = true;
telemtErr = null;
try {
const env = await fetchAggRadarTelemtDcs();
telemtServers = env.data.servers ?? [];
telemtPartial = !!env.partial;
telemtUpdated = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
} catch (e) {
telemtErr = e instanceof Error ? e.message : String(e);
telemtServers = [];
telemtPartial = false;
} finally {
telemtLoading = false;
}
}
async function refreshRadarAndTelemt() {
await Promise.all([loadRadar(), loadTelemtDcs()]);
}
onMount(() => {
void refreshRadarAndTelemt();
});
</script>
<div class="flex w-full flex-col gap-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2">
<RadarIcon class="text-primary size-8" />
<div>
<h1 class="text-xl font-semibold tracking-tight">Радар DC Telegram</h1>
<p class="text-muted-foreground text-sm">
Источник матрицы: <span class="text-foreground">radar.telemt.top</span>
{#if radarUpdated}
· обновлено {radarUpdated}
{/if}
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
disabled={radarLoading || telemtLoading}
onclick={() => void refreshRadarAndTelemt()}
>
<RefreshCwIcon
class="mr-1 size-4 {radarLoading || telemtLoading ? 'animate-spin' : ''}"
/>
Обновить
</Button>
</div>
{#if radarErr}
<Alert variant="destructive">
<AlertTitle>Ошибка загрузки радара</AlertTitle>
<AlertDescription>{radarErr}</AlertDescription>
</Alert>
{/if}
<Card.Root>
<Card.Header>
<Card.Title class="text-base">Сводка по регионам (TCP 443 IPv4)</Card.Title>
<Card.Description>
Матрица ниже строится из ответа шлюза <code class="text-xs">/api/radar/statuses</code> (прокси к
radar.telemt.top).
</Card.Description>
</Card.Header>
<Card.Content>
{#if radarLoading && !summaryHtml}
<p class="text-muted-foreground text-sm">Загрузка…</p>
{:else if summaryHtml}
<div class="grid gap-3 sm:grid-cols-3">{@html summaryHtml}</div>
{/if}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-base">Матрица доступности</Card.Title>
<Card.Description>
<span class="text-emerald-600">●</span> доступен
<span class="text-red-600 ms-2">●</span> заблокирован
<span class="text-muted-foreground ms-2">●</span> нет данных
</Card.Description>
</Card.Header>
<Card.Content>
{#if radarLoading && !matrixHtml}
<p class="text-muted-foreground text-sm">Загрузка…</p>
{:else if matrixHtml}
{@html matrixHtml}
{/if}
<Alert class="mt-4">
<InfoIcon class="size-4" />
<AlertTitle class="text-sm">Про ICMP и TCP 443</AlertTitle>
<AlertDescription class="text-xs">
ICMP может проходить даже при блокировке MTProto по TCP 443. Для Telegram важна доступность
TCP 443 до DC — её отражает radar и тест ниже.
</AlertDescription>
</Alert>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-base">Telemt ME — снимок по нодам</Card.Title>
<Card.Description>
<code class="text-xs">GET /api/agg/radar-telemt-dcs</code> — параллельно
<code class="text-xs">GET /v1/stats/dcs</code> на каждом upstream (как на странице «Состояние» ноды).
Требуется включённый minimal runtime API на Telemt; иначе в ячейках будет причина отключения.
{#if telemtUpdated}
<span class="text-foreground"> · обновлено {telemtUpdated}</span>
{/if}
</Card.Description>
</Card.Header>
<Card.Content>
{#if telemtErr}
<Alert variant="destructive">
<AlertTitle>Ошибка загрузки stats/dcs</AlertTitle>
<AlertDescription>{telemtErr}</AlertDescription>
</Alert>
{:else if telemtLoading && telemtServers.length === 0}
<p class="text-muted-foreground text-sm">Загрузка…</p>
{:else if telemtServers.length === 0}
<p class="text-muted-foreground text-sm">Нет серверов в конфигурации шлюза.</p>
{:else}
{#if telemtPartial}
<Alert class="mb-4">
<InfoIcon class="size-4" />
<AlertTitle class="text-sm">Частичные данные</AlertTitle>
<AlertDescription class="text-xs">
Не все ноды ответили успешно — смотрите ошибки в строках.
</AlertDescription>
</Alert>
{/if}
<div class="overflow-x-auto rounded-md border border-border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-28">Нода</Table.Head>
{#each telemtDcNums as dc (dc)}
<Table.Head class="min-w-[5.5rem] text-center">DC{dc}</Table.Head>
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#each telemtServers as srv (srv.alias)}
<Table.Row>
<Table.Cell class="font-mono text-sm font-medium">{srv.alias}</Table.Cell>
{#each telemtDcNums as dc (dc)}
<Table.Cell class="align-top text-center text-sm">
{#if !srv.ok}
<span class="text-destructive text-xs leading-tight" title={srv.error ?? ''}
>ошибка</span>
{:else if !srv.data?.middle_proxy_enabled}
<span
class="text-muted-foreground text-xs leading-tight"
title={srv.data?.reason ?? ''}
>
{srv.data?.reason === 'feature_disabled'
? 'ME API off'
: (srv.data?.reason ?? 'нет данных')}
</span>
{:else}
{@const cell = dcRowFind(srv, dc)}
{#if cell}
<div class={telemtCoverageClass(cell.coverage_pct)}>
{cell.coverage_pct.toFixed(0)}%
</div>
{#if cell.rtt_ms != null && cell.rtt_ms !== undefined}
<div class="text-muted-foreground mt-0.5 text-xs tabular-nums">
{Number(cell.rtt_ms).toFixed(0)} ms
</div>
{/if}
{:else}
<span class="text-muted-foreground">—</span>
{/if}
{/if}
</Table.Cell>
{/each}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
<p class="text-muted-foreground mt-3 text-xs">
Покрытие — доля alive writers к required для DC (Telemt). Это не сырой TCP-пинг с интернета, а
состояние middle proxy на процессе Telemt.
</p>
{/if}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2 text-base">
<ActivityIcon class="size-4" />
Диагностика TCP с хоста шлюза
</Card.Title>
<Card.Description>
Запрос <code class="text-xs">/api/radar/ping-dc</code> выполняется на <strong>процессе шлюза</strong>
(не на каждой ноде Telemt): TCP :443 до каждого DC, таймаут 2 с (как
<code class="text-xs">ping_proxy.php</code>). Для вида «с каждой ноды» используйте таблицу ME выше.
Поле «from» в ответе — метка источника.
{#if pingFrom}
<span class="mt-1 block text-foreground">from: {pingFrom}</span>
{/if}
</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<Button disabled={diagLoading} onclick={() => runDiag()}>
{diagLoading ? 'Тестирование…' : 'Запустить тест'}
</Button>
<div class="space-y-3">
{#each [1, 2, 3, 4, 5] as n (n)}
{@const row = pingByDc[n]}
{@const st = row ? pingStyle(row.ms, row.ok) : { text: '—', barPct: 0, color: 'bg-muted' }}
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-4">
<div class="w-24 shrink-0 text-sm font-medium">DC{n}</div>
<div class="min-w-0 flex-1">
<div class="bg-muted h-2 overflow-hidden rounded-full">
<div
class="h-2 rounded-full transition-all {st.color}"
style="width: {row ? st.barPct : 0}%"
></div>
</div>
</div>
<div class="w-28 shrink-0 text-sm tabular-nums {row?.ok ? '' : 'text-red-600'}">
{diagLoading && !row ? '…' : st.text}
</div>
<div class="text-muted-foreground w-32 shrink-0 text-xs">
{#if row}
{row.ok ? 'доступен' : 'заблокирован'}
{:else if !diagLoading}
ожидание
{/if}
</div>
</div>
{/each}
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-base">Справка — датацентры Telegram</Card.Title>
</Card.Header>
<Card.Content>
<div class="rounded-md border border-border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-24">DC</Table.Head>
<Table.Head>Локация</Table.Head>
<Table.Head>Назначение</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each legendRows as row (row.dc)}
<Table.Row>
<Table.Cell class="font-mono text-sm">{row.dc}</Table.Cell>
<Table.Cell class="text-sm">{row.loc}</Table.Cell>
<Table.Cell class="text-muted-foreground text-sm">{row.desc}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
<p class="text-muted-foreground mt-3 text-xs">
Telegram назначает DC при регистрации; при блокировке своего DC нужен прокси в регионе, где DC
доступен по TCP 443.
</p>
</Card.Content>
</Card.Root>
</div>
+70 -46
View File
@@ -1,22 +1,17 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { import { ApiError, fetchStatsSummary, fetchTelemt } from '$lib/api/client.js';
ApiError,
fetchStatsSummary,
fetchTelemt
} from '$lib/api/client.js';
import type { HealthData, StatsSummaryData, SystemInfoData } from '$lib/api/telemt-v1.js'; import type { HealthData, StatsSummaryData, SystemInfoData } from '$lib/api/telemt-v1.js';
import { formatDurationSeconds, formatMiB } from '$lib/format.js'; import { formatDurationSeconds } from '$lib/format.js';
import * as Card from '$lib/components/ui/card/index.js';
import KpiStatCard from '$lib/components/kpi-stat-card.svelte'; import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte'; import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import ClockIcon from '@lucide/svelte/icons/clock'; import ClockIcon from '@lucide/svelte/icons/clock';
import PlugIcon from '@lucide/svelte/icons/plug';
import UnplugIcon from '@lucide/svelte/icons/unplug';
import UsersIcon from '@lucide/svelte/icons/users';
import CpuIcon from '@lucide/svelte/icons/cpu'; import CpuIcon from '@lucide/svelte/icons/cpu';
import PlugIcon from '@lucide/svelte/icons/plug';
import ShieldAlertIcon from '@lucide/svelte/icons/shield-alert';
import TimerOffIcon from '@lucide/svelte/icons/timer-off';
import UsersIcon from '@lucide/svelte/icons/users';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js'; import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import { Skeleton } from '$lib/components/ui/skeleton/index.js'; import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import DataQueryState from '$lib/components/fleet/data-query-state.svelte'; import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
@@ -30,6 +25,15 @@
let summary = $state<StatsSummaryData | null>(null); let summary = $state<StatsSummaryData | null>(null);
let sys = $state<SystemInfoData | null>(null); let sys = $state<SystemInfoData | null>(null);
function num(v: unknown): number {
if (typeof v === 'number' && Number.isFinite(v)) return v;
if (typeof v === 'string' && v.trim() !== '') {
const n = Number(v);
if (Number.isFinite(n)) return n;
}
return 0;
}
async function loadFor(a: string) { async function loadFor(a: string) {
loading = true; loading = true;
err = null; err = null;
@@ -64,17 +68,19 @@
let hasStaleData = $derived(err != null && (health != null || summary != null)); let hasStaleData = $derived(err != null && (health != null || summary != null));
let showSkeleton = $derived(loading && !err && health == null && summary == null); let showSkeleton = $derived(loading && !err && health == null && summary == null);
let badConnAccent = $derived.by((): 'danger' | 'success' => { let uptimeSec = $derived(num(sys?.uptime_seconds));
const n = summary?.connections_bad_total; let connTotal = $derived(num(summary?.connections_total));
if (typeof n !== 'number' || !Number.isFinite(n) || n === 0) return 'success'; let badTotal = $derived(num(summary?.connections_bad_total));
return 'danger'; let cfgUsers = $derived(num(summary?.configured_users));
}); let handshakeT = $derived(num(summary?.handshake_timeouts_total));
</script> </script>
<div class="mb-6 flex items-center justify-between gap-4"> <div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div> <div>
<h1 class="text-2xl font-semibold tracking-tight">Dashboard</h1> <h1 class="text-2xl font-semibold tracking-tight">Сервер: обзор</h1>
<p class="font-mono text-sm text-muted-foreground">{alias}</p> <p class="text-sm text-muted-foreground">
Ключевые метрики и состояние Telemt для <span class="font-mono">{alias}</span>.
</p>
</div> </div>
<Button variant="outline" size="sm" onclick={load} disabled={loading}> <Button variant="outline" size="sm" onclick={load} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" /> <RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
@@ -90,25 +96,12 @@
partial={false} partial={false}
> >
{#snippet skeleton()} {#snippet skeleton()}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
{#each Array.from({ length: 4 }) as _, i (i)} {#each Array.from({ length: 5 }) as _, i (i)}
<Card.Root class="border-l-4 border-l-muted-foreground/30 shadow-sm"> <Skeleton class="h-28 rounded-xl" />
<Card.Header class="pb-2">
<div class="flex gap-3">
<Skeleton class="size-10 shrink-0 rounded-lg" />
<div class="min-w-0 flex-1 space-y-2">
<Skeleton class="h-3 w-24" />
<Skeleton class="h-8 w-20" />
</div>
</div>
</Card.Header>
<Card.Content class="pt-0">
<Skeleton class="h-3 w-32" />
</Card.Content>
</Card.Root>
{/each} {/each}
</div> </div>
<Skeleton class="h-40 w-full rounded-lg" /> <Skeleton class="h-64 w-full rounded-lg" />
{/snippet} {/snippet}
{#snippet children()} {#snippet children()}
{#if health?.status === 'ok'} {#if health?.status === 'ok'}
@@ -120,35 +113,66 @@
</Alert> </Alert>
{/if} {/if}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<KpiStatCard accent="teal" label="Аптайм" footer="uptime с ноды" valueClass="text-xl"> <KpiStatCard
accent="violet"
label="Аптайм"
valueClass="text-xl"
>
{#snippet icon()} {#snippet icon()}
<ClockIcon class="size-5" aria-hidden="true" /> <ClockIcon class="size-5" aria-hidden="true" />
{/snippet} {/snippet}
{sys ? formatDurationSeconds(sys.uptime_seconds) : '—'} {sys ? formatDurationSeconds(sys.uptime_seconds) : '—'}
</KpiStatCard> </KpiStatCard>
<KpiStatCard accent="info" label="Соединения (всего)" footer="stats/summary" valueClass="text-xl"> <KpiStatCard
accent="info"
label="Соединения (всего)"
valueClass="text-xl"
>
{#snippet icon()} {#snippet icon()}
<PlugIcon class="size-5" aria-hidden="true" /> <PlugIcon class="size-5" aria-hidden="true" />
{/snippet} {/snippet}
{summary?.connections_total ?? '—'} {summary ? String(connTotal) : '—'}
</KpiStatCard> </KpiStatCard>
<KpiStatCard accent={badConnAccent} label="Bad connections" footer="счётчик на ноде" valueClass="text-xl"> <KpiStatCard
accent="danger"
label="Плохие соединения"
valueClass="text-xl"
>
{#snippet icon()} {#snippet icon()}
<UnplugIcon class="size-5" aria-hidden="true" /> <ShieldAlertIcon class="size-5" aria-hidden="true" />
{/snippet} {/snippet}
{summary?.connections_bad_total ?? '—'} {summary ? String(badTotal) : '—'}
</KpiStatCard> </KpiStatCard>
<KpiStatCard accent="success" label="Пользователей в конфиге" footer="configured_users" valueClass="text-xl"> <KpiStatCard
accent="warning"
label="Таймауты рукопожатия"
valueClass="text-xl"
>
{#snippet icon()}
<TimerOffIcon class="size-5" aria-hidden="true" />
{/snippet}
{summary ? String(handshakeT) : '—'}
</KpiStatCard>
<KpiStatCard
accent="neutral"
label="Пользователей в конфиге"
valueClass="text-xl"
>
{#snippet icon()} {#snippet icon()}
<UsersIcon class="size-5" aria-hidden="true" /> <UsersIcon class="size-5" aria-hidden="true" />
{/snippet} {/snippet}
{summary?.configured_users ?? '—'} {summary ? String(cfgUsers) : '—'}
</KpiStatCard> </KpiStatCard>
</div> </div>
{#if sys} {#if sys}
<KpiPanelCard accent="neutral" title="Система" contentClass="grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-3"> <KpiPanelCard
accent="neutral"
title="Система"
description="Информация о версии, платформе и конфигурации ноды"
contentClass="grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-3"
>
{#snippet icon()} {#snippet icon()}
<CpuIcon class="size-5" aria-hidden="true" /> <CpuIcon class="size-5" aria-hidden="true" />
{/snippet} {/snippet}
@@ -1,22 +1,25 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte'; import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import ServerPageHeader from '$lib/components/server-page-header.svelte';
import SettingsIcon from '@lucide/svelte/icons/settings'; import SettingsIcon from '@lucide/svelte/icons/settings';
const alias = $derived(page.params.alias ?? ''); const alias = $derived(page.params.alias ?? '');
</script> </script>
<div class="mb-6"> <ServerPageHeader
<h1 class="mb-2 text-2xl font-semibold tracking-tight">Configuration</h1> title="Конфигурация"
<p class="text-sm text-muted-foreground"> subtitle="Конфигурация этой ноды не отдается через Control API"
Конфигурация Telemt на ноде <span class="font-mono text-foreground">{alias}</span> не доступна через API showRefresh={false}
— правьте <code class="rounded bg-muted px-1">config.toml</code> на сервере. />
</p>
</div>
<KpiPanelCard accent="neutral" title="Конфигурация" description="Настройка на сервере" contentClass="text-sm text-muted-foreground"> <KpiPanelCard accent="neutral" title="Конфигурация" description="Настройка на сервере" contentClass="text-sm text-muted-foreground">
{#snippet icon()} {#snippet icon()}
<SettingsIcon class="size-5" aria-hidden="true" /> <SettingsIcon class="size-5" aria-hidden="true" />
{/snippet} {/snippet}
<p class="mb-2">
Конфигурация Telemt на ноде <span class="font-mono text-foreground">{alias}</span> не доступна через API.
Правьте <code class="rounded bg-muted px-1">config.toml</code> на сервере.
</p>
См. <a См. <a
class="text-primary underline" class="text-primary underline"
href="https://github.com/telemt/telemt/blob/main/docs/API.md" href="https://github.com/telemt/telemt/blob/main/docs/API.md"
+390 -296
View File
@@ -2,30 +2,26 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { import { ApiError, fetchMihomoJson, fetchMihomoMeta, mihomoUrl, mihomoWsUrl } from '$lib/api/client.js';
ApiError, import type { MihomoConnectionsResponse, MihomoMetaResponse } from '$lib/api/mihomo-types.js';
fetchMihomoJson,
fetchMihomoMeta,
mihomoPut,
mihomoUrl
} from '$lib/api/client.js';
import type {
MihomoConnectionsResponse,
MihomoMetaResponse,
MihomoProxiesResponse,
MihomoProxyEntry
} from '$lib/api/mihomo-types.js';
import { formatBytes, formatRatePerSec } from '$lib/format.js'; import { formatBytes, formatRatePerSec } from '$lib/format.js';
import * as Card from '$lib/components/ui/card/index.js'; import * as Card from '$lib/components/ui/card/index.js';
import { Button } from '$lib/components/ui/button/index.js'; import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
import { Badge } from '$lib/components/ui/badge/index.js'; import ArrowBigUpIcon from '@lucide/svelte/icons/arrow-big-up';
import ArrowBigDownIcon from '@lucide/svelte/icons/arrow-big-down';
import CloudUploadIcon from '@lucide/svelte/icons/cloud-upload';
import CloudDownloadIcon from '@lucide/svelte/icons/cloud-download';
import PlugIcon from '@lucide/svelte/icons/plug';
import MemoryStickIcon from '@lucide/svelte/icons/memory-stick';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import DataQueryState from '$lib/components/fleet/data-query-state.svelte'; import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
import MihomoOverviewCharts from '$lib/components/mihomo/mihomo-overview-charts.svelte'; import MihomoOverviewCharts from '$lib/components/mihomo/mihomo-overview-charts.svelte';
import MihomoProxyGroups from '$lib/components/mihomo/mihomo-proxy-groups.svelte';
import { Skeleton } from '$lib/components/ui/skeleton/index.js'; import { Skeleton } from '$lib/components/ui/skeleton/index.js';
const alias = $derived(page.params.alias ?? ''); const alias = $derived(page.params.alias ?? '');
let tab = $state<'overview' | 'proxies'>('overview'); let tab = $state<string>('overview');
let loadingMeta = $state(true); let loadingMeta = $state(true);
let metaErr = $state<string | null>(null); let metaErr = $state<string | null>(null);
let meta = $state<MihomoMetaResponse | null>(null); let meta = $state<MihomoMetaResponse | null>(null);
@@ -34,7 +30,8 @@
let downBps = $state(0); let downBps = $state(0);
let totalUp = $state(0); let totalUp = $state(0);
let totalDown = $state(0); let totalDown = $state(0);
let memKB = $state(0); /** Память в KiB (кибибайты): из WS Mihomo приходит в байтах — нормализуем в одном месте. */
let memKiB = $state(0);
let connTotal = $state(0); let connTotal = $state(0);
let tcpN = $state(0); let tcpN = $state(0);
let udpN = $state(0); let udpN = $state(0);
@@ -46,16 +43,15 @@
let topList = $state<{ name: string; n: number }[]>([]); let topList = $state<{ name: string; n: number }[]>([]);
const MAX_POINTS = 72; const MAX_POINTS = 72;
/** Mihomo WS: `inuse` (/memory) и `memory` (/connections) — в байтах → KiB для карточки и графика. */
function mihomoWsMemoryToKiB(bytes: number): number {
if (!Number.isFinite(bytes) || bytes <= 0) return 0;
return bytes / 1024;
}
let lastTick = $state<number | null>(null); let lastTick = $state<number | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null; let pollTimer: ReturnType<typeof setInterval> | null = null;
let proxiesLoading = $state(false);
let proxiesErr = $state<string | null>(null);
let proxiesData = $state<MihomoProxiesResponse | null>(null);
let switching = $state<string | null>(null);
let testing = $state<string | null>(null);
let testingGroup = $state<string | null>(null);
/** Mihomo отдаёт upTotal/downTotal в потоке /traffic — используем их; иначе накапливаем из up/down. */ /** Mihomo отдаёт upTotal/downTotal в потоке /traffic — используем их; иначе накапливаем из up/down. */
function recordRates( function recordRates(
now: number, now: number,
@@ -97,28 +93,48 @@
} }
} }
function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(new DOMException('aborted', 'AbortError'));
return;
}
const t = setTimeout(() => {
signal.removeEventListener('abort', onAbort);
resolve();
}, ms);
function onAbort() {
clearTimeout(t);
reject(new DOMException('aborted', 'AbortError'));
}
signal.addEventListener('abort', onAbort);
});
}
/** /**
* Потоковый GET /traffic и /memory (как в hub/route/server.go Mihomo: без Upgrade — chunked JSON/s). * Короткий GET /traffic: читаем первую JSON-строку и закрываем соединение.
* Работает через тот же HTTP-прокси, что и /connections (Authorization на шлюзе). * Долгий chunked-стрим часто не виден в Network за прокси; такие запросы повторяются и стабильнее.
* WebSocket с браузера часто рвётся за nginx или без заголовков — метрики остаются нулями.
*/ */
async function pumpMihomoTrafficStream(a: string, signal: AbortSignal) { async function readOneTrafficSample(a: string, signal: AbortSignal) {
let res: Response;
try { try {
const res = await fetch(mihomoUrl(a, 'traffic'), { signal }); res = await fetch(mihomoUrl(a, 'traffic'), { signal, cache: 'no-store' });
} catch {
return;
}
if (!res.ok || !res.body) return; if (!res.ok || !res.body) return;
const reader = res.body.getReader(); const reader = res.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buf = ''; let buf = '';
try {
while (!signal.aborted) { while (!signal.aborted) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
buf += decoder.decode(value, { stream: true }); buf += decoder.decode(value, { stream: true });
for (;;) {
const nl = buf.indexOf('\n'); const nl = buf.indexOf('\n');
if (nl < 0) break; if (nl < 0) continue;
const line = buf.slice(0, nl).trim(); const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1); if (line) {
if (!line) continue;
try { try {
const o = JSON.parse(line) as { const o = JSON.parse(line) as {
up?: number; up?: number;
@@ -137,51 +153,211 @@
/* ignore line */ /* ignore line */
} }
} }
break;
} }
} catch { } finally {
/* aborted or network */
}
}
async function pumpMihomoMemoryStream(a: string, signal: AbortSignal) {
try { try {
const res = await fetch(mihomoUrl(a, 'memory'), { signal }); await reader.cancel();
if (!res.ok || !res.body) return;
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
for (;;) {
const nl = buf.indexOf('\n');
if (nl < 0) break;
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
try {
const o = JSON.parse(line) as { inuse?: number };
memKB = Number(o.inuse) || 0;
histMem = [...histMem, memKB];
while (histMem.length > MAX_POINTS) histMem.shift();
} catch { } catch {
/* ignore */ /* ignore */
} }
} }
} }
async function readOneMemorySample(a: string, signal: AbortSignal) {
let res: Response;
try {
res = await fetch(mihomoUrl(a, 'memory'), { signal, cache: 'no-store' });
} catch { } catch {
/* aborted or network */ return;
}
if (!res.ok || !res.body) return;
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
let seen = 0;
try {
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const nl = buf.indexOf('\n');
if (nl < 0) continue;
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (line) {
try {
const o = JSON.parse(line) as { inuse?: number };
memKiB = mihomoWsMemoryToKiB(Number(o.inuse) || 0);
histMem = [...histMem, memKiB];
while (histMem.length > MAX_POINTS) histMem.shift();
seen++;
} catch {
/* ignore */
}
}
// В Mihomo для /memory первая строка нередко 0 после нового подключения.
// Берём хотя бы две строки в рамках одного запроса, затем завершаем.
if (seen >= 2) break;
}
} finally {
try {
await reader.cancel();
} catch {
/* ignore */
}
} }
} }
async function runTrafficMemoryStreams(a: string, signal: AbortSignal) { async function pollTrafficLoop(a: string, signal: AbortSignal) {
while (!signal.aborted) { while (!signal.aborted) {
await Promise.all([pumpMihomoTrafficStream(a, signal), pumpMihomoMemoryStream(a, signal)]); await readOneTrafficSample(a, signal);
if (signal.aborted) break; try {
await new Promise((r) => setTimeout(r, 3000)); await sleep(1250, signal);
} catch {
break;
} }
} }
}
async function pollMemoryLoop(a: string, signal: AbortSignal) {
while (!signal.aborted) {
await readOneMemorySample(a, signal);
try {
await sleep(1250, signal);
} catch {
break;
}
}
}
function runTrafficMemoryPollers(a: string, signal: AbortSignal) {
void Promise.all([pollTrafficLoop(a, signal), pollMemoryLoop(a, signal)]);
}
/**
* Нативный WebSocket к шлюзу → Mihomo (раз в сек JSON). Переподключение при обрыве.
* Параллельно остаётся HTTP-опрос выше — что заработает в сети, то и заполнит метрики.
*/
async function trafficWsLoop(a: string, signal: AbortSignal) {
while (!signal.aborted) {
await new Promise<void>((resolve) => {
let settled = false;
const done = () => {
if (settled) return;
settled = true;
resolve();
};
let ws: WebSocket;
try {
ws = new WebSocket(mihomoWsUrl(a, 'traffic'));
} catch {
done();
return;
}
const onAbort = () => {
try {
ws.close();
} catch {
/* ignore */
}
done();
};
signal.addEventListener('abort', onAbort, { once: true });
ws.onmessage = (ev) => {
try {
const o = JSON.parse(String(ev.data)) as {
up?: number;
down?: number;
upTotal?: number;
downTotal?: number;
};
const up = Number(o.up) || 0;
const down = Number(o.down) || 0;
const totals =
typeof o.upTotal === 'number' && typeof o.downTotal === 'number'
? { upTotal: o.upTotal, downTotal: o.downTotal }
: undefined;
recordRates(performance.now(), up, down, totals);
} catch {
/* ignore frame */
}
};
ws.onerror = () => {
try {
ws.close();
} catch {
/* ignore */
}
};
ws.onclose = () => {
signal.removeEventListener('abort', onAbort);
done();
};
});
if (signal.aborted) break;
await new Promise((r) => setTimeout(r, 1500));
}
}
async function memoryWsLoop(a: string, signal: AbortSignal) {
while (!signal.aborted) {
await new Promise<void>((resolve) => {
let settled = false;
const done = () => {
if (settled) return;
settled = true;
resolve();
};
let ws: WebSocket;
try {
ws = new WebSocket(mihomoWsUrl(a, 'memory'));
} catch {
done();
return;
}
const onAbort = () => {
try {
ws.close();
} catch {
/* ignore */
}
done();
};
signal.addEventListener('abort', onAbort, { once: true });
ws.onmessage = (ev) => {
try {
const o = JSON.parse(String(ev.data)) as { inuse?: number };
memKiB = mihomoWsMemoryToKiB(Number(o.inuse) || 0);
histMem = [...histMem, memKiB];
while (histMem.length > MAX_POINTS) histMem.shift();
} catch {
/* ignore */
}
};
ws.onerror = () => {
try {
ws.close();
} catch {
/* ignore */
}
};
ws.onclose = () => {
signal.removeEventListener('abort', onAbort);
done();
};
});
if (signal.aborted) break;
await new Promise((r) => setTimeout(r, 1500));
}
}
function runTrafficMemoryRealtime(a: string, signal: AbortSignal) {
// Только нативный WS: без HTTP-обхода, чтобы сразу видеть проблемы апгрейда.
void trafficWsLoop(a, signal);
void memoryWsLoop(a, signal);
void connectionsWsLoop(a, signal);
}
function topProxyCounts(conns: MihomoConnectionsResponse['connections']): { name: string; n: number }[] { function topProxyCounts(conns: MihomoConnectionsResponse['connections']): { name: string; n: number }[] {
const m = new Map<string, number>(); const m = new Map<string, number>();
@@ -197,11 +373,19 @@
.slice(0, 8); .slice(0, 8);
} }
async function pollConnections(a: string) { /** Снимок как у WebSocket /connections у Mihomo (downloadTotal, uploadTotal, memory, connections). */
try { function applyConnectionsSnapshot(j: MihomoConnectionsResponse) {
const j = await fetchMihomoJson<MihomoConnectionsResponse>(a, 'connections');
const list = j.connections ?? []; const list = j.connections ?? [];
connTotal = typeof j.total === 'number' ? j.total : list.length; connTotal = typeof j.total === 'number' ? j.total : list.length;
if (typeof j.uploadTotal === 'number' && typeof j.downloadTotal === 'number') {
totalUp = j.uploadTotal;
totalDown = j.downloadTotal;
}
if (typeof j.memory === 'number') {
memKiB = mihomoWsMemoryToKiB(j.memory);
histMem = [...histMem, memKiB];
while (histMem.length > MAX_POINTS) histMem.shift();
}
let t = 0, let t = 0,
u = 0; u = 0;
for (const c of list) { for (const c of list) {
@@ -214,11 +398,71 @@
histConn = [...histConn, connTotal]; histConn = [...histConn, connTotal];
while (histConn.length > MAX_POINTS) histConn.shift(); while (histConn.length > MAX_POINTS) histConn.shift();
topList = topProxyCounts(list); topList = topProxyCounts(list);
}
async function pollConnections(a: string) {
try {
const j = await fetchMihomoJson<MihomoConnectionsResponse>(a, 'connections');
applyConnectionsSnapshot(j);
} catch { } catch {
/* ignore */ /* ignore */
} }
} }
/**
* WebSocket /connections: один JSON на кадр с полным снимком (как wss://…/connections?token=… у Mihomo).
* Шлюз сам подставляет token на upstream; браузер без секрета.
*/
async function connectionsWsLoop(a: string, signal: AbortSignal) {
while (!signal.aborted) {
await new Promise<void>((resolve) => {
let settled = false;
const done = () => {
if (settled) return;
settled = true;
resolve();
};
let ws: WebSocket;
try {
ws = new WebSocket(mihomoWsUrl(a, 'connections'));
} catch {
done();
return;
}
const onAbort = () => {
try {
ws.close();
} catch {
/* ignore */
}
done();
};
signal.addEventListener('abort', onAbort, { once: true });
ws.onmessage = (ev) => {
try {
const j = JSON.parse(String(ev.data)) as MihomoConnectionsResponse;
applyConnectionsSnapshot(j);
} catch {
/* ignore frame */
}
};
ws.onerror = () => {
try {
ws.close();
} catch {
/* ignore */
}
};
ws.onclose = () => {
signal.removeEventListener('abort', onAbort);
done();
};
});
if (signal.aborted) break;
await new Promise((r) => setTimeout(r, 1500));
}
}
$effect(() => { $effect(() => {
const a = alias; const a = alias;
if (!a || !browser) return; if (!a || !browser) return;
@@ -227,15 +471,16 @@
totalDown = 0; totalDown = 0;
upBps = 0; upBps = 0;
downBps = 0; downBps = 0;
memKB = 0; memKiB = 0;
histUp = []; histUp = [];
histDown = []; histDown = [];
histMem = []; histMem = [];
void loadMeta(a); void loadMeta(a);
const streamAc = new AbortController(); const streamAc = new AbortController();
void runTrafficMemoryStreams(a, streamAc.signal); runTrafficMemoryRealtime(a, streamAc.signal);
if (pollTimer) clearInterval(pollTimer); if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(() => void pollConnections(a), 2000); // Резервный HTTP, если WS /connections недоступен.
pollTimer = setInterval(() => void pollConnections(a), 15000);
void pollConnections(a); void pollConnections(a);
return () => { return () => {
streamAc.abort(); streamAc.abort();
@@ -246,131 +491,61 @@
}; };
}); });
async function loadProxies() {
const a = alias;
if (!a) return;
proxiesLoading = true;
proxiesErr = null;
try {
proxiesData = await fetchMihomoJson<MihomoProxiesResponse>(a, 'proxies');
} catch (e) {
proxiesData = null;
proxiesErr = e instanceof ApiError ? e.message : String(e);
} finally {
proxiesLoading = false;
}
}
$effect(() => {
const a = alias;
if (!a || tab !== 'proxies') return;
void loadProxies();
});
/** Группы, у которых есть `all` + `now` и PUT /proxies/{name} переключает узел (как в Clash/Mihomo). */
function isSelectableGroup(p: MihomoProxyEntry) {
const t = (p.type ?? '').toLowerCase();
if (t === 'selector' || t === 'urltest' || t === 'fallback') return true;
if (t === 'loadbalance' && Array.isArray(p.all) && p.all.length > 0) return true;
return false;
}
function lastDelay(p: MihomoProxyEntry): number | null {
const h = p.history;
if (!h?.length) return null;
return h[h.length - 1]?.delay ?? null;
}
async function selectProxy(groupName: string, nodeName: string) {
const a = alias;
if (!a) return;
switching = groupName;
try {
await mihomoPut(a, `proxies/${encodeURIComponent(groupName)}`, { name: nodeName });
await loadProxies();
} catch (e) {
proxiesErr = e instanceof ApiError ? e.message : String(e);
} finally {
switching = null;
}
}
async function pingOne(name: string) {
const a = alias;
if (!a) return;
testing = name;
try {
await fetchMihomoJson(a, `proxies/${encodeURIComponent(name)}/delay?timeout=5000`);
await loadProxies();
} catch (e) {
proxiesErr = e instanceof ApiError ? e.message : String(e);
} finally {
testing = null;
}
}
async function pingGroup(groupName: string) {
const a = alias;
if (!a) return;
testingGroup = groupName;
try {
await fetchMihomoJson(a, `group/${encodeURIComponent(groupName)}/delay?timeout=5000`);
await loadProxies();
} catch {
try {
const g = proxiesData?.proxies?.[groupName];
const names = g?.all ?? [];
for (const n of names) {
await fetchMihomoJson(a, `proxies/${encodeURIComponent(n)}/delay?timeout=5000`);
}
await loadProxies();
} catch (e) {
proxiesErr = e instanceof ApiError ? e.message : String(e);
}
} finally {
testingGroup = null;
}
}
onDestroy(() => { onDestroy(() => {
if (pollTimer) clearInterval(pollTimer); if (pollTimer) clearInterval(pollTimer);
}); });
</script> </script>
<div class="mb-6 flex flex-wrap items-center justify-between gap-3"> <div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div> <div>
<h1 class="text-2xl font-semibold tracking-tight">Mihomo</h1> <h1 class="text-2xl font-semibold tracking-tight">Mihomo</h1>
<p class="text-muted-foreground text-sm">Обзор и прокси (external-controller)</p> <p class="text-sm text-muted-foreground">Обзор и прокси (external-controller)</p>
</div>
<div class="flex gap-2">
<Button
variant={tab === 'overview' ? 'default' : 'outline'}
size="sm"
onclick={() => (tab = 'overview')}
>
Обзор
</Button>
<Button
variant={tab === 'proxies' ? 'default' : 'outline'}
size="sm"
onclick={() => (tab = 'proxies')}
>
Прокси
</Button>
</div> </div>
<Tabs.Root bind:value={tab} class="w-fit shrink-0">
<Tabs.List class="min-w-[220px] gap-0.5 p-1">
<Tabs.Trigger value="overview" class="flex-1 px-3">Обзор</Tabs.Trigger>
<Tabs.Trigger value="proxies" class="flex-1 px-3">Прокси</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
</div> </div>
<DataQueryState err={metaErr} showSkeleton={loadingMeta} isEmpty={false}> <DataQueryState err={metaErr} showSkeleton={loadingMeta} isEmpty={false}>
{#snippet skeleton()} {#snippet skeleton()}
<Skeleton class="mb-4 h-6 w-96 max-w-full" /> <Skeleton class="mb-4 h-6 w-96 max-w-full" />
<div class="mb-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6"> <div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{#each Array.from({ length: 6 }) as _, i (i)} {#each Array.from({ length: 6 }) as _, i (i)}
<Skeleton class="h-24 rounded-lg" /> <Card.Root class="border-l-4 border-l-muted-foreground/30 shadow-sm">
<Card.Header class="pb-2">
<div class="flex gap-3">
<Skeleton class="size-10 shrink-0 rounded-lg" />
<div class="min-w-0 flex-1 space-y-2">
<Skeleton class="h-3 w-24" />
<Skeleton class="h-8 w-20" />
</div>
</div>
</Card.Header>
<Card.Content class="pt-0">
<Skeleton class="h-3 w-32" />
</Card.Content>
</Card.Root>
{/each} {/each}
</div> </div>
<div class="grid gap-4 lg:grid-cols-2"> <div class="grid gap-4 lg:grid-cols-2">
{#each Array.from({ length: 6 }) as _, i (i)} {#each Array.from({ length: 6 }) as _, i (i)}
<Skeleton class="h-56 rounded-lg" /> <Card.Root class="border-l-4 border-l-muted-foreground/30 shadow-sm">
<Card.Header class="pb-2">
<div class="flex gap-3">
<Skeleton class="size-10 shrink-0 rounded-lg" />
<div class="min-w-0 flex-1 space-y-2">
<Skeleton class="h-4 w-40" />
<Skeleton class="h-3 w-full max-w-xs" />
</div>
</div>
</Card.Header>
<Card.Content class="h-56 px-4 pb-4 pt-0">
<Skeleton class="h-full w-full rounded-md" />
</Card.Content>
</Card.Root>
{/each} {/each}
</div> </div>
{/snippet} {/snippet}
@@ -381,45 +556,43 @@
</p> </p>
{/if} {/if}
{#if tab === 'overview' && !metaErr} {#if tab === 'overview' && !metaErr}
<div class="mb-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6"> <div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<Card.Root class="border-l-primary border-l-4 py-3"> <KpiStatCard accent="violet" label="Загрузка" footer="мгновенная скорость, /traffic" valueClass="text-xl">
<Card.Header class="pb-1"> {#snippet icon()}
<Card.Description>Загрузка</Card.Description> <ArrowBigUpIcon class="size-5" aria-hidden="true" />
</Card.Header> {/snippet}
<Card.Content class="text-lg font-semibold">{formatRatePerSec(upBps)}</Card.Content> {formatRatePerSec(upBps)}
</Card.Root> </KpiStatCard>
<Card.Root class="border-l-sky-500 border-l-4 py-3"> <KpiStatCard accent="info" label="Скачивание" footer="мгновенная скорость, /traffic" valueClass="text-xl">
<Card.Header class="pb-1"> {#snippet icon()}
<Card.Description>Скачивание</Card.Description> <ArrowBigDownIcon class="size-5" aria-hidden="true" />
</Card.Header> {/snippet}
<Card.Content class="text-lg font-semibold">{formatRatePerSec(downBps)}</Card.Content> {formatRatePerSec(downBps)}
</Card.Root> </KpiStatCard>
<Card.Root class="border-l-amber-500 border-l-4 py-3"> <KpiStatCard accent="warning" label="Всего загружено" footer="uploadTotal (снимок)" valueClass="text-xl">
<Card.Header class="pb-1"> {#snippet icon()}
<Card.Description>Всего загружено</Card.Description> <CloudUploadIcon class="size-5" aria-hidden="true" />
</Card.Header> {/snippet}
<Card.Content class="text-lg font-semibold">{formatBytes(totalUp)}</Card.Content> {formatBytes(totalUp)}
</Card.Root> </KpiStatCard>
<Card.Root class="border-l-cyan-500 border-l-4 py-3"> <KpiStatCard accent="teal" label="Всего скачано" footer="downloadTotal (снимок)" valueClass="text-xl">
<Card.Header class="pb-1"> {#snippet icon()}
<Card.Description>Всего скачано</Card.Description> <CloudDownloadIcon class="size-5" aria-hidden="true" />
</Card.Header> {/snippet}
<Card.Content class="text-lg font-semibold">{formatBytes(totalDown)}</Card.Content> {formatBytes(totalDown)}
</Card.Root> </KpiStatCard>
<Card.Root class="border-l-emerald-500 border-l-4 py-3"> <KpiStatCard accent="info" label="Активные соединения" footer="список /connections" valueClass="text-xl">
<Card.Header class="pb-1"> {#snippet icon()}
<Card.Description>Активные соединения</Card.Description> <PlugIcon class="size-5" aria-hidden="true" />
</Card.Header> {/snippet}
<Card.Content class="text-lg font-semibold">{connTotal}</Card.Content> {connTotal}
</Card.Root> </KpiStatCard>
<Card.Root class="border-l-violet-500 border-l-4 py-3"> <KpiStatCard accent="neutral" label="Память" footer="/memory или поле memory в WS" valueClass="text-xl">
<Card.Header class="pb-1"> {#snippet icon()}
<Card.Description>Память</Card.Description> <MemoryStickIcon class="size-5" aria-hidden="true" />
</Card.Header> {/snippet}
<Card.Content class="text-lg font-semibold"> {memKiB > 0 ? `${(memKiB / 1024).toFixed(1)} MiB` : '—'}
{memKB > 0 ? `${(memKB / 1024).toFixed(1)} MiB` : '—'} </KpiStatCard>
</Card.Content>
</Card.Root>
</div> </div>
<MihomoOverviewCharts <MihomoOverviewCharts
histUp={histUp} histUp={histUp}
@@ -437,84 +610,5 @@
</DataQueryState> </DataQueryState>
{#if tab === 'proxies'} {#if tab === 'proxies'}
<div class="mb-4 flex justify-end"> <MihomoProxyGroups alias={alias} />
<Button variant="outline" size="sm" onclick={() => loadProxies()} disabled={proxiesLoading}>
Обновить
</Button>
</div>
<DataQueryState err={proxiesErr} showSkeleton={proxiesLoading} isEmpty={false}>
{#snippet skeleton()}
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each Array.from({ length: 6 }) as _, i (i)}
<Skeleton class="h-32 rounded-xl" />
{/each}
</div>
{/snippet}
{#snippet children()}
{#if proxiesData?.proxies}
<div class="flex flex-col gap-8">
{#each Object.entries(proxiesData.proxies).filter(([_, v]) => isSelectableGroup(v)) as [gName, group] (gName)}
<div class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 class="text-lg font-semibold">{gName}</h2>
<p class="text-muted-foreground text-sm">
Сейчас: <span class="text-foreground">{group.now ?? '—'}</span>
</p>
</div>
<Button
variant="secondary"
size="sm"
onclick={() => pingGroup(gName)}
disabled={testingGroup === gName}
>
{testingGroup === gName ? 'Проверка…' : 'Проверить все'}
</Button>
</div>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each group.all ?? [] as nodeName (nodeName)}
{@const node = proxiesData.proxies?.[nodeName]}
{@const d = node ? lastDelay(node) : null}
{@const active = group.now === nodeName}
<div
class="bg-card text-card-foreground rounded-xl border p-4 transition-shadow {active
? 'ring-primary ring-2'
: ''}"
>
<button
type="button"
class="w-full text-left"
onclick={() => selectProxy(gName, nodeName)}
disabled={switching === gName}
>
<div class="flex items-start justify-between gap-2">
<span class="font-medium">{nodeName}</span>
<Badge variant="outline">{node?.type ?? '—'}</Badge>
</div>
<div
class="mt-2 text-sm {d != null && d < 500 ? 'text-emerald-400' : 'text-muted-foreground'}"
>
{d != null ? `${d} ms` : '—'}
</div>
</button>
<div class="mt-2 flex justify-end">
<Button
variant="ghost"
size="sm"
class="h-7 px-2"
onclick={() => pingOne(nodeName)}
disabled={testing === nodeName}
>
Ping
</Button>
</div>
</div>
{/each}
</div>
</div>
{/each}
</div>
{/if}
{/snippet}
</DataQueryState>
{/if} {/if}
@@ -3,13 +3,14 @@
import { ApiError, fetchTelemt } from '$lib/api/client.js'; import { ApiError, fetchTelemt } from '$lib/api/client.js';
import type { DcStatusData, RuntimeGatesData } from '$lib/api/telemt-v1.js'; import type { DcStatusData, RuntimeGatesData } from '$lib/api/telemt-v1.js';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte'; import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import ServerPageHeader from '$lib/components/server-page-header.svelte';
import ServerQueryState from '$lib/components/server-query-state.svelte';
import ServerJsonCard from '$lib/components/server-json-card.svelte';
import BuildingIcon from '@lucide/svelte/icons/building-2'; import BuildingIcon from '@lucide/svelte/icons/building-2';
import BracesIcon from '@lucide/svelte/icons/braces'; import BracesIcon from '@lucide/svelte/icons/braces';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js'; import { Alert, AlertDescription } from '$lib/components/ui/alert/index.js';
import { Badge } from '$lib/components/ui/badge/index.js'; import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Table from '$lib/components/ui/table/index.js'; import * as Table from '$lib/components/ui/table/index.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
const alias = $derived(page.params.alias ?? ''); const alias = $derived(page.params.alias ?? '');
@@ -56,44 +57,32 @@
} }
</script> </script>
<div class="mb-6 flex items-center justify-between"> <ServerPageHeader title="Состояние" {loading} onRefresh={load} />
<h1 class="text-2xl font-semibold tracking-tight">Runtime</h1>
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить
</Button>
</div>
{#if err} <ServerQueryState {loading} {err} isEmpty={false}>
<Alert variant="destructive"> {#snippet children()}
<AlertTitle>Ошибка</AlertTitle>
<AlertDescription>{err}</AlertDescription>
</Alert>
{:else if loading}
<p class="text-muted-foreground">Загрузка…</p>
{:else}
<div class="mb-4 flex flex-wrap gap-2"> <div class="mb-4 flex flex-wrap gap-2">
{#if gates} {#if gates}
<Badge variant="secondary" class="px-3 py-1">accepting: {onOff(gates.accepting_new_connections)}</Badge> <Badge variant="secondary" class="px-3 py-1">Приём новых: {onOff(gates.accepting_new_connections)}</Badge>
<Badge variant="secondary" class="px-3 py-1">conditional cast: {onOff(gates.conditional_cast_enabled)}</Badge> <Badge variant="secondary" class="px-3 py-1">Условный cast: {onOff(gates.conditional_cast_enabled)}</Badge>
<Badge variant="secondary" class="px-3 py-1">me2dc fallback: {onOff(gates.me2dc_fallback_enabled)}</Badge> <Badge variant="secondary" class="px-3 py-1">Резерв me2dc: {onOff(gates.me2dc_fallback_enabled)}</Badge>
<Badge variant="secondary" class="px-3 py-1">ME ready: {onOff(gates.me_runtime_ready)}</Badge> <Badge variant="secondary" class="px-3 py-1">ME готов: {onOff(gates.me_runtime_ready)}</Badge>
<Badge variant="outline" class="px-3 py-1">middle proxy: {onOff(gates.use_middle_proxy)}</Badge> <Badge variant="outline" class="px-3 py-1">Промежуточный прокси: {onOff(gates.use_middle_proxy)}</Badge>
{/if} {/if}
</div> </div>
{#if dcs && dcs.middle_proxy_enabled && dcs.dcs} {#if dcs && dcs.middle_proxy_enabled && dcs.dcs}
<KpiPanelCard class="mb-6" accent="info" title="Datacenter status" contentClass="p-0"> <KpiPanelCard class="mb-6" accent="info" title="Статус дата-центров" contentClass="p-0">
{#snippet icon()} {#snippet icon()}
<BuildingIcon class="size-5" aria-hidden="true" /> <BuildingIcon class="size-5" aria-hidden="true" />
{/snippet} {/snippet}
<Table.Root> <Table.Root>
<Table.Header> <Table.Header class="sticky top-0 z-10 bg-muted">
<Table.Row> <Table.Row class="border-b border-border/80 hover:bg-transparent">
<Table.Head>DC</Table.Head> <Table.Head>DC</Table.Head>
<Table.Head class="text-right">RTT</Table.Head> <Table.Head class="text-right">RTT</Table.Head>
<Table.Head class="text-right">Writers</Table.Head> <Table.Head class="text-right">Писатели</Table.Head>
<Table.Head class="text-right">Coverage</Table.Head> <Table.Head class="text-right">Покрытие</Table.Head>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
@@ -114,18 +103,14 @@
</KpiPanelCard> </KpiPanelCard>
{:else if dcs?.reason} {:else if dcs?.reason}
<Alert class="mb-4"> <Alert class="mb-4">
<AlertDescription>DC status: {dcs.reason}</AlertDescription> <AlertDescription>Статус ДЦ: {dcs.reason}</AlertDescription>
</Alert> </Alert>
{/if} {/if}
<KpiPanelCard accent="violet" title="ME pool state" description="Сырой JSON" contentClass=""> <ServerJsonCard accent="violet" title="ME pool state" description="Сырой JSON" payload={pool} preClass="max-h-[480px]">
{#snippet icon()} {#snippet iconSnippet()}
<BracesIcon class="size-5" aria-hidden="true" /> <BracesIcon class="size-5" aria-hidden="true" />
{/snippet} {/snippet}
<pre class="max-h-[480px] overflow-auto rounded-md border border-border bg-muted/40 p-3 text-xs">{JSON.stringify( </ServerJsonCard>
pool, {/snippet}
null, </ServerQueryState>
2
)}</pre>
</KpiPanelCard>
{/if}
@@ -1,11 +1,10 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { ApiError, fetchTelemt } from '$lib/api/client.js'; import { ApiError, fetchTelemt } from '$lib/api/client.js';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte'; import ServerPageHeader from '$lib/components/server-page-header.svelte';
import ServerQueryState from '$lib/components/server-query-state.svelte';
import ServerJsonCard from '$lib/components/server-json-card.svelte';
import ShieldIcon from '@lucide/svelte/icons/shield'; import ShieldIcon from '@lucide/svelte/icons/shield';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
const alias = $derived(page.params.alias ?? ''); const alias = $derived(page.params.alias ?? '');
@@ -39,30 +38,16 @@
} }
</script> </script>
<div class="mb-6 flex items-center justify-between"> <ServerPageHeader title="Безопасность" {loading} onRefresh={load} />
<h1 class="text-2xl font-semibold tracking-tight">Security</h1>
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить
</Button>
</div>
{#if err} <ServerQueryState {loading} {err} isEmpty={!data} emptyDescription="Endpoint вернул пустой ответ.">
<Alert variant="destructive"> {#snippet children()}
<AlertTitle>Ошибка</AlertTitle> {#if data}
<AlertDescription>{err}</AlertDescription> <ServerJsonCard accent="danger" title="Профиль безопасности" description="POST /security/posture" payload={data}>
</Alert> {#snippet iconSnippet()}
{:else if loading}
<p class="text-muted-foreground">Загрузка…</p>
{:else if data}
<KpiPanelCard accent="danger" title="Security posture" description="POST /security/posture" contentClass="p-0">
{#snippet icon()}
<ShieldIcon class="size-5" aria-hidden="true" /> <ShieldIcon class="size-5" aria-hidden="true" />
{/snippet} {/snippet}
<pre class="overflow-auto rounded-md border border-border bg-muted/40 p-4 text-xs">{JSON.stringify( </ServerJsonCard>
data,
null,
2
)}</pre>
</KpiPanelCard>
{/if} {/if}
{/snippet}
</ServerQueryState>
@@ -3,10 +3,9 @@
import { ApiError, fetchTelemt } from '$lib/api/client.js'; import { ApiError, fetchTelemt } from '$lib/api/client.js';
import type { SystemInfoData } from '$lib/api/telemt-v1.js'; import type { SystemInfoData } from '$lib/api/telemt-v1.js';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte'; import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import ServerPageHeader from '$lib/components/server-page-header.svelte';
import ServerQueryState from '$lib/components/server-query-state.svelte';
import PackageIcon from '@lucide/svelte/icons/package'; import PackageIcon from '@lucide/svelte/icons/package';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
const alias = $derived(page.params.alias ?? ''); const alias = $derived(page.params.alias ?? '');
@@ -40,22 +39,11 @@
} }
</script> </script>
<div class="mb-6 flex items-center justify-between"> <ServerPageHeader title="Обновление" {loading} onRefresh={load} />
<h1 class="text-2xl font-semibold tracking-tight">Update</h1>
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить
</Button>
</div>
{#if err} <ServerQueryState {loading} {err} isEmpty={!sys} emptyDescription="Информация о бинарнике не получена.">
<Alert variant="destructive"> {#snippet children()}
<AlertTitle>Ошибка</AlertTitle> {#if sys}
<AlertDescription>{err}</AlertDescription>
</Alert>
{:else if loading}
<p class="text-muted-foreground">Загрузка…</p>
{:else if sys}
<KpiPanelCard accent="teal" title="Версия бинарника" description="system/info" contentClass="space-y-2 text-sm"> <KpiPanelCard accent="teal" title="Версия бинарника" description="system/info" contentClass="space-y-2 text-sm">
{#snippet icon()} {#snippet icon()}
<PackageIcon class="size-5" aria-hidden="true" /> <PackageIcon class="size-5" aria-hidden="true" />
@@ -66,3 +54,5 @@
<p><span class="text-muted-foreground">rustc</span> {sys.rustc_version ?? '—'}</p> <p><span class="text-muted-foreground">rustc</span> {sys.rustc_version ?? '—'}</p>
</KpiPanelCard> </KpiPanelCard>
{/if} {/if}
{/snippet}
</ServerQueryState>
@@ -1,11 +1,10 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { ApiError, fetchTelemt } from '$lib/api/client.js'; import { ApiError, fetchTelemt } from '$lib/api/client.js';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte'; import ServerPageHeader from '$lib/components/server-page-header.svelte';
import ServerQueryState from '$lib/components/server-query-state.svelte';
import ServerJsonCard from '$lib/components/server-json-card.svelte';
import Share2Icon from '@lucide/svelte/icons/share-2'; import Share2Icon from '@lucide/svelte/icons/share-2';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
const alias = $derived(page.params.alias ?? ''); const alias = $derived(page.params.alias ?? '');
@@ -39,30 +38,22 @@
} }
</script> </script>
<div class="mb-6 flex items-center justify-between"> <ServerPageHeader title="Апстримы и ДЦ" {loading} onRefresh={load} />
<h1 class="text-2xl font-semibold tracking-tight">Upstreams &amp; DCs</h1>
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить
</Button>
</div>
{#if err} <ServerQueryState {loading} {err} isEmpty={!data} emptyDescription="Статистика upstream-ов пока не доступна.">
<Alert variant="destructive"> {#snippet children()}
<AlertTitle>Ошибка</AlertTitle> {#if data}
<AlertDescription>{err}</AlertDescription> <ServerJsonCard
</Alert> accent="info"
{:else if loading} title="Апстримы и ДЦ"
<p class="text-muted-foreground">Загрузка…</p> description="stats/upstreams"
{:else if data} payload={data}
<KpiPanelCard accent="info" title="Upstreams &amp; DCs" description="stats/upstreams" contentClass="p-0"> preClass="max-h-[70vh]"
{#snippet icon()} >
{#snippet iconSnippet()}
<Share2Icon class="size-5" aria-hidden="true" /> <Share2Icon class="size-5" aria-hidden="true" />
{/snippet} {/snippet}
<pre class="max-h-[70vh] overflow-auto rounded-md border border-border bg-muted/40 p-4 text-xs">{JSON.stringify( </ServerJsonCard>
data,
null,
2
)}</pre>
</KpiPanelCard>
{/if} {/if}
{/snippet}
</ServerQueryState>
@@ -199,7 +199,7 @@
</script> </script>
<div class="mb-6 flex flex-wrap items-center justify-between gap-4"> <div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<h1 class="text-2xl font-semibold tracking-tight">Users</h1> <h1 class="text-2xl font-semibold tracking-tight">Пользователи</h1>
<div class="flex gap-2"> <div class="flex gap-2">
<Button onclick={() => (createOpen = true)} disabled={readOnly}> <Button onclick={() => (createOpen = true)} disabled={readOnly}>
<PlusIcon class="mr-1 size-4" /> <PlusIcon class="mr-1 size-4" />
@@ -232,11 +232,11 @@
<UsersIcon class="size-5" aria-hidden="true" /> <UsersIcon class="size-5" aria-hidden="true" />
{/snippet} {/snippet}
<Table.Root> <Table.Root>
<Table.Header> <Table.Header class="sticky top-0 z-10 bg-muted">
<Table.Row> <Table.Row class="border-b border-border/80 hover:bg-transparent">
<Table.Head>Имя</Table.Head> <Table.Head>Имя</Table.Head>
<Table.Head>Ссылки</Table.Head> <Table.Head>Ссылки</Table.Head>
<Table.Head class="text-right">Conn</Table.Head> <Table.Head class="text-right">Соединения</Table.Head>
<Table.Head class="text-right">IP</Table.Head> <Table.Head class="text-right">IP</Table.Head>
<Table.Head class="text-right">Трафик</Table.Head> <Table.Head class="text-right">Трафик</Table.Head>
<Table.Head class="w-[100px]"></Table.Head> <Table.Head class="w-[100px]"></Table.Head>
@@ -248,9 +248,9 @@
<Table.Cell class="font-medium">{u.username}</Table.Cell> <Table.Cell class="font-medium">{u.username}</Table.Cell>
<Table.Cell> <Table.Cell>
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
{#each u.links?.tls ?? [] as link (link)} {#each u.links?.tls ?? [] as link, idx (`${link}:${idx}`)}
<Button variant="outline" size="xs" class="h-7" onclick={() => copy(link)}> <Button variant="outline" size="xs" class="h-7" onclick={() => copy(link)}>
TLS <CopyIcon class="size-3" /> TLS {idx + 1} <CopyIcon class="size-3" />
</Button> </Button>
{/each} {/each}
</div> </div>
@@ -269,6 +269,8 @@
size="icon-sm" size="icon-sm"
disabled={readOnly} disabled={readOnly}
onclick={() => openEdit(u)} onclick={() => openEdit(u)}
aria-label="Редактировать пользователя"
title="Редактировать пользователя"
> >
<PencilIcon class="size-4" /> <PencilIcon class="size-4" />
</Button> </Button>
@@ -277,6 +279,8 @@
size="icon-sm" size="icon-sm"
disabled={readOnly} disabled={readOnly}
onclick={() => (deleteUserRow = u)} onclick={() => (deleteUserRow = u)}
aria-label="Удалить пользователя"
title="Удалить пользователя"
> >
<TrashIcon class="size-4 text-destructive" /> <TrashIcon class="size-4 text-destructive" />
</Button> </Button>
@@ -325,15 +329,15 @@
<Input id="eq" bind:value={editQuota} type="number" min="0" /> <Input id="eq" bind:value={editQuota} type="number" min="0" />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="em">max_unique_ips</Label> <Label for="em">Макс. уникальных IP (max_unique_ips)</Label>
<Input id="em" bind:value={editMaxIp} type="number" min="0" /> <Input id="em" bind:value={editMaxIp} type="number" min="0" />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="et">max_tcp_conns</Label> <Label for="et">Макс. TCP-соединений (max_tcp_conns)</Label>
<Input id="et" bind:value={editMaxTcp} type="number" min="0" /> <Input id="et" bind:value={editMaxTcp} type="number" min="0" />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="ex">expiry (RFC3339)</Label> <Label for="ex">Срок действия (RFC3339)</Label>
<Input id="ex" bind:value={editExp} class="font-mono text-sm" /> <Input id="ex" bind:value={editExp} class="font-mono text-sm" />
</div> </div>
{#if formErr} {#if formErr}
+395 -41
View File
@@ -6,10 +6,23 @@
import type { components } from '$lib/api/aggregate.gen.js'; import type { components } from '$lib/api/aggregate.gen.js';
import { formatMiB } from '$lib/format.js'; import { formatMiB } from '$lib/format.js';
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as Table from '$lib/components/ui/table/index.js'; import * as Table from '$lib/components/ui/table/index.js';
import * as Card from '$lib/components/ui/card/index.js'; import * as Card from '$lib/components/ui/card/index.js';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte'; import DataTableCard from '$lib/components/data-table/data-table-card.svelte';
import UsersIcon from '@lucide/svelte/icons/users'; import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
import SortableTh from '$lib/components/data-table/sortable-th.svelte';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { cn } from '$lib/utils.js';
import Columns3Icon from '@lucide/svelte/icons/columns-3';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import MoreVerticalIcon from '@lucide/svelte/icons/more-vertical';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import SearchIcon from '@lucide/svelte/icons/search';
import { nextSortDir, compareStrings, compareNum, type SortDir } from '$lib/table-sort.js';
import { Skeleton } from '$lib/components/ui/skeleton/index.js'; import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import { Switch } from '$lib/components/ui/switch/index.js'; import { Switch } from '$lib/components/ui/switch/index.js';
import { Label } from '$lib/components/ui/label/index.js'; import { Label } from '$lib/components/ui/label/index.js';
@@ -19,6 +32,11 @@
import FleetRefreshInput from '$lib/components/fleet/fleet-refresh-input.svelte'; import FleetRefreshInput from '$lib/components/fleet/fleet-refresh-input.svelte';
import CopyIcon from '@lucide/svelte/icons/copy'; import CopyIcon from '@lucide/svelte/icons/copy';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'; import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import PlusIcon from '@lucide/svelte/icons/plus';
import UsersIcon from '@lucide/svelte/icons/users';
import ActivityIcon from '@lucide/svelte/icons/activity';
import GlobeIcon from '@lucide/svelte/icons/globe';
import CloudUploadIcon from '@lucide/svelte/icons/cloud-upload';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
let loading = $state(true); let loading = $state(true);
@@ -99,9 +117,10 @@
}); });
} }
async function applyAliasIfChanged(nextAlias: string) { async function handleAliasFilterChange(nextAlias: string) {
const currentAlias = parseAliasFilter(page.url.searchParams.get('aliases')); aliasFilter = nextAlias;
if (nextAlias === currentAlias) return; const currentUrlAlias = parseAliasFilter(page.url.searchParams.get('aliases'));
if (nextAlias === currentUrlAlias) return;
const q = new URLSearchParams(page.url.searchParams); const q = new URLSearchParams(page.url.searchParams);
if (nextAlias === 'all') q.delete('aliases'); if (nextAlias === 'all') q.delete('aliases');
else q.set('aliases', nextAlias); else q.set('aliases', nextAlias);
@@ -111,6 +130,7 @@
noScroll: true, noScroll: true,
keepFocus: true keepFocus: true
}); });
await load();
} }
let queryKey = $derived(page.url.searchParams.toString()); let queryKey = $derived(page.url.searchParams.toString());
@@ -125,11 +145,6 @@
void load(); void load();
}); });
$effect(() => {
if (typeof window === 'undefined') return;
void applyAliasIfChanged(aliasFilter);
});
let staleSeconds = $derived.by(() => let staleSeconds = $derived.by(() =>
lastSuccessAtMs == null ? null : Math.max(0, Math.floor((nowMs - lastSuccessAtMs) / 1000)) lastSuccessAtMs == null ? null : Math.max(0, Math.floor((nowMs - lastSuccessAtMs) / 1000))
); );
@@ -146,7 +161,6 @@
.filter((a): a is string => typeof a === 'string' && a.length > 0) .filter((a): a is string => typeof a === 'string' && a.length > 0)
.sort((a, b) => a.localeCompare(b)); .sort((a, b) => a.localeCompare(b));
availableAliases = aliases; availableAliases = aliases;
if (aliasFilter !== 'all' && !aliases.includes(aliasFilter)) aliasFilter = 'all';
} catch { } catch {
availableAliases = []; availableAliases = [];
} }
@@ -162,23 +176,108 @@
const usersPerPage = 25; const usersPerPage = 25;
let usersPage = $state(1); let usersPage = $state(1);
let userSearch = $state('');
let sortKey = $state<'name' | 'traffic' | 'ips' | 'exp' | null>(null);
let sortDir = $state<SortDir | null>(null);
let usersViewTab = $state<'all' | 'traffic' | 'active'>('all');
let showColLinks = $state(true);
let showColExpires = $state(true);
let selectedUsernames = $state<string[]>([]);
let hasStaleData = $derived(err != null && rows.length > 0); let hasStaleData = $derived(err != null && rows.length > 0);
let showSkeleton = $derived(loading && rows.length === 0 && !err); let showSkeleton = $derived(loading && rows.length === 0 && !err);
let isDataEmpty = $derived(!loading && rows.length === 0 && !err); let isDataEmpty = $derived(!loading && rows.length === 0 && !err);
let pageCount = $derived(Math.max(1, Math.ceil(rows.length / usersPerPage)));
let countAll = $derived(rows.length);
let countTraffic = $derived(rows.filter((r) => (r.total_megabytes ?? 0) > 0).length);
let countActive = $derived(rows.filter((r) => (r.active_unique_ips ?? 0) > 0).length);
let sumTraffic = $derived.by(() =>
rows.reduce((acc, row) => acc + (row.total_megabytes ?? 0), 0)
);
let sumActiveIps = $derived.by(() =>
rows.reduce((acc, row) => acc + (row.active_unique_ips ?? 0), 0)
);
let tabFilteredRows = $derived.by(() => {
if (usersViewTab === 'traffic') return rows.filter((r) => (r.total_megabytes ?? 0) > 0);
if (usersViewTab === 'active') return rows.filter((r) => (r.active_unique_ips ?? 0) > 0);
return rows;
});
let processedRows = $derived.by(() => {
let r = [...tabFilteredRows];
const q = userSearch.trim().toLowerCase();
if (q) r = r.filter((row) => (row.username ?? '').toLowerCase().includes(q));
const k = sortKey;
const d = sortDir;
if (k && d) {
r.sort((a, b) => {
if (k === 'name') return compareStrings(a.username ?? '', b.username ?? '', d);
if (k === 'traffic')
return compareNum(a.total_megabytes ?? 0, b.total_megabytes ?? 0, d);
if (k === 'ips')
return compareNum(a.active_unique_ips ?? 0, b.active_unique_ips ?? 0, d);
if (k === 'exp') {
const ta = a.expiration_rfc3339 ? new Date(a.expiration_rfc3339).getTime() : 0;
const tb = b.expiration_rfc3339 ? new Date(b.expiration_rfc3339).getTime() : 0;
return compareNum(ta, tb, d);
}
return 0;
});
}
return r;
});
let pageCount = $derived(Math.max(1, Math.ceil(processedRows.length / usersPerPage)));
let pagedRows = $derived.by(() => { let pagedRows = $derived.by(() => {
const start = (usersPage - 1) * usersPerPage; const start = (usersPage - 1) * usersPerPage;
return rows.slice(start, start + usersPerPage); return processedRows.slice(start, start + usersPerPage);
}); });
let pageUsernames = $derived(
pagedRows.map((r) => r.username ?? '').filter((u): u is string => u.length > 0)
);
let selectedOnPage = $derived(pageUsernames.filter((u) => selectedUsernames.includes(u)));
let headerCheckboxChecked = $derived(
pageUsernames.length > 0 && selectedOnPage.length === pageUsernames.length
);
let headerCheckboxIndeterminate = $derived(
selectedOnPage.length > 0 && selectedOnPage.length < pageUsernames.length
);
$effect(() => { $effect(() => {
aliasFilter; aliasFilter;
userSearch;
usersViewTab;
usersPage = 1; usersPage = 1;
}); });
$effect(() => {
usersViewTab;
selectedUsernames = [];
});
$effect(() => {
rows;
const valid = new Set(
rows.map((r) => r.username ?? '').filter((u): u is string => u.length > 0)
);
const next = selectedUsernames.filter((u) => valid.has(u));
const same =
next.length === selectedUsernames.length &&
next.every((u, i) => u === selectedUsernames[i]);
if (!same) selectedUsernames = next;
});
$effect(() => { $effect(() => {
if (usersPage > pageCount) usersPage = pageCount; if (usersPage > pageCount) usersPage = pageCount;
}); });
function toggleSort(key: 'name' | 'traffic' | 'ips' | 'exp') {
const next = nextSortDir(sortDir, key, sortKey);
sortKey = next === null ? null : key;
sortDir = next;
}
function copy(text: string) { function copy(text: string) {
const value = String(text ?? ''); const value = String(text ?? '');
const done = () => toast.success('Скопировано в буфер'); const done = () => toast.success('Скопировано в буфер');
@@ -199,6 +298,31 @@
void goto(`/users/${encodeURIComponent(u)}`); void goto(`/users/${encodeURIComponent(u)}`);
} }
function toggleRowSelect(username: string, checked: boolean) {
if (!username) return;
if (checked) {
if (!selectedUsernames.includes(username)) selectedUsernames = [...selectedUsernames, username];
} else {
selectedUsernames = selectedUsernames.filter((u) => u !== username);
}
}
function toggleSelectAllOnPage(checked: boolean) {
if (checked) {
selectedUsernames = [...new Set([...selectedUsernames, ...pageUsernames])];
} else {
selectedUsernames = selectedUsernames.filter((u) => !pageUsernames.includes(u));
}
}
function openCreateUser() {
if (!aliasFilter || aliasFilter === 'all') {
toast.info('Для создания выберите конкретную ноду в фильтре "Алиас".');
return;
}
void goto(`/servers/${encodeURIComponent(aliasFilter)}/users`);
}
function fallbackCopy(value: string) { function fallbackCopy(value: string) {
if (typeof document === 'undefined') return; if (typeof document === 'undefined') return;
const ta = document.createElement('textarea'); const ta = document.createElement('textarea');
@@ -219,7 +343,7 @@
} }
</script> </script>
<div class="mb-6 flex flex-wrap items-end justify-between gap-4"> <div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div> <div>
<h1 class="text-2xl font-semibold tracking-tight">Пользователи (флот)</h1> <h1 class="text-2xl font-semibold tracking-tight">Пользователи (флот)</h1>
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
@@ -238,6 +362,10 @@
>Ссылки tg://proxy</Label >Ссылки tg://proxy</Label
> >
</div> </div>
<Button variant="default" size="sm" onclick={openCreateUser}>
<PlusIcon class="mr-1 size-4" />
Создать
</Button>
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}> <Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" /> <RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить Обновить
@@ -246,7 +374,11 @@
</div> </div>
<div class="mb-4 flex flex-wrap items-end gap-2"> <div class="mb-4 flex flex-wrap items-end gap-2">
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} /> <AliasFilterSelect
availableAliases={availableAliases}
bind:value={aliasFilter}
onValueChange={(next) => void handleAliasFilterChange(next)}
/>
<FleetRefreshInput bind:value={refreshInput} onValueChange={() => void applyControls()} /> <FleetRefreshInput bind:value={refreshInput} onValueChange={() => void applyControls()} />
</div> </div>
@@ -281,32 +413,172 @@
</Card.Root> </Card.Root>
{/snippet} {/snippet}
{#snippet children()} {#snippet children()}
<KpiPanelCard <div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
accent="info" <KpiStatCard accent="violet" label="Пользователей всего" footer="в текущем фильтре" valueClass="text-xl">
title="Список пользователей"
description="Слияние по имени между серверами; клик по строке — карточка пользователя"
contentClass="p-0"
>
{#snippet icon()} {#snippet icon()}
<UsersIcon class="size-5" aria-hidden="true" /> <UsersIcon class="size-5" aria-hidden="true" />
{/snippet} {/snippet}
{countAll}
</KpiStatCard>
<KpiStatCard accent="success" label="С трафиком" footer="total_megabytes > 0" valueClass="text-xl">
{#snippet icon()}
<ActivityIcon class="size-5" aria-hidden="true" />
{/snippet}
{countTraffic}
</KpiStatCard>
<KpiStatCard accent="info" label="Активных IP (сумма)" footer="active_unique_ips" valueClass="text-xl">
{#snippet icon()}
<GlobeIcon class="size-5" aria-hidden="true" />
{/snippet}
{sumActiveIps}
</KpiStatCard>
<KpiStatCard accent="warning" label="Трафик (MiB)" footer="агрегат по пользователям" valueClass="text-lg">
{#snippet icon()}
<CloudUploadIcon class="size-5" aria-hidden="true" />
{/snippet}
{formatMiB(sumTraffic)}
</KpiStatCard>
</div>
<DataTableCard title="Пользователи" description="Слияние по имени между серверами" accent="violet">
{#snippet icon()}<UsersIcon class="size-5" aria-hidden="true" />{/snippet}
{#snippet toolbar()}
<div class="border-b border-border">
<div class="flex flex-wrap items-center justify-between gap-2 px-3 py-2.5">
<Tabs.Root bind:value={usersViewTab} class="w-full min-w-0 sm:w-auto">
<Tabs.List class="h-auto min-h-8 w-full flex-wrap justify-start gap-1 p-1 sm:w-fit">
<Tabs.Trigger value="all" class="shrink-0 gap-1.5 px-2.5 py-1 text-xs sm:text-sm">
Все
<Badge variant="secondary" class="h-5 min-w-5 justify-center px-1.5 text-[10px] font-semibold tabular-nums sm:text-xs">{countAll}</Badge>
</Tabs.Trigger>
<Tabs.Trigger value="traffic" class="shrink-0 gap-1.5 px-2.5 py-1 text-xs sm:text-sm">
С трафиком
<Badge variant="secondary" class="h-5 min-w-5 justify-center px-1.5 text-[10px] font-semibold tabular-nums sm:text-xs">{countTraffic}</Badge>
</Tabs.Trigger>
<Tabs.Trigger value="active" class="shrink-0 gap-1.5 px-2.5 py-1 text-xs sm:text-sm">
Активные IP
<Badge variant="secondary" class="h-5 min-w-5 justify-center px-1.5 text-[10px] font-semibold tabular-nums sm:text-xs">{countActive}</Badge>
</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
{#if selectedUsernames.length > 0}
<span class="text-xs text-muted-foreground tabular-nums">
Выбрано: {selectedUsernames.length}
</span>
{/if}
</div>
<div
class="flex flex-wrap items-center justify-between gap-2 border-t border-border/60 px-3 py-2"
>
<div class="relative max-w-sm min-w-[200px] flex-1">
<SearchIcon
class="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input class="h-9 ps-9" placeholder="Поиск по имени…" bind:value={userSearch} />
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" size="sm" class="h-9 gap-1.5">
<Columns3Icon class="size-4 opacity-70" />
Столбцы
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-52">
<DropdownMenu.Label class="text-xs">Видимость столбцов</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.CheckboxItem bind:checked={showColLinks}>
Ссылки
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem bind:checked={showColExpires}>
Истекает
</DropdownMenu.CheckboxItem>
</DropdownMenu.Content>
</DropdownMenu.Root>
<Button
variant="outline"
size="sm"
class="h-9"
onclick={() => {
userSearch = '';
sortKey = null;
sortDir = null;
selectedUsernames = [];
}}
>Сброс</Button>
</div>
</div>
</div>
{/snippet}
<div class="flex w-full flex-col">
<ScrollArea
class="h-[min(70vh,560px)] w-full shrink-0"
orientation="both"
>
<Table.Root> <Table.Root>
<Table.Header> <Table.Header class="sticky top-0 z-10 bg-muted">
<Table.Row> <Table.Row class="border-b border-border/80 hover:bg-transparent">
<Table.Head>Имя</Table.Head> <Table.Head class="w-10 px-2">
<Table.Head class="hidden sm:table-cell">Ссылки</Table.Head> {#if pageUsernames.length > 0}
<Table.Head class="text-right">Трафик</Table.Head> <Checkbox
<Table.Head class="text-right">Активных IP</Table.Head> checked={headerCheckboxChecked}
<Table.Head class="hidden sm:table-cell">Истекает</Table.Head> indeterminate={headerCheckboxIndeterminate}
aria-label="Выбрать всех на странице"
onCheckedChange={(v) => toggleSelectAllOnPage(v === true)}
/>
{/if}
</Table.Head>
<SortableTh
active={sortKey === 'name'}
dir={sortDir}
onToggle={() => toggleSort('name')}
class="min-w-[8rem]"
>Имя</SortableTh>
<Table.Head
class={cn(
'px-3 py-2 text-left align-middle text-sm font-medium text-muted-foreground',
!showColLinks && 'hidden',
showColLinks && 'hidden sm:table-cell'
)}
>Ссылки</Table.Head>
<SortableTh
numeric
active={sortKey === 'traffic'}
dir={sortDir}
onToggle={() => toggleSort('traffic')}
class="text-right"
>Трафик</SortableTh>
<SortableTh
numeric
active={sortKey === 'ips'}
dir={sortDir}
onToggle={() => toggleSort('ips')}
class="text-right"
>Активных IP</SortableTh>
<SortableTh
active={sortKey === 'exp'}
dir={sortDir}
onToggle={() => toggleSort('exp')}
class={cn(
!showColExpires && 'hidden',
showColExpires && 'hidden sm:table-cell'
)}
>Истекает</SortableTh>
<Table.Head class="w-12 px-2 text-right">
<span class="sr-only">Действия</span>
</Table.Head>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{#each pagedRows as row (row.username ?? '')} {#each pagedRows as row (row.username ?? '')}
{@const uname = row.username ?? ''}
<Table.Row <Table.Row
class="cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40" class="group cursor-pointer border-b border-border transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
tabindex={0} tabindex={0}
role="button" role="button"
aria-label={`Открыть пользователя ${row.username ?? ''}`} aria-label={`Открыть пользователя ${uname}`}
onclick={() => openUser(row.username)} onclick={() => openUser(row.username)}
onkeydown={(e: KeyboardEvent) => { onkeydown={(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -315,9 +587,25 @@
} }
}} }}
> >
<Table.Cell class="font-medium">{row.username}</Table.Cell> <Table.Cell class="w-10 px-2 align-middle" onclick={(e) => e.stopPropagation()}>
<Table.Cell class="hidden sm:table-cell"> {#if uname}
<div class="flex flex-wrap gap-1"> <Checkbox
checked={selectedUsernames.includes(uname)}
aria-label={`Выбрать ${uname}`}
onCheckedChange={(v) => toggleRowSelect(uname, v === true)}
/>
{/if}
</Table.Cell>
<Table.Cell class="py-3 font-semibold text-foreground">{row.username}</Table.Cell>
<Table.Cell
class={cn(
'py-3 align-middle',
!showColLinks && 'hidden',
showColLinks && 'hidden sm:table-cell'
)}
onclick={(e) => e.stopPropagation()}
>
<div class="flex max-w-[220px] flex-wrap gap-1">
{#each row.links?.tls ?? [] as link (link)} {#each row.links?.tls ?? [] as link (link)}
<Button <Button
variant="outline" variant="outline"
@@ -346,30 +634,95 @@
<CopyIcon class="size-3 opacity-60" /> <CopyIcon class="size-3 opacity-60" />
</Button> </Button>
{/each} {/each}
{#if !(row.links?.tls?.length) && !(row.links?.secure?.length)}
<span class="text-xs text-muted-foreground">—</span>
{/if}
</div> </div>
</Table.Cell> </Table.Cell>
<Table.Cell class="text-right tabular-nums">{formatMiB(row.total_megabytes ?? 0)}</Table.Cell> <Table.Cell class="py-3 text-right align-middle tabular-nums">
<Table.Cell class="text-right text-sm font-mono tabular-nums"> {#if (row.total_megabytes ?? 0) > 0}
<Badge variant="secondary" class="font-mono font-normal tabular-nums">
{formatMiB(row.total_megabytes ?? 0)}
</Badge>
{:else}
<Badge variant="outline" class="font-mono font-normal text-muted-foreground tabular-nums">
{formatMiB(0)}
</Badge>
{/if}
</Table.Cell>
<Table.Cell class="py-3 text-right align-middle">
{#if (row.active_unique_ips ?? 0) > 0}
<Badge variant="secondary" class="font-mono font-normal tabular-nums">
{row.active_unique_ips ?? 0} {row.active_unique_ips ?? 0}
{#if row.max_unique_ips != null} {#if row.max_unique_ips != null}
<span class="text-muted-foreground">/{row.max_unique_ips}</span> <span class="text-muted-foreground">/{row.max_unique_ips}</span>
{/if} {/if}
</Badge>
{:else}
<span class="font-mono text-sm tabular-nums text-muted-foreground">
{row.active_unique_ips ?? 0}
{#if row.max_unique_ips != null}
<span> / {row.max_unique_ips}</span>
{/if}
</span>
{/if}
</Table.Cell> </Table.Cell>
<Table.Cell class="hidden sm:table-cell text-sm text-muted-foreground"> <Table.Cell
class={cn(
'py-3 text-sm text-muted-foreground',
!showColExpires && 'hidden',
showColExpires && 'hidden sm:table-cell'
)}
>
{row.expiration_rfc3339 ? new Date(row.expiration_rfc3339).toLocaleString() : '—'} {row.expiration_rfc3339 ? new Date(row.expiration_rfc3339).toLocaleString() : '—'}
</Table.Cell> </Table.Cell>
<Table.Cell class="w-14 px-1 py-3 align-middle" onclick={(e) => e.stopPropagation()}>
<div class="flex items-center justify-end gap-0.5 pe-0.5">
<ChevronRightIcon
class="pointer-events-none size-4 text-muted-foreground/50 opacity-0 transition-opacity group-hover:opacity-100 max-sm:hidden"
aria-hidden="true"
/>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="ghost"
size="icon"
class="size-8 text-muted-foreground hover:text-foreground"
aria-label="Действия"
>
<MoreVerticalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-48">
<DropdownMenu.Item onclick={() => openUser(row.username)}>
Открыть карточку
</DropdownMenu.Item>
<DropdownMenu.Item
disabled={!uname}
onclick={() => uname && copy(uname)}
>
Копировать имя
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</Table.Cell>
</Table.Row> </Table.Row>
{/each} {/each}
</Table.Body> </Table.Body>
</Table.Root> </Table.Root>
{#if rows.length > usersPerPage} </ScrollArea>
{#if processedRows.length > usersPerPage}
<div <div
class="flex flex-wrap items-center justify-between gap-2 border-t px-4 py-3 text-sm text-muted-foreground" class="relative z-10 flex shrink-0 flex-wrap items-center justify-between gap-2 border-t border-border bg-card px-4 py-3 text-sm text-muted-foreground"
> >
<span> <span>
{rows.length === 0 {processedRows.length === 0
? '0' ? '0'
: `${(usersPage - 1) * usersPerPage + 1}–${Math.min(usersPage * usersPerPage, rows.length)}`} из {rows.length} : `${(usersPage - 1) * usersPerPage + 1}–${Math.min(usersPage * usersPerPage, processedRows.length)}`} из {processedRows.length}
</span> </span>
<div class="flex gap-2"> <div class="flex gap-2">
<Button <Button
@@ -387,6 +740,7 @@
</div> </div>
</div> </div>
{/if} {/if}
</KpiPanelCard> </div>
</DataTableCard>
{/snippet} {/snippet}
</DataQueryState> </DataQueryState>
+2 -2
View File
@@ -205,8 +205,8 @@
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<Table.Root> <Table.Root>
<Table.Header> <Table.Header class="sticky top-0 z-10 bg-muted">
<Table.Row> <Table.Row class="border-b border-border/80 hover:bg-transparent">
<Table.Head class="text-xs sm:text-sm">IP</Table.Head> <Table.Head class="text-xs sm:text-sm">IP</Table.Head>
<Table.Head class="text-xs sm:text-sm"> <Table.Head class="text-xs sm:text-sm">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">