Compare commits

...
77 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
Denozordec 50fe3df7e6 Refactor Mihomo traffic and memory handling to use streaming GET requests
Publish telemt-api gateway Docker image / test (push) Successful in 27s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m6s
- Updated `GATEWAY_RUN.md` to clarify the use of streaming HTTP GET for traffic and memory metrics instead of WebSocket connections, addressing potential issues with WebSocket stability behind nginx.
- Modified the Svelte component to implement streaming GET requests for `/traffic` and `/memory`, improving data retrieval and reducing connection issues.
- Enhanced the `recordRates` function to handle totals from the streaming response, ensuring accurate metric tracking.
2026-03-31 01:26:02 +07:00
Denozordec b1839c5ebe Update Mihomo documentation and proxy handling for improved error debugging
- Enhanced `GATEWAY_RUN.md` with detailed debugging steps for handling **400** errors in Mihomo routes, clarifying the configuration requirements and common pitfalls.
- Updated comments in `internal/proxy/mihomo.go` to reflect changes in error handling and proxy behavior, linking to the new debugging section in the documentation.
- Modified the Svelte component logic to ensure proper filtering of selectable groups, improving the user interface for managing Mihomo proxies.
2026-03-31 01:21:15 +07:00
Denozordec 0aa440477e Refactor Mihomo proxy to support WebSocket upgrades and enhance alias forwarding
Publish telemt-api gateway Docker image / test (push) Successful in 29s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m17s
- Introduced a new function `newAliasForward` to handle alias remapping for users, improving the flexibility of the proxy.
- Updated `NewMihomoForward` to differentiate between HTTP and WebSocket requests, ensuring proper handling of both types.
- Enhanced comments for clarity on the proxy behavior and request handling, improving maintainability.
2026-03-31 01:13:36 +07:00
Denozordec b8bb5bede8 Enhance Mihomo proxy behavior and test coverage
Publish telemt-api gateway Docker image / test (push) Successful in 27s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m17s
- Updated the Mihomo proxy implementation to ensure the client Host header is removed from outgoing requests, preventing strict upstream servers from returning errors.
- Revised the test for Mihomo forwarding to verify that the Host header is correctly set and not forwarded to the upstream server, improving test reliability and coverage.
2026-03-31 01:04:01 +07:00
Denozordec ed785cb8f3 Update Mihomo configuration and documentation for clarity
Publish telemt-api gateway Docker image / test (push) Successful in 28s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m10s
- Revised comments in `config.example.yaml` to enhance understanding of Mihomo integration, including environment variable usage and Docker Compose setup.
- Updated `docker-compose.yml` comments to clarify the relationship between the gateway and Mihomo service.
- Enhanced `GATEWAY_RUN.md` to provide clearer instructions on configuring Mihomo parameters and their usage in the gateway.
2026-03-31 00:54:41 +07:00
Denozordec a24fc90b39 Enhance Mihomo configuration documentation in example YAML
Publish telemt-api gateway Docker image / test (push) Successful in 30s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 41s
- Expanded comments in `config.example.yaml` to provide detailed guidance on setting up the Mihomo external-controller integration.
- Included typical Docker Compose configuration examples and environment variable usage for better clarity on Mihomo setup.
- Clarified the relationship between the gateway and Mihomo service for improved user understanding.
2026-03-31 00:35:54 +07:00
Denozordec 2d7b06260e Implement Mihomo external-controller support in configuration and gateway
Publish telemt-api gateway Docker image / test (push) Successful in 34s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m55s
- Added optional Mihomo configuration fields in `config.compose.yaml` and `config.example.yaml` for enhanced integration with the Mihomo external-controller.
- Updated the `Gateway` to handle Mihomo API requests, including proxying and error handling for Mihomo-specific endpoints.
- Enhanced the documentation in `GATEWAY_RUN.md` to guide users on configuring Mihomo integration.
- Introduced new utility functions in the web client for interacting with Mihomo API endpoints, improving the overall user experience.
- Updated the sidebar in the Svelte components to include a link to the Mihomo section, enhancing navigation.
2026-03-31 00:32:19 +07:00
Denozordec bbd9619290 Refactor Svelte components to utilize KpiStatCard and KpiPanelCard for improved UI consistency
Publish telemt-api gateway Docker image / test (push) Successful in 31s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m52s
- Replaced Card components with KpiStatCard and KpiPanelCard across various routes to enhance the visual presentation of key performance indicators and data summaries.
- Updated the structure of the dashboard and incident pages to streamline the display of critical information, improving user experience and readability.
- Enhanced loading states and skeleton displays for better feedback during data fetching processes.
2026-03-31 00:12:28 +07:00
Denozordec 992eebd842 Refactor IP server presence display and enhance tooltip functionality
Publish telemt-api gateway Docker image / test (push) Successful in 35s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m48s
- Updated the `IpServerPresence` component to improve the display of active and recent servers for IPs, enhancing code clarity.
- Added tooltips to the "Servers" column in various tables, providing users with additional context about the displayed server lists.
- Refactored server-related data handling to ensure consistency and better user experience across multiple routes.
2026-03-31 00:06:32 +07:00
Denozordec 46d080e70a Enhance IP display and server presence in Svelte components
Publish telemt-api gateway Docker image / test (push) Successful in 27s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m36s
- Added `IpServerPresence` component to streamline the display of active and recent servers for IPs, improving code readability and maintainability.
- Introduced tooltips for the "Servers" column in tables to provide additional context on active and recent server lists, enhancing user experience.
- Updated text descriptions for clarity and consistency across the IP and user pages, ensuring better understanding of the displayed data.
2026-03-30 23:58:05 +07:00
Denozordec 141d900594 Update package dependencies and refactor state management 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 2m25s
- Added `mode-watcher` and `svelte-sonner` as dependencies to enhance application functionality.
- Refactored state management in various Svelte components to use `$app/state` instead of `$app/stores`, improving consistency across the application.
- Updated UI components to utilize new features from the added dependencies, enhancing user experience and responsiveness.
- Improved loading states and error handling in data-fetching components, ensuring better feedback during asynchronous operations.
2026-03-30 23:27:59 +07:00
Denozordec ce2a7ed43e Refactor alias selection logic in AliasFilterSelect component
Publish telemt-api gateway Docker image / test (push) Successful in 26s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m11s
- Simplified the internal state management for alias selection, ensuring the selected value is consistent with available aliases.
- Updated the effect handling to directly modify the `value` prop based on alias availability, improving responsiveness.
- Streamlined the binding in the Select component to enhance the alias selection process.
2026-03-30 23:08:37 +07:00
153 changed files with 9278 additions and 924 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/…`, агрегация нескольких инстансов — [`/api/agg/…`](docs/AGGREGATE.md), live SSE поток — `/api/live/events`, метрики Prometheus на `/metrics`. **Web UI** (SvelteKit) встроен в тот же процесс/образ: статика на `/`, API на `/api/…` и `/health`. 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)
+2 -1
View File
@@ -1,10 +1,11 @@
# Пример для docker compose: без whitelist (только для локальной проверки). # Пример для docker compose: без whitelist (только для локальной проверки).
# На Linux в docker-compose.yml задано extra_hosts host.docker.internal:host-gateway. # На Linux в docker-compose.yml задано extra_hosts host.docker.internal:host-gateway.
# Без Mihomo: как ниже. С Mihomo в той же сети — см. config.example.yaml и docs/GATEWAY_RUN.md.
listen: ":8080" listen: ":8080"
allow_all: true allow_all: true
whitelist_cidrs: [] whitelist_cidrs: []
trusted_proxies: [] trusted_proxies: []
# UI отдаётся с того же порта, что и API (образ шлюза) — отдельный CORS для панели не нужен. # UI отдаётся с тем же портом, что и API (образ шлюза) — отдельный CORS для панели не нужен.
servers: servers:
- alias: main_srv - alias: main_srv
base_url: http://host.docker.internal:9091 base_url: http://host.docker.internal:9091
+14
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
@@ -52,6 +62,10 @@ servers:
base_url: http://127.0.0.1:9091 base_url: http://127.0.0.1:9091
path_prefix: /v1 path_prefix: /v1
# authorization_env: TELEMT_API_AUTH # authorization_env: TELEMT_API_AUTH
# Mihomo: тот же compose-сервис (container_name: mihomo, сеть proxy-net, external-controller :9090).
# У процесса/контейнера gateway в environment: TELEMT_MIHOMO_AUTH=Bearer ${CLASH_SECRET} (как у mihomo).
mihomo_base_url: http://172.20.0.2:9090
mihomo_authorization_env: Bearer Ifwrbqifwrbq1995
# ivx: HTTPS + nginx location /api/ → Telemt; base_url должен заканчиваться на /api/ # ivx: HTTPS + nginx location /api/ → Telemt; base_url должен заканчиваться на /api/
- alias: gt1 - alias: gt1
+7
View File
@@ -8,6 +8,13 @@ services:
- ./config.compose.yaml:/etc/telemt-gateway/config.yaml:ro - ./config.compose.yaml:/etc/telemt-gateway/config.yaml:ro
environment: environment:
CONFIG_PATH: /etc/telemt-gateway/config.yaml CONFIG_PATH: /etc/telemt-gateway/config.yaml
# Вместе с Mihomo в config.yaml: TELEMT_MIHOMO_AUTH: Bearer ${CLASH_SECRET}
# Доступ к Telemt на хосте Linux (host.docker.internal в config.compose.yaml): # Доступ к Telemt на хосте Linux (host.docker.internal в config.compose.yaml):
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
# С контейнером mihomo в сети proxy-net:
# networks:
# - proxy-net
# networks:
# proxy-net:
# external: 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 шлюза.
+98
View File
@@ -13,6 +13,7 @@
9. [Проверка](#проверка) 9. [Проверка](#проверка)
10. [Обновление и CI/CD](#обновление-и-cicd) 10. [Обновление и CI/CD](#обновление-и-cicd)
11. [Устранение неполадок](#устранение-неполадок) 11. [Устранение неполадок](#устранение-неполадок)
12. [Mihomo (external-controller)](#mihomo-external-controller) — при **400** на REST: [отладка](#mihomo-debug-400)
## Назначение ## Назначение
@@ -77,12 +78,108 @@ servers:
Значение должно **точно** совпадать с настроенным в Telemt `auth_header` (см. [API.md](API.md)). Значение должно **точно** совпадать с настроенным в Telemt `auth_header` (см. [API.md](API.md)).
### Mihomo (external-controller)
Опционально для каждого `servers[]` можно включить проксирование **Mihomo** (Clash Meta) API: в Web UI появится раздел **«Mihomo»** для выбранного alias.
- Клиент (браузер) обращается к шлюзу: `GET /api/{alias}/mihomo/proxies`, WebSocket `…/mihomo/traffic` и т.д.
- Шлюз проксирует на `{mihomo_base_url}/proxies`, `…/traffic` и т.д. с заголовком `Authorization` из переменной окружения (секрет **не** попадает в фронтенд).
- Служебный ответ: `GET /api/{alias}/mihomo/meta` — JSON с полем `controller_base` (без учётных данных) для строки «Подключено к: …» в панели.
В **config.yaml** задаются только параметры **шлюза** (`mihomo_base_url` / `mihomo_base_url_env`, `mihomo_authorization_env`). Сервис контейнера Mihomo (`build`, `CLASH_SECRET`, сеть `proxy-net` и т.д.) описывается в вашем Docker Compose отдельно; связка URL и переменных для gateway — ниже и в [docker-compose.yml](../docker-compose.yml).
Поля в конфиге:
| Поле | Описание |
|------|----------|
| `mihomo_base_url` | Базовый URL контроллера, например `http://mihomo:9090` (порт контроллера по умолчанию в контейнере Mihomo — **9090**). |
| `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` в конфиге ядра). |
У **нескольких** записей `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 при старте шлюза.
- Контейнер **gateway** должен иметь **сетевую связность** с контроллером Mihomo (лучше одна пользовательская Docker-сеть; имя сервиса `http://mihomo:9090` предпочтительнее статического IP). Публиковать порт **9090** на хост не обязательно: браузер ходит в шлюз, шлюз — в контейнер Mihomo по overlay-сети.
- Пример `environment` для compose (секреты не в git — через `.env`):
```yaml
services:
gateway:
environment:
MIHOMO_CONTROLLER_URL: http://mihomo:9090
TELEMT_MIHOMO_AUTH: Bearer ${CLASH_SECRET}
```
и в `config.yaml` для нужного сервера: `mihomo_base_url_env: MIHOMO_CONTROLLER_URL`, `mihomo_authorization_env: TELEMT_MIHOMO_AUTH`.
За **reverse proxy** (nginx) перед панелью убедитесь, что для WebSocket проксируются заголовки `Upgrade` и `Connection`. Панель Mihomo тянет трафик и память **потоковым HTTP** (не WS); при задержке или «залипании» графиков за nginx включите для upstream шлюза `proxy_buffering off` (или эквивалент), чтобы не буферизовать длинный ответ.
<a id="mihomo-debug-400"></a>
#### Отладка 400 на маршрутах Mihomo (`/api/{alias}/mihomo/…`)
Если **напрямую** к контроллеру всё ок (`curl -H "Authorization: Bearer …" http://mihomo:9090/version` → JSON), а **через шлюз** те же пути дают **400** и в логах шлюза `duration_ms` около **1** — проблема в **сборке исходящего HTTP на шлюзе**, а не в секрете Mihomo.
Что зафиксировано в коде и не стоит «упрощать» назад без причины:
| Что | Зачем |
|-----|--------|
| **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 при том, что прямой запрос работает. |
| **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`). |
Проверки конфигурации:
- `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[]`, если такой задан.
## Переменные окружения ## Переменные окружения
| Переменная | Описание | | Переменная | Описание |
|----------------|----------| |----------------|----------|
| `CONFIG_PATH` | Путь к YAML внутри контейнера. По умолчанию: `/etc/telemt-gateway/config.yaml`. | | `CONFIG_PATH` | Путь к YAML внутри контейнера. По умолчанию: `/etc/telemt-gateway/config.yaml`. |
| `TELEMT_API_AUTH` | Пример: секрет для `authorization_env` в конфиге (имя может быть любым). | | `TELEMT_API_AUTH` | Пример: секрет для `authorization_env` в конфиге (имя может быть любым). |
| `MIHOMO_CONTROLLER_URL` | Пример: URL для `mihomo_base_url_env` (если используете в конфиге). |
| `TELEMT_MIHOMO_AUTH` | Пример: `Bearer …` для `mihomo_authorization_env` (если используете). |
## Готовый образ из registry ## Готовый образ из registry
@@ -187,6 +284,7 @@ docker compose down
- **Nginx с `location /api/` и `proxy_pass http://…:9091/;` (со слэшем в конце)** на бэкенд уходит путь **без** префикса `/api/` (например запрос к nginx `GET /api/v1/users` превращается в `GET /v1/users` на Telemt). Шлюз при `base_url: https://gt2.example/api/` должен запрашивать именно **`/api/v1/…`** на стороне nginx. Если в `base_url` нет пути `/api/` (только `https://gt2.example`), шлюз обратится к `https://gt2.example/v1/…` — часто это **не** попадает в `location /api/`, и nginx отдаёт **чужой vhost / заглушку**. Задавайте `base_url` с завершающим слэшем: `https://gt2.example/api/`. - **Nginx с `location /api/` и `proxy_pass http://…:9091/;` (со слэшем в конце)** на бэкенд уходит путь **без** префикса `/api/` (например запрос к nginx `GET /api/v1/users` превращается в `GET /v1/users` на Telemt). Шлюз при `base_url: https://gt2.example/api/` должен запрашивать именно **`/api/v1/…`** на стороне nginx. Если в `base_url` нет пути `/api/` (только `https://gt2.example`), шлюз обратится к `https://gt2.example/v1/…` — часто это **не** попадает в `location /api/`, и nginx отдаёт **чужой vhost / заглушку**. Задавайте `base_url` с завершающим слэшем: `https://gt2.example/api/`.
- **Заголовок `Host`**: шлюз выставляет `Host` равным хосту из `base_url` (как у обычного клиента к этому имени). Если после обновления образа проблема остаётся, с хоста шлюза проверьте: `curl -sv -o /dev/null https://gt2…/api/v1/health` и сравните с запросом через шлюз. - **Заголовок `Host`**: шлюз выставляет `Host` равным хосту из `base_url` (как у обычного клиента к этому имени). Если после обновления образа проблема остаётся, с хоста шлюза проверьте: `curl -sv -o /dev/null https://gt2…/api/v1/health` и сравните с запросом через шлюз.
- **`400` на `/api/{alias}/…` при локальном `base_url` (например `http://172.20.0.3:9091`), хотя `curl` к `:9091/v1/…` даёт `200`**: частая причина — **несовпадение заголовка `Host`**: браузер шлёт `Host: публичное_имя:8888`, а при прямом `curl` к IP в `Host` попадает `172.20.0.3:9091`. Строгий upstream (часто hyper/Rust) отвечает `400`, если `Host` не совпадает с ожидаемым authority. Шлюз при проксировании **не пересылает** клиентский `Host` и выставляет authority из `base_url` (как серверные запросы агрегатора). Убедитесь также, что в YAML **нет пробела/переноса** в конце `base_url` (поля обрезаются `TrimSpace`), и в URL нет лишнего `/api//…` (путь под `/api` нормализуется). - **`400` на `/api/{alias}/…` при локальном `base_url` (например `http://172.20.0.3:9091`), хотя `curl` к `:9091/v1/…` даёт `200`**: частая причина — **несовпадение заголовка `Host`**: браузер шлёт `Host: публичное_имя:8888`, а при прямом `curl` к IP в `Host` попадает `172.20.0.3:9091`. Строгий upstream (часто hyper/Rust) отвечает `400`, если `Host` не совпадает с ожидаемым authority. Шлюз при проксировании **не пересылает** клиентский `Host` и выставляет authority из `base_url` (как серверные запросы агрегатора). Убедитесь также, что в YAML **нет пробела/переноса** в конце `base_url` (поля обрезаются `TrimSpace`), и в URL нет лишнего `/api//…` (путь под `/api` нормализуется).
- **`400` только на `/api/{alias}/mihomo/…`, при этом прямой `curl` к контроллеру Mihomo ок**: см. [Отладка 400 (Mihomo)](#mihomo-debug-400) в разделе [Mihomo](#mihomo-external-controller) — REST к Mihomo идёт через тот же механизм, что и к Telemt; не путать с отдельным WebSocket-прокси.
- **Список пользователей через шлюз**: запрос **`GET` или `HEAD`** на **`/api/{alias}/users`** шлюз перенаправляет на upstream **`GET/HEAD /v1/stats/users`** (как и агрегатор). Так совместимы сборки Telemt, где прямой **`GET /v1/users`** даёт ошибку (например `400`), а **`/v1/stats/users`** работает. **`POST /api/{alias}/users`** (создание) и **`GET /api/{alias}/users/{username}`** по-прежнему идут на **`/v1/users`** и **`/v1/users/{username}`**. Явный путь **`/api/{alias}/stats/users`** не меняется. См. [API.md](API.md). - **Список пользователей через шлюз**: запрос **`GET` или `HEAD`** на **`/api/{alias}/users`** шлюз перенаправляет на upstream **`GET/HEAD /v1/stats/users`** (как и агрегатор). Так совместимы сборки Telemt, где прямой **`GET /v1/users`** даёт ошибку (например `400`), а **`/v1/stats/users`** работает. **`POST /api/{alias}/users`** (создание) и **`GET /api/{alias}/users/{username}`** по-прежнему идут на **`/v1/users`** и **`/v1/users/{username}`**. Явный путь **`/api/{alias}/stats/users`** не меняется. См. [API.md](API.md).
- **`docker pull`: `unauthorized` / `denied`**: выполните `docker login git.shts.su` с учётной записью Gitea и PAT с **`read:package`**. - **`docker pull`: `unauthorized` / `denied`**: выполните `docker login git.shts.su` с учётной записью Gitea и PAT с **`read:package`**.
- **`403 forbidden` с хоста при `allow_all: false`**: добавьте CIDR клиента в `whitelist_cidrs`. Запросы из контейнера к самому себе идут с `127.0.0.1` — при необходимости добавьте `127.0.0.1/32`. - **`403 forbidden` с хоста при `allow_all: false`**: добавьте CIDR клиента в `whitelist_cidrs`. Запросы из контейнера к самому себе идут с `127.0.0.1` — при необходимости добавьте `127.0.0.1/32`.
+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"`
}
+93
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).
@@ -49,6 +62,16 @@ type Server struct {
BaseURL string `yaml:"base_url"` BaseURL string `yaml:"base_url"`
PathPrefix string `yaml:"path_prefix"` PathPrefix string `yaml:"path_prefix"`
AuthorizationEnv string `yaml:"authorization_env"` AuthorizationEnv string `yaml:"authorization_env"`
// Mihomo external-controller (optional): REST + WebSocket at controller root.
MihomoBaseURL string `yaml:"mihomo_base_url"`
MihomoBaseURLEnv string `yaml:"mihomo_base_url_env"`
MihomoAuthorizationEnv string `yaml:"mihomo_authorization_env"`
}
// Mihomo holds resolved external-controller upstream for a server alias.
type Mihomo struct {
Base *url.URL
Auth string // full Authorization header value (e.g. Bearer <secret>)
} }
// Load reads and validates configuration from path. // Load reads and validates configuration from path.
@@ -79,6 +102,9 @@ func (c *Config) Validate() error {
s.BaseURL = strings.TrimSpace(s.BaseURL) s.BaseURL = strings.TrimSpace(s.BaseURL)
s.PathPrefix = strings.TrimSpace(s.PathPrefix) s.PathPrefix = strings.TrimSpace(s.PathPrefix)
s.AuthorizationEnv = strings.TrimSpace(s.AuthorizationEnv) s.AuthorizationEnv = strings.TrimSpace(s.AuthorizationEnv)
s.MihomoBaseURL = strings.TrimSpace(s.MihomoBaseURL)
s.MihomoBaseURLEnv = strings.TrimSpace(s.MihomoBaseURLEnv)
s.MihomoAuthorizationEnv = strings.TrimSpace(s.MihomoAuthorizationEnv)
if s.Alias == "" { if s.Alias == "" {
return fmt.Errorf("servers[%d]: alias is required", i) return fmt.Errorf("servers[%d]: alias is required", i)
} }
@@ -106,6 +132,19 @@ func (c *Config) Validate() error {
if !strings.HasPrefix(s.PathPrefix, "/") { if !strings.HasPrefix(s.PathPrefix, "/") {
s.PathPrefix = "/" + s.PathPrefix s.PathPrefix = "/" + s.PathPrefix
} }
hasMihomoURL := s.MihomoBaseURL != "" || s.MihomoBaseURLEnv != ""
if hasMihomoURL && s.MihomoAuthorizationEnv == "" {
return fmt.Errorf("servers[%d]: mihomo_authorization_env is required when mihomo url is set", i)
}
if !hasMihomoURL && s.MihomoAuthorizationEnv != "" {
return fmt.Errorf("servers[%d]: mihomo_base_url or mihomo_base_url_env is required when mihomo_authorization_env is set", i)
}
if s.MihomoBaseURL != "" {
mu, err := url.Parse(s.MihomoBaseURL)
if err != nil || mu.Scheme == "" || mu.Host == "" {
return fmt.Errorf("servers[%d]: invalid mihomo_base_url %q", i, s.MihomoBaseURL)
}
}
} }
if len(c.Servers) == 0 { if len(c.Servers) == 0 {
return fmt.Errorf("at least one server entry is required") return fmt.Errorf("at least one server entry is required")
@@ -134,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
} }
@@ -160,6 +210,7 @@ type Parsed struct {
Trusted []netip.Prefix Trusted []netip.Prefix
ByAlias map[string]*Server ByAlias map[string]*Server
AuthByAlias map[string]string // non-empty Authorization value per alias AuthByAlias map[string]string // non-empty Authorization value per alias
MihomoByAlias map[string]*Mihomo
} }
// Parse compiles CIDRs and resolves authorization from environment. // Parse compiles CIDRs and resolves authorization from environment.
@@ -182,6 +233,7 @@ func (c *Config) Parse() (*Parsed, error) {
} }
by := make(map[string]*Server, len(c.Servers)) by := make(map[string]*Server, len(c.Servers))
auth := make(map[string]string) auth := make(map[string]string)
mihomo := make(map[string]*Mihomo)
for i := range c.Servers { for i := range c.Servers {
s := &c.Servers[i] s := &c.Servers[i]
by[s.Alias] = s by[s.Alias] = s
@@ -192,6 +244,13 @@ func (c *Config) Parse() (*Parsed, error) {
} }
auth[s.Alias] = v auth[s.Alias] = v
} }
mu, err := resolveMihomo(s)
if err != nil {
return nil, err
}
if mu != nil {
mihomo[s.Alias] = mu
}
} }
return &Parsed{ return &Parsed{
Config: c, Config: c,
@@ -199,5 +258,39 @@ func (c *Config) Parse() (*Parsed, error) {
Trusted: tr, Trusted: tr,
ByAlias: by, ByAlias: by,
AuthByAlias: auth, AuthByAlias: auth,
MihomoByAlias: mihomo,
}, nil }, nil
} }
// resolveMihomo returns non-nil only when Mihomo is configured for this server.
func resolveMihomo(s *Server) (*Mihomo, error) {
hasURL := s.MihomoBaseURL != "" || s.MihomoBaseURLEnv != ""
if !hasURL {
return nil, nil
}
if s.MihomoAuthorizationEnv == "" {
return nil, fmt.Errorf("server %q: mihomo_authorization_env is required when mihomo url is set", s.Alias)
}
var urlStr string
if s.MihomoBaseURLEnv != "" {
v := strings.TrimSpace(os.Getenv(s.MihomoBaseURLEnv))
if v != "" {
urlStr = v
}
}
if urlStr == "" && s.MihomoBaseURL != "" {
urlStr = s.MihomoBaseURL
}
if urlStr == "" {
return nil, fmt.Errorf("server %q: mihomo controller url is empty (set mihomo_base_url or env %q)", s.Alias, s.MihomoBaseURLEnv)
}
u, err := url.Parse(urlStr)
if err != nil || u.Scheme == "" || u.Host == "" {
return nil, fmt.Errorf("server %q: invalid mihomo controller url %q", s.Alias, urlStr)
}
authVal := os.Getenv(s.MihomoAuthorizationEnv)
if strings.TrimSpace(authVal) == "" {
return nil, fmt.Errorf("server %q: env %q is empty or unset", s.Alias, s.MihomoAuthorizationEnv)
}
return &Mihomo{Base: u, Auth: authVal}, 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")
}
}
+13 -1
View File
@@ -17,6 +17,18 @@ func NewAliasForward(
stripPrefix, pathPrefix, auth string, stripPrefix, pathPrefix, auth string,
rt http.RoundTripper, rt http.RoundTripper,
errHandler func(http.ResponseWriter, *http.Request, error), errHandler func(http.ResponseWriter, *http.Request, error),
) http.Handler {
return newAliasForward(target, stripPrefix, pathPrefix, auth, rt, errHandler, true)
}
// newAliasForward is the shared HTTP forwarder. remapUsers maps top-level "users" → "stats/users"
// for Telemt; Mihomo must pass false so /users is not rewritten.
func newAliasForward(
target *url.URL,
stripPrefix, pathPrefix, auth string,
rt http.RoundTripper,
errHandler func(http.ResponseWriter, *http.Request, error),
remapUsers bool,
) http.Handler { ) http.Handler {
if errHandler == nil { if errHandler == nil {
errHandler = defaultForwardErrorHandler errHandler = defaultForwardErrorHandler
@@ -35,7 +47,7 @@ func NewAliasForward(
} }
rest := strings.TrimPrefix(p, stripPrefix) rest := strings.TrimPrefix(p, stripPrefix)
rest = strings.TrimPrefix(rest, "/") rest = strings.TrimPrefix(rest, "/")
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && rest == "users" { if remapUsers && (r.Method == http.MethodGet || r.Method == http.MethodHead) && rest == "users" {
rest = "stats/users" rest = "stats/users"
} }
+214
View File
@@ -0,0 +1,214 @@
package proxy
import (
"crypto/tls"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/gorilla/websocket"
)
// NewMihomoForward proxies /api/{alias}/mihomo/... to Mihomo external-controller.
//
// REST: same path as NewAliasForward (http.NewRequest + RoundTrip).
// WebSocket (traffic, memory, …): gorilla/websocket Dial to upstream, then Upgrader on client, then frame relay.
func NewMihomoForward(
target *url.URL,
stripPrefix string,
auth string,
rt http.RoundTripper,
errHandler func(http.ResponseWriter, *http.Request, error),
) http.Handler {
httpH := newAliasForward(target, stripPrefix, "", auth, rt, errHandler, false)
wsH := newMihomoWSTunnel(target, stripPrefix, auth, errHandler)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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)
return
}
httpH.ServeHTTP(w, r)
})
}
// 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 {
return false
}
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
}
}
// 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,
stripPrefix string,
auth string,
errHandler func(http.ResponseWriter, *http.Request, error),
) http.Handler {
if errHandler == nil {
errHandler = defaultForwardErrorHandler
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
NormalizeRequestURLPath(r)
p := r.URL.Path
if !strings.HasPrefix(p, stripPrefix) {
errHandler(w, r, fmt.Errorf("path %q: missing strip prefix %q", p, stripPrefix))
return
}
rest := strings.TrimPrefix(strings.TrimPrefix(p, stripPrefix), "/")
dest := JoinPathPrefix(target, "/", rest)
du := *dest
du.RawQuery = r.URL.RawQuery
dest = &du
dialHdr := applyMihomoWSDialAuth(rest, dest, auth)
wsURL, err := mihomoWebSocketURL(dest)
if err != nil {
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
}
}
}
// MihomoMetaJSON returns a JSON body for GET .../mihomo/meta (display URL without credentials).
func MihomoMetaJSON(target *url.URL) []byte {
display := strings.TrimSuffix(target.String(), "/")
b, _ := json.Marshal(map[string]any{
"ok": true,
"controller_base": display,
})
return b
}
// MihomoJSONError writes a JSON error for Mihomo routes when proxy is not configured.
func MihomoJSONError(w http.ResponseWriter, code, msg string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": false,
"error": map[string]string{"code": code, "message": msg},
})
}
+151
View File
@@ -0,0 +1,151 @@
package proxy
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"testing"
)
func TestMihomoForwardRewritesPathAndAuth(t *testing.T) {
target, err := url.Parse("http://127.0.0.1:9090")
if err != nil {
t.Fatal(err)
}
cap := &captureTransport{}
h := NewMihomoForward(target, "/api/mtg/mihomo", "Bearer testsecret", cap, nil)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://gw/api/mtg/mihomo/proxies", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer client-should-not-forward")
req.Header.Set("Host", "public-gateway.example:8888")
h.ServeHTTP(httptest.NewRecorder(), req)
if cap.got == nil {
t.Fatal("no outgoing request captured")
}
if got, want := cap.got.Host, "127.0.0.1:9090"; got != want {
t.Fatalf("Host: got %q want %q (upstream must not see client Host)", got, want)
}
if got := cap.got.Header.Get("Authorization"); got != "Bearer testsecret" {
t.Fatalf("Authorization: got %q want Bearer testsecret", got)
}
want, err := url.Parse("http://127.0.0.1:9090/proxies")
if err != nil {
t.Fatal(err)
}
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)
}
}
+108 -8
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"
@@ -24,6 +26,7 @@ import (
type Gateway struct { type Gateway struct {
parsed *config.Parsed parsed *config.Parsed
proxies map[string]http.Handler proxies map[string]http.Handler
mihomo map[string]http.Handler
agg *aggregate.Handler agg *aggregate.Handler
geo *geoip.Service geo *geoip.Service
log *slog.Logger log *slog.Logger
@@ -31,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.
@@ -45,11 +64,13 @@ func NewGateway(p *config.Parsed, log *slog.Logger, geo *geoip.Service) (*Gatewa
g := &Gateway{ g := &Gateway{
parsed: p, parsed: p,
proxies: make(map[string]http.Handler), proxies: make(map[string]http.Handler),
mihomo: make(map[string]http.Handler),
geo: geo, geo: geo,
log: log, log: log,
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)
@@ -60,12 +81,15 @@ 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 {
a := alias
strip := "/api/" + a + "/mihomo"
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)
writeBadGatewayJSON(w, exposeErr, "bad_gateway", "mihomo upstream unreachable", err)
}) })
} }
var aggCacheTTL time.Duration var aggCacheTTL time.Duration
@@ -75,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
} }
@@ -221,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"
} }
@@ -230,7 +276,11 @@ func routeEndpoint(path string) string {
if i < 0 { if i < 0 {
return "proxy_root" return "proxy_root"
} }
return "proxy_" + rest[i+1:] sub := rest[i+1:]
if strings.HasPrefix(sub, "mihomo/") {
return "mihomo_" + sub[len("mihomo/"):]
}
return "proxy_" + sub
} }
return "ui" return "ui"
} }
@@ -258,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
@@ -266,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
@@ -285,7 +343,7 @@ func (g *Gateway) serve(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
rp, ok := g.proxies[alias] _, ok := g.proxies[alias]
if !ok { if !ok {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
@@ -295,7 +353,28 @@ func (g *Gateway) serve(w http.ResponseWriter, r *http.Request) {
}) })
return return
} }
rp.ServeHTTP(w, r) mihomoPfx := "/api/" + alias + "/mihomo"
if strings.HasPrefix(r.URL.Path, mihomoPfx) {
mh, have := g.mihomo[alias]
if !have {
proxy.MihomoJSONError(w, "mihomo_not_configured", "mihomo is not configured for this server")
return
}
if r.URL.Path == mihomoPfx+"/meta" && r.Method == http.MethodGet {
mu := g.parsed.MihomoByAlias[alias]
if mu == nil {
proxy.MihomoJSONError(w, "mihomo_not_configured", "mihomo is not configured for this server")
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(proxy.MihomoMetaJSON(mu.Base))
return
}
mh.ServeHTTP(w, r)
return
}
g.proxies[alias].ServeHTTP(w, r)
} }
func (g *Gateway) serveLiveEvents(w http.ResponseWriter, r *http.Request) { func (g *Gateway) serveLiveEvents(w http.ResponseWriter, r *http.Request) {
@@ -363,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)
}
}
}
+118
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"bits-ui": "^2.16.4", "bits-ui": "^2.16.4",
"chart.js": "^4.5.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
@@ -22,9 +23,11 @@
"@sveltejs/kit": "^2.50.2", "@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"mode-watcher": "^1.1.0",
"openapi-typescript": "^7.13.0", "openapi-typescript": "^7.13.0",
"svelte": "^5.54.0", "svelte": "^5.54.0",
"svelte-check": "^4.4.2", "svelte-check": "^4.4.2",
"svelte-sonner": "^1.1.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
@@ -551,6 +554,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@lucide/svelte": { "node_modules/@lucide/svelte": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.7.0.tgz", "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.7.0.tgz",
@@ -1479,6 +1488,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -2084,6 +2105,73 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/mode-watcher": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz",
"integrity": "sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"runed": "^0.25.0",
"svelte-toolbelt": "^0.7.1"
},
"peerDependencies": {
"svelte": "^5.27.0"
}
},
"node_modules/mode-watcher/node_modules/runed": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/runed/-/runed-0.25.0.tgz",
"integrity": "sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==",
"dev": true,
"funding": [
"https://github.com/sponsors/huntabyte",
"https://github.com/sponsors/tglide"
],
"dependencies": {
"esm-env": "^1.0.0"
},
"peerDependencies": {
"svelte": "^5.7.0"
}
},
"node_modules/mode-watcher/node_modules/svelte-toolbelt": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz",
"integrity": "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/huntabyte"
],
"dependencies": {
"clsx": "^2.1.1",
"runed": "^0.23.2",
"style-to-object": "^1.0.8"
},
"engines": {
"node": ">=18",
"pnpm": ">=8.7.0"
},
"peerDependencies": {
"svelte": "^5.0.0"
}
},
"node_modules/mode-watcher/node_modules/svelte-toolbelt/node_modules/runed": {
"version": "0.23.4",
"resolved": "https://registry.npmjs.org/runed/-/runed-0.23.4.tgz",
"integrity": "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==",
"dev": true,
"funding": [
"https://github.com/sponsors/huntabyte",
"https://github.com/sponsors/tglide"
],
"dependencies": {
"esm-env": "^1.0.0"
},
"peerDependencies": {
"svelte": "^5.7.0"
}
},
"node_modules/mri": { "node_modules/mri": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -2450,6 +2538,36 @@
"typescript": ">=5.0.0" "typescript": ">=5.0.0"
} }
}, },
"node_modules/svelte-sonner": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.1.0.tgz",
"integrity": "sha512-3lYM6ZIqWe+p9vwwWHGWP/ZdvHiUtzURsud2quIxivrX4rvpXh6i+geBGn0m3JS6KwW6W8VgbOl3xQMcDuh6gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"runed": "^0.28.0"
},
"peerDependencies": {
"svelte": "^5.0.0"
}
},
"node_modules/svelte-sonner/node_modules/runed": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/runed/-/runed-0.28.0.tgz",
"integrity": "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/huntabyte",
"https://github.com/sponsors/tglide"
],
"license": "MIT",
"dependencies": {
"esm-env": "^1.0.0"
},
"peerDependencies": {
"svelte": "^5.7.0"
}
},
"node_modules/svelte-toolbelt": { "node_modules/svelte-toolbelt": {
"version": "0.10.6", "version": "0.10.6",
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz", "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz",
+3
View File
@@ -19,9 +19,11 @@
"@sveltejs/kit": "^2.50.2", "@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"mode-watcher": "^1.1.0",
"openapi-typescript": "^7.13.0", "openapi-typescript": "^7.13.0",
"svelte": "^5.54.0", "svelte": "^5.54.0",
"svelte-check": "^4.4.2", "svelte-check": "^4.4.2",
"svelte-sonner": "^1.1.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
@@ -30,6 +32,7 @@
"dependencies": { "dependencies": {
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"bits-ui": "^2.16.4", "bits-ui": "^2.16.4",
"chart.js": "^4.5.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
+198
View File
@@ -7,12 +7,88 @@ import type {
TelemtSuccess, TelemtSuccess,
UserInfo UserInfo
} from './telemt-v1.js'; } from './telemt-v1.js';
import type { MihomoMetaResponse } from './mihomo-types.js';
export function gatewayBase(): string { export function gatewayBase(): string {
const u = PUBLIC_TELEMT_GATEWAY_URL || ''; const u = PUBLIC_TELEMT_GATEWAY_URL || '';
return u.replace(/\/$/, ''); return u.replace(/\/$/, '');
} }
/** Путь к Mihomo external-controller через шлюз: без ведущего слэша. */
export function mihomoUrl(alias: string, path: string): string {
const p = path.replace(/^\/+/, '');
return `${gatewayBase()}/api/${encodeURIComponent(alias)}/mihomo/${p}`;
}
/**
* WebSocket к шлюзу: `ws(s)://…/api/{alias}/mihomo/{path}`.
* Authorization к Mihomo подставляет шлюз; браузеру токен не нужен.
* Обзор Mihomo: WS для /traffic, /memory, /connections (токен на upstream подставляет шлюз; для /connections — ?token=…).
*/
export function mihomoWsUrl(alias: string, path: string): string {
const p = path.replace(/^\/+/, '');
const base =
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}`;
}
async function parseMihomoBody(res: Response): Promise<unknown> {
const text = await res.text();
if (!text) return null;
try {
return JSON.parse(text);
} catch {
const snip = text.length > 200 ? `${text.slice(0, 200)}…` : text;
throw new ApiError(`Ответ не JSON (HTTP ${res.status}): ${snip}`, res.status, text);
}
}
export async function fetchMihomoMeta(alias: string): Promise<MihomoMetaResponse> {
const res = await fetch(mihomoUrl(alias, 'meta'));
const body = (await parseMihomoBody(res)) as Record<string, unknown> | null;
if (res.status === 404) {
throw new ApiError('Mihomo не настроен для этой ноды', 404, body);
}
if (!res.ok) {
throw new ApiError(`Mihomo meta HTTP ${res.status}`, res.status, body);
}
if (!body || body.ok !== true) {
throw new ApiError('Mihomo meta: неверный ответ', res.status, body);
}
return body as unknown as MihomoMetaResponse;
}
export async function fetchMihomoJson<T>(alias: string, path: string): Promise<T> {
const res = await fetch(mihomoUrl(alias, path));
const body = await parseMihomoBody(res);
if (res.status === 404) {
throw new ApiError('Mihomo не настроен для этой ноды', 404, body);
}
if (!res.ok) {
throw new ApiError(`Mihomo HTTP ${res.status}`, res.status, body);
}
return body as T;
}
export async function mihomoPut(alias: string, path: string, jsonBody: unknown): Promise<void> {
const res = await fetch(mihomoUrl(alias, path), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(jsonBody)
});
if (res.status === 404) {
const body = await parseMihomoBody(res);
throw new ApiError('Mihomo не настроен для этой ноды', 404, body);
}
if (!res.ok) {
const body = await parseMihomoBody(res);
throw new ApiError(`Mihomo PUT HTTP ${res.status}`, res.status, body);
}
}
export type AggEnvelope<T> = { export type AggEnvelope<T> = {
ok: true; ok: true;
generated_at: string; generated_at: string;
@@ -104,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;
@@ -162,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);
@@ -281,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;
}
+41
View File
@@ -0,0 +1,41 @@
/** GET /proxies — фрагмент ответа Mihomo external-controller. */
export type MihomoProxiesResponse = {
proxies?: Record<string, MihomoProxyEntry>;
};
export type MihomoProxyEntry = {
type?: string;
name?: string;
now?: string;
all?: string[];
/** URL для healthcheck / GET …/delay (если задан в конфиге группы). */
testUrl?: string;
history?: { time: string; delay: number }[];
udp?: boolean;
[key: string]: unknown;
};
export type MihomoConnectionsResponse = {
total?: number;
connections?: MihomoConnection[];
/** Снимок по WebSocket /connections (Mihomo). */
downloadTotal?: number;
uploadTotal?: number;
memory?: number;
};
export type MihomoConnection = {
metadata?: { network?: string; [k: string]: unknown };
chains?: string[];
[key: string]: unknown;
};
export type MihomoMetaResponse = {
ok: true;
controller_base: string;
};
export type MihomoDelayResponse = {
delay?: number;
message?: string;
};
+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,46 +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 internalValue = $state(value ?? "all"); let prev = value || "all";
$effect(() => { $effect(() => {
const next = value ?? "all"; if (!value) {
if (internalValue !== next) internalValue = next; value = "all";
}); return;
}
$effect(() => { if (value !== prev) {
if (internalValue !== "all" && !availableAliases.includes(internalValue)) { prev = value;
internalValue = "all"; untrack(() => onValueChange?.(value));
} }
if (value !== internalValue) value = internalValue;
}); });
let selectedLabel = $derived(internalValue === "all" ? allLabel : internalValue);
</script> </script>
<label class="text-xs text-muted-foreground"> <label class="text-xs text-muted-foreground">
{label} {label}
<Select.Root type="single" bind:value={internalValue}> <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>
+54 -17
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/state';
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard'; import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard';
import UsersIcon from '@lucide/svelte/icons/users'; import UsersIcon from '@lucide/svelte/icons/users';
import GlobeIcon from '@lucide/svelte/icons/globe'; import GlobeIcon from '@lucide/svelte/icons/globe';
@@ -9,8 +9,10 @@
import NetworkIcon from '@lucide/svelte/icons/network'; import NetworkIcon from '@lucide/svelte/icons/network';
import SettingsIcon from '@lucide/svelte/icons/settings'; import SettingsIcon from '@lucide/svelte/icons/settings';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'; import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
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 {
@@ -22,10 +24,15 @@
} = $props(); } = $props();
function active(href: string): boolean { function active(href: string): boolean {
const p = $page.url.pathname; const p = page.url.pathname;
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">
@@ -79,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 />
@@ -98,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}
@@ -127,71 +154,81 @@
<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>
</Sidebar.MenuItem> </Sidebar.MenuItem>
<Sidebar.MenuItem> <Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active(serverPath + '/runtime')} tooltipContent="Runtime"> <Sidebar.MenuButton isActive={active(serverPath + '/mihomo')} tooltipContent="Mihomo">
{#snippet child({ props })}
<a href="{serverPath}/mihomo" {...props}>
<ZapIcon />
<span>Mihomo</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<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,68 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { cn } from '$lib/utils.js';
type PartialVariant = 'default' | 'destructive';
let {
partial = false,
partialVariant = 'default' as PartialVariant,
partialTitle = 'Частичные данные',
partialDescription = 'Не все upstream ответили успешно.',
partialSnippet,
err = null as string | null,
hasStaleData = false,
showSkeleton = false,
isEmpty = false,
children,
skeleton,
empty
}: {
partial?: boolean;
partialVariant?: PartialVariant;
partialTitle?: string;
partialDescription?: string;
partialSnippet?: Snippet;
err?: string | null;
hasStaleData?: boolean;
showSkeleton?: boolean;
isEmpty?: boolean;
children?: Snippet;
skeleton?: Snippet;
empty?: Snippet;
} = $props();
</script>
{#if partial}
{#if partialSnippet}
{@render partialSnippet()}
{:else}
<Alert variant={partialVariant} class="mb-4">
<AlertTitle>{partialTitle}</AlertTitle>
<AlertDescription>{partialDescription}</AlertDescription>
</Alert>
{/if}
{/if}
{#if err}
<Alert variant="destructive" class="mb-4">
<AlertTitle>{hasStaleData ? 'Ошибка обновления' : 'Ошибка'}</AlertTitle>
<AlertDescription>{err}</AlertDescription>
{#if hasStaleData}
<p class="mt-2 text-xs text-muted-foreground">Показаны последние успешные данные.</p>
{/if}
</Alert>
{/if}
{#if showSkeleton}
{@render skeleton?.()}
{:else if err && !hasStaleData}
<!-- блокируем контент при первичной ошибке -->
{:else if isEmpty}
{@render empty?.()}
{:else}
<div class={cn(err && hasStaleData && 'opacity-90')}>
{@render children?.()}
</div>
{/if}
@@ -0,0 +1,38 @@
<script lang="ts">
import { Badge } from '$lib/components/ui/badge/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
let {
isStale,
staleSeconds,
refreshSeconds,
staleMultiplier = 2,
tooltipOverride
}: {
isStale: boolean;
staleSeconds: number | null;
refreshSeconds: number;
staleMultiplier?: number;
/** Полный текст тултипа (например для Live/SSE с фиксированным порогом). */
tooltipOverride?: string;
} = $props();
const thresholdSec = $derived(refreshSeconds > 0 ? refreshSeconds * staleMultiplier : 60);
const tip = $derived(
tooltipOverride ??
(isStale
? `Статус stale: нет успешного обновления дольше ${thresholdSec} с (порог ≈ ${staleMultiplier}× интервал автообновления).`
: `Свежие данные: последний успешный снимок ${staleSeconds ?? 0} с назад.`)
);
</script>
<Tooltip.Root>
<Tooltip.Trigger class="inline-flex">
<Badge variant={isStale ? 'secondary' : 'default'}>
{isStale ? 'stale' : 'live'}{#if staleSeconds != null} · {staleSeconds}s{/if}
</Badge>
</Tooltip.Trigger>
<Tooltip.Content side="bottom" class="max-w-xs">
<p class="text-xs">{tip}</p>
</Tooltip.Content>
</Tooltip.Root>
@@ -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,27 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label/index.js';
import { Input } from '$lib/components/ui/input/index.js';
let {
id = 'fleet-refresh-sec',
value = $bindable('30'),
onValueChange
}: {
id?: string;
value?: string;
onValueChange?: () => void;
} = $props();
</script>
<div class="grid w-fit gap-1.5">
<Label for={id} class="text-xs text-muted-foreground">Refresh (sec)</Label>
<Input
{id}
type="number"
min="0"
max="300"
class="h-9 w-28"
bind:value
onchange={onValueChange}
/>
</div>
@@ -0,0 +1,123 @@
<script lang="ts">
import { Badge } from '$lib/components/ui/badge/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { cn } from '$lib/utils.js';
import InfoIcon from '@lucide/svelte/icons/info';
import StarIcon from '@lucide/svelte/icons/star';
let {
active_on_servers,
recent_on_servers,
primary_server,
linkToServer = true,
class: className
}: {
active_on_servers?: string[] | null;
recent_on_servers?: string[] | null;
primary_server?: string | null;
/** Ссылка на `/servers/{alias}` у бейджей */
linkToServer?: boolean;
class?: string;
} = $props();
const activeServers = $derived(((active_on_servers ?? []) as string[]).filter(Boolean));
const recentServers = $derived(((recent_on_servers ?? []) as string[]).filter(Boolean));
const recentOnly = $derived(recentServers.filter((s) => !activeServers.includes(s)));
const soleActiveTip =
'Единственный сервер в активных на этом снимке (не приоритет, а однозначность).';
function serverHref(alias: string): string | undefined {
if (!linkToServer) return undefined;
return `/servers/${encodeURIComponent(alias)}`;
}
</script>
<div class={cn('space-y-2 text-xs', className)}>
{#if activeServers.length > 0}
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-1">
<span class="text-muted-foreground">Активные</span>
<Tooltip.Root>
<Tooltip.Trigger
class="inline-flex size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
aria-label="Пояснение: активные"
>
<InfoIcon class="size-3.5" />
</Tooltip.Trigger>
<Tooltip.Content side="top" class="max-w-xs">
<p class="text-xs">
Серверы, где этот IP есть в списке активных уникальных адресов на момент снимка
шлюза.
</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
<div class="flex flex-wrap items-center gap-1">
{#each activeServers as srv (srv)}
{#if primary_server && srv === primary_server && activeServers.length === 1}
<Tooltip.Root>
<Tooltip.Trigger class="inline-flex">
<Badge
variant="secondary"
class="font-normal px-2 py-0.5 gap-1"
href={serverHref(srv)}
>
<StarIcon
class="size-3 shrink-0 fill-amber-400 stroke-amber-400 text-amber-400 drop-shadow-[0_0_5px_rgba(251,191,36,0.55)]"
aria-hidden="true"
/>
{srv}
</Badge>
</Tooltip.Trigger>
<Tooltip.Content side="bottom" class="max-w-xs">
<p class="text-xs">{soleActiveTip}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
<Badge variant="secondary" class="font-normal px-2 py-0.5" href={serverHref(srv)}>
{srv}
</Badge>
{/if}
{/each}
</div>
</div>
{:else}
<span class="text-muted-foreground">
{recentOnly.length > 0 ? 'Нет в активных' : '—'}
</span>
{/if}
{#if recentOnly.length > 0}
{#if activeServers.length > 0}
<Separator class="my-1" />
{/if}
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-1">
<span class="text-muted-foreground">Только недавние</span>
<Tooltip.Root>
<Tooltip.Trigger
class="inline-flex size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
aria-label="Пояснение: только недавние"
>
<InfoIcon class="size-3.5" />
</Tooltip.Trigger>
<Tooltip.Content side="top" class="max-w-xs">
<p class="text-xs">
Серверы, где IP есть в недавнем списке, но на этом снимке не в активных — например
недавнее подключение с другого шлюза.
</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
<div class="flex flex-wrap items-center gap-1">
{#each recentOnly as srv (srv)}
<Badge variant="outline" class="font-normal px-2 py-0.5" href={serverHref(srv)}>
{srv}
</Badge>
{/each}
</div>
</div>
{/if}
</div>
@@ -0,0 +1,51 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card/index.js';
import { cn } from '$lib/utils.js';
import type { KpiAccentKey } from '$lib/kpi-accents.js';
import { kpiAccent } from '$lib/kpi-accents.js';
import type { Snippet } from 'svelte';
let {
accent,
title,
description,
class: className,
contentClass,
headerClass,
icon,
children
}: {
accent: KpiAccentKey;
title: string;
description?: string;
class?: string;
contentClass?: string;
headerClass?: string;
icon: Snippet;
children: Snippet;
} = $props();
</script>
<Card.Root class={cn('border-l-4 shadow-sm', kpiAccent[accent].border, className)}>
<Card.Header class={cn(headerClass)}>
<div class="flex gap-3">
<div
class={cn(
'flex size-10 shrink-0 items-center justify-center rounded-lg',
kpiAccent[accent].iconWrap
)}
>
{@render icon()}
</div>
<div class="min-w-0 flex-1 space-y-1">
<Card.Title>{title}</Card.Title>
{#if description}
<Card.Description>{description}</Card.Description>
{/if}
</div>
</div>
</Card.Header>
<Card.Content class={contentClass}>
{@render children()}
</Card.Content>
</Card.Root>
@@ -0,0 +1,50 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card/index.js';
import { cn } from '$lib/utils.js';
import type { KpiAccentKey } from '$lib/kpi-accents.js';
import { kpiAccent } from '$lib/kpi-accents.js';
import type { Snippet } from 'svelte';
let {
accent,
label,
footer,
class: className,
valueClass,
icon,
children
}: {
accent: KpiAccentKey;
label: string;
footer?: string;
class?: string;
/** Дополнительные классы для значения (например `text-xl`). */
valueClass?: string;
icon: Snippet;
children: Snippet;
} = $props();
</script>
<Card.Root class={cn('border-l-4 shadow-sm transition-colors', kpiAccent[accent].border, className)}>
<Card.Header class="pb-2">
<div class="flex gap-3">
<div
class={cn(
'flex size-10 shrink-0 items-center justify-center rounded-lg',
kpiAccent[accent].iconWrap
)}
>
{@render icon()}
</div>
<div class="min-w-0 flex-1 space-y-1">
<Card.Description>{label}</Card.Description>
<Card.Title class={cn('text-2xl tabular-nums', kpiAccent[accent].value, valueClass)}>
{@render children()}
</Card.Title>
</div>
</div>
</Card.Header>
{#if footer}
<Card.Content class="text-xs text-muted-foreground">{footer}</Card.Content>
{/if}
</Card.Root>
@@ -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>
@@ -0,0 +1,401 @@
<script lang="ts">
import { onDestroy, onMount, tick } from 'svelte';
import Chart from 'chart.js/auto';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
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 GaugeIcon from '@lucide/svelte/icons/gauge';
import {
chartColorWithAlpha,
chartGridColor,
chartLegendColor,
chartLineAreaFillCallback,
chartSeriesColors,
chartTickColor,
readCssVar
} from '$lib/chart-theme.js';
let {
histUp,
histDown,
histMem,
histConn,
totalUp,
totalDown,
tcpN,
udpN,
topList
}: {
histUp: number[];
histDown: number[];
histMem: number[];
histConn: number[];
totalUp: number;
totalDown: number;
tcpN: number;
udpN: number;
topList: { name: string; n: number }[];
} = $props();
let elTraffic = $state<HTMLCanvasElement | null>(null);
let elFlow = $state<HTMLCanvasElement | null>(null);
let elMem = $state<HTMLCanvasElement | null>(null);
let elConn = $state<HTMLCanvasElement | null>(null);
let elNet = $state<HTMLCanvasElement | null>(null);
let elTop = $state<HTMLCanvasElement | null>(null);
let chartTraffic: Chart | null = null;
let chartFlow: Chart | null = null;
let chartMem: Chart | null = null;
let chartConn: Chart | null = null;
let chartNet: Chart | null = null;
let chartTop: Chart | null = null;
function lineOpts() {
const tick = chartTickColor();
const grid = chartGridColor();
const legend = chartLegendColor();
return {
responsive: true,
maintainAspectRatio: false,
animation: false as const,
plugins: { legend: { labels: { color: legend } } },
scales: {
x: { ticks: { color: tick, maxTicksLimit: 8 }, grid: { color: grid } },
y: { ticks: { color: tick }, grid: { color: grid } }
}
};
}
function rebuildAll() {
const colors = chartSeriesColors();
const legend = chartLegendColor();
const grid = chartGridColor();
const tick = chartTickColor();
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, {
type: 'line',
data: {
labels: lab,
datasets: [
{
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()
});
}
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, {
type: 'doughnut',
data: {
labels: ['Скачано', 'Загружено'],
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,
cutout: '58%',
plugins: { legend: { labels: { color: legend } } }
}
});
}
if (elMem) {
chartMem?.destroy();
const c2 = colors[2] ?? '#a78bfa';
chartMem = new Chart(elMem, {
type: 'line',
data: {
labels: histMem.map((_, i) => String(i)),
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()
});
}
if (elConn) {
chartConn?.destroy();
const c1 = colors[1] ?? '#34d399';
chartConn = new Chart(elConn, {
type: 'line',
data: {
labels: histConn.map((_, i) => String(i)),
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()
});
}
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, {
type: 'doughnut',
data: {
labels: ['TCP', 'UDP'],
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,
cutout: '58%',
plugins: { legend: { labels: { color: legend } } }
}
});
}
if (elTop) {
chartTop?.destroy();
const topBarColor = chartColorWithAlpha(colors[0] ?? '#818cf8', 0.52);
chartTop = new Chart(elTop, {
type: 'bar',
data: {
labels: topList.map((x) => x.name),
datasets: [
{
label: 'Сессии',
data: topList.map((x) => x.n),
backgroundColor: topBarColor,
hoverBackgroundColor: topBarColor,
borderRadius: 4,
borderSkipped: false
}
]
},
options: {
indexAxis: 'y' as const,
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: tick }, grid: { color: grid } },
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(() => {
const labels = histDown.map((_, i) => String(i));
if (chartTraffic) {
chartTraffic.data.labels = labels;
chartTraffic.data.datasets[0].data = [...histDown];
chartTraffic.data.datasets[1].data = [...histUp];
chartTraffic.update('none');
}
if (chartMem) {
chartMem.data.labels = histMem.map((_, i) => String(i));
chartMem.data.datasets[0].data = [...histMem];
chartMem.update('none');
}
if (chartConn) {
chartConn.data.labels = histConn.map((_, i) => String(i));
chartConn.data.datasets[0].data = [...histConn];
chartConn.update('none');
}
if (chartFlow) {
chartFlow.data.datasets[0].data = [Math.max(totalDown, 0), Math.max(totalUp, 0)];
chartFlow.update('none');
}
if (chartNet) {
chartNet.data.datasets[0].data = [tcpN, udpN];
chartNet.update('none');
}
if (chartTop) {
chartTop.data.labels = topList.map((x) => x.name);
chartTop.data.datasets[0].data = topList.map((x) => x.n);
chartTop.update('none');
}
});
onDestroy(() => {
chartTraffic?.destroy();
chartFlow?.destroy();
chartMem?.destroy();
chartConn?.destroy();
chartNet?.destroy();
chartTop?.destroy();
});
</script>
<div class="grid gap-4 lg:grid-cols-2">
<KpiPanelCard
accent="neutral"
title="Трафик"
description="Скорость загрузки и скачивания (история из /traffic)"
headerClass="pb-2"
contentClass="h-56 px-4 pb-4 pt-0"
>
{#snippet icon()}
<ActivityIcon class="size-5" aria-hidden="true" />
{/snippet}
<canvas bind:this={elTraffic} class="max-h-full w-full"></canvas>
</KpiPanelCard>
<KpiPanelCard
accent="neutral"
title="Поток (накоплено)"
description="Соотношение накопленного down / up"
headerClass="pb-2"
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>
</KpiPanelCard>
<KpiPanelCard
accent="neutral"
title="Память (KiB)"
description="Использование по снимкам (ось — KiB)"
headerClass="pb-2"
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>
</KpiPanelCard>
<KpiPanelCard
accent="neutral"
title="Подключения"
description="Число активных соединений во времени"
headerClass="pb-2"
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>
</KpiPanelCard>
<KpiPanelCard
accent="neutral"
title="Типы сети"
description="TCP и UDP по текущему снимку"
headerClass="pb-2"
contentClass="h-56 px-4 pb-4 pt-0"
>
{#snippet icon()}
<NetworkIcon class="size-5" aria-hidden="true" />
{/snippet}
<canvas bind:this={elNet} class="max-h-full w-full"></canvas>
</KpiPanelCard>
<KpiPanelCard
accent="neutral"
title="Топ прокси (по сессиям)"
description="Распределение по имени прокси"
headerClass="pb-2"
contentClass="h-56 px-4 pb-4 pt-0"
>
{#snippet icon()}
<GaugeIcon class="size-5" aria-hidden="true" />
{/snippet}
<canvas bind:this={elTop} class="max-h-full w-full"></canvas>
</KpiPanelCard>
</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,35 @@
<script lang="ts">
import { Badge, type BadgeVariant } from '$lib/components/ui/badge/index.js';
import { cn } from '$lib/utils.js';
let {
aliases,
variant = 'outline',
linkToServer = true,
class: className
}: {
aliases: string[] | null | undefined;
variant?: BadgeVariant;
linkToServer?: boolean;
class?: string;
} = $props();
const list = $derived(((aliases ?? []) as string[]).filter(Boolean));
function href(alias: string): string | undefined {
if (!linkToServer) return undefined;
return `/servers/${encodeURIComponent(alias)}`;
}
</script>
{#if list.length === 0}
<span class="text-muted-foreground">—</span>
{:else}
<div class={cn('flex flex-wrap items-center gap-1', className)}>
{#each list as alias (alias)}
<Badge variant={variant} class="font-normal px-2 py-0.5" href={href(alias)}>
{alias}
</Badge>
{/each}
</div>
{/if}
@@ -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,27 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import {
buttonVariants,
type ButtonVariant,
type ButtonSize,
} from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
variant = "default",
size = "default",
...restProps
}: AlertDialogPrimitive.ActionProps & {
variant?: ButtonVariant;
size?: ButtonSize;
} = $props();
</script>
<AlertDialogPrimitive.Action
bind:ref
data-slot="alert-dialog-action"
class={cn(buttonVariants({ variant, size }), "cn-alert-dialog-action", className)}
{...restProps}
/>
@@ -0,0 +1,27 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import {
buttonVariants,
type ButtonVariant,
type ButtonSize,
} from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
variant = "outline",
size = "default",
...restProps
}: AlertDialogPrimitive.CancelProps & {
variant?: ButtonVariant;
size?: ButtonSize;
} = $props();
</script>
<AlertDialogPrimitive.Cancel
bind:ref
data-slot="alert-dialog-cancel"
class={cn(buttonVariants({ variant, size }), "cn-alert-dialog-cancel", className)}
{...restProps}
/>
@@ -0,0 +1,32 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import AlertDialogPortal from "./alert-dialog-portal.svelte";
import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
import { cn, type WithoutChild, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
size = "default",
portalProps,
...restProps
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
size?: "default" | "sm";
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof AlertDialogPortal>>;
} = $props();
</script>
<AlertDialogPortal {...portalProps}>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
bind:ref
data-slot="alert-dialog-content"
data-size={size}
class={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/10 gap-4 rounded-xl p-4 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none",
className
)}
{...restProps}
/>
</AlertDialogPortal>
@@ -0,0 +1,17 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.DescriptionProps = $props();
</script>
<AlertDialogPrimitive.Description
bind:ref
data-slot="alert-dialog-description"
class={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3", className)}
{...restProps}
/>
@@ -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="alert-dialog-footer"
class={cn(
"bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<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<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-header"
class={cn("grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<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<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-media"
class={cn("bg-muted mb-2 inline-flex size-10 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,17 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.OverlayProps = $props();
</script>
<AlertDialogPrimitive.Overlay
bind:ref
data-slot="alert-dialog-overlay"
class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50", className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
</script>
<AlertDialogPrimitive.Portal {...restProps} />
@@ -0,0 +1,17 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.TitleProps = $props();
</script>
<AlertDialogPrimitive.Title
bind:ref
data-slot="alert-dialog-title"
class={cn("text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2", className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
</script>
<AlertDialogPrimitive.Trigger bind:ref data-slot="alert-dialog-trigger" {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: AlertDialogPrimitive.RootProps = $props();
</script>
<AlertDialogPrimitive.Root bind:open {...restProps} />
@@ -0,0 +1,40 @@
import Root from "./alert-dialog.svelte";
import Portal from "./alert-dialog-portal.svelte";
import Trigger from "./alert-dialog-trigger.svelte";
import Title from "./alert-dialog-title.svelte";
import Action from "./alert-dialog-action.svelte";
import Cancel from "./alert-dialog-cancel.svelte";
import Footer from "./alert-dialog-footer.svelte";
import Header from "./alert-dialog-header.svelte";
import Overlay from "./alert-dialog-overlay.svelte";
import Content from "./alert-dialog-content.svelte";
import Description from "./alert-dialog-description.svelte";
import Media from "./alert-dialog-media.svelte";
export {
Root,
Title,
Action,
Cancel,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Media,
//
Root as AlertDialog,
Title as AlertDialogTitle,
Action as AlertDialogAction,
Cancel as AlertDialogCancel,
Portal as AlertDialogPortal,
Footer as AlertDialogFooter,
Header as AlertDialogHeader,
Trigger as AlertDialogTrigger,
Overlay as AlertDialogOverlay,
Content as AlertDialogContent,
Description as AlertDialogDescription,
Media as AlertDialogMedia,
};
@@ -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,42 @@
<script lang="ts">
import type { Command as CommandPrimitive, Dialog as DialogPrimitive } from "bits-ui";
import type { Snippet } from "svelte";
import Command from "./command.svelte";
import * as Dialog from "$lib/components/ui/dialog/index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
open = $bindable(false),
ref = $bindable(null),
value = $bindable(""),
title = "Command Palette",
description = "Search for a command to run...",
showCloseButton = false,
portalProps,
children,
class: className,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.RootProps> &
WithoutChildrenOrChild<CommandPrimitive.RootProps> & {
portalProps?: DialogPrimitive.PortalProps;
children: Snippet;
title?: string;
description?: string;
showCloseButton?: boolean;
class?: string;
} = $props();
</script>
<Dialog.Root bind:open {...restProps}>
<Dialog.Header class="sr-only">
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>{description}</Dialog.Description>
</Dialog.Header>
<Dialog.Content
class={cn("rounded-xl! top-1/3 translate-y-0 overflow-hidden p-0", className)}
{showCloseButton}
{portalProps}
>
<Command {...restProps} bind:value bind:ref {children} />
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.EmptyProps = $props();
</script>
<CommandPrimitive.Empty
bind:ref
data-slot="command-empty"
class={cn("py-6 text-center text-sm", className)}
{...restProps}
/>
@@ -0,0 +1,32 @@
<script lang="ts">
import { Command as CommandPrimitive, useId } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
heading,
value,
...restProps
}: CommandPrimitive.GroupProps & {
heading?: string;
} = $props();
</script>
<CommandPrimitive.Group
bind:ref
data-slot="command-group"
class={cn("text-foreground **:[[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium", className)}
value={value ?? heading ?? `----${useId()}`}
{...restProps}
>
{#if heading}
<CommandPrimitive.GroupHeading
class="text-muted-foreground px-2 py-1.5 text-xs font-medium"
>
{heading}
</CommandPrimitive.GroupHeading>
{/if}
<CommandPrimitive.GroupItems {children} />
</CommandPrimitive.Group>
@@ -0,0 +1,31 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import * as InputGroup from "$lib/components/ui/input-group/index.js";
import SearchIcon from '@lucide/svelte/icons/search';
let {
ref = $bindable(null),
class: className,
value = $bindable(""),
...restProps
}: CommandPrimitive.InputProps = $props();
</script>
<div data-slot="command-input-wrapper" class="p-1 pb-0">
<InputGroup.Root class="bg-input/30 border-input/30 h-8! rounded-lg! shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
class={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
bind:ref
{...restProps}
bind:value
/>
<InputGroup.Addon>
<SearchIcon class="size-4 shrink-0 opacity-50" />
</InputGroup.Addon>
</InputGroup.Root>
</div>
@@ -0,0 +1,25 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import CheckIcon from '@lucide/svelte/icons/check';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: CommandPrimitive.ItemProps = $props();
</script>
<CommandPrimitive.Item
bind:ref
data-slot="command-item"
class={cn(
"group/command-item data-selected:bg-muted data-selected:text-foreground data-selected:*:[svg]:text-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{@render children?.()}
<CheckIcon class="cn-command-item-indicator ml-auto opacity-0 group-has-[[data-slot=command-shortcut]]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
@@ -0,0 +1,20 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.LinkItemProps = $props();
</script>
<CommandPrimitive.LinkItem
bind:ref
data-slot="command-item"
class={cn(
"aria-selected:bg-accent aria-selected:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
/>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.ListProps = $props();
</script>
<CommandPrimitive.List
bind:ref
data-slot="command-list"
class={cn("no-scrollbar max-h-72 scroll-py-1 outline-none overflow-x-hidden overflow-y-auto", className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: CommandPrimitive.LoadingProps = $props();
</script>
<CommandPrimitive.Loading bind:ref {...restProps} />
@@ -0,0 +1,17 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.SeparatorProps = $props();
</script>
<CommandPrimitive.Separator
bind:ref
data-slot="command-separator"
class={cn("bg-border -mx-1 h-px", className)}
{...restProps}
/>
@@ -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<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="command-shortcut"
class={cn("text-muted-foreground group-data-selected/command-item:text-foreground ml-auto text-xs tracking-widest", className)}
{...restProps}
>
{@render children?.()}
</span>
@@ -0,0 +1,25 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Command as CommandPrimitive } from "bits-ui";
export type CommandRootApi = CommandPrimitive.Root;
let {
api = $bindable(null),
ref = $bindable(null),
value = $bindable(""),
class: className,
...restProps
}: CommandPrimitive.RootProps & {
api?: CommandRootApi | null;
} = $props();
</script>
<CommandPrimitive.Root
bind:this={api}
bind:value
bind:ref
data-slot="command"
class={cn("bg-popover text-popover-foreground rounded-xl! p-1 flex size-full flex-col overflow-hidden", className)}
{...restProps}
/>
@@ -0,0 +1,37 @@
import Root from "./command.svelte";
import Loading from "./command-loading.svelte";
import Dialog from "./command-dialog.svelte";
import Empty from "./command-empty.svelte";
import Group from "./command-group.svelte";
import Item from "./command-item.svelte";
import Input from "./command-input.svelte";
import List from "./command-list.svelte";
import Separator from "./command-separator.svelte";
import Shortcut from "./command-shortcut.svelte";
import LinkItem from "./command-link-item.svelte";
export {
Root,
Dialog,
Empty,
Group,
Item,
LinkItem,
Input,
List,
Separator,
Shortcut,
Loading,
//
Root as Command,
Dialog as CommandDialog,
Empty as CommandEmpty,
Group as CommandGroup,
Item as CommandItem,
LinkItem as CommandLinkItem,
Input as CommandInput,
List as CommandList,
Separator as CommandSeparator,
Shortcut as CommandShortcut,
Loading as CommandLoading,
};
@@ -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,
};
@@ -0,0 +1,22 @@
import Root from "./input-group.svelte";
import Addon from "./input-group-addon.svelte";
import Button from "./input-group-button.svelte";
import Input from "./input-group-input.svelte";
import Text from "./input-group-text.svelte";
import Textarea from "./input-group-textarea.svelte";
export {
Root,
Addon,
Button,
Input,
Text,
Textarea,
//
Root as InputGroup,
Addon as InputGroupAddon,
Button as InputGroupButton,
Input as InputGroupInput,
Text as InputGroupText,
Textarea as InputGroupTextarea,
};
@@ -0,0 +1,52 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const inputGroupAddonVariants = tv({
base: "text-muted-foreground h-auto gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4 flex cursor-text items-center justify-center select-none",
variants: {
align: {
"inline-start": "pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem] order-first",
"inline-end": "pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem] order-last",
"block-start":
"px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2 order-first w-full justify-start",
"block-end": "px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2 order-last w-full justify-start",
},
},
defaultVariants: {
align: "inline-start",
},
});
export type InputGroupAddonAlign = VariantProps<typeof inputGroupAddonVariants>["align"];
</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,
align = "inline-start",
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
align?: InputGroupAddonAlign;
} = $props();
</script>
<div
bind:this={ref}
role="group"
data-slot="input-group-addon"
data-align={align}
class={cn(inputGroupAddonVariants({ align }), className)}
onclick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return;
}
e.currentTarget.parentElement?.querySelector("input")?.focus();
}}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,49 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
const inputGroupButtonVariants = tv({
base: "gap-2 text-sm flex items-center shadow-none",
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "cn-input-group-button-size-sm",
"icon-xs": "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
});
export type InputGroupButtonSize = VariantProps<typeof inputGroupButtonVariants>["size"];
</script>
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import { Button } from "$lib/components/ui/button/index.js";
let {
ref = $bindable(null),
class: className,
children,
type = "button",
variant = "ghost",
size = "xs",
...restProps
}: Omit<ComponentProps<typeof Button>, "href" | "size"> & {
size?: InputGroupButtonSize;
} = $props();
</script>
<Button
bind:ref
{type}
data-size={size}
{variant}
class={cn(inputGroupButtonVariants({ size }), className)}
{...restProps}
>
{@render children?.()}
</Button>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import { Input } from "$lib/components/ui/input/index.js";
let {
ref = $bindable(null),
value = $bindable(),
class: className,
...props
}: ComponentProps<typeof Input> = $props();
</script>
<Input
bind:ref
data-slot="input-group-control"
class={cn("rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1", className)}
bind:value
{...props}
/>
@@ -0,0 +1,19 @@
<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<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
class={cn("text-muted-foreground gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none", className)}
{...restProps}
>
{@render children?.()}
</span>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
value = $bindable(),
class: className,
...props
}: ComponentProps<typeof Textarea> = $props();
</script>
<Textarea
bind:ref
data-slot="input-group-control"
class={cn("rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1 resize-none", className)}
bind:value
{...props}
/>
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff. Show more