diff --git a/cmd/server/main.go b/cmd/server/main.go index 8ab76d3..1a303d6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -368,6 +368,7 @@ func main() { adminGroup.GET("/logs", handler.ListLogs) adminGroup.DELETE("/logs", handler.DeleteLogs) adminGroup.GET("/logs/stats", handler.LogStats) + adminGroup.GET("/logs/stats/traffic-chart", handler.GetTrafficChart) adminGroup.GET("/logs/webhook", handler.GetLogWebhookConfig) adminGroup.PUT("/logs/webhook", handler.UpdateLogWebhookConfig) adminGroup.GET("/stats", adminHandler.GetAdminStats) diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index d764076..b8f4071 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -1610,6 +1610,73 @@ const docTemplate = `{ } } }, + "/admin/logs/stats/traffic-chart": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "Get time × model aggregated data for stacked traffic charts. Returns time buckets with per-model breakdown.", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Traffic chart data (admin)", + "parameters": [ + { + "enum": [ + "hour", + "minute" + ], + "type": "string", + "description": "Time granularity: hour (default) or minute", + "name": "granularity", + "in": "query" + }, + { + "type": "integer", + "description": "Start time (unix seconds), defaults to 24h ago", + "name": "since", + "in": "query" + }, + { + "type": "integer", + "description": "End time (unix seconds), defaults to now", + "name": "until", + "in": "query" + }, + { + "type": "integer", + "description": "Number of top models to return (1-20), defaults to 5", + "name": "top_n", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.TrafficChartResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + } + }, "/admin/logs/webhook": { "get": { "security": [ @@ -5840,6 +5907,66 @@ const docTemplate = `{ } } }, + "internal_api.TrafficBucket": { + "type": "object", + "properties": { + "breakdown": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api.TrafficMetrics" + } + }, + "time": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "total": { + "$ref": "#/definitions/internal_api.TrafficMetrics" + } + } + }, + "internal_api.TrafficChartResponse": { + "type": "object", + "properties": { + "buckets": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_api.TrafficBucket" + } + }, + "granularity": { + "type": "string" + }, + "models": { + "type": "array", + "items": { + "type": "string" + } + }, + "since": { + "type": "integer" + }, + "until": { + "type": "integer" + } + } + }, + "internal_api.TrafficMetrics": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "tokens_in": { + "type": "integer" + }, + "tokens_out": { + "type": "integer" + } + } + }, "internal_api.UpdateAccessRequest": { "type": "object", "properties": { diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 59a8ba6..a20aab1 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -1604,6 +1604,73 @@ } } }, + "/admin/logs/stats/traffic-chart": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "Get time × model aggregated data for stacked traffic charts. Returns time buckets with per-model breakdown.", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Traffic chart data (admin)", + "parameters": [ + { + "enum": [ + "hour", + "minute" + ], + "type": "string", + "description": "Time granularity: hour (default) or minute", + "name": "granularity", + "in": "query" + }, + { + "type": "integer", + "description": "Start time (unix seconds), defaults to 24h ago", + "name": "since", + "in": "query" + }, + { + "type": "integer", + "description": "End time (unix seconds), defaults to now", + "name": "until", + "in": "query" + }, + { + "type": "integer", + "description": "Number of top models to return (1-20), defaults to 5", + "name": "top_n", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.TrafficChartResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + } + }, "/admin/logs/webhook": { "get": { "security": [ @@ -5834,6 +5901,66 @@ } } }, + "internal_api.TrafficBucket": { + "type": "object", + "properties": { + "breakdown": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api.TrafficMetrics" + } + }, + "time": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "total": { + "$ref": "#/definitions/internal_api.TrafficMetrics" + } + } + }, + "internal_api.TrafficChartResponse": { + "type": "object", + "properties": { + "buckets": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_api.TrafficBucket" + } + }, + "granularity": { + "type": "string" + }, + "models": { + "type": "array", + "items": { + "type": "string" + } + }, + "since": { + "type": "integer" + }, + "until": { + "type": "integer" + } + } + }, + "internal_api.TrafficMetrics": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "tokens_in": { + "type": "integer" + }, + "tokens_out": { + "type": "integer" + } + } + }, "internal_api.UpdateAccessRequest": { "type": "object", "properties": { diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index 55490a2..349413b 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -1055,6 +1055,45 @@ definitions: tokens: type: integer type: object + internal_api.TrafficBucket: + properties: + breakdown: + additionalProperties: + $ref: '#/definitions/internal_api.TrafficMetrics' + type: object + time: + type: string + timestamp: + type: integer + total: + $ref: '#/definitions/internal_api.TrafficMetrics' + type: object + internal_api.TrafficChartResponse: + properties: + buckets: + items: + $ref: '#/definitions/internal_api.TrafficBucket' + type: array + granularity: + type: string + models: + items: + type: string + type: array + since: + type: integer + until: + type: integer + type: object + internal_api.TrafficMetrics: + properties: + count: + type: integer + tokens_in: + type: integer + tokens_out: + type: integer + type: object internal_api.UpdateAccessRequest: properties: default_namespace: @@ -2333,6 +2372,50 @@ paths: summary: Log stats (admin) tags: - admin + /admin/logs/stats/traffic-chart: + get: + description: Get time × model aggregated data for stacked traffic charts. Returns + time buckets with per-model breakdown. + parameters: + - description: 'Time granularity: hour (default) or minute' + enum: + - hour + - minute + in: query + name: granularity + type: string + - description: Start time (unix seconds), defaults to 24h ago + in: query + name: since + type: integer + - description: End time (unix seconds), defaults to now + in: query + name: until + type: integer + - description: Number of top models to return (1-20), defaults to 5 + in: query + name: top_n + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_api.TrafficChartResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/gin.H' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/gin.H' + security: + - AdminAuth: [] + summary: Traffic chart data (admin) + tags: + - admin /admin/logs/webhook: get: description: Returns current webhook notification config diff --git a/internal/api/log_handler.go b/internal/api/log_handler.go index eed31aa..592f4c6 100644 --- a/internal/api/log_handler.go +++ b/internal/api/log_handler.go @@ -609,6 +609,223 @@ func (h *Handler) logStatsByMinute(c *gin.Context, q *gorm.DB, sinceTime, untilT c.JSON(http.StatusOK, GroupedStatsResponse{Items: items}) } +// TrafficMetrics contains the metrics for a model in a bucket +type TrafficMetrics struct { + Count int64 `json:"count"` + TokensIn int64 `json:"tokens_in"` + TokensOut int64 `json:"tokens_out"` +} + +// TrafficBucket represents one time bucket with model breakdown +type TrafficBucket struct { + Time string `json:"time"` + Timestamp int64 `json:"timestamp"` + Breakdown map[string]TrafficMetrics `json:"breakdown"` + Total TrafficMetrics `json:"total"` +} + +// TrafficChartResponse is the response for traffic chart API +type TrafficChartResponse struct { + Granularity string `json:"granularity"` + Since int64 `json:"since"` + Until int64 `json:"until"` + Models []string `json:"models"` + Buckets []TrafficBucket `json:"buckets"` +} + +const ( + defaultTrafficTopN = 5 + maxTrafficTopN = 20 +) + +// GetTrafficChart godoc +// @Summary Traffic chart data (admin) +// @Description Get time × model aggregated data for stacked traffic charts. Returns time buckets with per-model breakdown. +// @Tags admin +// @Produce json +// @Security AdminAuth +// @Param granularity query string false "Time granularity: hour (default) or minute" Enums(hour, minute) +// @Param since query int false "Start time (unix seconds), defaults to 24h ago" +// @Param until query int false "End time (unix seconds), defaults to now" +// @Param top_n query int false "Number of top models to return (1-20), defaults to 5" +// @Success 200 {object} TrafficChartResponse +// @Failure 400 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/logs/stats/traffic-chart [get] +func (h *Handler) GetTrafficChart(c *gin.Context) { + // Parse granularity + granularity := strings.TrimSpace(c.Query("granularity")) + if granularity == "" { + granularity = "hour" + } + if granularity != "hour" && granularity != "minute" { + c.JSON(http.StatusBadRequest, gin.H{"error": "granularity must be 'hour' or 'minute'"}) + return + } + + // Parse time range + now := time.Now().UTC() + var sinceTime, untilTime time.Time + + if t, ok := parseUnixSeconds(c.Query("since")); ok { + sinceTime = t + } else { + sinceTime = now.Add(-24 * time.Hour) + } + + if t, ok := parseUnixSeconds(c.Query("until")); ok { + untilTime = t + } else { + untilTime = now + } + + // Validate time range for minute granularity + if granularity == "minute" { + if c.Query("since") == "" || c.Query("until") == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "minute-level aggregation requires both 'since' and 'until' parameters"}) + return + } + duration := untilTime.Sub(sinceTime) + if duration > maxMinuteRangeDuration { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "time range too large for minute granularity", + "max_hours": 6, + "actual_hours": duration.Hours(), + }) + return + } + if duration < 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "'until' must be after 'since'"}) + return + } + } + + // Parse top_n + topN := defaultTrafficTopN + if raw := strings.TrimSpace(c.Query("top_n")); raw != "" { + if v, err := strconv.Atoi(raw); err == nil && v > 0 { + topN = v + } + } + if topN > maxTrafficTopN { + c.JSON(http.StatusBadRequest, gin.H{"error": "top_n cannot exceed 20"}) + return + } + + // Build query + q := h.logBaseQuery(). + Where("created_at >= ?", sinceTime). + Where("created_at <= ?", untilTime) + + // Select with time truncation based on granularity + var truncFunc string + if granularity == "minute" { + truncFunc = "DATE_TRUNC('minute', created_at)" + } else { + truncFunc = "DATE_TRUNC('hour', created_at)" + } + + type bucketModelStats struct { + Bucket time.Time + ModelName string + Cnt int64 + TokensIn int64 + TokensOut int64 + } + var rows []bucketModelStats + + if err := q.Select(truncFunc+" as bucket, model_name, COUNT(*) as cnt, COALESCE(SUM(tokens_in),0) as tokens_in, COALESCE(SUM(tokens_out),0) as tokens_out"). + Group("bucket, model_name"). + Order("bucket ASC, cnt DESC"). + Scan(&rows).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to aggregate traffic data", "details": err.Error()}) + return + } + + // Calculate global model counts for top_n selection + modelCounts := make(map[string]int64) + for _, r := range rows { + modelCounts[r.ModelName] += r.Cnt + } + + // Get top N models + type modelCount struct { + name string + count int64 + } + var modelList []modelCount + for name, cnt := range modelCounts { + modelList = append(modelList, modelCount{name, cnt}) + } + // Sort by count descending + for i := 0; i < len(modelList)-1; i++ { + for j := i + 1; j < len(modelList); j++ { + if modelList[j].count > modelList[i].count { + modelList[i], modelList[j] = modelList[j], modelList[i] + } + } + } + + topModels := make(map[string]bool) + var modelNames []string + for i := 0; i < len(modelList) && i < topN; i++ { + topModels[modelList[i].name] = true + modelNames = append(modelNames, modelList[i].name) + } + hasOther := len(modelList) > topN + if hasOther { + modelNames = append(modelNames, "other") + } + + // Build buckets with breakdown + bucketMap := make(map[int64]*TrafficBucket) + var bucketOrder []int64 + + for _, r := range rows { + ts := r.Bucket.Unix() + bucket, exists := bucketMap[ts] + if !exists { + bucket = &TrafficBucket{ + Time: r.Bucket.UTC().Format(time.RFC3339), + Timestamp: ts, + Breakdown: make(map[string]TrafficMetrics), + } + bucketMap[ts] = bucket + bucketOrder = append(bucketOrder, ts) + } + + modelKey := r.ModelName + if !topModels[modelKey] { + modelKey = "other" + } + + existing := bucket.Breakdown[modelKey] + bucket.Breakdown[modelKey] = TrafficMetrics{ + Count: existing.Count + r.Cnt, + TokensIn: existing.TokensIn + r.TokensIn, + TokensOut: existing.TokensOut + r.TokensOut, + } + + bucket.Total.Count += r.Cnt + bucket.Total.TokensIn += r.TokensIn + bucket.Total.TokensOut += r.TokensOut + } + + // Build response buckets in order + buckets := make([]TrafficBucket, 0, len(bucketOrder)) + for _, ts := range bucketOrder { + buckets = append(buckets, *bucketMap[ts]) + } + + c.JSON(http.StatusOK, TrafficChartResponse{ + Granularity: granularity, + Since: sinceTime.Unix(), + Until: untilTime.Unix(), + Models: modelNames, + Buckets: buckets, + }) +} + // ListSelfLogs godoc // @Summary List logs (master) // @Description List request logs for the authenticated master