diff --git a/docs/feat/admin-panel-dashboard-feat.md b/docs/feat/admin-panel-dashboard-feat.md index e779f6f..1afd8d2 100644 --- a/docs/feat/admin-panel-dashboard-feat.md +++ b/docs/feat/admin-panel-dashboard-feat.md @@ -72,15 +72,39 @@ | 参数 | 类型 | 说明 | |------|------|------| -| `period` | string | 预设周期: `today`, `week`, `month` | +| `period` | string | 预设周期: `today`, `week`, `month`, `last7d`, `last30d`, `all` | | `since` | int | 自定义起始时间 (Unix 秒) | | `until` | int | 自定义结束时间 (Unix 秒) | +| `include_trends` | bool | 是否包含趋势数据 (与前一周期对比) | -> **已知限制** (待 fix-dashboard-summary-api 实现后解决): -> - `period=week` 使用自然周起点 (周一),非滚动 7 天 -> - `period=month` 使用自然月起点 (1号),非滚动 30 天 -> - 趋势数据 (delta/trend) 当前不返回,前端需隐藏趋势指示器 -> - 建议使用 `since`/`until` 实现精确的滚动时间窗口 +**周期类型说明**: + +| 周期值 | 时间窗口 | 趋势对比周期 | +|--------|----------|--------------| +| `today` | 今日 00:00 UTC → 当前 | 昨日 | +| `week` | 本周一 00:00 UTC → 当前 | 上一自然周 | +| `month` | 本月 1 日 00:00 UTC → 当前 | 上一自然月 | +| `last7d` | 当前 - 7 天 → 当前 | 14 天前 → 7 天前 | +| `last30d` | 当前 - 30 天 → 当前 | 60 天前 → 30 天前 | +| `all` | 无时间过滤 | 不支持趋势 | + +**趋势数据** (当 `include_trends=true` 时返回): + +```json +{ + "trends": { + "requests": { "delta": 12.5, "direction": "up" }, + "tokens": { "delta": -1.1, "direction": "down" }, + "error_rate": { "delta": 0.0, "direction": "stable" }, + "latency": { "delta": -5.2, "direction": "down" } + } +} +``` + +| 字段 | 说明 | +|------|------| +| `delta` | 与前一周期相比的百分比变化 (四舍五入到 1 位小数) | +| `direction` | `up` (增长 >0.5%), `down` (下降 <-0.5%), `stable`, `new` (无基准数据) | ### 3.4 告警摘要 diff --git a/internal/api/dashboard_handler.go b/internal/api/dashboard_handler.go index e2cc1f5..5239ab9 100644 --- a/internal/api/dashboard_handler.go +++ b/internal/api/dashboard_handler.go @@ -1,6 +1,8 @@ package api import ( + "encoding/json" + "math" "net/http" "time" @@ -42,6 +44,52 @@ func (h *DashboardHandler) logBaseQuery() *gorm.DB { return logBaseQuery(h.logDBConn(), h.logPartitioner) } +// TrendInfo contains trend calculation results +type TrendInfo struct { + Delta *float64 `json:"delta,omitempty"` // Percentage change from previous period (nil if no baseline) + Direction string `json:"direction,omitempty"` // "up", "down", "stable", or "new" (no baseline) +} + +// CalculateTrend calculates the trend between current and previous values. +// Returns TrendInfo with delta percentage and direction. +func CalculateTrend(current, previous int64) TrendInfo { + if previous == 0 { + if current == 0 { + return TrendInfo{Direction: "stable"} + } + return TrendInfo{Direction: "new"} + } + delta := float64(current-previous) / float64(previous) * 100 + // Round to 1 decimal place + delta = math.Round(delta*10) / 10 + direction := "stable" + if delta > 0.5 { + direction = "up" + } else if delta < -0.5 { + direction = "down" + } + return TrendInfo{Delta: &delta, Direction: direction} +} + +// CalculateTrendFloat calculates the trend between current and previous float values. +func CalculateTrendFloat(current, previous float64) TrendInfo { + if previous == 0 { + if current == 0 { + return TrendInfo{Direction: "stable"} + } + return TrendInfo{Direction: "new"} + } + delta := (current - previous) / previous * 100 + delta = math.Round(delta*10) / 10 + direction := "stable" + if delta > 0.5 { + direction = "up" + } else if delta < -0.5 { + direction = "down" + } + return TrendInfo{Delta: &delta, Direction: direction} +} + // RequestStats contains request-related statistics type RequestStats struct { Total int64 `json:"total"` @@ -83,6 +131,14 @@ type TopModelStat struct { Tokens int64 `json:"tokens"` } +// DashboardTrends contains trend data for dashboard metrics +type DashboardTrends struct { + Requests TrendInfo `json:"requests"` + Tokens TrendInfo `json:"tokens"` + ErrorRate TrendInfo `json:"error_rate"` + Latency TrendInfo `json:"latency"` +} + // DashboardSummaryResponse is the response for dashboard summary endpoint type DashboardSummaryResponse struct { Period string `json:"period,omitempty"` @@ -93,6 +149,7 @@ type DashboardSummaryResponse struct { Keys CountStats `json:"keys"` ProviderKeys ProviderKeyStats `json:"provider_keys"` TopModels []TopModelStat `json:"top_models"` + Trends *DashboardTrends `json:"trends,omitempty"` // Only present when include_trends=true UpdatedAt int64 `json:"updated_at"` } @@ -102,9 +159,10 @@ type DashboardSummaryResponse struct { // @Tags admin // @Produce json // @Security AdminAuth -// @Param period query string false "time period: today, week, month, all" -// @Param since query int false "unix seconds" -// @Param until query int false "unix seconds" +// @Param period query string false "time period: today, week, month, last7d, last30d, all" +// @Param since query int false "unix seconds" +// @Param until query int false "unix seconds" +// @Param include_trends query bool false "include trend data comparing to previous period" // @Success 200 {object} DashboardSummaryResponse // @Failure 400 {object} gin.H // @Failure 500 {object} gin.H @@ -116,6 +174,9 @@ func (h *DashboardHandler) GetSummary(c *gin.Context) { return } + // Parse include_trends parameter + includeTrends := c.Query("include_trends") == "true" + // Build log query with time range logQuery := h.logBaseQuery() logQuery = applyStatsRange(logQuery, rng) @@ -235,6 +296,32 @@ func (h *DashboardHandler) GetSummary(c *gin.Context) { }) } + // Calculate trends if requested + var trends *DashboardTrends + if includeTrends && rng.Period != "" && rng.Period != "all" { + prevStart, prevEnd := previousPeriodWindow(rng.Period) + if !prevStart.IsZero() && !prevEnd.IsZero() { + prevStats, err := h.aggregateFromLogRecords(prevStart, prevEnd) + if err == nil { + prevErrorRate := 0.0 + if prevStats.Requests > 0 { + prevErrorRate = float64(prevStats.Failed) / float64(prevStats.Requests) + } + prevAvgLatency := 0.0 + if prevStats.Requests > 0 { + prevAvgLatency = float64(prevStats.LatencySumMs) / float64(prevStats.Requests) + } + + trends = &DashboardTrends{ + Requests: CalculateTrend(totalRequests, prevStats.Requests), + Tokens: CalculateTrend(ts.TokensIn+ts.TokensOut, prevStats.TokensIn+prevStats.TokensOut), + ErrorRate: CalculateTrendFloat(errorRate, prevErrorRate), + Latency: CalculateTrendFloat(ts.AvgLatency, prevAvgLatency), + } + } + } + } + c.JSON(http.StatusOK, DashboardSummaryResponse{ Period: rng.Period, Requests: RequestStats{ @@ -266,6 +353,147 @@ func (h *DashboardHandler) GetSummary(c *gin.Context) { AutoDisabled: autoDisabledProviderKeys, }, TopModels: topModelStats, + Trends: trends, UpdatedAt: time.Now().UTC().Unix(), }) } + +// aggregatedStats holds aggregated statistics from daily_stats or log_records +type aggregatedStats struct { + Requests int64 + Success int64 + Failed int64 + TokensIn int64 + TokensOut int64 + LatencySumMs int64 +} + +// previousPeriodWindow calculates the comparison window for trend calculation +func previousPeriodWindow(period string) (start, end time.Time) { + now := time.Now().UTC() + startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + + switch period { + case "today": + // Compare to yesterday + yesterday := startOfDay.AddDate(0, 0, -1) + return yesterday, startOfDay + case "last7d": + // Compare: last 7 days vs 14-7 days ago + return now.AddDate(0, 0, -14), now.AddDate(0, 0, -7) + case "last30d": + // Compare: last 30 days vs 60-30 days ago + return now.AddDate(0, 0, -60), now.AddDate(0, 0, -30) + case "week": + // Compare to previous calendar week + weekday := int(startOfDay.Weekday()) + if weekday == 0 { + weekday = 7 + } + currentWeekStart := startOfDay.AddDate(0, 0, -(weekday - 1)) + prevWeekStart := currentWeekStart.AddDate(0, 0, -7) + return prevWeekStart, currentWeekStart + case "month": + // Compare to previous calendar month + currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) + prevMonthStart := currentMonthStart.AddDate(0, -1, 0) + return prevMonthStart, currentMonthStart + default: + return time.Time{}, time.Time{} + } +} + +// aggregateFromDailyStats sums statistics from daily_stats table for the given date range +func (h *DashboardHandler) aggregateFromDailyStats(startDate, endDate string) (aggregatedStats, error) { + var stats aggregatedStats + err := h.db.Model(&model.DailyStat{}). + Select(` + COALESCE(SUM(requests), 0) as requests, + COALESCE(SUM(success), 0) as success, + COALESCE(SUM(failed), 0) as failed, + COALESCE(SUM(tokens_in), 0) as tokens_in, + COALESCE(SUM(tokens_out), 0) as tokens_out, + COALESCE(SUM(latency_sum_ms), 0) as latency_sum_ms + `). + Where("date >= ? AND date <= ?", startDate, endDate). + Scan(&stats).Error + return stats, err +} + +// aggregateFromLogRecords queries log_records directly for the given time range +func (h *DashboardHandler) aggregateFromLogRecords(start, end time.Time) (aggregatedStats, error) { + var stats struct { + Requests int64 + Success int64 + Failed int64 + TokensIn int64 + TokensOut int64 + LatencySumMs int64 + } + + err := h.logBaseQuery(). + Select(` + COUNT(*) as requests, + SUM(CASE WHEN status_code >= 200 AND status_code < 400 THEN 1 ELSE 0 END) as success, + SUM(CASE WHEN status_code >= 400 OR status_code = 0 THEN 1 ELSE 0 END) as failed, + COALESCE(SUM(tokens_in), 0) as tokens_in, + COALESCE(SUM(tokens_out), 0) as tokens_out, + COALESCE(SUM(latency_ms), 0) as latency_sum_ms + `). + Where("created_at >= ? AND created_at < ?", start, end). + Scan(&stats).Error + + return aggregatedStats{ + Requests: stats.Requests, + Success: stats.Success, + Failed: stats.Failed, + TokensIn: stats.TokensIn, + TokensOut: stats.TokensOut, + LatencySumMs: stats.LatencySumMs, + }, err +} + +// getTopModelsFromDailyStats aggregates top models from daily_stats JSON field +func (h *DashboardHandler) getTopModelsFromDailyStats(startDate, endDate string) ([]TopModelStat, error) { + var dailyStats []model.DailyStat + if err := h.db.Where("date >= ? AND date <= ?", startDate, endDate).Find(&dailyStats).Error; err != nil { + return nil, err + } + + // Aggregate top models from all days + modelMap := make(map[string]TopModelStat) + for _, ds := range dailyStats { + var topModels []model.TopModelStat + if err := json.Unmarshal([]byte(ds.TopModels), &topModels); err != nil { + continue + } + for _, tm := range topModels { + existing := modelMap[tm.Model] + existing.Model = tm.Model + existing.Requests += tm.Requests + existing.Tokens += tm.Tokens + modelMap[tm.Model] = existing + } + } + + // Convert to slice and sort by requests + result := make([]TopModelStat, 0, len(modelMap)) + for _, tm := range modelMap { + result = append(result, tm) + } + + // Simple bubble sort for top 10 (small dataset) + for i := 0; i < len(result)-1; i++ { + for j := i + 1; j < len(result); j++ { + if result[j].Requests > result[i].Requests { + result[i], result[j] = result[j], result[i] + } + } + } + + if len(result) > 10 { + result = result[:10] + } + + return result, nil +} diff --git a/internal/api/dashboard_handler_test.go b/internal/api/dashboard_handler_test.go new file mode 100644 index 0000000..ee8dd93 --- /dev/null +++ b/internal/api/dashboard_handler_test.go @@ -0,0 +1,237 @@ +package api + +import ( + "testing" + "time" +) + +func TestCalculateTrend(t *testing.T) { + tests := []struct { + name string + current int64 + previous int64 + wantDelta *float64 + wantDirection string + }{ + { + name: "zero previous, zero current - stable", + current: 0, + previous: 0, + wantDelta: nil, + wantDirection: "stable", + }, + { + name: "zero previous, positive current - new", + current: 100, + previous: 0, + wantDelta: nil, + wantDirection: "new", + }, + { + name: "100% increase - up", + current: 200, + previous: 100, + wantDelta: ptr(100.0), + wantDirection: "up", + }, + { + name: "50% decrease - down", + current: 50, + previous: 100, + wantDelta: ptr(-50.0), + wantDirection: "down", + }, + { + name: "0.3% increase - stable", + current: 1003, + previous: 1000, + wantDelta: ptr(0.3), + wantDirection: "stable", + }, + { + name: "0.5% increase - stable (boundary)", + current: 1005, + previous: 1000, + wantDelta: ptr(0.5), + wantDirection: "stable", + }, + { + name: "0.6% increase - up", + current: 1006, + previous: 1000, + wantDelta: ptr(0.6), + wantDirection: "up", + }, + { + name: "0.6% decrease - down", + current: 994, + previous: 1000, + wantDelta: ptr(-0.6), + wantDirection: "down", + }, + { + name: "no change - stable", + current: 100, + previous: 100, + wantDelta: ptr(0.0), + wantDirection: "stable", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CalculateTrend(tt.current, tt.previous) + + if tt.wantDelta == nil { + if got.Delta != nil { + t.Errorf("expected nil delta, got %v", *got.Delta) + } + } else { + if got.Delta == nil { + t.Errorf("expected delta %v, got nil", *tt.wantDelta) + } else if *got.Delta != *tt.wantDelta { + t.Errorf("expected delta %v, got %v", *tt.wantDelta, *got.Delta) + } + } + + if got.Direction != tt.wantDirection { + t.Errorf("expected direction %q, got %q", tt.wantDirection, got.Direction) + } + }) + } +} + +func TestCalculateTrendFloat(t *testing.T) { + tests := []struct { + name string + current float64 + previous float64 + wantDelta *float64 + wantDirection string + }{ + { + name: "zero previous, zero current - stable", + current: 0, + previous: 0, + wantDelta: nil, + wantDirection: "stable", + }, + { + name: "zero previous, positive current - new", + current: 0.5, + previous: 0, + wantDelta: nil, + wantDirection: "new", + }, + { + name: "50% increase - up", + current: 0.15, + previous: 0.10, + wantDelta: ptr(50.0), + wantDirection: "up", + }, + { + name: "33.3% decrease - down", + current: 0.10, + previous: 0.15, + wantDelta: ptr(-33.3), + wantDirection: "down", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CalculateTrendFloat(tt.current, tt.previous) + + if tt.wantDelta == nil { + if got.Delta != nil { + t.Errorf("expected nil delta, got %v", *got.Delta) + } + } else { + if got.Delta == nil { + t.Errorf("expected delta %v, got nil", *tt.wantDelta) + } else if *got.Delta != *tt.wantDelta { + t.Errorf("expected delta %v, got %v", *tt.wantDelta, *got.Delta) + } + } + + if got.Direction != tt.wantDirection { + t.Errorf("expected direction %q, got %q", tt.wantDirection, got.Direction) + } + }) + } +} + +func TestPreviousPeriodWindow(t *testing.T) { + // These tests verify the structure of the period calculation + // Not testing exact timestamps since they depend on current time + + tests := []struct { + period string + expectZero bool + expectWindow bool + }{ + {"today", false, true}, + {"last7d", false, true}, + {"last30d", false, true}, + {"week", false, true}, + {"month", false, true}, + {"all", true, false}, + {"invalid", true, false}, + } + + for _, tt := range tests { + t.Run(tt.period, func(t *testing.T) { + start, end := previousPeriodWindow(tt.period) + + if tt.expectZero { + if !start.IsZero() || !end.IsZero() { + t.Errorf("expected zero times for period %q", tt.period) + } + } else { + if start.IsZero() || end.IsZero() { + t.Errorf("expected non-zero times for period %q", tt.period) + } + if !start.Before(end) { + t.Errorf("expected start < end for period %q", tt.period) + } + } + }) + } +} + +func TestPreviousPeriodWindowToday(t *testing.T) { + start, end := previousPeriodWindow("today") + + // Previous period for "today" should be "yesterday" + now := time.Now().UTC() + startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + yesterday := startOfToday.AddDate(0, 0, -1) + + if !start.Equal(yesterday) { + t.Errorf("expected start to be yesterday (%v), got %v", yesterday, start) + } + if !end.Equal(startOfToday) { + t.Errorf("expected end to be start of today (%v), got %v", startOfToday, end) + } +} + +func TestPreviousPeriodWindowLast7d(t *testing.T) { + start, end := previousPeriodWindow("last7d") + + now := time.Now().UTC() + expected14DaysAgo := now.AddDate(0, 0, -14) + expected7DaysAgo := now.AddDate(0, 0, -7) + + // Allow 1 second tolerance for test execution time + if start.Sub(expected14DaysAgo).Abs() > time.Second { + t.Errorf("expected start ~14 days ago, got %v (expected %v)", start, expected14DaysAgo) + } + if end.Sub(expected7DaysAgo).Abs() > time.Second { + t.Errorf("expected end ~7 days ago, got %v (expected %v)", end, expected7DaysAgo) + } +} + +func ptr(v float64) *float64 { + return &v +}