feat(api): add trend analysis and extended periods to dashboard summary

- Add `include_trends` query parameter to enable trend calculation
- Implement trend comparison logic (delta % and direction) against previous periods
- Add support for `last7d`, `last30d`, and `all` time period options
- Update `DashboardSummaryResponse` to include optional `trends` field
- Add helper functions for custom time window aggregation
- Add unit tests for trend calculation and period window logic
- Update feature documentation with new parameters and response schemas
This commit is contained in:
zenfun
2026-01-02 22:30:38 +08:00
parent 5b2b176a55
commit 08a8a1e42f
3 changed files with 498 additions and 9 deletions

View File

@@ -72,15 +72,39 @@
| 参数 | 类型 | 说明 | | 参数 | 类型 | 说明 |
|------|------|------| |------|------|------|
| `period` | string | 预设周期: `today`, `week`, `month` | | `period` | string | 预设周期: `today`, `week`, `month`, `last7d`, `last30d`, `all` |
| `since` | int | 自定义起始时间 (Unix 秒) | | `since` | int | 自定义起始时间 (Unix 秒) |
| `until` | 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 告警摘要 ### 3.4 告警摘要

View File

@@ -1,6 +1,8 @@
package api package api
import ( import (
"encoding/json"
"math"
"net/http" "net/http"
"time" "time"
@@ -42,6 +44,52 @@ func (h *DashboardHandler) logBaseQuery() *gorm.DB {
return logBaseQuery(h.logDBConn(), h.logPartitioner) 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 // RequestStats contains request-related statistics
type RequestStats struct { type RequestStats struct {
Total int64 `json:"total"` Total int64 `json:"total"`
@@ -83,6 +131,14 @@ type TopModelStat struct {
Tokens int64 `json:"tokens"` 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 // DashboardSummaryResponse is the response for dashboard summary endpoint
type DashboardSummaryResponse struct { type DashboardSummaryResponse struct {
Period string `json:"period,omitempty"` Period string `json:"period,omitempty"`
@@ -93,6 +149,7 @@ type DashboardSummaryResponse struct {
Keys CountStats `json:"keys"` Keys CountStats `json:"keys"`
ProviderKeys ProviderKeyStats `json:"provider_keys"` ProviderKeys ProviderKeyStats `json:"provider_keys"`
TopModels []TopModelStat `json:"top_models"` TopModels []TopModelStat `json:"top_models"`
Trends *DashboardTrends `json:"trends,omitempty"` // Only present when include_trends=true
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
} }
@@ -102,9 +159,10 @@ type DashboardSummaryResponse struct {
// @Tags admin // @Tags admin
// @Produce json // @Produce json
// @Security AdminAuth // @Security AdminAuth
// @Param period query string false "time period: today, week, month, all" // @Param period query string false "time period: today, week, month, last7d, last30d, all"
// @Param since query int false "unix seconds" // @Param since query int false "unix seconds"
// @Param until 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 // @Success 200 {object} DashboardSummaryResponse
// @Failure 400 {object} gin.H // @Failure 400 {object} gin.H
// @Failure 500 {object} gin.H // @Failure 500 {object} gin.H
@@ -116,6 +174,9 @@ func (h *DashboardHandler) GetSummary(c *gin.Context) {
return return
} }
// Parse include_trends parameter
includeTrends := c.Query("include_trends") == "true"
// Build log query with time range // Build log query with time range
logQuery := h.logBaseQuery() logQuery := h.logBaseQuery()
logQuery = applyStatsRange(logQuery, rng) 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{ c.JSON(http.StatusOK, DashboardSummaryResponse{
Period: rng.Period, Period: rng.Period,
Requests: RequestStats{ Requests: RequestStats{
@@ -266,6 +353,147 @@ func (h *DashboardHandler) GetSummary(c *gin.Context) {
AutoDisabled: autoDisabledProviderKeys, AutoDisabled: autoDisabledProviderKeys,
}, },
TopModels: topModelStats, TopModels: topModelStats,
Trends: trends,
UpdatedAt: time.Now().UTC().Unix(), 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
}

View File

@@ -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
}