Compare commits

..
52 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
80 changed files with 5599 additions and 1056 deletions

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
# telemt-api
HTTP‑шлюз на Go для [Telemt Control API](docs/API.md): один порт, **белый список IP (CIDR)**, маршруты вида `/api/{alias}/…` → `{base_url}/v1/…`, опционально **Mihomo** — `/api/{alias}/mihomo/…` к external-controller (см. [docs/GATEWAY_RUN.md](docs/GATEWAY_RUN.md#mihomo-external-controller)), агрегация нескольких инстансов — [`/api/agg/…`](docs/AGGREGATE.md), live SSE поток — `/api/live/events`, метрики Prometheus на `/metrics`. **Web UI** (SvelteKit) встроен в тот же процесс/образ: статика на `/`, API на `/api/…` и `/health`, раздел **Mihomo** на `/servers/{alias}/mihomo`.
HTTP‑шлюз на Go для [Telemt Control API](docs/API.md): один порт, **белый список IP (CIDR)**, маршруты вида `/api/{alias}/…` → `{base_url}/v1/…`, опционально **Mihomo** — `/api/{alias}/mihomo/…` к external-controller (см. [docs/GATEWAY_RUN.md](docs/GATEWAY_RUN.md#mihomo-external-controller)), агрегация нескольких инстансов — [`/api/agg/…`](docs/AGGREGATE.md) (в т.ч. `GET /api/agg/radar-telemt-dcs` — `stats/dcs` по всем нодам для радара), live SSE поток — `/api/live/events`, **радар DC Telegram** — `GET /api/radar/statuses` и `GET /api/radar/ping-dc` (см. [docs/GATEWAY_RUN.md](docs/GATEWAY_RUN.md#radar-dc-telegram)), метрики Prometheus на `/metrics`. **Web UI** (SvelteKit) встроен в тот же процесс/образ: статика на `/`, API на `/api/…` и `/health`, раздел **Mihomo** на `/servers/{alias}/mihomo`, **Радар DC** на `/radar`.
## Быстрый старт (Linux)
+10
View File
@@ -8,6 +8,10 @@
listen: ":8080"
# Диагностика: при 502 от прокси в JSON добавляется error.detail с текстом ошибки RoundTrip (TLS, DNS, таймаут).
# Включайте только во внутренней сети — строка может содержать хосты/порты upstream.
# expose_upstream_errors: true
# If true, IP whitelist is not enforced (development only).
allow_all: false
@@ -39,6 +43,12 @@ trusted_proxies: []
# - gt2
# 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 не нужен)
# geoip:
# enabled: true
+2
View File
@@ -4,6 +4,7 @@
- Большинство маршрутов агрегации используют **`GET /v1/stats/users`** на каждом сервере из конфигурации.
- **`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 для удобства).
@@ -33,6 +34,7 @@
| 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/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**; действует тот же whitelist, что и для остального API шлюза.
+38
View File
@@ -96,6 +96,18 @@ servers:
| `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 при старте шлюза.
@@ -134,6 +146,32 @@ services:
- `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[]`, если такой задан.
## Переменные окружения
| Переменная | Описание |
+14
View File
@@ -8,3 +8,17 @@ require (
github.com/prometheus/client_golang v1.20.5
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
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).
@@ -113,6 +118,8 @@ func (h *Handler) dispatch(w http.ResponseWriter, r *http.Request, sub string) {
h.handleUsers(w, r)
case sub == "fleet-status":
h.handleFleetStatus(w, r)
case sub == "radar-telemt-dcs":
h.handleRadarTelemtDcs(w, r)
case sub == "incidents":
h.handleIncidents(w, r)
case strings.HasPrefix(sub, "user/"):
@@ -275,6 +282,25 @@ func (h *Handler) handleUsers(w http.ResponseWriter, r *http.Request) {
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) {
aliases, err := h.resolveAliases(r)
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) {
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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
continue
}
if data.ConnectionsBadTotal >= 10000 {
badEff := h.effectiveConnectionsBad(alias, data.ConnectionsBadTotal)
if badEff >= 10000 {
out.Items = append(out.Items, IncidentItem{
ID: fmt.Sprintf("bad_connections_high:%s", alias),
Kind: "bad_connections_high",
@@ -75,13 +76,13 @@ func BuildIncidents(ctx context.Context, h *Handler, aliases []string) (Incident
Summary: "Резкий рост ошибок клиентских соединений",
AffectedAliases: []string{alias},
MetricName: "connections_bad_total",
MetricValue: float64(data.ConnectionsBadTotal),
MetricValue: float64(badEff),
MetricThreshold: 10000,
Actions: []IncidentAction{
{Label: "Node dashboard", Href: "/servers/" + alias},
},
})
} else if data.ConnectionsBadTotal >= 1000 {
} else if badEff >= 1000 {
out.Items = append(out.Items, IncidentItem{
ID: fmt.Sprintf("bad_connections_warn:%s", alias),
Kind: "bad_connections_warn",
@@ -91,7 +92,7 @@ func BuildIncidents(ctx context.Context, h *Handler, aliases []string) (Incident
Summary: "Наблюдается рост ошибок клиентских соединений",
AffectedAliases: []string{alias},
MetricName: "connections_bad_total",
MetricValue: float64(data.ConnectionsBadTotal),
MetricValue: float64(badEff),
MetricThreshold: 1000,
Actions: []IncidentAction{
{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"`
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"`
}
+26 -2
View File
@@ -15,14 +15,27 @@ var aliasRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
// Config is the gateway YAML configuration.
type Config struct {
Listen string `yaml:"listen"`
AllowAll bool `yaml:"allow_all"`
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"`
WhitelistCIDRs []string `yaml:"whitelist_cidrs"`
TrustedProxies []string `yaml:"trusted_proxies"`
CorsAllowedOrigins []string `yaml:"cors_allowed_origins"`
Servers []Server `yaml:"servers"`
Aggregate *AggregateConfig `yaml:"aggregate"`
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).
@@ -160,6 +173,17 @@ func (c *Config) Validate() error {
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
}
+24
View File
@@ -114,3 +114,27 @@ func TestValidateAggregateCacheTTL(t *testing.T) {
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")
}
}
+49 -12
View File
@@ -34,6 +34,22 @@ type Gateway struct {
promHandler http.Handler
corsAllowed []string
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.
@@ -54,6 +70,7 @@ func NewGateway(p *config.Parsed, log *slog.Logger, geo *geoip.Service) (*Gatewa
transport: t,
promHandler: promhttp.Handler(),
}
exposeErr := p.Config.ExposeUpstreamErrors
for i := range p.Config.Servers {
s := &p.Config.Servers[i]
u, err := url.Parse(s.BaseURL)
@@ -64,12 +81,7 @@ func NewGateway(p *config.Parsed, log *slog.Logger, geo *geoip.Service) (*Gatewa
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) {
log.Error("upstream error", "alias", s.Alias, "err", err)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusBadGateway)
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": false,
"error": map[string]string{"code": "bad_gateway", "message": "upstream unreachable"},
})
writeBadGatewayJSON(w, exposeErr, "bad_gateway", "upstream unreachable", err)
})
}
for alias, m := range p.MihomoByAlias {
@@ -77,12 +89,7 @@ func NewGateway(p *config.Parsed, log *slog.Logger, geo *geoip.Service) (*Gatewa
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)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusBadGateway)
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": false,
"error": map[string]string{"code": "bad_gateway", "message": "mihomo upstream unreachable"},
})
writeBadGatewayJSON(w, exposeErr, "bad_gateway", "mihomo upstream unreachable", err)
})
}
var aggCacheTTL time.Duration
@@ -92,6 +99,21 @@ func NewGateway(p *config.Parsed, log *slog.Logger, geo *geoip.Service) (*Gatewa
g.corsAllowed = append([]string(nil), p.Config.CorsAllowedOrigins...)
g.agg = aggregate.NewHandler(p, &http.Client{Transport: t}, geo, aggCacheTTL)
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
}
@@ -238,6 +260,13 @@ func routeEndpoint(path string) string {
if path == "/api/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") {
return "live_events"
}
@@ -279,6 +308,10 @@ func (g *Gateway) serve(w http.ResponseWriter, r *http.Request) {
proxy.NormalizeRequestURLPath(r)
}
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/") {
g.agg.ServeHTTP(w, r)
return
@@ -287,6 +320,10 @@ func (g *Gateway) serve(w http.ResponseWriter, r *http.Request) {
g.serveLiveEvents(w, r)
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) {
g.webUI.ServeHTTP(w, r)
return
+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)
}
}
}
+122
View File
@@ -180,6 +180,50 @@ export async function fetchAggFleetStatus(params?: { aliases?: string }): Promis
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?: {
aliases?: string;
geo?: boolean;
@@ -238,6 +282,35 @@ export async function fetchAggIncidents(params?: { aliases?: string }): Promise<
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 {
const q = new URLSearchParams();
if (params?.aliases) q.set('aliases', params.aliases);
@@ -357,3 +430,52 @@ export async function deleteUser(
}
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 @@
export type BreadcrumbItem = { href: string; label: string; current?: boolean };
const SEG_LABELS: Record<string, string> = {
users: 'Пользователи',
ips: 'Уникальные IP',
incidents: 'Инциденты',
live: 'Поток',
radar: 'Радар DC',
mihomo: 'Mihomo',
servers: 'Серверы',
runtime: 'Состояние',
security: 'Безопасность',
upstreams: 'Апстримы и ДЦ',
config: 'Конфигурация',
update: 'Обновление'
};
function segLabel(seg: string): string {
return SEG_LABELS[seg] ?? decodeURIComponent(seg);
}
export function breadcrumbsFromPath(pathname: string): BreadcrumbItem[] {
const p = pathname || '/';
if (p === '/') {
return [{ href: '/', label: 'Обзор', current: true }];
}
const parts = p.split('/').filter(Boolean);
const items: BreadcrumbItem[] = [{ href: '/', label: 'Обзор' }];
let acc = '';
for (let i = 0; i < parts.length; i++) {
const seg = parts[i];
acc += `/${seg}`;
items.push({
href: acc,
label: segLabel(seg),
current: i === parts.length - 1
});
}
return items;
}
+94
View File
@@ -0,0 +1,94 @@
/** Цвета для Chart.js из CSS-переменных темы (светлая/тёмная). */
export function readCssVar(name: string): string {
if (typeof document === 'undefined') return '';
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
export function chartTickColor(): string {
return readCssVar('--muted-foreground') || 'oklch(0.552 0.016 285.938)';
}
export function chartGridColor(): string {
const b = readCssVar('--border');
return b ? `color-mix(in oklch, ${b} 45%, transparent)` : 'oklch(0.5 0.02 285 / 0.12)';
}
export function chartSeriesColors(): string[] {
return [
readCssVar('--chart-1') || '#6366f1',
readCssVar('--chart-2') || '#22c55e',
readCssVar('--chart-3') || '#eab308',
readCssVar('--chart-4') || '#f97316',
readCssVar('--chart-5') || '#ec4899'
];
}
export function chartLegendColor(): string {
return chartTickColor();
}
/**
* Добавляет альфа-канал к цвету из темы (oklch/hex/rgb).
* Нельзя склеивать `oklch(...) + "33"` — в canvas это даёт невалидный цвет и чёрную заливку.
*/
export function chartColorWithAlpha(base: string, alpha: number): string {
const t = base.trim();
const indigo = `rgba(99, 102, 241, ${alpha})`;
if (!t) return indigo;
const oklch = /^oklch\(\s*(.+)\s*\)$/i.exec(t);
if (oklch) {
let inner = oklch[1].trim().replace(/\s*\/\s*[\d.]+%?\s*$/, '').trim();
return `oklch(${inner} / ${alpha})`;
}
if (t.startsWith('#')) {
let h = t.slice(1);
if (h.length === 3) {
h = [...h].map((c) => c + c).join('');
}
if (h.length === 6 || h.length === 8) {
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
}
if (/^rgba?\(/i.test(t)) {
const m = t.match(/rgba?\(\s*([^)]+)\s*\)/i);
if (m) {
const parts = m[1].split(',').map((s) => s.trim());
if (parts.length >= 3) {
return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${alpha})`;
}
}
}
return `color-mix(in srgb, ${t} ${Math.round(alpha * 100)}%, transparent)`;
}
/** Лёгкая заливка под линией: сверху чуть насыщеннее, к низу к прозрачному. */
export function chartLineAreaGradient(
ctx: CanvasRenderingContext2D,
chartArea: { top: number; bottom: number },
lineColor: string
): CanvasGradient {
const g = ctx.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
const c = lineColor.trim() || '#6366f1';
g.addColorStop(0, chartColorWithAlpha(c, 0.16));
g.addColorStop(0.45, chartColorWithAlpha(c, 0.045));
g.addColorStop(1, chartColorWithAlpha(c, 0));
return g;
}
/** Callback для Chart.js `dataset.backgroundColor` у линейных графиков с заливкой. */
export function chartLineAreaFillCallback(lineColor: string) {
return (context: { chart: { ctx: CanvasRenderingContext2D; chartArea?: { top: number; bottom: number } } }) => {
const { chart } = context;
const area = chart.chartArea;
if (!area) return 'transparent';
return chartLineAreaGradient(chart.ctx, area, lineColor);
};
}
@@ -1,41 +1,43 @@
<script lang="ts">
import * as Select from "$lib/components/ui/select/index.js";
import { untrack } from 'svelte';
let {
availableAliases = [],
value = $bindable("all"),
label = "Alias",
allLabel = "All nodes"
allLabel = "All nodes",
onValueChange
}: {
availableAliases: string[];
value?: string;
label?: string;
allLabel?: string;
onValueChange?: (next: string) => void;
} = $props();
let prev = value || "all";
$effect(() => {
if ((value ?? "all") !== "all" && !availableAliases.includes(value ?? "all")) {
value = "all";
}
if (!value) {
value = "all";
return;
}
if (value !== prev) {
prev = value;
untrack(() => onValueChange?.(value));
}
});
let selectedLabel = $derived((value ?? "all") === "all" ? allLabel : (value ?? "all"));
</script>
<label class="text-xs text-muted-foreground">
{label}
<Select.Root type="single" bind:value>
<Select.Trigger class="mt-1 w-[220px]">
{selectedLabel}
</Select.Trigger>
<Select.Content class="z-[1200]">
<Select.Item value="all">{allLabel}</Select.Item>
{#each availableAliases as alias (alias)}
<Select.Item value={alias}>{alias}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<select
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"
bind:value
>
<option value="all">{allLabel}</option>
{#each availableAliases as alias (alias)}
<option value={alias}>{alias}</option>
{/each}
</select>
</label>
+36 -15
View File
@@ -12,6 +12,7 @@
import ZapIcon from '@lucide/svelte/icons/zap';
import SirenIcon from '@lucide/svelte/icons/siren';
import RadioIcon from '@lucide/svelte/icons/radio';
import RadarIcon from '@lucide/svelte/icons/radar';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
let {
@@ -85,7 +86,7 @@
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active('/incidents')} tooltipContent="Incidents">
<Sidebar.MenuButton isActive={active('/incidents')} tooltipContent="Инциденты">
{#snippet child({ props })}
<a href="/incidents" {...props}>
<SirenIcon />
@@ -104,6 +105,26 @@
{/snippet}
</Sidebar.MenuButton>
</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.Group>
{#if serverAliases.length > 0}
@@ -133,11 +154,11 @@
<Sidebar.GroupLabel>Нода</Sidebar.GroupLabel>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={activeExact(serverPath)} tooltipContent="Dashboard">
<Sidebar.MenuButton isActive={activeExact(serverPath)} tooltipContent="Обзор">
{#snippet child({ props })}
<a href={serverPath} {...props}>
<LayoutDashboardIcon />
<span>Dashboard</span>
<span>Обзор</span>
</a>
{/snippet}
</Sidebar.MenuButton>
@@ -153,61 +174,61 @@
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active(serverPath + '/runtime')} tooltipContent="Runtime">
<Sidebar.MenuButton isActive={active(serverPath + '/runtime')} tooltipContent="Состояние">
{#snippet child({ props })}
<a href="{serverPath}/runtime" {...props}>
<ActivityIcon />
<span>Runtime</span>
<span>Состояние</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active(serverPath + '/users')} tooltipContent="Users">
<Sidebar.MenuButton isActive={active(serverPath + '/users')} tooltipContent="Пользователи">
{#snippet child({ props })}
<a href="{serverPath}/users" {...props}>
<UsersIcon />
<span>Users</span>
<span>Пользователи</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active(serverPath + '/security')} tooltipContent="Security">
<Sidebar.MenuButton isActive={active(serverPath + '/security')} tooltipContent="Безопасность">
{#snippet child({ props })}
<a href="{serverPath}/security" {...props}>
<ShieldIcon />
<span>Security</span>
<span>Безопасность</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active(serverPath + '/upstreams')} tooltipContent="Upstreams">
<Sidebar.MenuButton isActive={active(serverPath + '/upstreams')} tooltipContent="Апстримы и ДЦ">
{#snippet child({ props })}
<a href="{serverPath}/upstreams" {...props}>
<NetworkIcon />
<span>Upstreams & DCs</span>
<span>Апстримы и ДЦ</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active(serverPath + '/config')} tooltipContent="Configuration">
<Sidebar.MenuButton isActive={active(serverPath + '/config')} tooltipContent="Конфигурация">
{#snippet child({ props })}
<a href="{serverPath}/config" {...props}>
<SettingsIcon />
<span>Configuration</span>
<span>Конфигурация</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active(serverPath + '/update')} tooltipContent="Update">
<Sidebar.MenuButton isActive={active(serverPath + '/update')} tooltipContent="Обновление">
{#snippet child({ props })}
<a href="{serverPath}/update" {...props}>
<RefreshCwIcon />
<span>Update</span>
<span>Обновление</span>
</a>
{/snippet}
</Sidebar.MenuButton>
@@ -0,0 +1,63 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card/index.js';
import { cn } from '$lib/utils.js';
import { kpiAccent, type KpiAccentKey } from '$lib/kpi-accents.js';
import type { Snippet } from 'svelte';
let {
title,
description,
accent,
icon,
class: className,
toolbar,
children
}: {
title?: string;
description?: string;
accent?: KpiAccentKey;
icon?: Snippet;
toolbar?: Snippet;
children: Snippet;
class?: string;
} = $props();
</script>
<Card.Root
density="compact"
class={cn(
'overflow-hidden rounded-lg border border-border shadow-sm',
accent && 'border-l-4',
accent && kpiAccent[accent].border,
className
)}
>
{#if title}
<Card.Header class="border-b border-border bg-muted/30 px-4 py-3.5">
<div class="flex gap-3">
{#if icon && accent}
<div
class={cn(
'flex size-10 shrink-0 items-center justify-center rounded-lg',
kpiAccent[accent].iconWrap
)}
>
{@render icon()}
</div>
{/if}
<div class="min-w-0 flex-1 space-y-0.5">
<Card.Title class="text-base font-semibold">{title}</Card.Title>
{#if description}
<Card.Description class="text-xs sm:text-sm">{description}</Card.Description>
{/if}
</div>
</div>
</Card.Header>
{/if}
{#if toolbar}
{@render toolbar()}
{/if}
<Card.Content class="p-0">
{@render children()}
</Card.Content>
</Card.Root>
@@ -0,0 +1,21 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { Snippet } from 'svelte';
let {
class: className,
children
}: {
class?: string;
children: Snippet;
} = $props();
</script>
<div
class={cn(
'flex flex-wrap items-center justify-between gap-2 border-b border-border px-3 py-2',
className
)}
>
{@render children()}
</div>
@@ -0,0 +1,55 @@
<script lang="ts">
import * as Table from '$lib/components/ui/table/index.js';
import { cn } from '$lib/utils.js';
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
import type { Snippet } from 'svelte';
import type { SortDir } from '$lib/table-sort.js';
let {
active,
dir,
onToggle,
class: className,
children,
numeric
}: {
active: boolean;
dir: SortDir | null;
onToggle: () => void;
class?: string;
children: Snippet;
numeric?: boolean;
} = $props();
</script>
<Table.Head
class={cn(
'px-3 py-2 text-left align-middle text-sm font-medium text-muted-foreground',
numeric && 'text-right',
className
)}
>
<button
type="button"
class={cn(
'inline-flex max-w-full items-center gap-1.5 rounded-md',
'text-muted-foreground transition-colors',
'hover:bg-accent/60 hover:text-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/45',
active && 'text-foreground',
numeric && 'ms-auto'
)}
onclick={onToggle}
>
{@render children()}
{#if active && dir === 'asc'}
<ArrowUpIcon class="size-3 shrink-0 opacity-70" aria-hidden="true" />
{:else if active && dir === 'desc'}
<ArrowDownIcon class="size-3 shrink-0 opacity-70" aria-hidden="true" />
{:else}
<ArrowUpDownIcon class="size-3 shrink-0 opacity-40" aria-hidden="true" />
{/if}
</button>
</Table.Head>
@@ -0,0 +1,26 @@
<script lang="ts">
import * as Empty from '$lib/components/ui/empty/index.js';
import InboxIcon from '@lucide/svelte/icons/inbox';
let {
title,
description
}: {
title: string;
description?: string;
} = $props();
</script>
<div class="flex justify-center py-10">
<Empty.Root class="max-w-sm border-0">
<Empty.Header>
<Empty.Media variant="icon">
<InboxIcon />
</Empty.Media>
<Empty.Title>{title}</Empty.Title>
{#if description}
<Empty.Description>{description}</Empty.Description>
{/if}
</Empty.Header>
</Empty.Root>
</div>
@@ -0,0 +1,178 @@
<script lang="ts">
import { onDestroy, onMount, tick } from 'svelte';
import Chart from 'chart.js/auto';
import * as Card from '$lib/components/ui/card/index.js';
import {
chartColorWithAlpha,
chartGridColor,
chartLegendColor,
chartSeriesColors,
chartTickColor,
readCssVar
} from '$lib/chart-theme.js';
import ServerIcon from '@lucide/svelte/icons/server';
import ChartNoAxesCombinedIcon from '@lucide/svelte/icons/chart-no-axes-combined';
let {
nodesOk,
nodesDegraded,
topUsers
}: {
nodesOk: number;
nodesDegraded: number;
topUsers: { username: string; total_megabytes: number }[];
} = $props();
let elDonut = $state<HTMLCanvasElement | null>(null);
let elBar = $state<HTMLCanvasElement | null>(null);
let chartDonut: Chart | null = null;
let chartBar: Chart | null = null;
function buildDonut() {
if (!elDonut) return;
const colors = chartSeriesColors();
const legend = chartLegendColor();
chartDonut?.destroy();
const hole = readCssVar('--card') || readCssVar('--background') || '#18181b';
const okColor = chartColorWithAlpha(colors[1] ?? '#22c55e', 0.88);
const degradedColor = chartColorWithAlpha(colors[4] ?? '#ef4444', 0.88);
chartDonut = new Chart(elDonut, {
type: 'doughnut',
data: {
labels: ['OK', 'Degraded'],
datasets: [
{
data: [Math.max(nodesOk, 0), Math.max(nodesDegraded, 0)],
backgroundColor: [okColor, degradedColor],
hoverBackgroundColor: [okColor, degradedColor],
borderWidth: 2,
borderColor: hole,
hoverBorderWidth: 2,
hoverBorderColor: hole,
hoverOffset: 4
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
cutout: '58%',
plugins: { legend: { labels: { color: legend } } }
}
});
}
function buildBar() {
if (!elBar) return;
const colors = chartSeriesColors();
const tickC = chartTickColor();
const grid = chartGridColor();
const slice = topUsers.slice(0, 8);
const barColor = chartColorWithAlpha(colors[0] ?? '#6366f1', 0.52);
chartBar?.destroy();
chartBar = new Chart(elBar, {
type: 'bar',
data: {
labels: slice.map((x) => x.username),
datasets: [
{
label: 'MiB',
data: slice.map((x) => x.total_megabytes),
backgroundColor: barColor,
hoverBackgroundColor: barColor,
borderRadius: 4,
borderSkipped: false
}
]
},
options: {
indexAxis: 'y' as const,
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: tickC }, grid: { color: grid } },
y: { ticks: { color: tickC }, grid: { display: false } }
}
}
});
}
function rebuildAll() {
buildDonut();
buildBar();
}
onMount(() => {
let cancelled = false;
void tick().then(() => {
if (!cancelled) rebuildAll();
});
const obs = new MutationObserver(() => {
rebuildAll();
});
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => {
cancelled = true;
obs.disconnect();
};
});
$effect(() => {
nodesOk;
nodesDegraded;
topUsers;
if (chartDonut) {
chartDonut.data.datasets[0].data = [Math.max(nodesOk, 0), Math.max(nodesDegraded, 0)];
chartDonut.update('none');
}
if (chartBar) {
const slice = topUsers.slice(0, 8);
chartBar.data.labels = slice.map((x) => x.username);
chartBar.data.datasets[0].data = slice.map((x) => x.total_megabytes);
chartBar.update('none');
}
});
onDestroy(() => {
chartDonut?.destroy();
chartBar?.destroy();
});
</script>
<div class="mb-6 grid gap-4 lg:grid-cols-2">
<Card.Root density="compact" class="overflow-hidden rounded-lg border border-border shadow-sm">
<Card.Header class="border-b border-border bg-muted/40 px-4 py-3">
<div class="flex items-center gap-2">
<div class="flex size-9 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<ServerIcon class="size-5" />
</div>
<div>
<Card.Title class="text-base">Статус нод</Card.Title>
<Card.Description class="text-xs">OK vs degraded по fleet-status</Card.Description>
</div>
</div>
</Card.Header>
<Card.Content class="h-56 p-4">
<canvas bind:this={elDonut} class="max-h-full w-full"></canvas>
</Card.Content>
</Card.Root>
<Card.Root density="compact" class="overflow-hidden rounded-lg border border-border shadow-sm">
<Card.Header class="border-b border-border bg-muted/40 px-4 py-3">
<div class="flex items-center gap-2">
<div class="flex size-9 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<ChartNoAxesCombinedIcon class="size-5" />
</div>
<div>
<Card.Title class="text-base">Топ трафика</Card.Title>
<Card.Description class="text-xs">До 8 пользователей, MiB</Card.Description>
</div>
</div>
</Card.Header>
<Card.Content class="h-56 p-4">
<canvas bind:this={elBar} class="max-h-full w-full"></canvas>
</Card.Content>
</Card.Root>
</div>
@@ -0,0 +1,98 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import { cn } from '$lib/utils.js';
import TrendingUpIcon from '@lucide/svelte/icons/trending-up';
import TrendingDownIcon from '@lucide/svelte/icons/trending-down';
import MinusIcon from '@lucide/svelte/icons/minus';
import { formatSignedPercent, percentChange, trendFromZero } from '$lib/kpi-trend-math.js';
let {
label,
insight,
valueDisplay,
prevValue,
currentValue,
invertTrend = false,
class: className
}: {
label: string;
/** Короткий текст под разделителем */
insight: string;
/** Отображаемое основное значение */
valueDisplay: string;
prevValue: number | null;
currentValue: number | null;
/** true: рост значения = хуже (красный «вверх») */
invertTrend?: boolean;
class?: string;
} = $props();
type TrendOut =
| null
| { raw: 'flat'; label: string; good: boolean }
| { raw: 'up' | 'down'; label: string; good: boolean };
const trend = $derived.by((): TrendOut => {
if (prevValue == null || currentValue == null) return null;
if (!Number.isFinite(prevValue) || !Number.isFinite(currentValue)) return null;
const dir = trendFromZero(prevValue, currentValue);
if (dir === null || dir === 'flat') {
if (dir === 'flat' && prevValue === currentValue)
return {
raw: 'flat' as const,
label: '0%',
good: true
};
return null;
}
const raw: 'up' | 'down' = dir === 'up' ? 'up' : 'down';
const good = invertTrend ? raw === 'down' : raw === 'up';
const pct = percentChange(prevValue, currentValue);
if (pct != null) {
return { raw, label: formatSignedPercent(pct), good };
}
if (prevValue === 0 && currentValue !== 0) {
return { raw, label: 'от нуля', good };
}
return null;
});
</script>
<Card.Root
class={cn(
'border-l-4 border-l-muted-foreground/35 bg-card shadow-sm transition-colors dark:bg-card/80',
className
)}
>
<Card.Header class="space-y-0 pb-2">
<div class="flex items-start justify-between gap-2">
<Card.Description class="text-xs sm:text-sm">{label}</Card.Description>
{#if trend}
<Badge
variant="secondary"
class={cn(
'gap-0.5 border-0 font-medium tabular-nums',
trend.good &&
'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
!trend.good && trend.raw !== 'flat' && 'bg-red-500/15 text-red-700 dark:text-red-400',
trend.raw === 'flat' && 'text-muted-foreground'
)}
>
{#if trend.raw === 'up'}
<TrendingUpIcon class="size-3.5" aria-hidden="true" />
{:else if trend.raw === 'down'}
<TrendingDownIcon class="size-3.5" aria-hidden="true" />
{:else}
<MinusIcon class="size-3.5" aria-hidden="true" />
{/if}
{trend.label}
</Badge>
{/if}
</div>
<Card.Title class="pt-1 text-2xl font-semibold tabular-nums tracking-tight">{valueDisplay}</Card.Title>
</Card.Header>
<Separator />
<Card.Content class="pt-3 text-sm text-muted-foreground">{insight}</Card.Content>
</Card.Root>
@@ -1,10 +1,22 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { onDestroy, onMount, tick } from 'svelte';
import Chart from 'chart.js/auto';
import * as Card from '$lib/components/ui/card/index.js';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import ActivityIcon from '@lucide/svelte/icons/activity';
import 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,
@@ -42,85 +54,232 @@
let chartNet: Chart | null = null;
let chartTop: Chart | null = null;
const grid = '#64748b';
function lineOpts() {
const tick = chartTickColor();
const grid = chartGridColor();
const legend = chartLegendColor();
return {
responsive: true,
maintainAspectRatio: false,
animation: false as const,
plugins: { legend: { labels: { color: grid } } },
plugins: { legend: { labels: { color: legend } } },
scales: {
x: { ticks: { color: grid, maxTicksLimit: 8 }, grid: { color: '#33415555' } },
y: { ticks: { color: grid }, grid: { color: '#33415555' } }
x: { ticks: { color: tick, maxTicksLimit: 8 }, grid: { color: grid } },
y: { ticks: { color: tick }, grid: { color: grid } }
}
};
}
onMount(() => {
if (!elTraffic || !elFlow || !elMem || !elConn || !elNet || !elTop) return;
function rebuildAll() {
const colors = chartSeriesColors();
const legend = chartLegendColor();
const grid = chartGridColor();
const tick = chartTickColor();
const lab = histDown.map((_, i) => String(i));
chartTraffic = new Chart(elTraffic, {
type: 'line',
data: {
labels: lab,
datasets: [
{ label: 'Скачивание', data: [...histDown], borderColor: '#38bdf8', tension: 0.2, fill: true },
{ label: 'Загрузка', data: [...histUp], borderColor: '#f472b6', tension: 0.2, fill: true }
]
},
options: lineOpts()
});
chartFlow = new Chart(elFlow, {
type: 'doughnut',
data: {
labels: ['Скачано', 'Загружено'],
datasets: [{ data: [Math.max(totalDown, 0), Math.max(totalUp, 0)], backgroundColor: ['#38bdf8', '#f472b6'] }]
},
options: { responsive: true, maintainAspectRatio: false, animation: false, plugins: { legend: { labels: { color: grid } } } }
});
chartMem = new Chart(elMem, {
type: 'line',
data: {
labels: histMem.map((_, i) => String(i)),
datasets: [{ label: 'KB', data: [...histMem], borderColor: '#a78bfa', tension: 0.2, fill: true }]
},
options: lineOpts()
});
chartConn = new Chart(elConn, {
type: 'line',
data: {
labels: histConn.map((_, i) => String(i)),
datasets: [{ label: 'Соединения', data: [...histConn], borderColor: '#34d399', tension: 0.2, fill: true }]
},
options: lineOpts()
});
chartNet = new Chart(elNet, {
type: 'doughnut',
data: {
labels: ['TCP', 'UDP'],
datasets: [{ data: [tcpN, udpN], backgroundColor: ['#22c55e', '#eab308'] }]
},
options: { responsive: true, maintainAspectRatio: false, animation: false, plugins: { legend: { labels: { color: grid } } } }
});
chartTop = new Chart(elTop, {
type: 'bar',
data: {
labels: topList.map((x) => x.name),
datasets: [{ label: 'Сессии', data: topList.map((x) => x.n), backgroundColor: '#818cf8' }]
},
options: {
indexAxis: 'y' as const,
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: grid }, grid: { color: '#33415555' } },
y: { ticks: { color: grid }, grid: { display: false } }
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(() => {
@@ -167,58 +326,76 @@
</script>
<div class="grid gap-4 lg:grid-cols-2">
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2 text-base">
<ActivityIcon class="size-4" /> Трафик
</Card.Title>
</Card.Header>
<Card.Content class="h-56">
<canvas bind:this={elTraffic} class="max-h-full w-full"></canvas>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-base">Поток (накоплено)</Card.Title>
</Card.Header>
<Card.Content class="h-56">
<canvas bind:this={elFlow} class="max-h-full w-full"></canvas>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-base">Память (KB)</Card.Title>
</Card.Header>
<Card.Content class="h-56">
<canvas bind:this={elMem} class="max-h-full w-full"></canvas>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-base">Подключения</Card.Title>
</Card.Header>
<Card.Content class="h-56">
<canvas bind:this={elConn} class="max-h-full w-full"></canvas>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2 text-base">
<NetworkIcon class="size-4" /> Типы сети
</Card.Title>
</Card.Header>
<Card.Content class="h-56">
<canvas bind:this={elNet} class="max-h-full w-full"></canvas>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2 text-base">
<GaugeIcon class="size-4" /> Топ прокси (по сессиям)
</Card.Title>
</Card.Header>
<Card.Content class="h-56">
<canvas bind:this={elTop} class="max-h-full w-full"></canvas>
</Card.Content>
</Card.Root>
<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,40 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { KpiAccentKey } from '$lib/kpi-accents.js';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import BracesIcon from '@lucide/svelte/icons/braces';
let {
title,
description,
accent = 'info',
payload,
class: className,
preClass = 'max-h-[70vh]',
iconSnippet
}: {
title: string;
description?: string;
accent?: KpiAccentKey;
payload: unknown;
class?: string;
preClass?: string;
iconSnippet?: Snippet;
} = $props();
</script>
<KpiPanelCard {accent} {title} {description} class={className} contentClass="p-0">
{#snippet icon()}
{#if iconSnippet}
{@render iconSnippet()}
{:else}
<BracesIcon class="size-5" aria-hidden="true" />
{/if}
{/snippet}
<pre
class={[
preClass,
'overflow-auto rounded-md border border-border bg-muted/40 p-4 text-xs leading-relaxed'
]}
>{JSON.stringify(payload, null, 2)}</pre>
</KpiPanelCard>
@@ -0,0 +1,39 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { Button } from '$lib/components/ui/button/index.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
let {
title,
subtitle,
loading = false,
showRefresh = true,
onRefresh,
actions
}: {
title: string;
subtitle?: string;
loading?: boolean;
showRefresh?: boolean;
onRefresh?: () => void | Promise<void>;
actions?: Snippet;
} = $props();
</script>
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div class="space-y-1">
<h1 class="text-2xl font-semibold tracking-tight">{title}</h1>
{#if subtitle}
<p class="text-sm text-muted-foreground">{subtitle}</p>
{/if}
</div>
<div class="flex items-center gap-2">
{@render actions?.()}
{#if showRefresh}
<Button variant="outline" size="sm" onclick={onRefresh} disabled={loading || !onRefresh}>
<RefreshCwIcon class={['mr-1 size-4', loading && 'animate-spin']} />
Обновить
</Button>
{/if}
</div>
</div>
@@ -0,0 +1,62 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle';
import InboxIcon from '@lucide/svelte/icons/inbox';
let {
loading = false,
err = null,
isEmpty = false,
loadingTitle = 'Загрузка данных',
loadingDescription = 'Пожалуйста, подождите...',
emptyTitle = 'Нет данных',
emptyDescription = 'По этому endpoint пока нет содержимого.',
children
}: {
loading?: boolean;
err?: string | null;
isEmpty?: boolean;
loadingTitle?: string;
loadingDescription?: string;
emptyTitle?: string;
emptyDescription?: string;
children?: Snippet;
} = $props();
</script>
{#if err}
<Alert variant="destructive" class="mb-4">
<AlertTitle>Ошибка</AlertTitle>
<AlertDescription>{err}</AlertDescription>
</Alert>
{/if}
{#if loading}
<KpiPanelCard
accent="neutral"
title={loadingTitle}
description={loadingDescription}
contentClass="pt-0 text-sm text-muted-foreground"
>
{#snippet icon()}
<LoaderCircleIcon class="size-5 animate-spin" aria-hidden="true" />
{/snippet}
Идет запрос к ноде, обновляем показатели dashboard.
</KpiPanelCard>
{:else if !err && isEmpty}
<KpiPanelCard
accent="neutral"
title={emptyTitle}
description={emptyDescription}
contentClass="pt-0 text-sm text-muted-foreground"
>
{#snippet icon()}
<InboxIcon class="size-5" aria-hidden="true" />
{/snippet}
Проверьте параметры запроса или состояние сервера.
</KpiPanelCard>
{:else if !loading && !isEmpty}
{@render children?.()}
{/if}
+94
View File
@@ -0,0 +1,94 @@
<script lang="ts">
import { page } from '$app/state';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { breadcrumbsFromPath } from '$lib/breadcrumb-trail.js';
import { setMode, resetMode, userPrefersMode } from 'mode-watcher';
import MonitorIcon from '@lucide/svelte/icons/monitor';
import SunIcon from '@lucide/svelte/icons/sun';
import MoonIcon from '@lucide/svelte/icons/moon';
const items = $derived(breadcrumbsFromPath(page.url.pathname));
</script>
<div class="flex min-w-0 flex-1 items-center justify-between gap-3">
<div class="flex min-w-0 flex-1 items-center gap-2">
<Sidebar.Trigger class="-ml-1" />
<Separator orientation="vertical" class="me-1 h-4" />
<Breadcrumb.Root class="min-w-0">
<Breadcrumb.List class="flex-wrap">
{#each items as it, i (it.href + String(i))}
<Breadcrumb.Item class="min-w-0 max-w-[140px] sm:max-w-[220px]">
{#if it.current}
<Breadcrumb.Page class="truncate">{it.label}</Breadcrumb.Page>
{:else}
<Breadcrumb.Link href={it.href} class="truncate">{it.label}</Breadcrumb.Link>
{/if}
</Breadcrumb.Item>
{#if i < items.length - 1}
<Breadcrumb.Separator />
{/if}
{/each}
</Breadcrumb.List>
</Breadcrumb.Root>
</div>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
size="icon-sm"
class="relative shrink-0"
aria-label="Тема оформления"
>
<SunIcon
class="size-4 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90"
/>
<MoonIcon
class="absolute size-4 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0"
/>
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-44">
<DropdownMenu.Label class="text-xs text-muted-foreground">Тема</DropdownMenu.Label>
<DropdownMenu.Item
class="gap-2"
onclick={() => {
setMode('light');
}}
>
<SunIcon class="size-4" /> Светлая
{#if userPrefersMode.current === 'light'}
<span class="ms-auto text-xs text-muted-foreground">✓</span>
{/if}
</DropdownMenu.Item>
<DropdownMenu.Item
class="gap-2"
onclick={() => {
setMode('dark');
}}
>
<MoonIcon class="size-4" /> Тёмная
{#if userPrefersMode.current === 'dark'}
<span class="ms-auto text-xs text-muted-foreground">✓</span>
{/if}
</DropdownMenu.Item>
<DropdownMenu.Item
class="gap-2"
onclick={() => {
resetMode();
}}
>
<MonitorIcon class="size-4" /> Системная
{#if userPrefersMode.current === 'system'}
<span class="ms-auto text-xs text-muted-foreground">✓</span>
{/if}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
@@ -0,0 +1,39 @@
<script lang="ts">
import { Badge } from '$lib/components/ui/badge/index.js';
import { cn } from '$lib/utils.js';
import CheckIcon from '@lucide/svelte/icons/check';
import Loader2Icon from '@lucide/svelte/icons/loader-circle';
import AlertCircleIcon from '@lucide/svelte/icons/circle-alert';
let {
variant
}: {
variant: 'ok' | 'degraded' | 'pending' | 'error';
} = $props();
</script>
{#if variant === 'ok'}
<Badge
class={cn(
'gap-1 border-0 bg-emerald-500/15 font-medium text-emerald-700 dark:text-emerald-400'
)}
>
<CheckIcon class="size-3.5 shrink-0" aria-hidden="true" />
OK
</Badge>
{:else if variant === 'degraded'}
<Badge variant="destructive" class="gap-1 font-medium">
<AlertCircleIcon class="size-3.5 shrink-0" aria-hidden="true" />
degraded
</Badge>
{:else if variant === 'pending'}
<Badge variant="secondary" class="gap-1 font-medium text-muted-foreground">
<Loader2Icon class="size-3.5 shrink-0 animate-spin" aria-hidden="true" />
ожидание
</Badge>
{:else}
<Badge variant="destructive" class="gap-1 font-medium">
<AlertCircleIcon class="size-3.5 shrink-0" aria-hidden="true" />
ошибка
</Badge>
{/if}
@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLSpanElement>>> = $props();
</script>
<span
bind:this={ref}
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
class={cn("size-5 [&>svg]:size-4 flex items-center justify-center", className)}
{...restProps}
>
<MoreHorizontalIcon />
<span class="sr-only">More</span>
</span>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLLiAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLLiAttributes> = $props();
</script>
<li
bind:this={ref}
data-slot="breadcrumb-item"
class={cn("gap-1 inline-flex items-center", className)}
{...restProps}
>
{@render children?.()}
</li>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import type { Snippet } from "svelte";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
href = undefined,
child,
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
child?: Snippet<[{ props: HTMLAnchorAttributes }]>;
} = $props();
const attrs = $derived({
"data-slot": "breadcrumb-link",
class: cn("hover:text-foreground transition-colors", className),
href,
...restProps,
});
</script>
{#if child}
{@render child({ props: attrs })}
{:else}
<a bind:this={ref} {...attrs}>
{@render children?.()}
</a>
{/if}
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLOlAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLOlAttributes> = $props();
</script>
<ol
bind:this={ref}
data-slot="breadcrumb-list"
class={cn("text-muted-foreground gap-1.5 text-sm flex flex-wrap items-center wrap-break-word", className)}
{...restProps}
>
{@render children?.()}
</ol>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
class={cn("text-foreground font-normal", className)}
{...restProps}
>
{@render children?.()}
</span>
@@ -0,0 +1,27 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLLiAttributes } from "svelte/elements";
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLLiAttributes> = $props();
</script>
<li
bind:this={ref}
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
class={cn("[&>svg]:size-3.5", className)}
{...restProps}
>
{#if children}
{@render children?.()}
{:else}
<ChevronRightIcon />
{/if}
</li>
@@ -0,0 +1,22 @@
<script lang="ts">
import type { WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<nav
bind:this={ref}
data-slot="breadcrumb"
aria-label="breadcrumb"
class={cn("cn-breadcrumb", className)}
{...restProps}
>
{@render children?.()}
</nav>
@@ -0,0 +1,25 @@
import Root from "./breadcrumb.svelte";
import Ellipsis from "./breadcrumb-ellipsis.svelte";
import Item from "./breadcrumb-item.svelte";
import Separator from "./breadcrumb-separator.svelte";
import Link from "./breadcrumb-link.svelte";
import List from "./breadcrumb-list.svelte";
import Page from "./breadcrumb-page.svelte";
export {
Root,
Ellipsis,
Item,
Separator,
Link,
List,
Page,
//
Root as Breadcrumb,
Ellipsis as BreadcrumbEllipsis,
Item as BreadcrumbItem,
Separator as BreadcrumbSeparator,
Link as BreadcrumbLink,
List as BreadcrumbList,
Page as BreadcrumbPage,
};
+12 -2
View File
@@ -7,15 +7,25 @@
class: className,
children,
size = "default",
density = "default",
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: "default" | "sm" } = $props();
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
size?: "default" | "sm";
density?: "default" | "compact";
} = $props();
</script>
<div
bind:this={ref}
data-slot="card"
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}
>
{@render children?.()}
@@ -0,0 +1,39 @@
<script lang="ts">
import { Checkbox as CheckboxPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import CheckIcon from '@lucide/svelte/icons/check';
import MinusIcon from '@lucide/svelte/icons/minus';
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
...restProps
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
</script>
<CheckboxPrimitive.Root
bind:ref
data-slot="checkbox"
class={cn(
"border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-colors group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
bind:checked
bind:indeterminate
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<div
data-slot="checkbox-indicator"
class="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
>
{#if checked}
<CheckIcon />
{:else if indeterminate}
<MinusIcon />
{/if}
</div>
{/snippet}
</CheckboxPrimitive.Root>
@@ -0,0 +1,6 @@
import Root from "./checkbox.svelte";
export {
Root,
//
Root as Checkbox,
};
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="empty-content"
class={cn(
"gap-2.5 text-sm flex w-full max-w-sm min-w-0 flex-col items-center text-balance",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="empty-description"
class={cn(
"text-sm/relaxed text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="empty-header"
class={cn("gap-2 flex max-w-sm flex-col items-center", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,41 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const emptyMediaVariants = tv({
base: "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-8 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
},
});
export type EmptyMediaVariant = VariantProps<typeof emptyMediaVariants>["variant"];
</script>
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
variant = "default",
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { variant?: EmptyMediaVariant } = $props();
</script>
<div
bind:this={ref}
data-slot="empty-icon"
data-variant={variant}
class={cn(emptyMediaVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,15 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} data-slot="empty-title" class={cn("text-sm font-medium tracking-tight", className)} {...restProps}>
{@render children?.()}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="empty"
class={cn(
"gap-4 rounded-xl border-dashed p-6 flex w-full min-w-0 flex-1 flex-col items-center justify-center text-center text-balance",
className
)}
{...restProps}
>
{@render children?.()}
</div>
+22
View File
@@ -0,0 +1,22 @@
import Root from "./empty.svelte";
import Header from "./empty-header.svelte";
import Media from "./empty-media.svelte";
import Title from "./empty-title.svelte";
import Description from "./empty-description.svelte";
import Content from "./empty-content.svelte";
export {
Root,
Header,
Media,
Title,
Description,
Content,
//
Root as Empty,
Header as EmptyHeader,
Media as EmptyMedia,
Title as EmptyTitle,
Description as EmptyDescription,
Content as EmptyContent,
};
@@ -13,13 +13,13 @@
</script>
{#snippet Fallback()}
<span>Next</span>
<span>Следующая</span>
<ChevronRightIcon class="size-4" />
{/snippet}
<PaginationPrimitive.NextButton
bind:ref
aria-label="Go to next page"
aria-label="Перейти на следующую страницу"
class={cn(buttonVariants({ variant: "ghost" }), "pr-1.5!", className)}
{...restProps}
>
@@ -10,11 +10,11 @@
</script>
<PaginationLink
aria-label="Go to next page"
aria-label="Перейти на следующую страницу"
size="default"
class={cn("pr-1.5!", className)}
{...restProps}
>
<span class="cn-pagination-next-text hidden sm:block">Next</span>
<span class="cn-pagination-next-text hidden sm:block">Следующая</span>
<ChevronRightIcon data-icon="inline-end" />
</PaginationLink>
@@ -14,12 +14,12 @@
{#snippet Fallback()}
<ChevronLeftIcon class="size-4" />
<span>Previous</span>
<span>Предыдущая</span>
{/snippet}
<PaginationPrimitive.PrevButton
bind:ref
aria-label="Go to previous page"
aria-label="Перейти на предыдущую страницу"
class={cn(buttonVariants({ variant: "ghost" }), "pl-1.5!", className)}
{...restProps}
>
@@ -10,11 +10,11 @@
</script>
<PaginationLink
aria-label="Go to previous page"
aria-label="Перейти на предыдущую страницу"
size="default"
class={cn("pl-1.5!", className)}
{...restProps}
>
<ChevronLeftIcon data-icon="inline-start" />
<span class="cn-pagination-previous-text hidden sm:block">Previous</span>
<span class="cn-pagination-previous-text hidden sm:block">Предыдущая</span>
</PaginationLink>
@@ -18,7 +18,7 @@
bind:ref
bind:page
role="navigation"
aria-label="pagination"
aria-label="Пагинация"
data-slot="pagination"
{count}
{perPage}
@@ -51,8 +51,8 @@
{side}
>
<Sheet.Header class="sr-only">
<Sheet.Title>Sidebar</Sheet.Title>
<Sheet.Description>Displays the mobile sidebar.</Sheet.Description>
<Sheet.Title>Боковая панель</Sheet.Title>
<Sheet.Description>Отображает мобильную боковую панель.</Sheet.Description>
</Sheet.Header>
<div class="flex h-full w-full flex-col">
{@render children?.()}
+23
View File
@@ -0,0 +1,23 @@
/** Доля изменения для тренд-бейджа; null если сравнивать не с чем. */
export function percentChange(prev: number, cur: number): number | null {
if (!Number.isFinite(prev) || !Number.isFinite(cur)) return null;
if (prev === 0 && cur === 0) return null;
if (prev === 0) return cur === 0 ? null : null; // избегаем бесконечного %; покажем только направление отдельно
const p = ((cur - prev) / Math.abs(prev)) * 100;
return Number.isFinite(p) ? p : null;
}
/** Для prev===0 и cur>0 возвращает «рост от нуля». */
export function trendFromZero(prev: number, cur: number): 'up' | 'down' | 'flat' | null {
if (!Number.isFinite(prev) || !Number.isFinite(cur)) return null;
if (prev === 0 && cur === 0) return null;
if (prev === 0) return cur > 0 ? 'up' : cur < 0 ? 'down' : 'flat';
if (cur > prev) return 'up';
if (cur < prev) return 'down';
return 'flat';
}
export function formatSignedPercent(p: number): string {
const sign = p > 0 ? '+' : '';
return `${sign}${p.toFixed(1)}%`;
}
+39
View File
@@ -0,0 +1,39 @@
const SEG_LABELS: Record<string, string> = {
users: 'Пользователи',
ips: 'Уникальные IP',
incidents: 'Инциденты',
live: 'Поток',
radar: 'Радар DC',
mihomo: 'Mihomo',
servers: 'Серверы',
runtime: 'Состояние',
security: 'Безопасность',
upstreams: 'Апстримы и ДЦ',
config: 'Конфигурация',
update: 'Обновление'
};
export function panelTitleFromPath(pathname: string): string {
const p = pathname === '' ? '/' : pathname;
if (p === '/') return 'Обзор флота';
const parts = p.split('/').filter(Boolean);
if (parts.length === 0) return 'Telemt Panel';
if (parts[0] === 'servers' && parts.length >= 2) {
const alias = decodeURIComponent(parts[1]);
if (parts.length === 2) return `Нода ${alias}`;
const sub = parts.slice(2).map((s) => SEG_LABELS[s] ?? s).join(' · ');
return `${alias} · ${sub}`;
}
if (parts.length === 1) {
return SEG_LABELS[parts[0]] ?? parts[0];
}
if (parts[0] === 'users' && parts[1]) {
return `Пользователь ${decodeURIComponent(parts[1])}`;
}
return parts.map((s) => SEG_LABELS[s] ?? decodeURIComponent(s)).join(' · ');
}
+20
View File
@@ -0,0 +1,20 @@
export type SortDir = 'asc' | 'desc';
export function nextSortDir(
current: SortDir | null,
key: string,
activeKey: string | null
): SortDir | null {
if (activeKey !== key) return 'asc';
if (current === 'asc') return 'desc';
return null;
}
export function compareStrings(a: string, b: string, dir: SortDir): number {
const c = a.localeCompare(b, undefined, { sensitivity: 'base' });
return dir === 'asc' ? c : -c;
}
export function compareNum(a: number, b: number, dir: SortDir): number {
return dir === 'asc' ? a - b : b - a;
}
+15 -6
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import './layout.css';
import { page } from '$app/state';
import { page, navigating } from '$app/state';
import { onMount } from 'svelte';
import { ModeWatcher } from 'mode-watcher';
import favicon from '$lib/assets/favicon.svg';
@@ -9,9 +9,15 @@
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { Toaster } from '$lib/components/ui/sonner/index.js';
import SiteHeader from '$lib/components/site-header.svelte';
import { panelTitleFromPath } from '$lib/page-title.js';
let { children } = $props();
const docTitle = $derived(`${panelTitleFromPath(page.url.pathname)} · Telemt Panel`);
/** Нельзя использовать `!== null`: при `navigating === undefined` выражение истинно и полоска «зависает». */
const isNavigating = $derived(!!navigating);
let serverAliases = $state<string[]>([]);
const serverMatch = $derived.by(() => {
@@ -42,7 +48,7 @@
<svelte:head>
<link rel="icon" href={favicon} />
<title>Telemt Panel</title>
<title>{docTitle}</title>
</svelte:head>
<ModeWatcher />
@@ -50,10 +56,13 @@
<Sidebar.Provider>
<AppSidebar {serverAliases} {serverPath} />
<Sidebar.Inset>
<header
class="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4"
>
<Sidebar.Trigger class="-ml-1" />
{#if isNavigating}
<div class="h-0.5 w-full overflow-hidden bg-transparent">
<div class="h-full w-1/3 animate-pulse rounded-full bg-primary"></div>
</div>
{/if}
<header class="flex h-14 w-full shrink-0 items-center gap-2 border-b border-border px-4">
<SiteHeader />
</header>
<div class="flex flex-1 flex-col p-4">
{@render children()}
+633 -267
View File
@@ -9,35 +9,44 @@
fetchAggUniqueIps,
fetchStatsSummary
} from '$lib/api/client.js';
import type { SummaryData } from '$lib/api/summary-types.js';
import type { SummaryData, TopUser } from '$lib/api/summary-types.js';
import type { components } from '$lib/api/aggregate.gen.js';
import { formatDurationSeconds, formatMiB, flagEmoji } from '$lib/format.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import IpServerPresence from '$lib/components/ip-server-presence.svelte';
import * as Card from '$lib/components/ui/card/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import AliasFilterSelect from '$lib/components/alias-filter-select.svelte';
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
import FleetLiveStaleBadge from '$lib/components/fleet/fleet-live-stale-badge.svelte';
import FleetRefreshInput from '$lib/components/fleet/fleet-refresh-input.svelte';
import FleetOverviewCharts from '$lib/components/fleet/fleet-overview-charts.svelte';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import { cn } from '$lib/utils.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import AlertTriangleIcon from '@lucide/svelte/icons/triangle-alert';
import ServerIcon from '@lucide/svelte/icons/server';
import UsersIcon from '@lucide/svelte/icons/users';
import UnplugIcon from '@lucide/svelte/icons/unplug';
import GlobeIcon from '@lucide/svelte/icons/globe';
import ActivityIcon from '@lucide/svelte/icons/activity';
import LockKeyholeIcon from '@lucide/svelte/icons/lock-keyhole';
import InfoIcon from '@lucide/svelte/icons/info';
import type { KpiAccentKey } from '$lib/kpi-accents.js';
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import ChartNoAxesCombinedIcon from '@lucide/svelte/icons/chart-no-axes-combined';
import DataTableCard from '$lib/components/data-table/data-table-card.svelte';
import DataTableToolbar from '$lib/components/data-table/data-table-toolbar.svelte';
import SortableTh from '$lib/components/data-table/sortable-th.svelte';
import TableEmpty from '$lib/components/data-table/table-empty.svelte';
import StatusBadge from '$lib/components/status-badge.svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import MoreVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import SearchIcon from '@lucide/svelte/icons/search';
import { Badge } from '$lib/components/ui/badge/index.js';
import ServerIcon from '@lucide/svelte/icons/server';
import PlugIcon from '@lucide/svelte/icons/plug';
import ShieldAlertIcon from '@lucide/svelte/icons/shield-alert';
import GlobeIcon from '@lucide/svelte/icons/globe';
import CloudUploadIcon from '@lucide/svelte/icons/cloud-upload';
import LockIcon from '@lucide/svelte/icons/lock';
import UsersIcon from '@lucide/svelte/icons/users';
import { nextSortDir, compareStrings, compareNum, type SortDir } from '$lib/table-sort.js';
let loading = $state(true);
let err = $state<string | null>(null);
@@ -61,6 +70,46 @@
>
>({});
function sumBad(stats: typeof nodeStats): number {
let n = 0;
for (const v of Object.values(stats)) {
if (v && 'ok' in v && v.ok && typeof v.bad === 'number') n += v.bad;
}
return n;
}
function countUniqueIps(rows: components['schemas']['UniqueIPsRow'][] | null): number {
const set = new Set<string>();
for (const row of rows ?? []) {
for (const ip of row.ips ?? []) {
if (ip.ip) set.add(ip.ip);
}
}
return set.size;
}
function countReadOnlyNodes(f: components['schemas']['FleetStatusData'] | null): number {
let n = 0;
for (const s of f?.servers ?? []) {
if (s.health?.read_only) n++;
}
return n;
}
/** Таблицы: сортировка и поиск */
let ipFilter = $state('');
let ipSortKey = $state<'ip' | 'user' | null>(null);
let ipSortDir = $state<SortDir | null>(null);
let nodeFilter = $state('');
let nodeStatusFilter = $state<'all' | 'ok' | 'degraded'>('all');
let nodeSortKey = $state<'alias' | 'latency' | 'bad' | null>(null);
let nodeSortDir = $state<SortDir | null>(null);
let topFilter = $state('');
let topSortKey = $state<'user' | 'traffic' | 'ips' | null>(null);
let topSortDir = $state<SortDir | null>(null);
function parseAliasFilter(raw: string | null): string {
const value = raw?.trim() ?? '';
return !value || value.includes(',') ? 'all' : value;
@@ -90,10 +139,15 @@
summary = s.data;
fleet = f.data ?? null;
uniqueIps = u.data ?? null;
availableAliases = collectAliases(
const nextAliases = collectAliases(
(fleet?.servers ?? []).map((x) => x.alias),
(summary?.servers ?? []).map((x) => x.alias)
);
// Не сужаем список до текущего фильтра: иначе выбранный alias
// может исчезать из селекта при частичных/пустых ответах.
availableAliases = Array.from(new Set([...availableAliases, ...nextAliases])).sort((a, b) =>
a.localeCompare(b)
);
lastSuccessAtMs = Date.now();
// Как бейдж «OK» в таблице: только health + system/info (без x.ok — в JSON поле ok опционально).
@@ -166,19 +220,21 @@
});
}
async function applyAliasIfChanged(nextAlias: string) {
const currentAlias = parseAliasFilter(page.url.searchParams.get('aliases'));
if (nextAlias === currentAlias) return;
const q = new URLSearchParams(page.url.searchParams);
if (nextAlias === 'all') q.delete('aliases');
else q.set('aliases', nextAlias);
const qs = q.toString();
await goto(`${page.url.pathname}${qs ? `?${qs}` : ''}`, {
replaceState: true,
noScroll: true,
keepFocus: true
});
}
async function handleAliasFilterChange(nextAlias: string) {
aliasFilter = nextAlias;
const currentUrlAlias = parseAliasFilter(page.url.searchParams.get('aliases'));
if (nextAlias === currentUrlAlias) return;
const q = new URLSearchParams(page.url.searchParams);
if (nextAlias === 'all') q.delete('aliases');
else q.set('aliases', nextAlias);
const qs = q.toString();
await goto(`${page.url.pathname}${qs ? `?${qs}` : ''}`, {
replaceState: true,
noScroll: true,
keepFocus: true
});
await load();
}
let queryKey = $derived(page.url.searchParams.toString());
$effect(() => {
@@ -191,11 +247,6 @@
void load();
});
$effect(() => {
if (typeof window === 'undefined') return;
void applyAliasIfChanged(aliasFilter);
});
let staleSeconds = $derived.by(() =>
lastSuccessAtMs == null ? null : Math.max(0, Math.floor((nowMs - lastSuccessAtMs) / 1000))
);
@@ -266,26 +317,15 @@
return n;
});
let nodesHealthAccent = $derived.by((): KpiAccentKey => {
const t = (fleet?.servers_total ?? summary?.servers_total) ?? 0;
const ok = (fleet?.servers_all_ok ?? summary?.servers_ok) ?? 0;
if (t === 0) return 'neutral';
if (ok === t) return 'success';
if (ok === 0) return 'danger';
return 'warning';
let nodesOkChart = $derived.by(() => {
const list = fleet?.servers ?? [];
return list.filter((s) => s.health_ok && s.system_info_ok).length;
});
/** Пороги условные: 0 — норма; дальше — внимание и «сильное» внимание к метрике bad. */
let badConnectionsAccent = $derived.by((): KpiAccentKey => {
if (totalBad === 0) return 'success';
if (totalBad < 10_000) return 'warning';
return 'danger';
let nodesDegradedChart = $derived.by(() => {
const list = fleet?.servers ?? [];
return list.filter((s) => !s.health_ok || !s.system_info_ok).length;
});
let readOnlyAccent = $derived.by((): KpiAccentKey =>
readOnlyNodes === 0 ? 'success' : 'warning'
);
function chipLabel(ip: components['schemas']['IPAssignments']): string {
const cc = ip.country_code?.trim();
const flag = flagEmoji(cc ?? undefined);
@@ -373,19 +413,106 @@
out.sort((a, b) => a.ip.localeCompare(b.ip) || a.username.localeCompare(b.username));
return out;
});
let filteredSortedIpRows = $derived.by(() => {
let rows = activeIpServerRows;
const q = ipFilter.trim().toLowerCase();
if (q) {
rows = rows.filter(
(r) =>
r.username.toLowerCase().includes(q) ||
r.ip.toLowerCase().includes(q) ||
r.activeLabel.toLowerCase().includes(q)
);
}
const k = ipSortKey;
const d = ipSortDir;
if (!k || !d) return rows;
const copy = [...rows];
if (k === 'ip') copy.sort((a, b) => compareStrings(a.ip, b.ip, d));
else copy.sort((a, b) => compareStrings(a.username, b.username, d));
return copy;
});
type NodeRow = components['schemas']['FleetServerStatus'];
let filteredSortedNodeRows = $derived.by((): NodeRow[] => {
let rows = [...(fleet?.servers ?? [])];
const q = nodeFilter.trim().toLowerCase();
if (q) rows = rows.filter((s) => (s.alias ?? '').toLowerCase().includes(q));
if (nodeStatusFilter === 'ok') rows = rows.filter((s) => s.health_ok && s.system_info_ok);
if (nodeStatusFilter === 'degraded')
rows = rows.filter((s) => !s.health_ok || !s.system_info_ok);
const k = nodeSortKey;
const d = nodeSortDir;
if (k && d) {
rows.sort((a, b) => {
if (k === 'alias') return compareStrings(a.alias ?? '', b.alias ?? '', d);
if (k === 'latency') {
const la = a.health_latency_ms ?? -1;
const lb = b.health_latency_ms ?? -1;
return compareNum(la, lb, d);
}
if (k === 'bad') {
const ba =
a.alias && nodeStats[a.alias] && 'bad' in nodeStats[a.alias]!
? (nodeStats[a.alias] as { bad?: number }).bad ?? -1
: -1;
const bb =
b.alias && nodeStats[b.alias] && 'bad' in nodeStats[b.alias]!
? (nodeStats[b.alias] as { bad?: number }).bad ?? -1
: -1;
return compareNum(ba, bb, d);
}
return 0;
});
}
return rows;
});
let filteredSortedTopUsers = $derived.by((): TopUser[] => {
if (!summary) return [];
let rows = [...summary.top_users.slice(0, 10)];
const q = topFilter.trim().toLowerCase();
if (q) rows = rows.filter((t) => t.username.toLowerCase().includes(q));
const k = topSortKey;
const d = topSortDir;
if (k && d) {
rows.sort((a, b) => {
if (k === 'user') return compareStrings(a.username, b.username, d);
if (k === 'traffic') return compareNum(a.total_megabytes, b.total_megabytes, d);
return compareNum(uniqueIpCountByUser[a.username] ?? 0, uniqueIpCountByUser[b.username] ?? 0, d);
});
}
return rows;
});
function toggleIpSort(key: 'ip' | 'user') {
const next = nextSortDir(ipSortDir, key, ipSortKey);
ipSortKey = next === null ? null : key;
ipSortDir = next;
}
function toggleNodeSort(key: 'alias' | 'latency' | 'bad') {
const next = nextSortDir(nodeSortDir, key, nodeSortKey);
nodeSortKey = next === null ? null : key;
nodeSortDir = next;
}
function toggleTopSort(key: 'user' | 'traffic' | 'ips') {
const next = nextSortDir(topSortDir, key, topSortKey);
topSortKey = next === null ? null : key;
topSortDir = next;
}
</script>
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div>
<p class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Telemt multi-server overview
</p>
<h1 class="text-2xl font-semibold tracking-tight">Обзор флота</h1>
{#if generatedAt}
<p class="mt-1 text-sm text-muted-foreground">
Снимок: {new Date(generatedAt).toLocaleString()}
</p>
{/if}
<p class="mt-1 text-sm text-muted-foreground">
Состояние нод и агрегированные метрики Telemt
{#if generatedAt}
<span class="hidden sm:inline"> · Снимок: {new Date(generatedAt).toLocaleString()}</span>
{/if}
</p>
</div>
<div class="flex items-center gap-2">
<FleetLiveStaleBadge {isStale} {staleSeconds} refreshSeconds={refreshSeconds} />
@@ -397,7 +524,11 @@
</div>
<div class="mb-4 flex flex-wrap items-end gap-2">
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} />
<AliasFilterSelect
availableAliases={availableAliases}
bind:value={aliasFilter}
onValueChange={(next) => void handleAliasFilterChange(next)}
/>
<FleetRefreshInput bind:value={refreshInput} onValueChange={() => void applyControls()} />
</div>
@@ -420,229 +551,464 @@
{#snippet skeleton()}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{#each Array.from({ length: 6 }) as _, i (i)}
<Card.Root class="border-l-4 border-l-muted-foreground/30 shadow-sm">
<Card.Header class="pb-2">
<div class="flex gap-3">
<Skeleton class="size-10 shrink-0 rounded-lg" />
<div class="min-w-0 flex-1 space-y-2">
<Skeleton class="h-3 w-20" />
<Skeleton class="h-8 w-14" />
</div>
</div>
</Card.Header>
<Card.Content class="pt-0">
<Skeleton class="h-3 w-28" />
</Card.Content>
</Card.Root>
<Skeleton class="h-28 rounded-xl" />
{/each}
</div>
<Skeleton class="mb-4 h-32 w-full rounded-lg" />
<Skeleton class="mb-4 h-56 w-full rounded-lg" />
<Skeleton class="mb-4 h-56 w-full rounded-lg" />
{/snippet}
{#snippet children()}
{#if summary}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<KpiStatCard accent={nodesHealthAccent} label="Ноды" footer="успешный опрос">
{#snippet icon()}
<ServerIcon class="size-5" aria-hidden="true" />
{/snippet}
{fleet?.servers_all_ok ?? summary.servers_ok}/{fleet?.servers_total ?? summary.servers_total}
</KpiStatCard>
<KpiStatCard accent="info" label="Соединения (флот)" footer="текущие по stats/users">
{#snippet icon()}
<UsersIcon class="size-5" aria-hidden="true" />
{/snippet}
{summary.fleet_total_connections}
</KpiStatCard>
<KpiStatCard accent={badConnectionsAccent} label="Bad connections" footer="сумма по нодам (stats/summary)">
{#snippet icon()}
<UnplugIcon class="size-5" aria-hidden="true" />
{/snippet}
{totalBad}
</KpiStatCard>
<KpiStatCard accent="violet" label="Уникальные IP" footer="по снимку unique-ips">
{#snippet icon()}
<GlobeIcon class="size-5" aria-hidden="true" />
{/snippet}
{uniqueIpCount}
</KpiStatCard>
<KpiStatCard accent="teal" label="Трафик флота" footer="сумма MiB">
{#snippet icon()}
<ActivityIcon class="size-5" aria-hidden="true" />
{/snippet}
{formatMiB(summary.fleet_total_megabytes)}
</KpiStatCard>
<KpiStatCard accent={readOnlyAccent} label="Read-only API" footer="нод с read_only">
{#snippet icon()}
<LockKeyholeIcon class="size-5" aria-hidden="true" />
{/snippet}
{readOnlyNodes}
</KpiStatCard>
</div>
{@const okN = fleet?.servers_all_ok ?? summary.servers_ok}
{@const totN = fleet?.servers_total ?? summary.servers_total}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<KpiStatCard accent="violet" label="Ноды OK / всего" footer="health + system/info" valueClass="text-xl">
{#snippet icon()}
<ServerIcon class="size-5" aria-hidden="true" />
{/snippet}
{okN}/{totN}
</KpiStatCard>
<KpiStatCard
accent="info"
label="Соединения (флот)"
footer="агрегат stats/users"
valueClass="text-xl"
>
{#snippet icon()}
<PlugIcon class="size-5" aria-hidden="true" />
{/snippet}
{summary.fleet_total_connections}
</KpiStatCard>
<KpiStatCard accent="danger" label="Плохие соединения" footer="сумма по нодам" valueClass="text-xl">
{#snippet icon()}
<ShieldAlertIcon class="size-5" aria-hidden="true" />
{/snippet}
{totalBad}
</KpiStatCard>
<KpiStatCard accent="teal" label="Уникальные IP" footer="по снимку unique-ips" valueClass="text-xl">
{#snippet icon()}
<GlobeIcon class="size-5" aria-hidden="true" />
{/snippet}
{uniqueIpCount}
</KpiStatCard>
<KpiStatCard
accent="warning"
label="Трафик флота (MiB)"
footer="суммарный трафик summary"
valueClass="text-lg"
>
{#snippet icon()}
<CloudUploadIcon class="size-5" aria-hidden="true" />
{/snippet}
{formatMiB(summary.fleet_total_megabytes)}
</KpiStatCard>
<KpiStatCard accent="neutral" label="Read-only API (нод)" footer="health.read_only" valueClass="text-xl">
{#snippet icon()}
<LockIcon class="size-5" aria-hidden="true" />
{/snippet}
{readOnlyNodes}
</KpiStatCard>
</div>
<KpiPanelCard
class="mb-6"
accent="violet"
title="Активные IP (агрегат)"
description="С GeoIP при включённой базе на шлюзе"
>
{#snippet icon()}
<GlobeIcon class="size-5" aria-hidden="true" />
{/snippet}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="text-xs sm:text-sm">IP со страной</Table.Head>
<Table.Head class="text-xs sm:text-sm">Клиент</Table.Head>
<Table.Head class="text-xs sm:text-sm">
<div class="flex items-center gap-1">
Серверы
<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="bottom" class="max-w-xs">
<p class="text-xs">
Активные и недавние — списки на момент снимка шлюза; в ячейке пояснены
группы.
</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if (activeIpServerRows?.length ?? 0) === 0}
<Table.Row>
<Table.Cell colspan={3} class="p-4 text-center text-muted-foreground">
Нет активных подключений
</Table.Cell>
<FleetOverviewCharts
nodesOk={nodesOkChart}
nodesDegraded={nodesDegradedChart}
topUsers={summary.top_users.slice(0, 8)}
/>
<DataTableCard
class="mb-6"
title="Активные IP (агрегат)"
description="С GeoIP при включённой базе на шлюзе"
accent="teal"
>
{#snippet icon()}<GlobeIcon class="size-5" aria-hidden="true" />{/snippet}
{#snippet toolbar()}
<DataTableToolbar>
<div class="relative max-w-xs flex-1">
<SearchIcon
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
class="h-8 ps-8"
placeholder="Поиск IP, пользователя…"
bind:value={ipFilter}
/>
</div>
<Button
variant="outline"
size="sm"
class="h-8"
onclick={() => {
ipFilter = '';
ipSortKey = null;
ipSortDir = null;
}}
>Сброс</Button>
</DataTableToolbar>
{/snippet}
<ScrollArea class="max-h-[min(70vh,520px)] w-full" orientation="both">
<Table.Root>
<Table.Header class="sticky top-0 z-10 bg-muted">
<Table.Row class="border-b border-border/80 hover:bg-transparent">
<SortableTh
active={ipSortKey === 'ip'}
dir={ipSortDir}
onToggle={() => toggleIpSort('ip')}
>IP / страна</SortableTh>
<SortableTh
active={ipSortKey === 'user'}
dir={ipSortDir}
onToggle={() => toggleIpSort('user')}
>Пользователь</SortableTh>
<Table.Head class="text-xs sm:text-sm">
<div class="flex items-center gap-1">
Серверы
<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="bottom" class="max-w-xs">
<p class="text-xs">
Активные и недавние — на момент снимка шлюза.
</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
</Table.Head>
<Table.Head class="w-10"></Table.Head>
</Table.Row>
{:else}
{#each activeIpServerRows as r (r.username + '|' + r.ip)}
</Table.Header>
<Table.Body>
{#if activeIpServerRows.length === 0}
<Table.Row>
<Table.Cell class="font-mono text-xs sm:text-sm whitespace-normal break-all">
{r.activeLabel}
</Table.Cell>
<Table.Cell class="text-xs sm:text-sm">
<a
href="/users/{encodeURIComponent(r.username)}"
class="text-primary hover:underline"
>
{r.username}
</a>
</Table.Cell>
<Table.Cell class="max-w-[280px] align-top text-xs sm:text-sm whitespace-normal">
<IpServerPresence
active_on_servers={r.active_on_servers}
recent_on_servers={r.recent_on_servers}
primary_server={r.primary_server}
<Table.Cell colspan={4} class="p-0">
<TableEmpty
title="Нет активных подключений"
description="В снимке unique-ips нет IP с активными серверами."
/>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</KpiPanelCard>
{:else if filteredSortedIpRows.length === 0}
<Table.Row>
<Table.Cell colspan={4} class="p-0">
<TableEmpty
title="Нет строк по фильтру"
description="Измените поиск или сбросьте фильтры."
/>
</Table.Cell>
</Table.Row>
{:else}
{#each filteredSortedIpRows as r (r.username + '|' + r.ip)}
<Table.Row>
<Table.Cell class="font-mono text-xs sm:text-sm whitespace-normal break-all">
<Badge variant="secondary" class="font-mono font-normal">{r.activeLabel}</Badge>
</Table.Cell>
<Table.Cell class="text-xs sm:text-sm">
<a
href="/users/{encodeURIComponent(r.username)}"
class="text-primary hover:underline"
>
{r.username}
</a>
</Table.Cell>
<Table.Cell class="max-w-[280px] align-top text-xs sm:text-sm whitespace-normal">
<IpServerPresence
active_on_servers={r.active_on_servers}
recent_on_servers={r.recent_on_servers}
primary_server={r.primary_server}
/>
</Table.Cell>
<Table.Cell class="w-10 text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon-xs" aria-label="Действия">
<MoreVerticalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
onclick={() =>
window.open(
`/users/${encodeURIComponent(r.username)}`,
'_blank'
)}
>Открыть пользователя</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</ScrollArea>
</DataTableCard>
<KpiPanelCard accent="info" title="По нодам" description="Health, версия, аптайм, трафик ноды" contentClass="p-0">
{#snippet icon()}
<ServerIcon class="size-5" aria-hidden="true" />
{/snippet}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Сервер</Table.Head>
<Table.Head>Статус</Table.Head>
<Table.Head class="text-right">Latency</Table.Head>
<Table.Head class="text-right">Версия</Table.Head>
<Table.Head class="text-right">Аптайм</Table.Head>
<Table.Head class="text-right">Bad conn.</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each fleet?.servers ?? [] as srv (srv.alias)}
<Table.Row>
<Table.Cell class="font-medium">
<a class="text-primary hover:underline" href="/servers/{encodeURIComponent(srv.alias ?? '')}">
{srv.alias}
</a>
</Table.Cell>
<Table.Cell>
{#if srv.health_ok && srv.system_info_ok}
<Badge class="bg-emerald-600/20 text-emerald-400">OK</Badge>
{:else}
<Badge variant="destructive">degraded</Badge>
{/if}
</Table.Cell>
<Table.Cell class="text-right text-muted-foreground tabular-nums">
{srv.health_latency_ms ?? '—'} ms
</Table.Cell>
<Table.Cell class="text-right text-muted-foreground">
{srv.system_info?.version ?? '—'}
</Table.Cell>
<Table.Cell class="text-right text-muted-foreground">
{srv.system_info && srv.system_info.uptime_seconds != null
? formatDurationSeconds(srv.system_info.uptime_seconds)
: '—'}
</Table.Cell>
<Table.Cell class="text-right tabular-nums">
{#if srv.alias}
{@const st = nodeStats[srv.alias]}
{#if !st}
<span title="Нет ответа stats/summary для этой ноды">—</span>
{:else if st && 'ok' in st && !st.ok && 'error' in st}
<span class="text-xs text-muted-foreground" title={st.error}>—</span>
{:else if st && 'bad' in st && st.ok}
{st.bad ?? 0}
{:else}
<span title="Неожиданное состояние stats">—</span>
{/if}
{:else}
—
{/if}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</KpiPanelCard>
<DataTableCard
class="mb-6"
title="По нодам"
description="Health, версия, аптайм, bad connections"
accent="violet"
>
{#snippet icon()}<ServerIcon class="size-5" aria-hidden="true" />{/snippet}
{#snippet toolbar()}
<DataTableToolbar>
<div class="relative max-w-xs flex-1">
<SearchIcon
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
class="h-8 ps-8"
placeholder="Поиск по alias…"
bind:value={nodeFilter}
/>
</div>
<div class="flex flex-wrap gap-1">
<Button
size="sm"
variant={nodeStatusFilter === 'all' ? 'default' : 'outline'}
class="h-8"
onclick={() => (nodeStatusFilter = 'all')}
>Все</Button>
<Button
size="sm"
variant={nodeStatusFilter === 'ok' ? 'default' : 'outline'}
class="h-8"
onclick={() => (nodeStatusFilter = 'ok')}
>OK</Button>
<Button
size="sm"
variant={nodeStatusFilter === 'degraded' ? 'default' : 'outline'}
class="h-8"
onclick={() => (nodeStatusFilter = 'degraded')}
>Degraded</Button>
</div>
<Button
variant="outline"
size="sm"
class="h-8"
onclick={() => {
nodeFilter = '';
nodeStatusFilter = 'all';
nodeSortKey = null;
nodeSortDir = null;
}}
>Сброс</Button>
</DataTableToolbar>
{/snippet}
<ScrollArea class="max-h-[min(70vh,480px)] w-full" orientation="both">
<Table.Root>
<Table.Header class="sticky top-0 z-10 bg-muted">
<Table.Row class="border-b border-border/80 hover:bg-transparent">
<SortableTh
active={nodeSortKey === 'alias'}
dir={nodeSortDir}
onToggle={() => toggleNodeSort('alias')}
>Сервер</SortableTh>
<Table.Head>Статус</Table.Head>
<SortableTh
numeric
active={nodeSortKey === 'latency'}
dir={nodeSortDir}
onToggle={() => toggleNodeSort('latency')}
class="text-right"
>Latency</SortableTh>
<Table.Head class="text-right">Версия</Table.Head>
<Table.Head class="text-right">Аптайм</Table.Head>
<SortableTh
numeric
active={nodeSortKey === 'bad'}
dir={nodeSortDir}
onToggle={() => toggleNodeSort('bad')}
class="text-right"
>Bad conn.</SortableTh>
<Table.Head class="w-10"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if filteredSortedNodeRows.length === 0}
<Table.Row>
<Table.Cell colspan={7} class="p-0">
<TableEmpty title="Нет нод по фильтру" />
</Table.Cell>
</Table.Row>
{:else}
{#each filteredSortedNodeRows as srv (srv.alias)}
<Table.Row>
<Table.Cell class="font-medium">
<a
class="text-primary hover:underline"
href="/servers/{encodeURIComponent(srv.alias ?? '')}"
>
{srv.alias}
</a>
</Table.Cell>
<Table.Cell>
{#if srv.health_ok && srv.system_info_ok}
<StatusBadge variant="ok" />
{:else}
<StatusBadge variant="degraded" />
{/if}
</Table.Cell>
<Table.Cell class="text-right text-muted-foreground tabular-nums">
{srv.health_latency_ms ?? '—'} ms
</Table.Cell>
<Table.Cell class="text-right text-muted-foreground">
<Badge variant="secondary">{srv.system_info?.version ?? '—'}</Badge>
</Table.Cell>
<Table.Cell class="text-right text-muted-foreground">
{srv.system_info && srv.system_info.uptime_seconds != null
? formatDurationSeconds(srv.system_info.uptime_seconds)
: '—'}
</Table.Cell>
<Table.Cell class="text-right tabular-nums">
{#if srv.alias}
{@const st = nodeStats[srv.alias]}
{#if !st}
<StatusBadge variant="pending" />
{:else if st && 'ok' in st && !st.ok && 'error' in st}
<span class="text-xs text-muted-foreground" title={st.error}>—</span>
{:else if st && 'bad' in st && st.ok}
{st.bad ?? 0}
{:else}
<span title="Неожиданное состояние stats">—</span>
{/if}
{:else}
—
{/if}
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon-xs" aria-label="Действия">
<MoreVerticalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
onclick={() =>
window.open(
`/servers/${encodeURIComponent(srv.alias ?? '')}`,
'_blank'
)}
>Открыть ноду</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</ScrollArea>
</DataTableCard>
<KpiPanelCard class="mt-6" accent="teal" title="Топ пользователей по трафику" contentClass="">
{#snippet icon()}
<ChartNoAxesCombinedIcon class="size-5" aria-hidden="true" />
{/snippet}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Пользователь</Table.Head>
<Table.Head class="text-right">Трафик</Table.Head>
<Table.Head class="text-right">Замеченные IP</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each summary.top_users.slice(0, 10) as t (t.username)}
<Table.Row>
<Table.Cell class="font-medium">
<a href="/users/{encodeURIComponent(t.username)}" class="text-primary hover:underline">
{t.username}
</a>
</Table.Cell>
<Table.Cell class="text-right text-muted-foreground tabular-nums">{formatMiB(t.total_megabytes)}</Table.Cell>
<Table.Cell class="text-right text-muted-foreground tabular-nums">
{uniqueIpCountByUser[t.username] ?? 0}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</KpiPanelCard>
<DataTableCard
class="mt-6"
title="Топ пользователей по трафику"
description="До 10 записей из summary"
accent="info"
>
{#snippet icon()}<UsersIcon class="size-5" aria-hidden="true" />{/snippet}
{#snippet toolbar()}
<DataTableToolbar>
<div class="relative max-w-xs flex-1">
<SearchIcon
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input class="h-8 ps-8" placeholder="Поиск пользователя…" bind:value={topFilter} />
</div>
<Button
variant="outline"
size="sm"
class="h-8"
onclick={() => {
topFilter = '';
topSortKey = null;
topSortDir = null;
}}
>Сброс</Button>
</DataTableToolbar>
{/snippet}
<ScrollArea class="max-h-[min(60vh,400px)] w-full" orientation="vertical">
<Table.Root>
<Table.Header class="sticky top-0 z-10 bg-muted">
<Table.Row class="border-b border-border/80 hover:bg-transparent">
<SortableTh
active={topSortKey === 'user'}
dir={topSortDir}
onToggle={() => toggleTopSort('user')}
>Пользователь</SortableTh>
<SortableTh
numeric
active={topSortKey === 'traffic'}
dir={topSortDir}
onToggle={() => toggleTopSort('traffic')}
class="text-right"
>Трафик</SortableTh>
<SortableTh
numeric
active={topSortKey === 'ips'}
dir={topSortDir}
onToggle={() => toggleTopSort('ips')}
class="text-right"
>IP</SortableTh>
<Table.Head class="w-10"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if filteredSortedTopUsers.length === 0}
<Table.Row>
<Table.Cell colspan={4} class="p-0">
<TableEmpty title="Нет данных" />
</Table.Cell>
</Table.Row>
{:else}
{#each filteredSortedTopUsers as t (t.username)}
<Table.Row>
<Table.Cell class="font-medium">
<a href="/users/{encodeURIComponent(t.username)}" class="text-primary hover:underline">
{t.username}
</a>
</Table.Cell>
<Table.Cell class="text-right text-muted-foreground tabular-nums">
{formatMiB(t.total_megabytes)}
</Table.Cell>
<Table.Cell class="text-right text-muted-foreground tabular-nums">
{uniqueIpCountByUser[t.username] ?? 0}
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon-xs" aria-label="Действия">
<MoreVerticalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
onclick={() =>
window.open(`/users/${encodeURIComponent(t.username)}`, '_blank')}
>Открыть</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</ScrollArea>
</DataTableCard>
{/if}
{/snippet}
</DataQueryState>
+163 -28
View File
@@ -6,16 +6,19 @@
ApiError,
fetchAggIncidents,
fetchAggSummary,
postAggResetBadConnectionsBaseline,
type IncidentItem,
type IncidentsData
} from '$lib/api/client.js';
import { toast } from 'svelte-sonner';
import * as Card from '$lib/components/ui/card/index.js';
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import CircleAlertIcon from '@lucide/svelte/icons/circle-alert';
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
import InfoSmallIcon from '@lucide/svelte/icons/info';
import ClipboardListIcon from '@lucide/svelte/icons/clipboard-list';
import DataTableCard from '$lib/components/data-table/data-table-card.svelte';
import DataTableToolbar from '$lib/components/data-table/data-table-toolbar.svelte';
import SortableTh from '$lib/components/data-table/sortable-th.svelte';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import SearchIcon from '@lucide/svelte/icons/search';
import { nextSortDir, compareStrings, compareNum, type SortDir } from '$lib/table-sort.js';
import * as Table from '$lib/components/ui/table/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
@@ -29,6 +32,10 @@
import FleetRefreshInput from '$lib/components/fleet/fleet-refresh-input.svelte';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import InfoIcon from '@lucide/svelte/icons/info';
import SirenIcon from '@lucide/svelte/icons/siren';
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
import InfoIconBadge from '@lucide/svelte/icons/info';
import EraserIcon from '@lucide/svelte/icons/eraser';
type TriageState = {
ack?: boolean;
@@ -54,6 +61,11 @@
let staleTimer: ReturnType<typeof setInterval> | null = null;
let triageById = $state<Record<string, TriageState>>({});
let incSearch = $state('');
let incSortKey = $state<'sev' | 'title' | null>(null);
let incSortDir = $state<SortDir | null>(null);
let resetBadBusy = $state(false);
function parseRefresh(raw: string | null): number {
if (raw == null || raw.trim() === '') return 30;
const n = Number(raw);
@@ -115,6 +127,12 @@
return 'outline';
}
function severityLabel(sev: IncidentItem['severity']): string {
if (sev === 'critical') return 'критично';
if (sev === 'warning') return 'предупреждение';
return 'инфо';
}
function restartPolling() {
if (pollTimer) {
clearInterval(pollTimer);
@@ -127,6 +145,12 @@
}
}
function sevRank(s: IncidentItem['severity']): number {
if (s === 'critical') return 3;
if (s === 'warning') return 2;
return 1;
}
async function load(aliasOverride?: string) {
loading = true;
err = null;
@@ -225,18 +249,91 @@
let hasStaleData = $derived(err != null && data != null);
let showSkeleton = $derived(loading && data === null && !err);
let filteredIncidents = $derived.by((): IncidentItem[] => {
const items = data?.items ?? [];
const q = incSearch.trim().toLowerCase();
let out = items;
if (q) {
out = items.filter(
(it) =>
(it.title ?? '').toLowerCase().includes(q) ||
(it.summary ?? '').toLowerCase().includes(q) ||
(it.severity ?? '').toLowerCase().includes(q)
);
}
const k = incSortKey;
const d = incSortDir;
if (k && d) {
out = [...out].sort((a, b) => {
if (k === 'sev') return compareNum(sevRank(a.severity), sevRank(b.severity), d);
return compareStrings(a.title ?? '', b.title ?? '', d);
});
}
return out;
});
function toggleIncSort(key: 'sev' | 'title') {
const next = nextSortDir(incSortDir, key, incSortKey);
incSortKey = next === null ? null : key;
incSortDir = next;
}
async function resetBadConnectionsBaseline() {
if (resetBadBusy) return;
resetBadBusy = true;
try {
const env = await postAggResetBadConnectionsBaseline({
aliases: aliasFilter === 'all' ? undefined : aliasFilter
});
const failed = env.data.servers.filter((s) => !s.ok);
if (failed.length > 0) {
toast.warning(
`Не на всех нодах удалось прочитать stats/summary: ${failed.map((f) => `${f.alias}: ${f.error ?? 'ошибка'}`).join('; ')}`
);
} else {
toast.success(
'Для алертов bad connections зафиксированы текущие значения счётчиков (отметка на шлюзе).'
);
}
await load();
} catch (e) {
toast.error(e instanceof ApiError ? e.message : String(e));
} finally {
resetBadBusy = false;
}
}
</script>
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Инциденты</h1>
<p class="text-sm text-muted-foreground">Сводка и triage по всему флоту.</p>
<p class="text-sm text-muted-foreground">Сводка и разбор инцидентов по всему флоту.</p>
{#if generatedAt}
<p class="mt-1 text-xs text-muted-foreground">Снимок: {new Date(generatedAt).toLocaleString()}</p>
{/if}
</div>
<div class="flex items-center gap-2">
<FleetLiveStaleBadge {isStale} {staleSeconds} refreshSeconds={refreshSeconds} />
<Tooltip.Root>
<Tooltip.Trigger class="inline-flex">
<Button
variant="outline"
size="sm"
disabled={resetBadBusy || loading}
onclick={() => void resetBadConnectionsBaseline()}
>
<EraserIcon class="mr-1 size-4 {resetBadBusy ? 'animate-pulse' : ''}" />
Сброс bad connections
</Button>
</Tooltip.Trigger>
<Tooltip.Content class="max-w-xs">
<p class="text-xs">
Запомнить текущий <span class="font-mono">connections_bad_total</span> на выбранных нодах как ноль для
инцидентов на этом шлюзе (память процесса). Сырые счётчики на нодах Telemt не меняются.
</p>
</Tooltip.Content>
</Tooltip.Root>
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить
@@ -277,35 +374,62 @@
{/snippet}
{#snippet children()}
<div class="mb-4 grid gap-4 sm:grid-cols-3">
<KpiStatCard accent="danger" label="Critical" footer="открытые critical">
<KpiStatCard accent="danger" label="Критичные" footer="Открытые инциденты" valueClass="text-xl">
{#snippet icon()}
<CircleAlertIcon class="size-5" aria-hidden="true" />
<SirenIcon class="size-5" aria-hidden="true" />
{/snippet}
{data?.critical_total ?? 0}
</KpiStatCard>
<KpiStatCard accent="warning" label="Warning" footer="открытые warning">
<KpiStatCard accent="warning" label="Предупреждения" footer="Открытые инциденты" valueClass="text-xl">
{#snippet icon()}
<TriangleAlertIcon class="size-5" aria-hidden="true" />
{/snippet}
{data?.warning_total ?? 0}
</KpiStatCard>
<KpiStatCard accent="info" label="Info" footer="открытые info">
<KpiStatCard accent="info" label="Инфо" footer="Открытые инциденты" valueClass="text-xl">
{#snippet icon()}
<InfoSmallIcon class="size-5" aria-hidden="true" />
<InfoIconBadge class="size-5" aria-hidden="true" />
{/snippet}
{data?.info_total ?? 0}
</KpiStatCard>
</div>
<KpiPanelCard accent="neutral" title="Инциденты" description="Список и triage" contentClass="p-0">
{#snippet icon()}
<ClipboardListIcon class="size-5" aria-hidden="true" />
<DataTableCard title="Инциденты" description="Список и разбор" accent="danger">
{#snippet icon()}<SirenIcon class="size-5" aria-hidden="true" />{/snippet}
{#snippet toolbar()}
<DataTableToolbar>
<div class="relative max-w-md flex-1">
<SearchIcon
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input class="h-8 ps-8" placeholder="Поиск по заголовку, описанию, критичности…" bind:value={incSearch} />
</div>
<Button
variant="outline"
size="sm"
class="h-8"
onclick={() => {
incSearch = '';
incSortKey = null;
incSortDir = null;
}}
>Сброс</Button>
</DataTableToolbar>
{/snippet}
<ScrollArea class="max-h-[min(70vh,560px)] w-full" orientation="both">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Severity</Table.Head>
<Table.Head>Инцидент</Table.Head>
<Table.Header class="sticky top-0 z-10 bg-muted">
<Table.Row class="border-b border-border/80 hover:bg-transparent">
<SortableTh
active={incSortKey === 'sev'}
dir={incSortDir}
onToggle={() => toggleIncSort('sev')}
>Критичность</SortableTh>
<SortableTh
active={incSortKey === 'title'}
dir={incSortDir}
onToggle={() => toggleIncSort('title')}
>Инцидент</SortableTh>
<Table.Head>
<div class="flex items-center gap-1">
Серверы
@@ -322,8 +446,8 @@
</Tooltip.Root>
</div>
</Table.Head>
<Table.Head>Runbook</Table.Head>
<Table.Head>Triage</Table.Head>
<Table.Head>Инструкции</Table.Head>
<Table.Head>Разбор</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
@@ -333,11 +457,17 @@
Инцидентов нет
</Table.Cell>
</Table.Row>
{:else if filteredIncidents.length === 0}
<Table.Row>
<Table.Cell colspan={5} class="p-4 text-center text-muted-foreground">
Нет строк по фильтру
</Table.Cell>
</Table.Row>
{:else}
{#each data?.items ?? [] as item (item.id)}
{#each filteredIncidents as item (item.id)}
<Table.Row>
<Table.Cell>
<Badge variant={severityVariant(item.severity)}>{item.severity}</Badge>
<Badge variant={severityVariant(item.severity)}>{severityLabel(item.severity)}</Badge>
</Table.Cell>
<Table.Cell class="max-w-[360px]">
<div class="font-medium">{item.title}</div>
@@ -360,29 +490,33 @@
{/each}
</div>
</Table.Cell>
<Table.Cell class="min-w-[280px]">
<Table.Cell class="min-w-[240px]">
{@const triage = triageFor(item.id)}
<div class="mb-2 flex flex-wrap gap-1">
<Button
size="xs"
class="h-7 px-2 text-xs"
variant={triage.ack ? 'default' : 'outline'}
onclick={() => toggleAck(item.id)}
title={triage.ack ? 'Снять подтверждение' : 'Подтвердить'}
>
{triage.ack ? 'unack' : 'ack'}
{triage.ack ? 'Снять' : 'Подтв.'}
</Button>
<Button
size="xs"
class="h-7 px-2 text-xs"
variant={triage.resolved ? 'default' : 'outline'}
onclick={() => toggleResolved(item.id)}
title={triage.resolved ? 'Вернуть в работу' : 'Решить'}
>
{triage.resolved ? 'unresolve' : 'resolve'}
{triage.resolved ? 'В работу' : 'Решить'}
</Button>
</div>
<div class="mb-1">
<Input
type="text"
class="h-8 w-full text-xs"
placeholder="owner"
placeholder="Ответственный"
value={triage.owner ?? ''}
oninput={(e) =>
patchTriage(item.id, {
@@ -394,7 +528,7 @@
<Input
type="text"
class="h-8 w-full text-xs"
placeholder="note"
placeholder="Примечание"
value={triage.note ?? ''}
oninput={(e) =>
patchTriage(item.id, {
@@ -408,6 +542,7 @@
{/if}
</Table.Body>
</Table.Root>
</KpiPanelCard>
</ScrollArea>
</DataTableCard>
{/snippet}
</DataQueryState>
+175 -16
View File
@@ -9,6 +9,13 @@
import { flagEmoji } from '$lib/format.js';
import * as Card from '$lib/components/ui/card/index.js';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import DataTableCard from '$lib/components/data-table/data-table-card.svelte';
import DataTableToolbar from '$lib/components/data-table/data-table-toolbar.svelte';
import SortableTh from '$lib/components/data-table/sortable-th.svelte';
import { Input } from '$lib/components/ui/input/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import SearchIcon from '@lucide/svelte/icons/search';
import { nextSortDir, compareStrings, type SortDir } from '$lib/table-sort.js';
import MapIcon from '@lucide/svelte/icons/map';
import GlobeIcon from '@lucide/svelte/icons/globe';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
@@ -49,6 +56,77 @@
let mapStatus = $state<string | null>(null);
let renderAttempts = $state(0);
type IpFlatRow = {
username: string;
ip: components['schemas']['IPAssignments'];
};
let ipSearch = $state('');
let ipSortKey = $state<'user' | 'ip' | 'geo' | null>(null);
let ipSortDir = $state<SortDir | null>(null);
const ipsPerPage = 25;
let ipsPage = $state(1);
let flatIpRows = $derived.by((): IpFlatRow[] => {
const out: IpFlatRow[] = [];
for (const ur of rows) {
const u = ur.username ?? '';
for (const ipa of ur.ips ?? []) {
if (ipa?.ip) out.push({ username: u, ip: ipa });
}
}
return out;
});
let displayedIpRows = $derived.by(() => {
let r = flatIpRows;
const q = ipSearch.trim().toLowerCase();
if (q) {
r = r.filter(
({ username, ip }) =>
username.toLowerCase().includes(q) ||
(ip.ip ?? '').toLowerCase().includes(q) ||
(ip.country_code ?? '').toLowerCase().includes(q) ||
(ip.city_name ?? '').toLowerCase().includes(q) ||
String(ip.asn ?? '').includes(q)
);
}
const k = ipSortKey;
const d = ipSortDir;
if (k && d) {
r = [...r].sort((a, b) => {
if (k === 'user') return compareStrings(a.username, b.username, d);
if (k === 'ip') return compareStrings(a.ip.ip ?? '', b.ip.ip ?? '', d);
const ga = `${a.ip.country_code ?? ''} ${a.ip.city_name ?? ''}`;
const gb = `${b.ip.country_code ?? ''} ${b.ip.city_name ?? ''}`;
return compareStrings(ga, gb, d);
});
}
return r;
});
let ipsPageCount = $derived(Math.max(1, Math.ceil(displayedIpRows.length / ipsPerPage)));
let pagedIpRows = $derived.by(() => {
const start = (ipsPage - 1) * ipsPerPage;
return displayedIpRows.slice(start, start + ipsPerPage);
});
$effect(() => {
ipSearch;
aliasFilter;
ipsPage = 1;
});
$effect(() => {
if (ipsPage > ipsPageCount) ipsPage = ipsPageCount;
});
function toggleIpSort(key: 'user' | 'ip' | 'geo') {
const next = nextSortDir(ipSortDir, key, ipSortKey);
ipSortKey = next === null ? null : key;
ipSortDir = next;
}
function parseAliasFilter(raw: string | null): string {
const value = raw?.trim() ?? '';
return !value || value.includes(',') ? 'all' : value;
@@ -373,6 +451,14 @@
noScroll: true,
keepFocus: true
});
// Страхуемся от кейсов, когда URL уже сменился, а перезапрос не стартовал.
await load();
}
async function handleAliasFilterChange(nextAlias: string) {
if (nextAlias === aliasFilter) return;
aliasFilter = nextAlias;
await applyAliasIfChanged(nextAlias);
}
let queryKey = $derived(page.url.searchParams.toString());
@@ -431,7 +517,7 @@
let isDataEmpty = $derived(!loading && totalIpRows === 0 && !err);
</script>
<div class="mb-6 flex flex-wrap items-end justify-between gap-4">
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Уникальные IP</h1>
<p class="text-sm text-muted-foreground">
@@ -456,7 +542,11 @@
</div>
<div class="mb-4 flex flex-wrap items-end gap-2">
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} />
<AliasFilterSelect
availableAliases={availableAliases}
bind:value={aliasFilter}
onValueChange={(next) => void handleAliasFilterChange(next)}
/>
<FleetRefreshInput bind:value={refreshInput} onValueChange={() => void applyControls()} />
</div>
@@ -528,16 +618,52 @@
</Alert>
{/if}
<KpiPanelCard accent="violet" title="Уникальные IP" description="Снимок шлюза: активные и недавние списки" contentClass="p-0">
{#snippet icon()}
<GlobeIcon class="size-5" aria-hidden="true" />
<DataTableCard title="Уникальные IP" description="Снимок шлюза: активные и недавние списки" accent="teal">
{#snippet icon()}<GlobeIcon class="size-5" aria-hidden="true" />{/snippet}
{#snippet toolbar()}
<DataTableToolbar>
<div class="relative max-w-md flex-1">
<SearchIcon
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input class="h-8 ps-8" placeholder="Поиск: пользователь, IP, страна, город…" bind:value={ipSearch} />
</div>
<Button
variant="outline"
size="sm"
class="h-8"
onclick={() => {
ipSearch = '';
ipSortKey = null;
ipSortDir = null;
ipsPage = 1;
}}
>Сброс</Button>
</DataTableToolbar>
{/snippet}
<div class="flex w-full flex-col">
<ScrollArea
class="h-[min(75vh,560px)] w-full shrink-0"
orientation="both"
>
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Пользователь</Table.Head>
<Table.Head>IP</Table.Head>
<Table.Head>Geo</Table.Head>
<Table.Header class="sticky top-0 z-10 bg-muted">
<Table.Row class="border-b border-border/80 hover:bg-transparent">
<SortableTh
active={ipSortKey === 'user'}
dir={ipSortDir}
onToggle={() => toggleIpSort('user')}
>Пользователь</SortableTh>
<SortableTh
active={ipSortKey === 'ip'}
dir={ipSortDir}
onToggle={() => toggleIpSort('ip')}
>IP</SortableTh>
<SortableTh
active={ipSortKey === 'geo'}
dir={ipSortDir}
onToggle={() => toggleIpSort('geo')}
>Geo</SortableTh>
<Table.Head>
<div class="flex items-center gap-1">
Серверы
@@ -559,12 +685,18 @@
</Table.Row>
</Table.Header>
<Table.Body>
{#each rows as ur (ur.username)}
{#each ur.ips ?? [] as ip (ur.username + (ip.ip ?? ''))}
{#if displayedIpRows.length === 0}
<Table.Row>
<Table.Cell colspan={4} class="p-4 text-center text-muted-foreground">
Нет строк по фильтру или снимок пуст.
</Table.Cell>
</Table.Row>
{:else}
{#each pagedIpRows as { username, ip } (username + (ip.ip ?? ''))}
<Table.Row>
<Table.Cell class="font-medium">
<a href="/users/{encodeURIComponent(ur.username ?? '')}" class="text-primary hover:underline">
{ur.username}
<a href="/users/{encodeURIComponent(username)}" class="text-primary hover:underline">
{username}
</a>
</Table.Cell>
<Table.Cell class="font-mono text-sm">{ip.ip}</Table.Cell>
@@ -587,10 +719,37 @@
</Table.Cell>
</Table.Row>
{/each}
{/each}
{/if}
</Table.Body>
</Table.Root>
</KpiPanelCard>
</ScrollArea>
{#if displayedIpRows.length > ipsPerPage}
<div
class="relative z-10 flex shrink-0 flex-wrap items-center justify-between gap-2 border-t border-border bg-card px-4 py-3 text-sm text-muted-foreground"
>
<span>
{displayedIpRows.length === 0
? '0'
: `${(ipsPage - 1) * ipsPerPage + 1}–${Math.min(ipsPage * ipsPerPage, displayedIpRows.length)}`} из {displayedIpRows.length}
</span>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={ipsPage <= 1}
onclick={() => (ipsPage = Math.max(1, ipsPage - 1))}>Назад</Button
>
<Button
variant="outline"
size="sm"
disabled={ipsPage >= ipsPageCount}
onclick={() => (ipsPage = Math.min(ipsPageCount, ipsPage + 1))}>Вперёд</Button
>
</div>
</div>
{/if}
</div>
</DataTableCard>
{/snippet}
</DataQueryState>
+163 -27
View File
@@ -5,16 +5,24 @@
import {
liveEventsUrl,
fetchAggSummary,
postAggResetBadConnectionsBaseline,
ApiError,
type IncidentItem,
type IncidentSeverity,
type IncidentStatus
} from '$lib/api/client.js';
import { toast } from 'svelte-sonner';
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import DataTableCard from '$lib/components/data-table/data-table-card.svelte';
import DataTableToolbar from '$lib/components/data-table/data-table-toolbar.svelte';
import SortableTh from '$lib/components/data-table/sortable-th.svelte';
import { Input } from '$lib/components/ui/input/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import SearchIcon from '@lucide/svelte/icons/search';
import { nextSortDir, compareStrings, compareNum, type SortDir } from '$lib/table-sort.js';
import ActivityIcon from '@lucide/svelte/icons/activity';
import LayersIcon from '@lucide/svelte/icons/layers';
import ClockIcon from '@lucide/svelte/icons/clock';
import BellIcon from '@lucide/svelte/icons/bell';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
@@ -25,6 +33,7 @@
import FleetLiveStaleBadge from '$lib/components/fleet/fleet-live-stale-badge.svelte';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import InfoIcon from '@lucide/svelte/icons/info';
import EraserIcon from '@lucide/svelte/icons/eraser';
type LiveStatus = 'healthy' | 'degraded' | 'critical';
@@ -56,18 +65,95 @@
let staleTimer: ReturnType<typeof setInterval> | null = null;
let es: EventSource | null = null;
let liveIncSearch = $state('');
let liveSortKey = $state<'sev' | 'title' | null>(null);
let liveSortDir = $state<SortDir | null>(null);
let resetBadBusy = $state(false);
function liveSevRank(s: IncidentSeverity): number {
if (s === 'critical') return 3;
if (s === 'warning') return 2;
return 1;
}
let filteredLiveIncidents = $derived.by((): IncidentItem[] => {
const items = snapshot?.incidents ?? [];
const q = liveIncSearch.trim().toLowerCase();
let out = items;
if (q) {
out = items.filter(
(it) =>
(it.title ?? '').toLowerCase().includes(q) ||
(it.summary ?? '').toLowerCase().includes(q) ||
(it.severity ?? '').toLowerCase().includes(q)
);
}
const k = liveSortKey;
const d = liveSortDir;
if (k && d) {
out = [...out].sort((a, b) => {
if (k === 'sev') return compareNum(liveSevRank(a.severity), liveSevRank(b.severity), d);
return compareStrings(a.title, b.title, d);
});
}
return out;
});
async function resetBadConnectionsBaseline() {
if (resetBadBusy) return;
resetBadBusy = true;
try {
const env = await postAggResetBadConnectionsBaseline({
aliases: aliasFilter === 'all' ? undefined : aliasFilter
});
const failed = env.data.servers.filter((s) => !s.ok);
if (failed.length > 0) {
toast.warning(
`Не на всех нодах удалось прочитать stats/summary: ${failed.map((f) => `${f.alias}: ${f.error ?? 'ошибка'}`).join('; ')}`
);
} else {
toast.success(
'Для алертов bad connections зафиксированы текущие значения счётчиков (отметка на шлюзе). Следующий снимок потока обновит список.'
);
}
connectStream();
} catch (e) {
toast.error(e instanceof ApiError ? e.message : String(e));
} finally {
resetBadBusy = false;
}
}
function toggleLiveSort(key: 'sev' | 'title') {
const next = nextSortDir(liveSortDir, key, liveSortKey);
liveSortKey = next === null ? null : key;
liveSortDir = next;
}
function severityVariant(sev: IncidentSeverity): 'default' | 'secondary' | 'destructive' | 'outline' {
if (sev === 'critical') return 'destructive';
if (sev === 'warning') return 'secondary';
return 'outline';
}
function severityLabel(sev: IncidentSeverity): string {
if (sev === 'critical') return 'критично';
if (sev === 'warning') return 'предупреждение';
return 'инфо';
}
function statusVariant(st: LiveStatus | undefined): 'default' | 'secondary' | 'destructive' {
if (st === 'critical') return 'destructive';
if (st === 'degraded') return 'secondary';
return 'default';
}
function statusLabel(st: LiveStatus | undefined): string {
if (st === 'critical') return 'критично';
if (st === 'degraded') return 'деградация';
return 'норма';
}
function parseAliasFilter(raw: string | null): string {
const value = raw?.trim() ?? '';
return !value || value.includes(',') ? 'all' : value;
@@ -268,12 +354,12 @@
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Live</h1>
<h1 class="text-2xl font-semibold tracking-tight">Поток</h1>
<p class="text-sm text-muted-foreground">SSE поток инцидентов и статуса флота.</p>
</div>
<div class="flex items-center gap-2">
<Badge variant={connected ? 'default' : 'secondary'}>
{connected ? 'connected' : 'disconnected'}
{connected ? 'подключено' : 'отключено'}
</Badge>
<FleetLiveStaleBadge
{isStale}
@@ -283,9 +369,28 @@
? 'Нет событий SSE дольше 12 с — снимок считается устаревшим.'
: `Последнее событие потока: ${staleSeconds ?? 0} с назад.`}
/>
<Tooltip.Root>
<Tooltip.Trigger class="inline-flex">
<Button
variant="outline"
size="sm"
disabled={resetBadBusy}
onclick={() => void resetBadConnectionsBaseline()}
>
<EraserIcon class="mr-1 size-4 {resetBadBusy ? 'animate-pulse' : ''}" />
Сброс bad connections
</Button>
</Tooltip.Trigger>
<Tooltip.Content class="max-w-xs">
<p class="text-xs">
Запомнить текущий <span class="font-mono">connections_bad_total</span> на выбранных нодах как ноль для
инцидентов на этом шлюзе (память процесса). Сырые счётчики на нодах Telemt не меняются.
</p>
</Tooltip.Content>
</Tooltip.Root>
<Button variant="outline" size="sm" onclick={() => connectStream()}>
<RefreshCwIcon class="mr-1 size-4" />
Reconnect
Переподключить
</Button>
</div>
</div>
@@ -296,11 +401,11 @@
{#if err}
<Alert class="mb-4">
<AlertTitle>Live stream</AlertTitle>
<AlertTitle>Поток событий</AlertTitle>
<AlertDescription>
{err}
{#if reconnectInSec != null}
Переподключение через {reconnectInSec}s.
Переподключение через {reconnectInSec} с.
{/if}
</AlertDescription>
</Alert>
@@ -311,19 +416,17 @@
{#snippet icon()}
<ActivityIcon class="size-5" aria-hidden="true" />
{/snippet}
<Badge variant={statusVariant(snapshot?.status)}>
{snapshot?.status ?? 'healthy'}
</Badge>
<Badge variant={statusVariant(snapshot?.status)}>{statusLabel(snapshot?.status)}</Badge>
</KpiStatCard>
<KpiStatCard accent="warning" label="Partial" footer="неполный снимок">
<KpiStatCard accent="warning" label="Частичность" footer="неполный снимок">
{#snippet icon()}
<LayersIcon class="size-5" aria-hidden="true" />
{/snippet}
{snapshot?.partial ? 'yes' : 'no'}
{snapshot?.partial ? 'да' : 'нет'}
</KpiStatCard>
<KpiStatCard
accent="info"
label="Timestamp"
label="Временная метка"
footer="время snapshot"
valueClass="text-base font-normal tabular-nums leading-tight"
>
@@ -334,21 +437,47 @@
</KpiStatCard>
</div>
<KpiPanelCard
accent="danger"
title="Последние incidents из snapshot"
<DataTableCard
title="Последние инциденты из снимка"
description="Всего: {snapshot?.counts?.total ?? snapshot?.incidents?.length ?? 0}"
contentClass="p-0"
accent="warning"
>
{#snippet icon()}
<BellIcon class="size-5" aria-hidden="true" />
{#snippet icon()}<ActivityIcon class="size-5" aria-hidden="true" />{/snippet}
{#snippet toolbar()}
<DataTableToolbar>
<div class="relative max-w-md flex-1">
<SearchIcon
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input class="h-8 ps-8" placeholder="Поиск по критичности, заголовку, описанию…" bind:value={liveIncSearch} />
</div>
<Button
variant="outline"
size="sm"
class="h-8"
onclick={() => {
liveIncSearch = '';
liveSortKey = null;
liveSortDir = null;
}}
>Сброс</Button>
</DataTableToolbar>
{/snippet}
<ScrollArea class="max-h-[min(70vh,520px)] w-full" orientation="both">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Severity</Table.Head>
<Table.Head>Title</Table.Head>
<Table.Head>Summary</Table.Head>
<Table.Header class="sticky top-0 z-10 bg-muted">
<Table.Row class="border-b border-border/80 hover:bg-transparent">
<SortableTh
active={liveSortKey === 'sev'}
dir={liveSortDir}
onToggle={() => toggleLiveSort('sev')}
>Критичность</SortableTh>
<SortableTh
active={liveSortKey === 'title'}
dir={liveSortDir}
onToggle={() => toggleLiveSort('title')}
>Заголовок</SortableTh>
<Table.Head>Описание</Table.Head>
<Table.Head>
<div class="flex items-center gap-1">
Серверы
@@ -374,11 +503,17 @@
Инцидентов нет
</Table.Cell>
</Table.Row>
{:else if filteredLiveIncidents.length === 0}
<Table.Row>
<Table.Cell colspan={4} class="p-4 text-center text-muted-foreground">
Нет строк по фильтру
</Table.Cell>
</Table.Row>
{:else}
{#each snapshot?.incidents ?? [] as item (item.id)}
{#each filteredLiveIncidents as item (item.id)}
<Table.Row>
<Table.Cell>
<Badge variant={severityVariant(item.severity)}>{item.severity}</Badge>
<Badge variant={severityVariant(item.severity)}>{severityLabel(item.severity)}</Badge>
</Table.Cell>
<Table.Cell class="font-medium">{item.title}</Table.Cell>
<Table.Cell class="max-w-[380px] text-sm text-muted-foreground">{item.summary}</Table.Cell>
@@ -390,4 +525,5 @@
{/if}
</Table.Body>
</Table.Root>
</KpiPanelCard>
</ScrollArea>
</DataTableCard>
+425
View File
@@ -0,0 +1,425 @@
<script lang="ts">
import { browser } from '$app/environment';
import { onMount } from 'svelte';
import {
ApiError,
fetchAggSummary,
fetchMihomoJson,
fetchMihomoMeta
} from '$lib/api/client.js';
import type { MihomoConnectionsResponse, MihomoMetaResponse } from '$lib/api/mihomo-types.js';
import { formatBytes } from '$lib/format.js';
import * as Table from '$lib/components/ui/table/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
import DataTableCard from '$lib/components/data-table/data-table-card.svelte';
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
import MihomoProxyGroups from '$lib/components/mihomo/mihomo-proxy-groups.svelte';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import { cn } from '$lib/utils.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import ServerIcon from '@lucide/svelte/icons/server';
import PlugIcon from '@lucide/svelte/icons/plug';
import MemoryStickIcon from '@lucide/svelte/icons/memory-stick';
import CloudUploadIcon from '@lucide/svelte/icons/cloud-upload';
import CheckCircleIcon from '@lucide/svelte/icons/circle-check';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
type RowStatus = 'ok' | 'no_mihomo' | 'error';
type FleetRow = {
alias: string;
metaStatus: RowStatus;
meta: MihomoMetaResponse | null;
metaMessage?: string;
alive?: boolean;
connections?: number;
memory?: number;
uploadTotal?: number;
downloadTotal?: number;
pollErr?: string | null;
};
const POLL_MS = 8000;
let loading = $state(true);
let err = $state<string | null>(null);
let rows = $state<FleetRow[]>([]);
/** Список alias с рабочим meta; обновляется только при полной перезагрузке. */
let mihomoOkAliases: string[] = [];
let pollTimer: ReturnType<typeof setInterval> | null = null;
/** Принудительная перезагрузка /proxies для карточки ноды. */
let proxyRefreshNonce = $state<Record<string, number>>({});
let proxyDelayAllNonce = $state<Record<string, number>>({});
let nodeProxyLoading = $state<Record<string, boolean>>({});
let nodeDelayAllBusy = $state<Record<string, boolean>>({});
function bumpProxyRefresh(a: string) {
proxyRefreshNonce = { ...proxyRefreshNonce, [a]: (proxyRefreshNonce[a] ?? 0) + 1 };
}
function bumpProxyDelayAll(a: string) {
proxyDelayAllNonce = { ...proxyDelayAllNonce, [a]: (proxyDelayAllNonce[a] ?? 0) + 1 };
}
let withMihomo = $derived(rows.filter((r) => r.metaStatus === 'ok').length);
let aliveCount = $derived(rows.filter((r) => r.metaStatus === 'ok' && r.alive).length);
let sumConn = $derived(
rows.reduce((acc, r) => acc + (r.metaStatus === 'ok' && typeof r.connections === 'number' ? r.connections : 0), 0)
);
let sumUpload = $derived(
rows.reduce((acc, r) => acc + (typeof r.uploadTotal === 'number' ? r.uploadTotal : 0), 0)
);
let sumDownload = $derived(
rows.reduce((acc, r) => acc + (typeof r.downloadTotal === 'number' ? r.downloadTotal : 0), 0)
);
let sumMemory = $derived(
rows.reduce((acc, r) => acc + (typeof r.memory === 'number' && r.memory > 0 ? r.memory : 0), 0)
);
async function probeMeta(alias: string): Promise<FleetRow> {
try {
const meta = await fetchMihomoMeta(alias);
return {
alias,
metaStatus: 'ok',
meta,
alive: false,
pollErr: null
};
} catch (e) {
const ae = e instanceof ApiError ? e : null;
if (ae?.status === 404) {
return {
alias,
metaStatus: 'no_mihomo',
meta: null,
metaMessage: ae.message
};
}
return {
alias,
metaStatus: 'error',
meta: null,
metaMessage: ae?.message ?? String(e)
};
}
}
async function pollMetrics() {
if (mihomoOkAliases.length === 0) return;
await Promise.all(
mihomoOkAliases.map(async (alias) => {
try {
const j = await fetchMihomoJson<MihomoConnectionsResponse>(alias, 'connections');
const list = j.connections ?? [];
const connTotal = typeof j.total === 'number' ? j.total : list.length;
rows = rows.map((r) =>
r.alias !== alias
? r
: {
...r,
alive: true,
connections: connTotal,
memory: typeof j.memory === 'number' ? j.memory : undefined,
uploadTotal: typeof j.uploadTotal === 'number' ? j.uploadTotal : undefined,
downloadTotal: typeof j.downloadTotal === 'number' ? j.downloadTotal : undefined,
pollErr: null
}
);
} catch (e) {
const msg = e instanceof ApiError ? e.message : String(e);
rows = rows.map((r) =>
r.alias !== alias ? r : { ...r, alive: false, pollErr: msg }
);
}
})
);
}
async function reloadAll() {
if (!browser) return;
loading = true;
err = null;
try {
const env = await fetchAggSummary({ top_n: 1 });
const set = new Set<string>();
for (const s of env.data.servers ?? []) {
if (s.alias) set.add(s.alias);
}
const aliases = [...set].sort((a, b) => a.localeCompare(b));
if (aliases.length === 0) {
rows = [];
mihomoOkAliases = [];
return;
}
const probed = await Promise.all(aliases.map((a) => probeMeta(a)));
probed.sort((a, b) => a.alias.localeCompare(b.alias));
rows = probed;
mihomoOkAliases = probed.filter((r) => r.metaStatus === 'ok').map((r) => r.alias);
await pollMetrics();
} catch (e) {
err = e instanceof ApiError ? e.message : String(e);
rows = [];
mihomoOkAliases = [];
} finally {
loading = false;
}
}
onMount(() => {
if (!browser) return;
void reloadAll();
pollTimer = setInterval(() => void pollMetrics(), POLL_MS);
return () => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
};
});
function statusBadge(r: FleetRow) {
if (r.metaStatus === 'no_mihomo') return { label: 'Нет Mihomo', variant: 'secondary' as const };
if (r.metaStatus === 'error') return { label: 'Meta ошибка', variant: 'destructive' as const };
if (r.alive === false && r.pollErr) return { label: 'Недоступен', variant: 'destructive' as const };
if (r.alive) return { label: 'Онлайн', variant: 'default' as const };
return { label: 'Ожидание', variant: 'outline' as const };
}
</script>
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Mihomo (флот)</h1>
<p class="text-sm text-muted-foreground">
Сводка по нодам и управление прокси. Опрос соединений каждые {POLL_MS / 1000} с.
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onclick={() => void reloadAll()} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить
</Button>
</div>
</div>
<DataQueryState {err} showSkeleton={loading} isEmpty={!loading && rows.length === 0 && !err}>
{#snippet empty()}
<p class="text-sm text-muted-foreground">
В снимке агрегата нет серверов. Проверьте шлюз и конфигурацию <code class="text-xs">servers</code>.
</p>
{/snippet}
{#snippet skeleton()}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
{#each Array.from({ length: 5 }) as _, i (i)}
<Skeleton class="h-28 rounded-xl" />
{/each}
</div>
<Skeleton class="h-64 w-full rounded-lg" />
{/snippet}
{#snippet children()}
{#if rows.length > 0}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<KpiStatCard accent="violet" label="Нод с Mihomo" footer="meta OK" valueClass="text-xl">
{#snippet icon()}
<ServerIcon class="size-5" aria-hidden="true" />
{/snippet}
{withMihomo}
</KpiStatCard>
<KpiStatCard accent="success" label="Онлайн (опрос)" footer="успешный /connections" valueClass="text-xl">
{#snippet icon()}
<CheckCircleIcon class="size-5" aria-hidden="true" />
{/snippet}
{aliveCount}/{withMihomo}
</KpiStatCard>
<KpiStatCard accent="info" label="Соединения ∑" footer="по снимку" valueClass="text-xl">
{#snippet icon()}
<PlugIcon class="size-5" aria-hidden="true" />
{/snippet}
{sumConn}
</KpiStatCard>
<KpiStatCard accent="neutral" label="Память ∑" footer="поле memory" valueClass="text-xl">
{#snippet icon()}
<MemoryStickIcon class="size-5" aria-hidden="true" />
{/snippet}
{sumMemory > 0 ? formatBytes(sumMemory) : '—'}
</KpiStatCard>
<KpiStatCard accent="warning" label="Трафик ∑" footer="upload / download" valueClass="text-lg">
{#snippet icon()}
<CloudUploadIcon class="size-5" aria-hidden="true" />
{/snippet}
<span class="block truncate" title="{formatBytes(sumUpload)} ↑ · {formatBytes(sumDownload)} ↓">
{formatBytes(sumUpload)} ↑ · {formatBytes(sumDownload)} ↓
</span>
</KpiStatCard>
</div>
<DataTableCard
title="Ноды"
description="Контроллер и метрики. Ниже — компактные группы прокси (автозагрузка). «Детально» — обзор с графиками."
accent="violet"
>
{#snippet icon()}<ServerIcon class="size-5" aria-hidden="true" />{/snippet}
<ScrollArea class="w-full" orientation="both">
<Table.Root>
<Table.Header class="sticky top-0 z-10 bg-muted">
<Table.Row class="border-b border-border/80 hover:bg-transparent">
<Table.Head class="text-xs sm:text-sm">Alias</Table.Head>
<Table.Head class="text-xs sm:text-sm">Контроллер</Table.Head>
<Table.Head class="text-xs sm:text-sm">Статус</Table.Head>
<Table.Head class="text-xs sm:text-sm text-right">Conn</Table.Head>
<Table.Head class="text-xs sm:text-sm text-right">Память</Table.Head>
<Table.Head class="text-xs sm:text-sm text-right">Трафик</Table.Head>
<Table.Head class="text-xs sm:text-sm w-[1%]">Прокси</Table.Head>
<Table.Head class="text-xs sm:text-sm w-[1%]"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each rows as r (r.alias)}
{@const b = statusBadge(r)}
<Table.Row>
<Table.Cell class="font-medium text-sm">{r.alias}</Table.Cell>
<Table.Cell class="max-w-[200px] truncate font-mono text-xs sm:text-sm">
{r.meta?.controller_base ?? '—'}
</Table.Cell>
<Table.Cell>
<Badge variant={b.variant}>{b.label}</Badge>
{#if r.metaStatus !== 'ok' && r.metaMessage}
<p class="text-muted-foreground mt-1 max-w-xs text-xs">{r.metaMessage}</p>
{/if}
{#if r.pollErr}
<p class="text-destructive mt-1 max-w-xs text-xs">{r.pollErr}</p>
{/if}
</Table.Cell>
<Table.Cell class="text-right text-sm">
{r.metaStatus === 'ok' ? (r.connections ?? '—') : '—'}
</Table.Cell>
<Table.Cell class="text-right text-sm">
{r.memory != null && r.memory > 0 ? formatBytes(r.memory) : '—'}
</Table.Cell>
<Table.Cell class="text-right font-mono text-xs sm:text-sm">
{#if r.metaStatus === 'ok' && (r.uploadTotal != null || r.downloadTotal != null)}
{formatBytes(r.uploadTotal ?? 0)} ↑<br />
{formatBytes(r.downloadTotal ?? 0)} ↓
{:else}
—
{/if}
</Table.Cell>
<Table.Cell class="text-right">
{#if r.metaStatus === 'ok'}
<a
href="#mihomo-proxies-{encodeURIComponent(r.alias)}"
class="text-primary text-sm hover:underline"
>
К настройке
</a>
{/if}
</Table.Cell>
<Table.Cell class="text-right">
{#if r.metaStatus === 'ok'}
<a
href="/servers/{encodeURIComponent(r.alias)}/mihomo"
class="text-primary inline-flex items-center gap-1 text-sm hover:underline"
>
Детально
<ExternalLinkIcon class="size-3.5 opacity-70" aria-hidden="true" />
</a>
{/if}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</ScrollArea>
</DataTableCard>
<div class="mt-8 space-y-3">
<div>
<h2 class="text-base font-semibold tracking-tight">Прокси по нодам</h2>
<p class="text-muted-foreground mt-0.5 max-w-2xl text-xs leading-relaxed">
Автозагрузка групп. В шапке: «Все задержки» и обновление списка. Клик по узлу — выбор; справа —
проверка одного узла.
</p>
</div>
<div
class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4"
>
{#each rows.filter((r) => r.metaStatus === 'ok') as r (r.alias)}
<Card.Root
id="mihomo-proxies-{encodeURIComponent(r.alias)}"
size="sm"
density="compact"
class="min-h-0 min-w-0 scroll-mt-20 overflow-hidden shadow-sm"
>
<Card.Header class="border-border/80 border-b px-3 pb-3 pt-3 sm:px-4">
<Card.Title class="font-mono text-sm font-semibold leading-none tracking-tight">
{r.alias}
</Card.Title>
{#if r.meta?.controller_base}
<Card.Description
class="text-muted-foreground line-clamp-2 pt-1 font-mono text-[10px] leading-snug"
>
{r.meta.controller_base}
</Card.Description>
{/if}
<Card.Action class="flex flex-wrap items-center justify-end gap-1">
<Button
variant="outline"
size="sm"
class="h-8 px-2 text-xs"
disabled={!!nodeProxyLoading[r.alias] || !!nodeDelayAllBusy[r.alias]}
onclick={() => bumpProxyDelayAll(r.alias)}
>
{nodeDelayAllBusy[r.alias] ? 'Проверка…' : 'Все задержки'}
</Button>
<Button
variant="outline"
size="icon"
class="size-8 shrink-0"
aria-label="Обновить прокси для {r.alias}"
disabled={!!nodeProxyLoading[r.alias] || !!nodeDelayAllBusy[r.alias]}
onclick={() => bumpProxyRefresh(r.alias)}
>
<RefreshCwIcon
class={cn(
'size-3.5',
nodeProxyLoading[r.alias] ? 'animate-spin' : ''
)}
aria-hidden="true"
/>
</Button>
</Card.Action>
</Card.Header>
<Card.Content class="px-0 pb-0 pt-0">
<div class="px-2 py-2 sm:px-3 sm:py-2.5">
<MihomoProxyGroups
alias={r.alias}
compact={true}
hideToolbar={true}
hideCompactPerGroupDelayButton={true}
refreshNonce={proxyRefreshNonce[r.alias] ?? 0}
delayAllNonce={proxyDelayAllNonce[r.alias] ?? 0}
onLoadingChange={(v) => {
nodeProxyLoading = { ...nodeProxyLoading, [r.alias]: v };
}}
onDelayAllChange={(v) => {
nodeDelayAllBusy = { ...nodeDelayAllBusy, [r.alias]: v };
}}
/>
</div>
</Card.Content>
</Card.Root>
{/each}
{#if rows.filter((r) => r.metaStatus === 'ok').length === 0}
<p class="text-sm text-muted-foreground">
Нет нод с настроенным Mihomo — переключать прокси негде. Проверьте конфиг шлюза.
</p>
{/if}
</div>
</div>
{/if}
{/snippet}
</DataQueryState>
+492
View File
@@ -0,0 +1,492 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
fetchAggRadarTelemtDcs,
fetchRadarPingDC,
fetchRadarStatuses,
type AggRadarTelemtDcsRow,
type RadarPingRow
} from '$lib/api/client.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import RadarIcon from '@lucide/svelte/icons/radar';
import ActivityIcon from '@lucide/svelte/icons/activity';
import InfoIcon from '@lucide/svelte/icons/info';
type RadarEp = {
group?: string;
name?: string;
results?: { success?: boolean }[];
};
const dcs = ['DC1', 'DC2', 'DC3', 'DC4', 'DC5'] as const;
const locs: Record<(typeof dcs)[number], string> = {
DC1: '🇺🇸 США',
DC2: '🇳🇱 NL',
DC3: '🇺🇸 США',
DC4: '🇳🇱 NL',
DC5: '🇸🇬 SG'
};
const legendRows = [
{ dc: 'DC1', loc: '🇺🇸 США, Майами', desc: 'Основной для пользователей Америки. Сообщения, звонки.' },
{ dc: 'DC2', loc: '🇳🇱 Нидерланды', desc: 'Основной для большинства RU/EU. Сообщения, файлы, медиа.' },
{ dc: 'DC3', loc: '🇺🇸 США, Майами', desc: 'Резервный для DC1. Медиафайлы и CDN.' },
{ dc: 'DC4', loc: '🇳🇱 Нидерланды', desc: 'Резервный для DC2. Медиа и файлы EU пользователей.' },
{ dc: 'DC5', loc: '🇸🇬 Сингапур', desc: 'Основной для пользователей Азии и Океании.' },
{ dc: 'DC203', loc: '🌐 CDN', desc: 'Специальный CDN узел для ускорения загрузки медиа.' }
];
let radarLoading = $state(false);
let radarErr = $state<string | null>(null);
let radarUpdated = $state<string | null>(null);
let summaryHtml = $state<string>('');
let matrixHtml = $state<string>('');
let diagLoading = $state(false);
let pingByDc = $state<Record<number, RadarPingRow | null>>({
1: null,
2: null,
3: null,
4: null,
5: null
});
let pingFrom = $state<string | null>(null);
let telemtLoading = $state(false);
let telemtErr = $state<string | null>(null);
let telemtPartial = $state(false);
let telemtUpdated = $state<string | null>(null);
let telemtServers = $state<AggRadarTelemtDcsRow[]>([]);
const telemtDcNums = [1, 2, 3, 4, 5] as const;
function dcRowFind(row: AggRadarTelemtDcsRow, dc: number) {
return row.data?.dcs?.find((d) => d.dc === dc);
}
function telemtCoverageClass(cov: number | undefined): string {
if (cov === undefined) return 'text-muted-foreground';
if (cov >= 90) return 'text-emerald-600 font-medium';
if (cov >= 50) return 'text-amber-600 font-medium';
return 'text-red-600 font-medium';
}
function buildMatrix(data: unknown[]) {
const regions: Record<string, { ok: number; total: number }> = {
SE: { ok: 0, total: 0 },
DE: { ok: 0, total: 0 },
RU: { ok: 0, total: 0 }
};
const matrix: Record<string, Record<string, boolean | null>> = {
SE: {},
DE: {},
RU: {}
};
const ruSeen: Record<string, boolean> = {};
for (const raw of data) {
const ep = raw as RadarEp;
const g = ep.group ?? '';
const name = ep.name ?? '';
const results = ep.results;
const last =
results && results.length > 0 ? results[results.length - 1] : null;
const ok = last ? last.success : null;
if (!g.includes('TCP 443 IPv4') || name.includes('IPv6')) continue;
const dcM = name.match(/DC(\d+)/);
if (!dcM) continue;
const dcNum = 'DC' + dcM[1];
let region: string | null = null;
if (g.includes('from SE')) region = 'SE';
else if (g.includes('from DE')) region = 'DE';
else if (g.includes('from RU')) region = 'RU';
if (!region) continue;
if (region === 'RU') {
if (ruSeen[dcNum]) continue;
ruSeen[dcNum] = true;
}
matrix[region][dcNum] = ok ?? null;
regions[region].total++;
if (ok) regions[region].ok++;
}
const info: Record<string, { flag: string; name: string; note: string }> = {
SE: {
flag: '🇸🇪',
name: 'Швеция',
note: 'Референс из мониторинга radar.telemt.top (from SE)'
},
DE: { flag: '🇩🇪', name: 'Германия', note: 'Для сравнения (from DE)' },
RU: { flag: '🇷🇺', name: 'Россия', note: 'Напрямую (from RU)' }
};
summaryHtml = (['SE', 'DE', 'RU'] as const)
.map((code) => {
const i = info[code];
const ok = regions[code].ok || 0;
const total = regions[code].total || 5;
const color = ok === total ? 'text-emerald-600' : ok === 0 ? 'text-red-600' : 'text-amber-600';
return `<div class="rounded-lg border border-border bg-card p-3 text-sm">
<div class="font-medium">${i.flag} ${i.name}</div>
<div class="mt-1 text-2xl font-semibold ${color}">${ok}/${total}</div>
<div class="text-muted-foreground mt-1 text-xs">${i.note}</div>
</div>`;
})
.join('');
let tbl = `<div class="overflow-x-auto"><table class="w-full min-w-[520px] border-collapse text-sm">
<thead><tr class="border-b border-border">
<th class="p-2 text-start font-medium">Из страны</th>
${dcs.map((dc) => `<th class="p-2 text-center font-medium">${dc}<br/><span class="text-muted-foreground text-xs font-normal">${locs[dc]}</span></th>`).join('')}
</tr></thead><tbody>`;
for (const row of [
{ code: 'SE', flag: '🇸🇪', label: 'Швеция' },
{ code: 'DE', flag: '🇩🇪', label: 'Германия' },
{ code: 'RU', flag: '🇷🇺', label: 'Россия' }
] as const) {
tbl += `<tr class="border-b border-border"><td class="p-2 whitespace-nowrap">${row.flag} ${row.label}</td>`;
for (const dc of dcs) {
const v = matrix[row.code][dc];
const color =
v === true ? 'text-emerald-600' : v === false ? 'text-red-600' : 'text-muted-foreground';
const title = v === true ? 'доступен' : v === false ? 'заблокирован' : 'нет данных';
tbl += `<td class="p-2 text-center ${color}" title="${title}">●</td>`;
}
tbl += '</tr>';
}
tbl += '</tbody></table></div>';
matrixHtml = tbl;
}
async function loadRadar() {
radarLoading = true;
radarErr = null;
try {
const data = await fetchRadarStatuses();
buildMatrix(data);
radarUpdated = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
} catch (e) {
radarErr = e instanceof Error ? e.message : String(e);
summaryHtml = '';
matrixHtml = '';
} finally {
radarLoading = false;
}
}
async function runDiag() {
diagLoading = true;
for (let i = 1; i <= 5; i++) {
pingByDc = { ...pingByDc, [i]: null };
}
try {
const data = await fetchRadarPingDC();
pingFrom = data.from;
const next: Record<number, RadarPingRow | null> = { ...pingByDc };
for (let i = 1; i <= 5; i++) {
const r = data.results[String(i)];
next[i] = r ?? null;
}
pingByDc = next;
} catch {
for (let i = 1; i <= 5; i++) {
pingByDc = { ...pingByDc, [i]: { ok: false, ms: null } };
}
} finally {
diagLoading = false;
}
}
function pingStyle(ms: number | null, ok: boolean): { text: string; barPct: number; color: string } {
if (!ok || ms === null) {
return { text: 'блок.', barPct: 100, color: 'bg-red-500' };
}
const color = ms < 100 ? 'bg-emerald-500' : ms < 300 ? 'bg-amber-500' : 'bg-red-500';
return { text: `${ms} мс`, barPct: Math.min(ms / 3, 100), color };
}
async function loadTelemtDcs() {
telemtLoading = true;
telemtErr = null;
try {
const env = await fetchAggRadarTelemtDcs();
telemtServers = env.data.servers ?? [];
telemtPartial = !!env.partial;
telemtUpdated = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
} catch (e) {
telemtErr = e instanceof Error ? e.message : String(e);
telemtServers = [];
telemtPartial = false;
} finally {
telemtLoading = false;
}
}
async function refreshRadarAndTelemt() {
await Promise.all([loadRadar(), loadTelemtDcs()]);
}
onMount(() => {
void refreshRadarAndTelemt();
});
</script>
<div class="flex w-full flex-col gap-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2">
<RadarIcon class="text-primary size-8" />
<div>
<h1 class="text-xl font-semibold tracking-tight">Радар DC Telegram</h1>
<p class="text-muted-foreground text-sm">
Источник матрицы: <span class="text-foreground">radar.telemt.top</span>
{#if radarUpdated}
· обновлено {radarUpdated}
{/if}
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
disabled={radarLoading || telemtLoading}
onclick={() => void refreshRadarAndTelemt()}
>
<RefreshCwIcon
class="mr-1 size-4 {radarLoading || telemtLoading ? 'animate-spin' : ''}"
/>
Обновить
</Button>
</div>
{#if radarErr}
<Alert variant="destructive">
<AlertTitle>Ошибка загрузки радара</AlertTitle>
<AlertDescription>{radarErr}</AlertDescription>
</Alert>
{/if}
<Card.Root>
<Card.Header>
<Card.Title class="text-base">Сводка по регионам (TCP 443 IPv4)</Card.Title>
<Card.Description>
Матрица ниже строится из ответа шлюза <code class="text-xs">/api/radar/statuses</code> (прокси к
radar.telemt.top).
</Card.Description>
</Card.Header>
<Card.Content>
{#if radarLoading && !summaryHtml}
<p class="text-muted-foreground text-sm">Загрузка…</p>
{:else if summaryHtml}
<div class="grid gap-3 sm:grid-cols-3">{@html summaryHtml}</div>
{/if}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-base">Матрица доступности</Card.Title>
<Card.Description>
<span class="text-emerald-600">●</span> доступен
<span class="text-red-600 ms-2">●</span> заблокирован
<span class="text-muted-foreground ms-2">●</span> нет данных
</Card.Description>
</Card.Header>
<Card.Content>
{#if radarLoading && !matrixHtml}
<p class="text-muted-foreground text-sm">Загрузка…</p>
{:else if matrixHtml}
{@html matrixHtml}
{/if}
<Alert class="mt-4">
<InfoIcon class="size-4" />
<AlertTitle class="text-sm">Про ICMP и TCP 443</AlertTitle>
<AlertDescription class="text-xs">
ICMP может проходить даже при блокировке MTProto по TCP 443. Для Telegram важна доступность
TCP 443 до DC — её отражает radar и тест ниже.
</AlertDescription>
</Alert>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-base">Telemt ME — снимок по нодам</Card.Title>
<Card.Description>
<code class="text-xs">GET /api/agg/radar-telemt-dcs</code> — параллельно
<code class="text-xs">GET /v1/stats/dcs</code> на каждом upstream (как на странице «Состояние» ноды).
Требуется включённый minimal runtime API на Telemt; иначе в ячейках будет причина отключения.
{#if telemtUpdated}
<span class="text-foreground"> · обновлено {telemtUpdated}</span>
{/if}
</Card.Description>
</Card.Header>
<Card.Content>
{#if telemtErr}
<Alert variant="destructive">
<AlertTitle>Ошибка загрузки stats/dcs</AlertTitle>
<AlertDescription>{telemtErr}</AlertDescription>
</Alert>
{:else if telemtLoading && telemtServers.length === 0}
<p class="text-muted-foreground text-sm">Загрузка…</p>
{:else if telemtServers.length === 0}
<p class="text-muted-foreground text-sm">Нет серверов в конфигурации шлюза.</p>
{:else}
{#if telemtPartial}
<Alert class="mb-4">
<InfoIcon class="size-4" />
<AlertTitle class="text-sm">Частичные данные</AlertTitle>
<AlertDescription class="text-xs">
Не все ноды ответили успешно — смотрите ошибки в строках.
</AlertDescription>
</Alert>
{/if}
<div class="overflow-x-auto rounded-md border border-border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-28">Нода</Table.Head>
{#each telemtDcNums as dc (dc)}
<Table.Head class="min-w-[5.5rem] text-center">DC{dc}</Table.Head>
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#each telemtServers as srv (srv.alias)}
<Table.Row>
<Table.Cell class="font-mono text-sm font-medium">{srv.alias}</Table.Cell>
{#each telemtDcNums as dc (dc)}
<Table.Cell class="align-top text-center text-sm">
{#if !srv.ok}
<span class="text-destructive text-xs leading-tight" title={srv.error ?? ''}
>ошибка</span>
{:else if !srv.data?.middle_proxy_enabled}
<span
class="text-muted-foreground text-xs leading-tight"
title={srv.data?.reason ?? ''}
>
{srv.data?.reason === 'feature_disabled'
? 'ME API off'
: (srv.data?.reason ?? 'нет данных')}
</span>
{:else}
{@const cell = dcRowFind(srv, dc)}
{#if cell}
<div class={telemtCoverageClass(cell.coverage_pct)}>
{cell.coverage_pct.toFixed(0)}%
</div>
{#if cell.rtt_ms != null && cell.rtt_ms !== undefined}
<div class="text-muted-foreground mt-0.5 text-xs tabular-nums">
{Number(cell.rtt_ms).toFixed(0)} ms
</div>
{/if}
{:else}
<span class="text-muted-foreground">—</span>
{/if}
{/if}
</Table.Cell>
{/each}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
<p class="text-muted-foreground mt-3 text-xs">
Покрытие — доля alive writers к required для DC (Telemt). Это не сырой TCP-пинг с интернета, а
состояние middle proxy на процессе Telemt.
</p>
{/if}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2 text-base">
<ActivityIcon class="size-4" />
Диагностика TCP с хоста шлюза
</Card.Title>
<Card.Description>
Запрос <code class="text-xs">/api/radar/ping-dc</code> выполняется на <strong>процессе шлюза</strong>
(не на каждой ноде Telemt): TCP :443 до каждого DC, таймаут 2 с (как
<code class="text-xs">ping_proxy.php</code>). Для вида «с каждой ноды» используйте таблицу ME выше.
Поле «from» в ответе — метка источника.
{#if pingFrom}
<span class="mt-1 block text-foreground">from: {pingFrom}</span>
{/if}
</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<Button disabled={diagLoading} onclick={() => runDiag()}>
{diagLoading ? 'Тестирование…' : 'Запустить тест'}
</Button>
<div class="space-y-3">
{#each [1, 2, 3, 4, 5] as n (n)}
{@const row = pingByDc[n]}
{@const st = row ? pingStyle(row.ms, row.ok) : { text: '—', barPct: 0, color: 'bg-muted' }}
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-4">
<div class="w-24 shrink-0 text-sm font-medium">DC{n}</div>
<div class="min-w-0 flex-1">
<div class="bg-muted h-2 overflow-hidden rounded-full">
<div
class="h-2 rounded-full transition-all {st.color}"
style="width: {row ? st.barPct : 0}%"
></div>
</div>
</div>
<div class="w-28 shrink-0 text-sm tabular-nums {row?.ok ? '' : 'text-red-600'}">
{diagLoading && !row ? '…' : st.text}
</div>
<div class="text-muted-foreground w-32 shrink-0 text-xs">
{#if row}
{row.ok ? 'доступен' : 'заблокирован'}
{:else if !diagLoading}
ожидание
{/if}
</div>
</div>
{/each}
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-base">Справка — датацентры Telegram</Card.Title>
</Card.Header>
<Card.Content>
<div class="rounded-md border border-border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-24">DC</Table.Head>
<Table.Head>Локация</Table.Head>
<Table.Head>Назначение</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each legendRows as row (row.dc)}
<Table.Row>
<Table.Cell class="font-mono text-sm">{row.dc}</Table.Cell>
<Table.Cell class="text-sm">{row.loc}</Table.Cell>
<Table.Cell class="text-muted-foreground text-sm">{row.desc}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
<p class="text-muted-foreground mt-3 text-xs">
Telegram назначает DC при регистрации; при блокировке своего DC нужен прокси в регионе, где DC
доступен по TCP 443.
</p>
</Card.Content>
</Card.Root>
</div>
+101 -77
View File
@@ -1,22 +1,17 @@
<script lang="ts">
import { page } from '$app/state';
import {
ApiError,
fetchStatsSummary,
fetchTelemt
} from '$lib/api/client.js';
import { ApiError, fetchStatsSummary, fetchTelemt } from '$lib/api/client.js';
import type { HealthData, StatsSummaryData, SystemInfoData } from '$lib/api/telemt-v1.js';
import { formatDurationSeconds, formatMiB } from '$lib/format.js';
import * as Card from '$lib/components/ui/card/index.js';
import { formatDurationSeconds } from '$lib/format.js';
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import ClockIcon from '@lucide/svelte/icons/clock';
import PlugIcon from '@lucide/svelte/icons/plug';
import UnplugIcon from '@lucide/svelte/icons/unplug';
import UsersIcon from '@lucide/svelte/icons/users';
import CpuIcon from '@lucide/svelte/icons/cpu';
import PlugIcon from '@lucide/svelte/icons/plug';
import ShieldAlertIcon from '@lucide/svelte/icons/shield-alert';
import TimerOffIcon from '@lucide/svelte/icons/timer-off';
import UsersIcon from '@lucide/svelte/icons/users';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
@@ -30,6 +25,15 @@
let summary = $state<StatsSummaryData | null>(null);
let sys = $state<SystemInfoData | null>(null);
function num(v: unknown): number {
if (typeof v === 'number' && Number.isFinite(v)) return v;
if (typeof v === 'string' && v.trim() !== '') {
const n = Number(v);
if (Number.isFinite(n)) return n;
}
return 0;
}
async function loadFor(a: string) {
loading = true;
err = null;
@@ -64,17 +68,19 @@
let hasStaleData = $derived(err != null && (health != null || summary != null));
let showSkeleton = $derived(loading && !err && health == null && summary == null);
let badConnAccent = $derived.by((): 'danger' | 'success' => {
const n = summary?.connections_bad_total;
if (typeof n !== 'number' || !Number.isFinite(n) || n === 0) return 'success';
return 'danger';
});
let uptimeSec = $derived(num(sys?.uptime_seconds));
let connTotal = $derived(num(summary?.connections_total));
let badTotal = $derived(num(summary?.connections_bad_total));
let cfgUsers = $derived(num(summary?.configured_users));
let handshakeT = $derived(num(summary?.handshake_timeouts_total));
</script>
<div class="mb-6 flex items-center justify-between gap-4">
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p class="font-mono text-sm text-muted-foreground">{alias}</p>
<h1 class="text-2xl font-semibold tracking-tight">Сервер: обзор</h1>
<p class="text-sm text-muted-foreground">
Ключевые метрики и состояние Telemt для <span class="font-mono">{alias}</span>.
</p>
</div>
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
@@ -90,68 +96,86 @@
partial={false}
>
{#snippet skeleton()}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{#each Array.from({ length: 4 }) as _, i (i)}
<Card.Root class="border-l-4 border-l-muted-foreground/30 shadow-sm">
<Card.Header class="pb-2">
<div class="flex gap-3">
<Skeleton class="size-10 shrink-0 rounded-lg" />
<div class="min-w-0 flex-1 space-y-2">
<Skeleton class="h-3 w-24" />
<Skeleton class="h-8 w-20" />
</div>
</div>
</Card.Header>
<Card.Content class="pt-0">
<Skeleton class="h-3 w-32" />
</Card.Content>
</Card.Root>
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
{#each Array.from({ length: 5 }) as _, i (i)}
<Skeleton class="h-28 rounded-xl" />
{/each}
</div>
<Skeleton class="h-40 w-full rounded-lg" />
<Skeleton class="h-64 w-full rounded-lg" />
{/snippet}
{#snippet children()}
{#if health?.status === 'ok'}
<Alert class="mb-4 border-emerald-500/30 bg-emerald-500/10">
<AlertTitle>Telemt отвечает</AlertTitle>
<AlertDescription>
read_only: {health.read_only ? 'да' : 'нет'}
</AlertDescription>
</Alert>
{/if}
{#if health?.status === 'ok'}
<Alert class="mb-4 border-emerald-500/30 bg-emerald-500/10">
<AlertTitle>Telemt отвечает</AlertTitle>
<AlertDescription>
read_only: {health.read_only ? 'да' : 'нет'}
</AlertDescription>
</Alert>
{/if}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<KpiStatCard accent="teal" label="Аптайм" footer="uptime с ноды" valueClass="text-xl">
{#snippet icon()}
<ClockIcon class="size-5" aria-hidden="true" />
{/snippet}
{sys ? formatDurationSeconds(sys.uptime_seconds) : '—'}
</KpiStatCard>
<KpiStatCard accent="info" label="Соединения (всего)" footer="stats/summary" valueClass="text-xl">
{#snippet icon()}
<PlugIcon class="size-5" aria-hidden="true" />
{/snippet}
{summary?.connections_total ?? '—'}
</KpiStatCard>
<KpiStatCard accent={badConnAccent} label="Bad connections" footer="счётчик на ноде" valueClass="text-xl">
{#snippet icon()}
<UnplugIcon class="size-5" aria-hidden="true" />
{/snippet}
{summary?.connections_bad_total ?? '—'}
</KpiStatCard>
<KpiStatCard accent="success" label="Пользователей в конфиге" footer="configured_users" valueClass="text-xl">
{#snippet icon()}
<UsersIcon class="size-5" aria-hidden="true" />
{/snippet}
{summary?.configured_users ?? '—'}
</KpiStatCard>
</div>
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<KpiStatCard
accent="violet"
label="Аптайм"
valueClass="text-xl"
>
{#snippet icon()}
<ClockIcon class="size-5" aria-hidden="true" />
{/snippet}
{sys ? formatDurationSeconds(sys.uptime_seconds) : '—'}
</KpiStatCard>
<KpiStatCard
accent="info"
label="Соединения (всего)"
valueClass="text-xl"
>
{#snippet icon()}
<PlugIcon class="size-5" aria-hidden="true" />
{/snippet}
{summary ? String(connTotal) : '—'}
</KpiStatCard>
<KpiStatCard
accent="danger"
label="Плохие соединения"
valueClass="text-xl"
>
{#snippet icon()}
<ShieldAlertIcon class="size-5" aria-hidden="true" />
{/snippet}
{summary ? String(badTotal) : '—'}
</KpiStatCard>
<KpiStatCard
accent="warning"
label="Таймауты рукопожатия"
valueClass="text-xl"
>
{#snippet icon()}
<TimerOffIcon class="size-5" aria-hidden="true" />
{/snippet}
{summary ? String(handshakeT) : '—'}
</KpiStatCard>
<KpiStatCard
accent="neutral"
label="Пользователей в конфиге"
valueClass="text-xl"
>
{#snippet icon()}
<UsersIcon class="size-5" aria-hidden="true" />
{/snippet}
{summary ? String(cfgUsers) : '—'}
</KpiStatCard>
</div>
{#if sys}
<KpiPanelCard accent="neutral" title="Система" contentClass="grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-3">
{#snippet icon()}
<CpuIcon class="size-5" aria-hidden="true" />
{/snippet}
{#if sys}
<KpiPanelCard
accent="neutral"
title="Система"
description="Информация о версии, платформе и конфигурации ноды"
contentClass="grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-3"
>
{#snippet icon()}
<CpuIcon class="size-5" aria-hidden="true" />
{/snippet}
<div><span class="text-muted-foreground">version</span> {sys.version}</div>
<div><span class="text-muted-foreground">OS / arch</span> {sys.target_os} / {sys.target_arch}</div>
<div><span class="text-muted-foreground">build</span> {sys.build_profile}</div>
@@ -162,7 +186,7 @@
<span class="text-muted-foreground">hash</span> {sys.config_hash}
</div>
<div><span class="text-muted-foreground">reload count</span> {sys.config_reload_count}</div>
</KpiPanelCard>
{/if}
</KpiPanelCard>
{/if}
{/snippet}
</DataQueryState>
@@ -1,22 +1,25 @@
<script lang="ts">
import { page } from '$app/state';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import ServerPageHeader from '$lib/components/server-page-header.svelte';
import SettingsIcon from '@lucide/svelte/icons/settings';
const alias = $derived(page.params.alias ?? '');
</script>
<div class="mb-6">
<h1 class="mb-2 text-2xl font-semibold tracking-tight">Configuration</h1>
<p class="text-sm text-muted-foreground">
Конфигурация Telemt на ноде <span class="font-mono text-foreground">{alias}</span> не доступна через API
— правьте <code class="rounded bg-muted px-1">config.toml</code> на сервере.
</p>
</div>
<ServerPageHeader
title="Конфигурация"
subtitle="Конфигурация этой ноды не отдается через Control API"
showRefresh={false}
/>
<KpiPanelCard accent="neutral" title="Конфигурация" description="Настройка на сервере" contentClass="text-sm text-muted-foreground">
{#snippet icon()}
<SettingsIcon class="size-5" aria-hidden="true" />
{/snippet}
<p class="mb-2">
Конфигурация Telemt на ноде <span class="font-mono text-foreground">{alias}</span> не доступна через API.
Правьте <code class="rounded bg-muted px-1">config.toml</code> на сервере.
</p>
См. <a
class="text-primary underline"
href="https://github.com/telemt/telemt/blob/main/docs/API.md"
@@ -2,27 +2,21 @@
import { page } from '$app/state';
import { browser } from '$app/environment';
import { onDestroy } from 'svelte';
import {
ApiError,
fetchMihomoJson,
fetchMihomoMeta,
mihomoPut,
mihomoUrl,
mihomoWsUrl
} from '$lib/api/client.js';
import type {
MihomoConnectionsResponse,
MihomoMetaResponse,
MihomoProxiesResponse,
MihomoProxyEntry
} from '$lib/api/mihomo-types.js';
import { ApiError, fetchMihomoJson, fetchMihomoMeta, mihomoUrl, mihomoWsUrl } from '$lib/api/client.js';
import type { MihomoConnectionsResponse, MihomoMetaResponse } from '$lib/api/mihomo-types.js';
import { formatBytes, formatRatePerSec } from '$lib/format.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
import ArrowBigUpIcon from '@lucide/svelte/icons/arrow-big-up';
import ArrowBigDownIcon from '@lucide/svelte/icons/arrow-big-down';
import CloudUploadIcon from '@lucide/svelte/icons/cloud-upload';
import CloudDownloadIcon from '@lucide/svelte/icons/cloud-download';
import PlugIcon from '@lucide/svelte/icons/plug';
import MemoryStickIcon from '@lucide/svelte/icons/memory-stick';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
import MihomoOverviewCharts from '$lib/components/mihomo/mihomo-overview-charts.svelte';
import MihomoProxyGroups from '$lib/components/mihomo/mihomo-proxy-groups.svelte';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
const alias = $derived(page.params.alias ?? '');
@@ -36,7 +30,8 @@
let downBps = $state(0);
let totalUp = $state(0);
let totalDown = $state(0);
let memKB = $state(0);
/** Память в KiB (кибибайты): из WS Mihomo приходит в байтах — нормализуем в одном месте. */
let memKiB = $state(0);
let connTotal = $state(0);
let tcpN = $state(0);
let udpN = $state(0);
@@ -48,19 +43,15 @@
let topList = $state<{ name: string; n: number }[]>([]);
const MAX_POINTS = 72;
/** Mihomo hub/route/proxies.go: без query `url` URLTest часто даёт delay=0 → HTTP 503. */
const MIHOMO_DELAY_DEFAULT_URL = 'https://www.gstatic.com/generate_204';
/** Mihomo WS: `inuse` (/memory) и `memory` (/connections) — в байтах → KiB для карточки и графика. */
function mihomoWsMemoryToKiB(bytes: number): number {
if (!Number.isFinite(bytes) || bytes <= 0) return 0;
return bytes / 1024;
}
let lastTick = $state<number | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
let proxiesLoading = $state(false);
let proxiesErr = $state<string | null>(null);
let proxiesData = $state<MihomoProxiesResponse | null>(null);
let switching = $state<string | null>(null);
let testing = $state<string | null>(null);
let testingGroup = $state<string | null>(null);
/** Mihomo отдаёт upTotal/downTotal в потоке /traffic — используем их; иначе накапливаем из up/down. */
function recordRates(
now: number,
@@ -102,12 +93,6 @@
}
}
function delayQuery(testUrl?: string) {
const u =
testUrl && String(testUrl).trim() !== '' ? String(testUrl).trim() : MIHOMO_DELAY_DEFAULT_URL;
return `timeout=5000&url=${encodeURIComponent(u)}`;
}
function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
@@ -203,8 +188,8 @@
if (line) {
try {
const o = JSON.parse(line) as { inuse?: number };
memKB = Number(o.inuse) || 0;
histMem = [...histMem, memKB];
memKiB = mihomoWsMemoryToKiB(Number(o.inuse) || 0);
histMem = [...histMem, memKiB];
while (histMem.length > MAX_POINTS) histMem.shift();
seen++;
} catch {
@@ -343,8 +328,8 @@
ws.onmessage = (ev) => {
try {
const o = JSON.parse(String(ev.data)) as { inuse?: number };
memKB = Number(o.inuse) || 0;
histMem = [...histMem, memKB];
memKiB = mihomoWsMemoryToKiB(Number(o.inuse) || 0);
histMem = [...histMem, memKiB];
while (histMem.length > MAX_POINTS) histMem.shift();
} catch {
/* ignore */
@@ -397,8 +382,8 @@
totalDown = j.downloadTotal;
}
if (typeof j.memory === 'number') {
memKB = j.memory;
histMem = [...histMem, memKB];
memKiB = mihomoWsMemoryToKiB(j.memory);
histMem = [...histMem, memKiB];
while (histMem.length > MAX_POINTS) histMem.shift();
}
let t = 0,
@@ -486,7 +471,7 @@
totalDown = 0;
upBps = 0;
downBps = 0;
memKB = 0;
memKiB = 0;
histUp = [];
histDown = [];
histMem = [];
@@ -506,110 +491,15 @@
};
});
async function loadProxies() {
const a = alias;
if (!a) return;
proxiesLoading = true;
proxiesErr = null;
try {
proxiesData = await fetchMihomoJson<MihomoProxiesResponse>(a, 'proxies');
} catch (e) {
proxiesData = null;
proxiesErr = e instanceof ApiError ? e.message : String(e);
} finally {
proxiesLoading = false;
}
}
$effect(() => {
const a = alias;
if (!a || tab !== 'proxies') return;
void loadProxies();
});
/** Группы, у которых есть `all` + `now` и PUT /proxies/{name} переключает узел (как в Clash/Mihomo). */
function isSelectableGroup(p: MihomoProxyEntry) {
const t = (p.type ?? '').toLowerCase();
if (t === 'selector' || t === 'urltest' || t === 'fallback') return true;
if (t === 'loadbalance' && Array.isArray(p.all) && p.all.length > 0) return true;
return false;
}
function lastDelay(p: MihomoProxyEntry): number | null {
const h = p.history;
if (!h?.length) return null;
return h[h.length - 1]?.delay ?? null;
}
async function selectProxy(groupName: string, nodeName: string) {
const a = alias;
if (!a) return;
switching = groupName;
try {
await mihomoPut(a, `proxies/${encodeURIComponent(groupName)}`, { name: nodeName });
await loadProxies();
} catch (e) {
proxiesErr = e instanceof ApiError ? e.message : String(e);
} finally {
switching = null;
}
}
async function pingOne(name: string) {
const a = alias;
if (!a) return;
testing = name;
try {
const testUrl = 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 = alias;
if (!a) return;
testingGroup = groupName;
const groupTestUrl = proxiesData?.proxies?.[groupName]?.testUrl;
try {
await fetchMihomoJson(
a,
`group/${encodeURIComponent(groupName)}/delay?${delayQuery(groupTestUrl)}`
);
await loadProxies();
} catch {
try {
const g = proxiesData?.proxies?.[groupName];
const names = g?.all ?? [];
for (const n of names) {
const u = proxiesData?.proxies?.[n]?.testUrl;
await fetchMihomoJson(a, `proxies/${encodeURIComponent(n)}/delay?${delayQuery(u)}`);
}
await loadProxies();
} catch (e) {
proxiesErr = e instanceof ApiError ? e.message : String(e);
}
} finally {
testingGroup = null;
}
}
onDestroy(() => {
if (pollTimer) clearInterval(pollTimer);
});
</script>
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Mihomo</h1>
<p class="text-muted-foreground text-sm">Обзор и прокси (external-controller)</p>
<p class="text-sm text-muted-foreground">Обзор и прокси (external-controller)</p>
</div>
<Tabs.Root bind:value={tab} class="w-fit shrink-0">
<Tabs.List class="min-w-[220px] gap-0.5 p-1">
@@ -622,14 +512,40 @@
<DataQueryState err={metaErr} showSkeleton={loadingMeta} isEmpty={false}>
{#snippet skeleton()}
<Skeleton class="mb-4 h-6 w-96 max-w-full" />
<div class="mb-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{#each Array.from({ length: 6 }) as _, i (i)}
<Skeleton class="h-24 rounded-lg" />
<Card.Root class="border-l-4 border-l-muted-foreground/30 shadow-sm">
<Card.Header class="pb-2">
<div class="flex gap-3">
<Skeleton class="size-10 shrink-0 rounded-lg" />
<div class="min-w-0 flex-1 space-y-2">
<Skeleton class="h-3 w-24" />
<Skeleton class="h-8 w-20" />
</div>
</div>
</Card.Header>
<Card.Content class="pt-0">
<Skeleton class="h-3 w-32" />
</Card.Content>
</Card.Root>
{/each}
</div>
<div class="grid gap-4 lg:grid-cols-2">
{#each Array.from({ length: 6 }) as _, i (i)}
<Skeleton class="h-56 rounded-lg" />
<Card.Root class="border-l-4 border-l-muted-foreground/30 shadow-sm">
<Card.Header class="pb-2">
<div class="flex gap-3">
<Skeleton class="size-10 shrink-0 rounded-lg" />
<div class="min-w-0 flex-1 space-y-2">
<Skeleton class="h-4 w-40" />
<Skeleton class="h-3 w-full max-w-xs" />
</div>
</div>
</Card.Header>
<Card.Content class="h-56 px-4 pb-4 pt-0">
<Skeleton class="h-full w-full rounded-md" />
</Card.Content>
</Card.Root>
{/each}
</div>
{/snippet}
@@ -640,45 +556,43 @@
</p>
{/if}
{#if tab === 'overview' && !metaErr}
<div class="mb-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<Card.Root class="border-l-primary border-l-4 py-3">
<Card.Header class="pb-1">
<Card.Description>Загрузка</Card.Description>
</Card.Header>
<Card.Content class="text-lg font-semibold">{formatRatePerSec(upBps)}</Card.Content>
</Card.Root>
<Card.Root class="border-l-sky-500 border-l-4 py-3">
<Card.Header class="pb-1">
<Card.Description>Скачивание</Card.Description>
</Card.Header>
<Card.Content class="text-lg font-semibold">{formatRatePerSec(downBps)}</Card.Content>
</Card.Root>
<Card.Root class="border-l-amber-500 border-l-4 py-3">
<Card.Header class="pb-1">
<Card.Description>Всего загружено</Card.Description>
</Card.Header>
<Card.Content class="text-lg font-semibold">{formatBytes(totalUp)}</Card.Content>
</Card.Root>
<Card.Root class="border-l-cyan-500 border-l-4 py-3">
<Card.Header class="pb-1">
<Card.Description>Всего скачано</Card.Description>
</Card.Header>
<Card.Content class="text-lg font-semibold">{formatBytes(totalDown)}</Card.Content>
</Card.Root>
<Card.Root class="border-l-emerald-500 border-l-4 py-3">
<Card.Header class="pb-1">
<Card.Description>Активные соединения</Card.Description>
</Card.Header>
<Card.Content class="text-lg font-semibold">{connTotal}</Card.Content>
</Card.Root>
<Card.Root class="border-l-violet-500 border-l-4 py-3">
<Card.Header class="pb-1">
<Card.Description>Память</Card.Description>
</Card.Header>
<Card.Content class="text-lg font-semibold">
{memKB > 0 ? `${(memKB / 1024).toFixed(1)} MiB` : '—'}
</Card.Content>
</Card.Root>
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<KpiStatCard accent="violet" label="Загрузка" footer="мгновенная скорость, /traffic" valueClass="text-xl">
{#snippet icon()}
<ArrowBigUpIcon class="size-5" aria-hidden="true" />
{/snippet}
{formatRatePerSec(upBps)}
</KpiStatCard>
<KpiStatCard accent="info" label="Скачивание" footer="мгновенная скорость, /traffic" valueClass="text-xl">
{#snippet icon()}
<ArrowBigDownIcon class="size-5" aria-hidden="true" />
{/snippet}
{formatRatePerSec(downBps)}
</KpiStatCard>
<KpiStatCard accent="warning" label="Всего загружено" footer="uploadTotal (снимок)" valueClass="text-xl">
{#snippet icon()}
<CloudUploadIcon class="size-5" aria-hidden="true" />
{/snippet}
{formatBytes(totalUp)}
</KpiStatCard>
<KpiStatCard accent="teal" label="Всего скачано" footer="downloadTotal (снимок)" valueClass="text-xl">
{#snippet icon()}
<CloudDownloadIcon class="size-5" aria-hidden="true" />
{/snippet}
{formatBytes(totalDown)}
</KpiStatCard>
<KpiStatCard accent="info" label="Активные соединения" footer="список /connections" valueClass="text-xl">
{#snippet icon()}
<PlugIcon class="size-5" aria-hidden="true" />
{/snippet}
{connTotal}
</KpiStatCard>
<KpiStatCard accent="neutral" label="Память" footer="/memory или поле memory в WS" valueClass="text-xl">
{#snippet icon()}
<MemoryStickIcon class="size-5" aria-hidden="true" />
{/snippet}
{memKiB > 0 ? `${(memKiB / 1024).toFixed(1)} MiB` : '—'}
</KpiStatCard>
</div>
<MihomoOverviewCharts
histUp={histUp}
@@ -696,84 +610,5 @@
</DataQueryState>
{#if tab === 'proxies'}
<div class="mb-4 flex justify-end">
<Button variant="outline" size="sm" onclick={() => loadProxies()} disabled={proxiesLoading}>
Обновить
</Button>
</div>
<DataQueryState err={proxiesErr} showSkeleton={proxiesLoading} isEmpty={false}>
{#snippet skeleton()}
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each Array.from({ length: 6 }) as _, i (i)}
<Skeleton class="h-32 rounded-xl" />
{/each}
</div>
{/snippet}
{#snippet children()}
{#if proxiesData?.proxies}
<div class="flex flex-col gap-8">
{#each Object.entries(proxiesData.proxies).filter(([_, v]) => isSelectableGroup(v)) as [gName, group] (gName)}
<div class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 class="text-lg font-semibold">{gName}</h2>
<p class="text-muted-foreground text-sm">
Сейчас: <span class="text-foreground">{group.now ?? '—'}</span>
</p>
</div>
<Button
variant="secondary"
size="sm"
onclick={() => pingGroup(gName)}
disabled={testingGroup === gName}
>
{testingGroup === gName ? 'Проверка…' : 'Проверить все'}
</Button>
</div>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each group.all ?? [] as nodeName (nodeName)}
{@const node = proxiesData.proxies?.[nodeName]}
{@const d = node ? lastDelay(node) : null}
{@const active = group.now === nodeName}
<div
class="bg-card text-card-foreground rounded-xl border p-4 transition-shadow {active
? 'ring-primary ring-2'
: ''}"
>
<button
type="button"
class="w-full text-left"
onclick={() => selectProxy(gName, nodeName)}
disabled={switching === gName}
>
<div class="flex items-start justify-between gap-2">
<span class="font-medium">{nodeName}</span>
<Badge variant="outline">{node?.type ?? '—'}</Badge>
</div>
<div
class="mt-2 text-sm {d != null && d < 500 ? 'text-emerald-400' : 'text-muted-foreground'}"
>
{d != null ? `${d} ms` : '—'}
</div>
</button>
<div class="mt-2 flex justify-end">
<Button
variant="ghost"
size="sm"
class="h-7 px-2"
onclick={() => pingOne(nodeName)}
disabled={testing === nodeName}
>
Ping
</Button>
</div>
</div>
{/each}
</div>
</div>
{/each}
</div>
{/if}
{/snippet}
</DataQueryState>
<MihomoProxyGroups alias={alias} />
{/if}
@@ -3,13 +3,14 @@
import { ApiError, fetchTelemt } from '$lib/api/client.js';
import type { DcStatusData, RuntimeGatesData } from '$lib/api/telemt-v1.js';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import ServerPageHeader from '$lib/components/server-page-header.svelte';
import ServerQueryState from '$lib/components/server-query-state.svelte';
import ServerJsonCard from '$lib/components/server-json-card.svelte';
import BuildingIcon from '@lucide/svelte/icons/building-2';
import BracesIcon from '@lucide/svelte/icons/braces';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Alert, AlertDescription } from '$lib/components/ui/alert/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
const alias = $derived(page.params.alias ?? '');
@@ -56,44 +57,32 @@
}
</script>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-semibold tracking-tight">Runtime</h1>
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить
</Button>
</div>
<ServerPageHeader title="Состояние" {loading} onRefresh={load} />
{#if err}
<Alert variant="destructive">
<AlertTitle>Ошибка</AlertTitle>
<AlertDescription>{err}</AlertDescription>
</Alert>
{:else if loading}
<p class="text-muted-foreground">Загрузка…</p>
{:else}
<ServerQueryState {loading} {err} isEmpty={false}>
{#snippet children()}
<div class="mb-4 flex flex-wrap gap-2">
{#if gates}
<Badge variant="secondary" class="px-3 py-1">accepting: {onOff(gates.accepting_new_connections)}</Badge>
<Badge variant="secondary" class="px-3 py-1">conditional cast: {onOff(gates.conditional_cast_enabled)}</Badge>
<Badge variant="secondary" class="px-3 py-1">me2dc fallback: {onOff(gates.me2dc_fallback_enabled)}</Badge>
<Badge variant="secondary" class="px-3 py-1">ME ready: {onOff(gates.me_runtime_ready)}</Badge>
<Badge variant="outline" class="px-3 py-1">middle proxy: {onOff(gates.use_middle_proxy)}</Badge>
<Badge variant="secondary" class="px-3 py-1">Приём новых: {onOff(gates.accepting_new_connections)}</Badge>
<Badge variant="secondary" class="px-3 py-1">Условный cast: {onOff(gates.conditional_cast_enabled)}</Badge>
<Badge variant="secondary" class="px-3 py-1">Резерв me2dc: {onOff(gates.me2dc_fallback_enabled)}</Badge>
<Badge variant="secondary" class="px-3 py-1">ME готов: {onOff(gates.me_runtime_ready)}</Badge>
<Badge variant="outline" class="px-3 py-1">Промежуточный прокси: {onOff(gates.use_middle_proxy)}</Badge>
{/if}
</div>
{#if dcs && dcs.middle_proxy_enabled && dcs.dcs}
<KpiPanelCard class="mb-6" accent="info" title="Datacenter status" contentClass="p-0">
<KpiPanelCard class="mb-6" accent="info" title="Статус дата-центров" contentClass="p-0">
{#snippet icon()}
<BuildingIcon class="size-5" aria-hidden="true" />
{/snippet}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Header class="sticky top-0 z-10 bg-muted">
<Table.Row class="border-b border-border/80 hover:bg-transparent">
<Table.Head>DC</Table.Head>
<Table.Head class="text-right">RTT</Table.Head>
<Table.Head class="text-right">Writers</Table.Head>
<Table.Head class="text-right">Coverage</Table.Head>
<Table.Head class="text-right">Писатели</Table.Head>
<Table.Head class="text-right">Покрытие</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
@@ -114,18 +103,14 @@
</KpiPanelCard>
{:else if dcs?.reason}
<Alert class="mb-4">
<AlertDescription>DC status: {dcs.reason}</AlertDescription>
<AlertDescription>Статус ДЦ: {dcs.reason}</AlertDescription>
</Alert>
{/if}
<KpiPanelCard accent="violet" title="ME pool state" description="Сырой JSON" contentClass="">
{#snippet icon()}
<ServerJsonCard accent="violet" title="ME pool state" description="Сырой JSON" payload={pool} preClass="max-h-[480px]">
{#snippet iconSnippet()}
<BracesIcon class="size-5" aria-hidden="true" />
{/snippet}
<pre class="max-h-[480px] overflow-auto rounded-md border border-border bg-muted/40 p-3 text-xs">{JSON.stringify(
pool,
null,
2
)}</pre>
</KpiPanelCard>
{/if}
</ServerJsonCard>
{/snippet}
</ServerQueryState>
@@ -1,11 +1,10 @@
<script lang="ts">
import { page } from '$app/state';
import { ApiError, fetchTelemt } from '$lib/api/client.js';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import ServerPageHeader from '$lib/components/server-page-header.svelte';
import ServerQueryState from '$lib/components/server-query-state.svelte';
import ServerJsonCard from '$lib/components/server-json-card.svelte';
import ShieldIcon from '@lucide/svelte/icons/shield';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
const alias = $derived(page.params.alias ?? '');
@@ -39,30 +38,16 @@
}
</script>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-semibold tracking-tight">Security</h1>
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить
</Button>
</div>
<ServerPageHeader title="Безопасность" {loading} onRefresh={load} />
{#if err}
<Alert variant="destructive">
<AlertTitle>Ошибка</AlertTitle>
<AlertDescription>{err}</AlertDescription>
</Alert>
{:else if loading}
<p class="text-muted-foreground">Загрузка…</p>
{:else if data}
<KpiPanelCard accent="danger" title="Security posture" description="POST /security/posture" contentClass="p-0">
{#snippet icon()}
<ShieldIcon class="size-5" aria-hidden="true" />
{/snippet}
<pre class="overflow-auto rounded-md border border-border bg-muted/40 p-4 text-xs">{JSON.stringify(
data,
null,
2
)}</pre>
</KpiPanelCard>
{/if}
<ServerQueryState {loading} {err} isEmpty={!data} emptyDescription="Endpoint вернул пустой ответ.">
{#snippet children()}
{#if data}
<ServerJsonCard accent="danger" title="Профиль безопасности" description="POST /security/posture" payload={data}>
{#snippet iconSnippet()}
<ShieldIcon class="size-5" aria-hidden="true" />
{/snippet}
</ServerJsonCard>
{/if}
{/snippet}
</ServerQueryState>
@@ -3,10 +3,9 @@
import { ApiError, fetchTelemt } from '$lib/api/client.js';
import type { SystemInfoData } from '$lib/api/telemt-v1.js';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import ServerPageHeader from '$lib/components/server-page-header.svelte';
import ServerQueryState from '$lib/components/server-query-state.svelte';
import PackageIcon from '@lucide/svelte/icons/package';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
const alias = $derived(page.params.alias ?? '');
@@ -40,29 +39,20 @@
}
</script>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-semibold tracking-tight">Update</h1>
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить
</Button>
</div>
<ServerPageHeader title="Обновление" {loading} onRefresh={load} />
{#if err}
<Alert variant="destructive">
<AlertTitle>Ошибка</AlertTitle>
<AlertDescription>{err}</AlertDescription>
</Alert>
{:else if loading}
<p class="text-muted-foreground">Загрузка…</p>
{:else if sys}
<KpiPanelCard accent="teal" title="Версия бинарника" description="system/info" contentClass="space-y-2 text-sm">
{#snippet icon()}
<PackageIcon class="size-5" aria-hidden="true" />
{/snippet}
<p><span class="text-muted-foreground">version</span> {sys.version}</p>
<p><span class="text-muted-foreground">git</span> {sys.git_commit ?? '—'}</p>
<p><span class="text-muted-foreground">build</span> {sys.build_time_utc ?? '—'}</p>
<p><span class="text-muted-foreground">rustc</span> {sys.rustc_version ?? '—'}</p>
</KpiPanelCard>
{/if}
<ServerQueryState {loading} {err} isEmpty={!sys} emptyDescription="Информация о бинарнике не получена.">
{#snippet children()}
{#if sys}
<KpiPanelCard accent="teal" title="Версия бинарника" description="system/info" contentClass="space-y-2 text-sm">
{#snippet icon()}
<PackageIcon class="size-5" aria-hidden="true" />
{/snippet}
<p><span class="text-muted-foreground">version</span> {sys.version}</p>
<p><span class="text-muted-foreground">git</span> {sys.git_commit ?? '—'}</p>
<p><span class="text-muted-foreground">build</span> {sys.build_time_utc ?? '—'}</p>
<p><span class="text-muted-foreground">rustc</span> {sys.rustc_version ?? '—'}</p>
</KpiPanelCard>
{/if}
{/snippet}
</ServerQueryState>
@@ -1,11 +1,10 @@
<script lang="ts">
import { page } from '$app/state';
import { ApiError, fetchTelemt } from '$lib/api/client.js';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import ServerPageHeader from '$lib/components/server-page-header.svelte';
import ServerQueryState from '$lib/components/server-query-state.svelte';
import ServerJsonCard from '$lib/components/server-json-card.svelte';
import Share2Icon from '@lucide/svelte/icons/share-2';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
const alias = $derived(page.params.alias ?? '');
@@ -39,30 +38,22 @@
}
</script>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-semibold tracking-tight">Upstreams &amp; DCs</h1>
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить
</Button>
</div>
<ServerPageHeader title="Апстримы и ДЦ" {loading} onRefresh={load} />
{#if err}
<Alert variant="destructive">
<AlertTitle>Ошибка</AlertTitle>
<AlertDescription>{err}</AlertDescription>
</Alert>
{:else if loading}
<p class="text-muted-foreground">Загрузка…</p>
{:else if data}
<KpiPanelCard accent="info" title="Upstreams &amp; DCs" description="stats/upstreams" contentClass="p-0">
{#snippet icon()}
<Share2Icon class="size-5" aria-hidden="true" />
{/snippet}
<pre class="max-h-[70vh] overflow-auto rounded-md border border-border bg-muted/40 p-4 text-xs">{JSON.stringify(
data,
null,
2
)}</pre>
</KpiPanelCard>
{/if}
<ServerQueryState {loading} {err} isEmpty={!data} emptyDescription="Статистика upstream-ов пока не доступна.">
{#snippet children()}
{#if data}
<ServerJsonCard
accent="info"
title="Апстримы и ДЦ"
description="stats/upstreams"
payload={data}
preClass="max-h-[70vh]"
>
{#snippet iconSnippet()}
<Share2Icon class="size-5" aria-hidden="true" />
{/snippet}
</ServerJsonCard>
{/if}
{/snippet}
</ServerQueryState>
@@ -199,7 +199,7 @@
</script>
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<h1 class="text-2xl font-semibold tracking-tight">Users</h1>
<h1 class="text-2xl font-semibold tracking-tight">Пользователи</h1>
<div class="flex gap-2">
<Button onclick={() => (createOpen = true)} disabled={readOnly}>
<PlusIcon class="mr-1 size-4" />
@@ -232,11 +232,11 @@
<UsersIcon class="size-5" aria-hidden="true" />
{/snippet}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Header class="sticky top-0 z-10 bg-muted">
<Table.Row class="border-b border-border/80 hover:bg-transparent">
<Table.Head>Имя</Table.Head>
<Table.Head>Ссылки</Table.Head>
<Table.Head class="text-right">Conn</Table.Head>
<Table.Head class="text-right">Соединения</Table.Head>
<Table.Head class="text-right">IP</Table.Head>
<Table.Head class="text-right">Трафик</Table.Head>
<Table.Head class="w-[100px]"></Table.Head>
@@ -248,9 +248,9 @@
<Table.Cell class="font-medium">{u.username}</Table.Cell>
<Table.Cell>
<div class="flex flex-wrap gap-1">
{#each u.links?.tls ?? [] as link (link)}
{#each u.links?.tls ?? [] as link, idx (`${link}:${idx}`)}
<Button variant="outline" size="xs" class="h-7" onclick={() => copy(link)}>
TLS <CopyIcon class="size-3" />
TLS {idx + 1} <CopyIcon class="size-3" />
</Button>
{/each}
</div>
@@ -269,6 +269,8 @@
size="icon-sm"
disabled={readOnly}
onclick={() => openEdit(u)}
aria-label="Редактировать пользователя"
title="Редактировать пользователя"
>
<PencilIcon class="size-4" />
</Button>
@@ -277,6 +279,8 @@
size="icon-sm"
disabled={readOnly}
onclick={() => (deleteUserRow = u)}
aria-label="Удалить пользователя"
title="Удалить пользователя"
>
<TrashIcon class="size-4 text-destructive" />
</Button>
@@ -325,15 +329,15 @@
<Input id="eq" bind:value={editQuota} type="number" min="0" />
</div>
<div class="grid gap-2">
<Label for="em">max_unique_ips</Label>
<Label for="em">Макс. уникальных IP (max_unique_ips)</Label>
<Input id="em" bind:value={editMaxIp} type="number" min="0" />
</div>
<div class="grid gap-2">
<Label for="et">max_tcp_conns</Label>
<Label for="et">Макс. TCP-соединений (max_tcp_conns)</Label>
<Input id="et" bind:value={editMaxTcp} type="number" min="0" />
</div>
<div class="grid gap-2">
<Label for="ex">expiry (RFC3339)</Label>
<Label for="ex">Срок действия (RFC3339)</Label>
<Input id="ex" bind:value={editExp} class="font-mono text-sm" />
</div>
{#if formErr}
+400 -46
View File
@@ -6,10 +6,23 @@
import type { components } from '$lib/api/aggregate.gen.js';
import { formatMiB } from '$lib/format.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import UsersIcon from '@lucide/svelte/icons/users';
import DataTableCard from '$lib/components/data-table/data-table-card.svelte';
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
import SortableTh from '$lib/components/data-table/sortable-th.svelte';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { cn } from '$lib/utils.js';
import Columns3Icon from '@lucide/svelte/icons/columns-3';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import MoreVerticalIcon from '@lucide/svelte/icons/more-vertical';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import SearchIcon from '@lucide/svelte/icons/search';
import { nextSortDir, compareStrings, compareNum, type SortDir } from '$lib/table-sort.js';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
import { Label } from '$lib/components/ui/label/index.js';
@@ -19,6 +32,11 @@
import FleetRefreshInput from '$lib/components/fleet/fleet-refresh-input.svelte';
import CopyIcon from '@lucide/svelte/icons/copy';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import PlusIcon from '@lucide/svelte/icons/plus';
import UsersIcon from '@lucide/svelte/icons/users';
import ActivityIcon from '@lucide/svelte/icons/activity';
import GlobeIcon from '@lucide/svelte/icons/globe';
import CloudUploadIcon from '@lucide/svelte/icons/cloud-upload';
import { toast } from 'svelte-sonner';
let loading = $state(true);
@@ -99,9 +117,10 @@
});
}
async function applyAliasIfChanged(nextAlias: string) {
const currentAlias = parseAliasFilter(page.url.searchParams.get('aliases'));
if (nextAlias === currentAlias) return;
async function handleAliasFilterChange(nextAlias: string) {
aliasFilter = nextAlias;
const currentUrlAlias = parseAliasFilter(page.url.searchParams.get('aliases'));
if (nextAlias === currentUrlAlias) return;
const q = new URLSearchParams(page.url.searchParams);
if (nextAlias === 'all') q.delete('aliases');
else q.set('aliases', nextAlias);
@@ -111,6 +130,7 @@
noScroll: true,
keepFocus: true
});
await load();
}
let queryKey = $derived(page.url.searchParams.toString());
@@ -125,11 +145,6 @@
void load();
});
$effect(() => {
if (typeof window === 'undefined') return;
void applyAliasIfChanged(aliasFilter);
});
let staleSeconds = $derived.by(() =>
lastSuccessAtMs == null ? null : Math.max(0, Math.floor((nowMs - lastSuccessAtMs) / 1000))
);
@@ -146,7 +161,6 @@
.filter((a): a is string => typeof a === 'string' && a.length > 0)
.sort((a, b) => a.localeCompare(b));
availableAliases = aliases;
if (aliasFilter !== 'all' && !aliases.includes(aliasFilter)) aliasFilter = 'all';
} catch {
availableAliases = [];
}
@@ -162,23 +176,108 @@
const usersPerPage = 25;
let usersPage = $state(1);
let userSearch = $state('');
let sortKey = $state<'name' | 'traffic' | 'ips' | 'exp' | null>(null);
let sortDir = $state<SortDir | null>(null);
let usersViewTab = $state<'all' | 'traffic' | 'active'>('all');
let showColLinks = $state(true);
let showColExpires = $state(true);
let selectedUsernames = $state<string[]>([]);
let hasStaleData = $derived(err != null && rows.length > 0);
let showSkeleton = $derived(loading && rows.length === 0 && !err);
let isDataEmpty = $derived(!loading && rows.length === 0 && !err);
let pageCount = $derived(Math.max(1, Math.ceil(rows.length / usersPerPage)));
let countAll = $derived(rows.length);
let countTraffic = $derived(rows.filter((r) => (r.total_megabytes ?? 0) > 0).length);
let countActive = $derived(rows.filter((r) => (r.active_unique_ips ?? 0) > 0).length);
let sumTraffic = $derived.by(() =>
rows.reduce((acc, row) => acc + (row.total_megabytes ?? 0), 0)
);
let sumActiveIps = $derived.by(() =>
rows.reduce((acc, row) => acc + (row.active_unique_ips ?? 0), 0)
);
let tabFilteredRows = $derived.by(() => {
if (usersViewTab === 'traffic') return rows.filter((r) => (r.total_megabytes ?? 0) > 0);
if (usersViewTab === 'active') return rows.filter((r) => (r.active_unique_ips ?? 0) > 0);
return rows;
});
let processedRows = $derived.by(() => {
let r = [...tabFilteredRows];
const q = userSearch.trim().toLowerCase();
if (q) r = r.filter((row) => (row.username ?? '').toLowerCase().includes(q));
const k = sortKey;
const d = sortDir;
if (k && d) {
r.sort((a, b) => {
if (k === 'name') return compareStrings(a.username ?? '', b.username ?? '', d);
if (k === 'traffic')
return compareNum(a.total_megabytes ?? 0, b.total_megabytes ?? 0, d);
if (k === 'ips')
return compareNum(a.active_unique_ips ?? 0, b.active_unique_ips ?? 0, d);
if (k === 'exp') {
const ta = a.expiration_rfc3339 ? new Date(a.expiration_rfc3339).getTime() : 0;
const tb = b.expiration_rfc3339 ? new Date(b.expiration_rfc3339).getTime() : 0;
return compareNum(ta, tb, d);
}
return 0;
});
}
return r;
});
let pageCount = $derived(Math.max(1, Math.ceil(processedRows.length / usersPerPage)));
let pagedRows = $derived.by(() => {
const start = (usersPage - 1) * usersPerPage;
return rows.slice(start, start + usersPerPage);
return processedRows.slice(start, start + usersPerPage);
});
let pageUsernames = $derived(
pagedRows.map((r) => r.username ?? '').filter((u): u is string => u.length > 0)
);
let selectedOnPage = $derived(pageUsernames.filter((u) => selectedUsernames.includes(u)));
let headerCheckboxChecked = $derived(
pageUsernames.length > 0 && selectedOnPage.length === pageUsernames.length
);
let headerCheckboxIndeterminate = $derived(
selectedOnPage.length > 0 && selectedOnPage.length < pageUsernames.length
);
$effect(() => {
aliasFilter;
userSearch;
usersViewTab;
usersPage = 1;
});
$effect(() => {
usersViewTab;
selectedUsernames = [];
});
$effect(() => {
rows;
const valid = new Set(
rows.map((r) => r.username ?? '').filter((u): u is string => u.length > 0)
);
const next = selectedUsernames.filter((u) => valid.has(u));
const same =
next.length === selectedUsernames.length &&
next.every((u, i) => u === selectedUsernames[i]);
if (!same) selectedUsernames = next;
});
$effect(() => {
if (usersPage > pageCount) usersPage = pageCount;
});
function toggleSort(key: 'name' | 'traffic' | 'ips' | 'exp') {
const next = nextSortDir(sortDir, key, sortKey);
sortKey = next === null ? null : key;
sortDir = next;
}
function copy(text: string) {
const value = String(text ?? '');
const done = () => toast.success('Скопировано в буфер');
@@ -199,6 +298,31 @@
void goto(`/users/${encodeURIComponent(u)}`);
}
function toggleRowSelect(username: string, checked: boolean) {
if (!username) return;
if (checked) {
if (!selectedUsernames.includes(username)) selectedUsernames = [...selectedUsernames, username];
} else {
selectedUsernames = selectedUsernames.filter((u) => u !== username);
}
}
function toggleSelectAllOnPage(checked: boolean) {
if (checked) {
selectedUsernames = [...new Set([...selectedUsernames, ...pageUsernames])];
} else {
selectedUsernames = selectedUsernames.filter((u) => !pageUsernames.includes(u));
}
}
function openCreateUser() {
if (!aliasFilter || aliasFilter === 'all') {
toast.info('Для создания выберите конкретную ноду в фильтре "Алиас".');
return;
}
void goto(`/servers/${encodeURIComponent(aliasFilter)}/users`);
}
function fallbackCopy(value: string) {
if (typeof document === 'undefined') return;
const ta = document.createElement('textarea');
@@ -219,7 +343,7 @@
}
</script>
<div class="mb-6 flex flex-wrap items-end justify-between gap-4">
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Пользователи (флот)</h1>
<p class="text-sm text-muted-foreground">
@@ -238,6 +362,10 @@
>Ссылки tg://proxy</Label
>
</div>
<Button variant="default" size="sm" onclick={openCreateUser}>
<PlusIcon class="mr-1 size-4" />
Создать
</Button>
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить
@@ -246,7 +374,11 @@
</div>
<div class="mb-4 flex flex-wrap items-end gap-2">
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} />
<AliasFilterSelect
availableAliases={availableAliases}
bind:value={aliasFilter}
onValueChange={(next) => void handleAliasFilterChange(next)}
/>
<FleetRefreshInput bind:value={refreshInput} onValueChange={() => void applyControls()} />
</div>
@@ -281,32 +413,172 @@
</Card.Root>
{/snippet}
{#snippet children()}
<KpiPanelCard
accent="info"
title="Список пользователей"
description="Слияние по имени между серверами; клик по строке — карточка пользователя"
contentClass="p-0"
>
{#snippet icon()}
<UsersIcon class="size-5" aria-hidden="true" />
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<KpiStatCard accent="violet" label="Пользователей всего" footer="в текущем фильтре" valueClass="text-xl">
{#snippet icon()}
<UsersIcon class="size-5" aria-hidden="true" />
{/snippet}
{countAll}
</KpiStatCard>
<KpiStatCard accent="success" label="С трафиком" footer="total_megabytes > 0" valueClass="text-xl">
{#snippet icon()}
<ActivityIcon class="size-5" aria-hidden="true" />
{/snippet}
{countTraffic}
</KpiStatCard>
<KpiStatCard accent="info" label="Активных IP (сумма)" footer="active_unique_ips" valueClass="text-xl">
{#snippet icon()}
<GlobeIcon class="size-5" aria-hidden="true" />
{/snippet}
{sumActiveIps}
</KpiStatCard>
<KpiStatCard accent="warning" label="Трафик (MiB)" footer="агрегат по пользователям" valueClass="text-lg">
{#snippet icon()}
<CloudUploadIcon class="size-5" aria-hidden="true" />
{/snippet}
{formatMiB(sumTraffic)}
</KpiStatCard>
</div>
<DataTableCard title="Пользователи" description="Слияние по имени между серверами" accent="violet">
{#snippet icon()}<UsersIcon class="size-5" aria-hidden="true" />{/snippet}
{#snippet toolbar()}
<div class="border-b border-border">
<div class="flex flex-wrap items-center justify-between gap-2 px-3 py-2.5">
<Tabs.Root bind:value={usersViewTab} class="w-full min-w-0 sm:w-auto">
<Tabs.List class="h-auto min-h-8 w-full flex-wrap justify-start gap-1 p-1 sm:w-fit">
<Tabs.Trigger value="all" class="shrink-0 gap-1.5 px-2.5 py-1 text-xs sm:text-sm">
Все
<Badge variant="secondary" class="h-5 min-w-5 justify-center px-1.5 text-[10px] font-semibold tabular-nums sm:text-xs">{countAll}</Badge>
</Tabs.Trigger>
<Tabs.Trigger value="traffic" class="shrink-0 gap-1.5 px-2.5 py-1 text-xs sm:text-sm">
С трафиком
<Badge variant="secondary" class="h-5 min-w-5 justify-center px-1.5 text-[10px] font-semibold tabular-nums sm:text-xs">{countTraffic}</Badge>
</Tabs.Trigger>
<Tabs.Trigger value="active" class="shrink-0 gap-1.5 px-2.5 py-1 text-xs sm:text-sm">
Активные IP
<Badge variant="secondary" class="h-5 min-w-5 justify-center px-1.5 text-[10px] font-semibold tabular-nums sm:text-xs">{countActive}</Badge>
</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
{#if selectedUsernames.length > 0}
<span class="text-xs text-muted-foreground tabular-nums">
Выбрано: {selectedUsernames.length}
</span>
{/if}
</div>
<div
class="flex flex-wrap items-center justify-between gap-2 border-t border-border/60 px-3 py-2"
>
<div class="relative max-w-sm min-w-[200px] flex-1">
<SearchIcon
class="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input class="h-9 ps-9" placeholder="Поиск по имени…" bind:value={userSearch} />
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" size="sm" class="h-9 gap-1.5">
<Columns3Icon class="size-4 opacity-70" />
Столбцы
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-52">
<DropdownMenu.Label class="text-xs">Видимость столбцов</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.CheckboxItem bind:checked={showColLinks}>
Ссылки
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem bind:checked={showColExpires}>
Истекает
</DropdownMenu.CheckboxItem>
</DropdownMenu.Content>
</DropdownMenu.Root>
<Button
variant="outline"
size="sm"
class="h-9"
onclick={() => {
userSearch = '';
sortKey = null;
sortDir = null;
selectedUsernames = [];
}}
>Сброс</Button>
</div>
</div>
</div>
{/snippet}
<div class="flex w-full flex-col">
<ScrollArea
class="h-[min(70vh,560px)] w-full shrink-0"
orientation="both"
>
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Имя</Table.Head>
<Table.Head class="hidden sm:table-cell">Ссылки</Table.Head>
<Table.Head class="text-right">Трафик</Table.Head>
<Table.Head class="text-right">Активных IP</Table.Head>
<Table.Head class="hidden sm:table-cell">Истекает</Table.Head>
<Table.Header class="sticky top-0 z-10 bg-muted">
<Table.Row class="border-b border-border/80 hover:bg-transparent">
<Table.Head class="w-10 px-2">
{#if pageUsernames.length > 0}
<Checkbox
checked={headerCheckboxChecked}
indeterminate={headerCheckboxIndeterminate}
aria-label="Выбрать всех на странице"
onCheckedChange={(v) => toggleSelectAllOnPage(v === true)}
/>
{/if}
</Table.Head>
<SortableTh
active={sortKey === 'name'}
dir={sortDir}
onToggle={() => toggleSort('name')}
class="min-w-[8rem]"
>Имя</SortableTh>
<Table.Head
class={cn(
'px-3 py-2 text-left align-middle text-sm font-medium text-muted-foreground',
!showColLinks && 'hidden',
showColLinks && 'hidden sm:table-cell'
)}
>Ссылки</Table.Head>
<SortableTh
numeric
active={sortKey === 'traffic'}
dir={sortDir}
onToggle={() => toggleSort('traffic')}
class="text-right"
>Трафик</SortableTh>
<SortableTh
numeric
active={sortKey === 'ips'}
dir={sortDir}
onToggle={() => toggleSort('ips')}
class="text-right"
>Активных IP</SortableTh>
<SortableTh
active={sortKey === 'exp'}
dir={sortDir}
onToggle={() => toggleSort('exp')}
class={cn(
!showColExpires && 'hidden',
showColExpires && 'hidden sm:table-cell'
)}
>Истекает</SortableTh>
<Table.Head class="w-12 px-2 text-right">
<span class="sr-only">Действия</span>
</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each pagedRows as row (row.username ?? '')}
{@const uname = row.username ?? ''}
<Table.Row
class="cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
class="group cursor-pointer border-b border-border transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
tabindex={0}
role="button"
aria-label={`Открыть пользователя ${row.username ?? ''}`}
aria-label={`Открыть пользователя ${uname}`}
onclick={() => openUser(row.username)}
onkeydown={(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -315,9 +587,25 @@
}
}}
>
<Table.Cell class="font-medium">{row.username}</Table.Cell>
<Table.Cell class="hidden sm:table-cell">
<div class="flex flex-wrap gap-1">
<Table.Cell class="w-10 px-2 align-middle" onclick={(e) => e.stopPropagation()}>
{#if uname}
<Checkbox
checked={selectedUsernames.includes(uname)}
aria-label={`Выбрать ${uname}`}
onCheckedChange={(v) => toggleRowSelect(uname, v === true)}
/>
{/if}
</Table.Cell>
<Table.Cell class="py-3 font-semibold text-foreground">{row.username}</Table.Cell>
<Table.Cell
class={cn(
'py-3 align-middle',
!showColLinks && 'hidden',
showColLinks && 'hidden sm:table-cell'
)}
onclick={(e) => e.stopPropagation()}
>
<div class="flex max-w-[220px] flex-wrap gap-1">
{#each row.links?.tls ?? [] as link (link)}
<Button
variant="outline"
@@ -346,30 +634,95 @@
<CopyIcon class="size-3 opacity-60" />
</Button>
{/each}
{#if !(row.links?.tls?.length) && !(row.links?.secure?.length)}
<span class="text-xs text-muted-foreground">—</span>
{/if}
</div>
</Table.Cell>
<Table.Cell class="text-right tabular-nums">{formatMiB(row.total_megabytes ?? 0)}</Table.Cell>
<Table.Cell class="text-right text-sm font-mono tabular-nums">
{row.active_unique_ips ?? 0}
{#if row.max_unique_ips != null}
<span class="text-muted-foreground"> / {row.max_unique_ips}</span>
<Table.Cell class="py-3 text-right align-middle tabular-nums">
{#if (row.total_megabytes ?? 0) > 0}
<Badge variant="secondary" class="font-mono font-normal tabular-nums">
{formatMiB(row.total_megabytes ?? 0)}
</Badge>
{:else}
<Badge variant="outline" class="font-mono font-normal text-muted-foreground tabular-nums">
{formatMiB(0)}
</Badge>
{/if}
</Table.Cell>
<Table.Cell class="hidden sm:table-cell text-sm text-muted-foreground">
<Table.Cell class="py-3 text-right align-middle">
{#if (row.active_unique_ips ?? 0) > 0}
<Badge variant="secondary" class="font-mono font-normal tabular-nums">
{row.active_unique_ips ?? 0}
{#if row.max_unique_ips != null}
<span class="text-muted-foreground">/{row.max_unique_ips}</span>
{/if}
</Badge>
{:else}
<span class="font-mono text-sm tabular-nums text-muted-foreground">
{row.active_unique_ips ?? 0}
{#if row.max_unique_ips != null}
<span> / {row.max_unique_ips}</span>
{/if}
</span>
{/if}
</Table.Cell>
<Table.Cell
class={cn(
'py-3 text-sm text-muted-foreground',
!showColExpires && 'hidden',
showColExpires && 'hidden sm:table-cell'
)}
>
{row.expiration_rfc3339 ? new Date(row.expiration_rfc3339).toLocaleString() : '—'}
</Table.Cell>
<Table.Cell class="w-14 px-1 py-3 align-middle" onclick={(e) => e.stopPropagation()}>
<div class="flex items-center justify-end gap-0.5 pe-0.5">
<ChevronRightIcon
class="pointer-events-none size-4 text-muted-foreground/50 opacity-0 transition-opacity group-hover:opacity-100 max-sm:hidden"
aria-hidden="true"
/>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="ghost"
size="icon"
class="size-8 text-muted-foreground hover:text-foreground"
aria-label="Действия"
>
<MoreVerticalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-48">
<DropdownMenu.Item onclick={() => openUser(row.username)}>
Открыть карточку
</DropdownMenu.Item>
<DropdownMenu.Item
disabled={!uname}
onclick={() => uname && copy(uname)}
>
Копировать имя
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{#if rows.length > usersPerPage}
</ScrollArea>
{#if processedRows.length > usersPerPage}
<div
class="flex flex-wrap items-center justify-between gap-2 border-t px-4 py-3 text-sm text-muted-foreground"
class="relative z-10 flex shrink-0 flex-wrap items-center justify-between gap-2 border-t border-border bg-card px-4 py-3 text-sm text-muted-foreground"
>
<span>
{rows.length === 0
{processedRows.length === 0
? '0'
: `${(usersPage - 1) * usersPerPage + 1}–${Math.min(usersPage * usersPerPage, rows.length)}`} из {rows.length}
: `${(usersPage - 1) * usersPerPage + 1}–${Math.min(usersPage * usersPerPage, processedRows.length)}`} из {processedRows.length}
</span>
<div class="flex gap-2">
<Button
@@ -387,6 +740,7 @@
</div>
</div>
{/if}
</KpiPanelCard>
</div>
</DataTableCard>
{/snippet}
</DataQueryState>
+2 -2
View File
@@ -205,8 +205,8 @@
<div class="overflow-x-auto">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Header class="sticky top-0 z-10 bg-muted">
<Table.Row class="border-b border-border/80 hover:bg-transparent">
<Table.Head class="text-xs sm:text-sm">IP</Table.Head>
<Table.Head class="text-xs sm:text-sm">
<div class="flex items-center gap-1">