Files
ez-api/internal/api/dashboard_handler.go
zenfun 5c01497ce0 fix(api): handle zero-baseline edge cases in trend calculation
Introduce `CalculateTrendFloatWithBaseline` to correctly handle scenarios where previous period metrics (Error Rate, Latency) are zero or missing. This prevents arithmetic errors and distinguishes between "new" data and actual increases ("up") when starting from zero.

Also updates the admin panel dashboard documentation to reflect current project status.
2026-01-02 23:17:55 +08:00

463 lines
14 KiB
Go

package api
import (
"math"
"net/http"
"time"
"github.com/ez-api/ez-api/internal/model"
"github.com/ez-api/ez-api/internal/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// DashboardHandler handles dashboard-related API endpoints
type DashboardHandler struct {
db *gorm.DB
logDB *gorm.DB
statsService *service.StatsService
logPartitioner *service.LogPartitioner
}
// NewDashboardHandler creates a new DashboardHandler
func NewDashboardHandler(db *gorm.DB, logDB *gorm.DB, statsService *service.StatsService, logPartitioner *service.LogPartitioner) *DashboardHandler {
if logDB == nil {
logDB = db
}
return &DashboardHandler{
db: db,
logDB: logDB,
statsService: statsService,
logPartitioner: logPartitioner,
}
}
func (h *DashboardHandler) logDBConn() *gorm.DB {
if h == nil || h.logDB == nil {
return h.db
}
return h.logDB
}
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}
}
// CalculateTrendFloatWithBaseline calculates trend when baseline existence is explicit.
func CalculateTrendFloatWithBaseline(current, previous float64, hasBaseline bool) TrendInfo {
if !hasBaseline {
if current == 0 {
return TrendInfo{Direction: "stable"}
}
return TrendInfo{Direction: "new"}
}
if previous == 0 {
if current == 0 {
return TrendInfo{Direction: "stable"}
}
return TrendInfo{Direction: "up"}
}
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"`
Success int64 `json:"success"`
Failed int64 `json:"failed"`
ErrorRate float64 `json:"error_rate"`
}
// TokenStats contains token usage statistics
type TokenStats struct {
Total int64 `json:"total"`
Input int64 `json:"input"`
Output int64 `json:"output"`
}
// LatencyStats contains latency statistics
type LatencyStats struct {
AvgMs float64 `json:"avg_ms"`
}
// CountStats contains simple count statistics
type CountStats struct {
Total int64 `json:"total"`
Active int64 `json:"active"`
}
// ProviderKeyStats contains provider key statistics
type ProviderKeyStats struct {
Total int64 `json:"total"`
Active int64 `json:"active"`
Suspended int64 `json:"suspended"`
AutoDisabled int64 `json:"auto_disabled"`
}
// TopModelStat contains model usage statistics
type TopModelStat struct {
Model string `json:"model"`
Requests int64 `json:"requests"`
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"`
Requests RequestStats `json:"requests"`
Tokens TokenStats `json:"tokens"`
Latency LatencyStats `json:"latency"`
Masters CountStats `json:"masters"`
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"`
}
// GetSummary godoc
// @Summary Dashboard summary
// @Description Returns aggregated metrics for dashboard display including requests, tokens, latency, masters, keys, and provider keys statistics
// @Tags admin
// @Produce json
// @Security AdminAuth
// @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
// @Router /admin/dashboard/summary [get]
func (h *DashboardHandler) GetSummary(c *gin.Context) {
rng, err := parseStatsRange(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Parse include_trends parameter
includeTrends := c.Query("include_trends") == "true"
// Build log query with time range
logQuery := h.logBaseQuery()
logQuery = applyStatsRange(logQuery, rng)
// 1. Request statistics
var totalRequests int64
if err := logQuery.Session(&gorm.Session{}).Count(&totalRequests).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count requests", "details": err.Error()})
return
}
type statusCount struct {
StatusCode int
Cnt int64
}
var statusCounts []statusCount
if err := logQuery.Session(&gorm.Session{}).
Select("status_code, COUNT(*) as cnt").
Group("status_code").
Scan(&statusCounts).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count by status", "details": err.Error()})
return
}
var successCount, failedCount int64
for _, sc := range statusCounts {
if sc.StatusCode >= 200 && sc.StatusCode < 400 {
successCount += sc.Cnt
} else {
failedCount += sc.Cnt
}
}
errorRate := 0.0
if totalRequests > 0 {
errorRate = float64(failedCount) / float64(totalRequests)
}
// 2. Token statistics
type tokenSums struct {
TokensIn int64
TokensOut int64
AvgLatency float64
}
var ts tokenSums
if err := logQuery.Session(&gorm.Session{}).
Select("COALESCE(SUM(tokens_in),0) as tokens_in, COALESCE(SUM(tokens_out),0) as tokens_out, COALESCE(AVG(latency_ms),0) as avg_latency").
Scan(&ts).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to aggregate tokens", "details": err.Error()})
return
}
// 3. Master statistics
var totalMasters, activeMasters int64
if err := h.db.Model(&model.Master{}).Count(&totalMasters).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count masters", "details": err.Error()})
return
}
if err := h.db.Model(&model.Master{}).Where("status = ?", "active").Count(&activeMasters).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count active masters", "details": err.Error()})
return
}
// 4. Key (child token) statistics
var totalKeys, activeKeys int64
if err := h.db.Model(&model.Key{}).Count(&totalKeys).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count keys", "details": err.Error()})
return
}
if err := h.db.Model(&model.Key{}).Where("status = ?", "active").Count(&activeKeys).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count active keys", "details": err.Error()})
return
}
// 5. Provider key statistics
var totalProviderKeys, activeProviderKeys, suspendedProviderKeys, autoDisabledProviderKeys int64
if err := h.db.Model(&model.APIKey{}).Count(&totalProviderKeys).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count provider keys", "details": err.Error()})
return
}
if err := h.db.Model(&model.APIKey{}).Where("status = ?", "active").Count(&activeProviderKeys).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count active provider keys", "details": err.Error()})
return
}
if err := h.db.Model(&model.APIKey{}).Where("status = ?", "suspended").Count(&suspendedProviderKeys).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count suspended provider keys", "details": err.Error()})
return
}
if err := h.db.Model(&model.APIKey{}).Where("status = ?", "auto_disabled").Count(&autoDisabledProviderKeys).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count auto_disabled provider keys", "details": err.Error()})
return
}
// 6. Top models (limit to 10)
type modelStat struct {
ModelName string
Cnt int64
Tokens int64
}
var topModels []modelStat
if err := logQuery.Session(&gorm.Session{}).
Select("model_name, COUNT(*) as cnt, COALESCE(SUM(tokens_in + tokens_out),0) as tokens").
Group("model_name").
Order("cnt DESC").
Limit(10).
Scan(&topModels).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get top models", "details": err.Error()})
return
}
topModelStats := make([]TopModelStat, 0, len(topModels))
for _, m := range topModels {
topModelStats = append(topModelStats, TopModelStat{
Model: m.ModelName,
Requests: m.Cnt,
Tokens: m.Tokens,
})
}
// 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 {
hasBaseline := prevStats.Requests > 0
prevErrorRate := 0.0
if hasBaseline {
prevErrorRate = float64(prevStats.Failed) / float64(prevStats.Requests)
}
prevAvgLatency := 0.0
if hasBaseline {
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: CalculateTrendFloatWithBaseline(errorRate, prevErrorRate, hasBaseline),
Latency: CalculateTrendFloatWithBaseline(ts.AvgLatency, prevAvgLatency, hasBaseline),
}
}
}
}
c.JSON(http.StatusOK, DashboardSummaryResponse{
Period: rng.Period,
Requests: RequestStats{
Total: totalRequests,
Success: successCount,
Failed: failedCount,
ErrorRate: errorRate,
},
Tokens: TokenStats{
Total: ts.TokensIn + ts.TokensOut,
Input: ts.TokensIn,
Output: ts.TokensOut,
},
Latency: LatencyStats{
AvgMs: ts.AvgLatency,
},
Masters: CountStats{
Total: totalMasters,
Active: activeMasters,
},
Keys: CountStats{
Total: totalKeys,
Active: activeKeys,
},
ProviderKeys: ProviderKeyStats{
Total: totalProviderKeys,
Active: activeProviderKeys,
Suspended: suspendedProviderKeys,
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{}
}
}
// 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
}