diff --git a/internal/app.go b/internal/app.go index 6bd1d34..7992f88 100644 --- a/internal/app.go +++ b/internal/app.go @@ -195,6 +195,9 @@ func setupApi(app *orz.App, components *AppComponents) { // Logo(公开访问)- 用于公共页面只获取 Logo publicApiWithOptionalAuth.GET("/logo", components.PropertyHandler.GetLogo) + + // 指标配置(公开访问)- 用于获取时间范围选项等配置 + publicApiWithOptionalAuth.GET("/metrics-config", components.PropertyHandler.GetMetricsConfig) } // WebSocket 路由(探针连接) diff --git a/internal/handler/property_handler.go b/internal/handler/property_handler.go index e21f031..192f542 100644 --- a/internal/handler/property_handler.go +++ b/internal/handler/property_handler.go @@ -150,6 +150,12 @@ func (h *PropertyHandler) GetLogo(c echo.Context) error { return c.Blob(http.StatusOK, contentType, imageData) } +// GetMetricsConfig 获取指标配置(公开访问) +func (h *PropertyHandler) GetMetricsConfig(c echo.Context) error { + config := h.service.GetMetricsConfig(c.Request().Context()) + return c.JSON(http.StatusOK, config) +} + // TestNotificationChannel 测试通知渠道(从数据库读取配置) func (h *PropertyHandler) TestNotificationChannel(c echo.Context) error { channelType := c.Param("type") diff --git a/internal/models/property.go b/internal/models/property.go index 96370e0..7fd9886 100644 --- a/internal/models/property.go +++ b/internal/models/property.go @@ -49,8 +49,15 @@ type SystemConfig struct { DefaultView string `json:"defaultView"` // 默认视图 grid | list } +// TimeRangeOption 时间范围选项 +type TimeRangeOption struct { + Label string `json:"label"` // 显示标签,如 "15分钟" + Value string `json:"value"` // 值,如 "15m" +} + // MetricsConfig 指标数据配置 type MetricsConfig struct { - RetentionHours int `json:"retentionHours"` // 原始数据保留小时数(默认168小时=7天) - MaxQueryPoints int `json:"maxQueryPoints"` // 查询返回的最大数据点数(默认720) + RetentionHours int `json:"retentionHours"` // 原始数据保留小时数(默认168小时=7天) + MaxQueryPoints int `json:"maxQueryPoints"` // 查询返回的最大数据点数(默认300) + TimeRangeOptions []TimeRangeOption `json:"timeRangeOptions"` // 前端时间范围选项 } diff --git a/internal/service/metric_service.go b/internal/service/metric_service.go index cfc932b..32c4882 100644 --- a/internal/service/metric_service.go +++ b/internal/service/metric_service.go @@ -15,7 +15,7 @@ import ( const ( defaultMetricsRetentionHours = 24 * 7 // 默认保留 7 天 - defaultMaxQueryPoints = 720 // 默认最多返回 720 个点 + defaultMaxQueryPoints = 300 // 默认最多返回 300 个点(优化前端渲染性能) ) var allowedIntervals = []int{ @@ -26,6 +26,17 @@ var allowedIntervals = []int{ 3600, 7200, 14400, } +// 聚合任务支持的 bucket 列表(升序) +// 与前端 timeRangeOptions 对齐,优化查询效率 +var aggregationBuckets = []int{ + 60, // 1 分钟 - 适用于 15分钟、30分钟时间范围 + 300, // 5 分钟 - 适用于 1小时、3小时时间范围 + 900, // 15 分钟 - 适用于 6小时、12小时时间范围 + 1800, // 30 分钟 - 适用于 1天时间范围 + 3600, // 1 小时 - 适用于 3天时间范围 + 7200, // 2 小时 - 适用于 7天时间范围 +} + // MetricService 指标服务 type MetricService struct { logger *zap.Logger @@ -390,18 +401,17 @@ func (s *MetricService) GetMetrics(ctx context.Context, agentID, metricType stri var bucketSeconds int useAgg := false if aggCapable[metricType] { - if interval >= 3600 { - bucketSeconds = 3600 - useAgg = true - } else if interval >= 300 { - bucketSeconds = 300 - useAgg = true - } else if interval >= 60 { - bucketSeconds = 60 - useAgg = true - } + bucketSeconds = chooseAggregationBucket(interval) + useAgg = bucketSeconds > 0 } + // 将时间范围对齐到最终使用的 bucket,避免不同时间框架出现桶数量偏差 + bucketMs := int64(interval * 1000) + if useAgg { + bucketMs = int64(bucketSeconds * 1000) + } + start, end = alignTimeRangeToBucket(start, end, bucketMs) + switch metricType { case "cpu": if useAgg { @@ -563,6 +573,33 @@ func maxInt(a, b int) int { return b } +// alignTimeRangeToBucket 将时间范围对齐到桶边界,确保不同时间框架的桶数一致 +func alignTimeRangeToBucket(start, end int64, bucketMs int64) (int64, int64) { + if bucketMs <= 0 { + return start, end + } + alignedStart := (start / bucketMs) * bucketMs + endBucket := ((end - 1) / bucketMs) * bucketMs + alignedEnd := endBucket + bucketMs - 1 + if alignedEnd < alignedStart { + alignedEnd = alignedStart + } + return alignedStart, alignedEnd +} + +// chooseAggregationBucket 根据请求的 interval 选择最合适的聚合 bucket(取不小于 interval 的最小桶) +func chooseAggregationBucket(interval int) int { + if interval <= 0 { + return 0 + } + for _, bucket := range aggregationBuckets { + if interval <= bucket { + return bucket + } + } + return aggregationBuckets[len(aggregationBuckets)-1] +} + // getMetricsConfig 获取指标配置 func (s *MetricService) getMetricsConfig(ctx context.Context) models.MetricsConfig { cfg := models.MetricsConfig{ @@ -606,10 +643,8 @@ func (s *MetricService) StartAggregationTask(ctx context.Context) { func (s *MetricService) runAggregation(ctx context.Context) { cfg := s.getMetricsConfig(ctx) retention := time.Duration(cfg.RetentionHours) * time.Hour - // 标准 bucket:1m、5m、1h - buckets := []int{60, 300, 3600} - for _, bucket := range buckets { + for _, bucket := range aggregationBuckets { s.aggregateMetric(ctx, "cpu", bucket, retention, s.metricRepo.AggregateCPUToAgg) s.aggregateMetric(ctx, "memory", bucket, retention, s.metricRepo.AggregateMemoryToAgg) s.aggregateMetric(ctx, "disk", bucket, retention, s.metricRepo.AggregateDiskToAgg) diff --git a/internal/service/property_service.go b/internal/service/property_service.go index 466f71f..8f99335 100644 --- a/internal/service/property_service.go +++ b/internal/service/property_service.go @@ -173,7 +173,18 @@ func (s *PropertyService) InitializeDefaultConfigs(ctx context.Context) error { Name: "指标数据配置", Value: models.MetricsConfig{ RetentionHours: 168, // 默认7天 - MaxQueryPoints: 720, // 默认720个点 + MaxQueryPoints: 300, // 默认300个点(优化前端渲染性能) + TimeRangeOptions: []models.TimeRangeOption{ + {Label: "15分钟", Value: "15m"}, + {Label: "30分钟", Value: "30m"}, + {Label: "1小时", Value: "1h"}, + {Label: "3小时", Value: "3h"}, + {Label: "6小时", Value: "6h"}, + {Label: "12小时", Value: "12h"}, + {Label: "1天", Value: "1d"}, + {Label: "3天", Value: "3d"}, + {Label: "7天", Value: "7d"}, + }, }, }, } diff --git a/web/src/api/agent.ts b/web/src/api/agent.ts index ec8c893..276c74a 100644 --- a/web/src/api/agent.ts +++ b/web/src/api/agent.ts @@ -9,7 +9,7 @@ export interface ListAgentsResponse { export interface GetAgentMetricsRequest { agentId: string; type: 'cpu' | 'memory' | 'disk' | 'network' | 'network_connection' | 'disk_io' | 'gpu' | 'temperature'; - range?: '1m' | '5m' | '15m' | '30m' | '1h' | '3h' | '6h' | '12h' | '1d' | '24h' | '3d' | '7d' | '30d'; + range?: string; // 时间范围,如 '15m', '1h', '1d' 等,从后端配置获取 interface?: string; // 网卡过滤参数(仅对 network 类型有效) } diff --git a/web/src/api/property.ts b/web/src/api/property.ts index 1599576..e226803 100644 --- a/web/src/api/property.ts +++ b/web/src/api/property.ts @@ -27,16 +27,28 @@ export const saveProperty = async (propertyId: string, name: string, value: T const PROPERTY_ID_METRICS_CONFIG = 'metrics_config'; -export interface MetricsConfig { - retentionHours: number; // 数据保留时长(小时) - maxQueryPoints: number; // 最大查询点数 +export interface TimeRangeOption { + label: string; // 显示标签,如 "15分钟" + value: string; // 值,如 "15m" } -// 获取指标配置 +export interface MetricsConfig { + retentionHours: number; // 数据保留时长(小时) + maxQueryPoints: number; // 最大查询点数 + timeRangeOptions: TimeRangeOption[]; // 时间范围选项 +} + +// 获取指标配置(管理后台使用,需认证) export const getMetricsConfig = async (): Promise => { return getProperty(PROPERTY_ID_METRICS_CONFIG); }; +// 获取指标配置(公开访问,用于前端获取时间范围选项等) +export const getMetricsConfigPublic = async (): Promise => { + const response = await get('/metrics-config'); + return response.data; +}; + // 保存指标配置 export const saveMetricsConfig = async (config: MetricsConfig): Promise => { return saveProperty(PROPERTY_ID_METRICS_CONFIG, '指标配置', config); diff --git a/web/src/pages/Public/MonitorDetail.tsx b/web/src/pages/Public/MonitorDetail.tsx index 4ee2ebe..0cb001c 100644 --- a/web/src/pages/Public/MonitorDetail.tsx +++ b/web/src/pages/Public/MonitorDetail.tsx @@ -16,6 +16,7 @@ import { import type {TooltipProps} from 'recharts'; import {Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis,} from 'recharts'; import {type AggregatedMonitorMetric, getMonitorHistory, getMonitorStatsById} from '@/api/monitor.ts'; +import { getMetricsConfigPublic, type TimeRangeOption } from '@/api/property.ts'; import type {MonitorStats} from '@/types'; const formatTime = (ms: number): string => { @@ -300,19 +301,37 @@ const AGENT_COLORS = [ '#14b8a6', // teal ]; -const timeRangeOptions = [ - {label: '15分钟', value: '15m'}, - {label: '30分钟', value: '30m'}, - {label: '1小时', value: '1h'}, -] as const; - -type TimeRange = typeof timeRangeOptions[number]['value']; - const MonitorDetail = () => { const navigate = useNavigate(); const {id} = useParams<{ id: string }>(); const [selectedAgent, setSelectedAgent] = useState('all'); - const [timeRange, setTimeRange] = useState('15m'); + const [timeRange, setTimeRange] = useState('15m'); + const [timeRangeOptions, setTimeRangeOptions] = useState([]); + + // 从后端获取时间范围选项 + useEffect(() => { + getMetricsConfigPublic() + .then(config => { + if (config.timeRangeOptions && config.timeRangeOptions.length > 0) { + setTimeRangeOptions(config.timeRangeOptions); + } else { + // 如果后端没有配置,使用默认值 + setTimeRangeOptions([ + {label: '15分钟', value: '15m'}, + {label: '30分钟', value: '30m'}, + {label: '1小时', value: '1h'}, + ]); + } + }) + .catch(() => { + // 如果请求失败,使用默认值 + setTimeRangeOptions([ + {label: '15分钟', value: '15m'}, + {label: '30分钟', value: '30m'}, + {label: '1小时', value: '1h'}, + ]); + }); + }, []); const {data: monitorStats = [], isLoading} = useQuery({ queryKey: ['monitorStats', id], diff --git a/web/src/pages/Public/ServerDetail.tsx b/web/src/pages/Public/ServerDetail.tsx index c79099f..31375eb 100644 --- a/web/src/pages/Public/ServerDetail.tsx +++ b/web/src/pages/Public/ServerDetail.tsx @@ -32,6 +32,7 @@ import { getAvailableNetworkInterfaces, type GetAgentMetricsRequest, } from '@/api/agent.ts'; +import { getMetricsConfigPublic, type TimeRangeOption } from '@/api/property.ts'; import type { Agent, AggregatedCPUMetric, @@ -87,20 +88,6 @@ const formatDateTime = (value: string | number | undefined | null): string => { return date.toLocaleString('zh-CN'); }; -const timeRangeOptions = [ - {label: '15分钟', value: '15m'}, - {label: '30分钟', value: '30m'}, - {label: '1小时', value: '1h'}, - {label: '3小时', value: '3h'}, - {label: '6小时', value: '6h'}, - {label: '12小时', value: '12h'}, - {label: '1天', value: '1d'}, - {label: '3天', value: '3d'}, - {label: '7天', value: '7d'}, -] as const; - -type TimeRange = typeof timeRangeOptions[number]['value']; - // 网卡颜色配置(上行和下行使用不同的色调) const INTERFACE_COLORS = [ {upload: '#6FD598', download: '#2C70F6'}, // 绿/蓝 @@ -221,12 +208,14 @@ const InfoGrid = ({items}: { items: Array<{ label: string; value: ReactNode }> } const TimeRangeSelector = ({ value, onChange, + options, }: { - value: TimeRange; - onChange: (value: TimeRange) => void; + value: string; + onChange: (value: string) => void; + options: TimeRangeOption[]; }) => (
- {timeRangeOptions.map((option) => { + {options.map((option) => { const isActive = option.value === value; return (