diff --git a/cmd/server/main.go b/cmd/server/main.go index 285d974..5c1878a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -219,6 +219,7 @@ func main() { handler := api.NewHandler(db, logDB, syncService, logWriter, rdb, logPartitioner) adminHandler := api.NewAdminHandler(db, logDB, masterService, syncService, statsService, logPartitioner) masterHandler := api.NewMasterHandler(db, logDB, masterService, syncService, statsService, logPartitioner) + dashboardHandler := api.NewDashboardHandler(db, logDB, statsService, logPartitioner) internalHandler := api.NewInternalHandler(db) featureHandler := api.NewFeatureHandler(rdb) authHandler := api.NewAuthHandler(db, rdb, adminService, masterService) @@ -354,6 +355,8 @@ func main() { adminGroup.GET("/logs/webhook", handler.GetLogWebhookConfig) adminGroup.PUT("/logs/webhook", handler.UpdateLogWebhookConfig) adminGroup.GET("/stats", adminHandler.GetAdminStats) + adminGroup.GET("/realtime", adminHandler.GetAdminRealtime) + adminGroup.GET("/dashboard/summary", dashboardHandler.GetSummary) adminGroup.GET("/apikey-stats/summary", adminHandler.GetAPIKeyStatsSummary) adminGroup.POST("/bindings", handler.CreateBinding) adminGroup.GET("/bindings", handler.ListBindings) diff --git a/docs/docs.go b/docs/docs.go index 313fbdb..333b2a5 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -51,7 +51,7 @@ const docTemplate = `{ "AdminAuth": [] } ], - "description": "List API keys", + "description": "List API keys with optional filters", "produces": [ "application/json" ], @@ -77,6 +77,12 @@ const docTemplate = `{ "description": "filter by group_id", "name": "group_id", "in": "query" + }, + { + "type": "string", + "description": "filter by status (active, suspended, auto_disabled, manual_disabled)", + "name": "status", + "in": "query" } ], "responses": { @@ -362,6 +368,37 @@ const docTemplate = `{ } } }, + "/admin/apikey-stats/summary": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "Aggregate APIKey success/failure stats across all provider groups", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "APIKey stats summary (admin)", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.APIKeyStatsSummaryResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + } + }, "/admin/bindings": { "get": { "security": [ @@ -680,6 +717,63 @@ const docTemplate = `{ } } }, + "/admin/dashboard/summary": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "Returns aggregated metrics for dashboard display including requests, tokens, latency, masters, keys, and provider keys statistics", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Dashboard summary", + "parameters": [ + { + "type": "string", + "description": "time period: today, week, month, all", + "name": "period", + "in": "query" + }, + { + "type": "integer", + "description": "unix seconds", + "name": "since", + "in": "query" + }, + { + "type": "integer", + "description": "unix seconds", + "name": "until", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.DashboardSummaryResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + } + }, "/admin/features": { "get": { "security": [ @@ -1011,7 +1105,7 @@ const docTemplate = `{ "AdminAuth": [] } ], - "description": "Aggregate log stats with basic filtering. Use group_by param for grouped statistics (model/day/month). Without group_by returns LogStatsResponse; with group_by returns GroupedStatsResponse.", + "description": "Aggregate log stats with basic filtering. Use group_by param for grouped statistics (model/day/month/hour). Without group_by returns LogStatsResponse; with group_by returns GroupedStatsResponse.", "produces": [ "application/json" ], @@ -1036,10 +1130,11 @@ const docTemplate = `{ "enum": [ "model", "day", - "month" + "month", + "hour" ], "type": "string", - "description": "group by dimension: model, day, month. Returns GroupedStatsResponse when specified.", + "description": "group by dimension: model, day, month, hour. Returns GroupedStatsResponse when specified.", "name": "group_by", "in": "query" } @@ -2776,6 +2871,37 @@ const docTemplate = `{ } } }, + "/admin/realtime": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "Return aggregated realtime counters across all masters", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "System-level realtime stats (admin)", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.SystemRealtimeView" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + } + }, "/admin/stats": { "get": { "security": [ @@ -2898,6 +3024,52 @@ const docTemplate = `{ } } }, + "/internal/apikey-stats/flush": { + "post": { + "description": "Internal endpoint for flushing accumulated APIKey stats from DP to CP database", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "internal" + ], + "summary": "Flush API key stats", + "parameters": [ + { + "description": "Stats to flush", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_api.apiKeyStatsFlushRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + } + }, "/internal/stats/flush": { "post": { "description": "Internal endpoint for flushing accumulated key usage stats from DP to CP database", @@ -3573,6 +3745,12 @@ const docTemplate = `{ "github_com_ez-api_ez-api_internal_dto.APIKeyDTO": { "type": "object", "properties": { + "access_token": { + "type": "string" + }, + "account_id": { + "type": "string" + }, "api_key": { "type": "string" }, @@ -3585,9 +3763,18 @@ const docTemplate = `{ "ban_until": { "type": "string" }, + "expires_at": { + "type": "string" + }, "group_id": { "type": "integer" }, + "project_id": { + "type": "string" + }, + "refresh_token": { + "type": "string" + }, "status": { "type": "string" }, @@ -3666,6 +3853,9 @@ const docTemplate = `{ "google_project": { "type": "string" }, + "headers_profile": { + "type": "string" + }, "models": { "type": "array", "items": { @@ -3675,6 +3865,9 @@ const docTemplate = `{ "name": { "type": "string" }, + "static_headers": { + "type": "string" + }, "status": { "type": "string" }, @@ -3686,6 +3879,12 @@ const docTemplate = `{ "github_com_ez-api_ez-api_internal_model.APIKey": { "type": "object", "properties": { + "access_token": { + "type": "string" + }, + "account_id": { + "type": "string" + }, "api_key": { "type": "string" }, @@ -3704,15 +3903,36 @@ const docTemplate = `{ "deletedAt": { "$ref": "#/definitions/gorm.DeletedAt" }, + "expires_at": { + "type": "string" + }, + "failure_rate": { + "type": "number" + }, + "failure_requests": { + "type": "integer" + }, "group_id": { "type": "integer" }, "id": { "type": "integer" }, + "project_id": { + "type": "string" + }, "status": { "type": "string" }, + "success_rate": { + "type": "number" + }, + "success_requests": { + "type": "integer" + }, + "total_requests": { + "type": "integer" + }, "updatedAt": { "type": "string" }, @@ -3911,12 +4131,21 @@ const docTemplate = `{ "deletedAt": { "$ref": "#/definitions/gorm.DeletedAt" }, + "failure_rate": { + "type": "number" + }, + "failure_requests": { + "type": "integer" + }, "google_location": { "type": "string" }, "google_project": { "type": "string" }, + "headers_profile": { + "type": "string" + }, "id": { "type": "integer" }, @@ -3927,9 +4156,21 @@ const docTemplate = `{ "name": { "type": "string" }, + "static_headers": { + "type": "string" + }, "status": { "type": "string" }, + "success_rate": { + "type": "number" + }, + "success_requests": { + "type": "integer" + }, + "total_requests": { + "type": "integer" + }, "type": { "description": "openai, anthropic, gemini", "type": "string" @@ -4046,6 +4287,26 @@ const docTemplate = `{ } } }, + "internal_api.APIKeyStatsSummaryResponse": { + "type": "object", + "properties": { + "failure_rate": { + "type": "number" + }, + "failure_requests": { + "type": "integer" + }, + "success_rate": { + "type": "number" + }, + "success_requests": { + "type": "integer" + }, + "total_requests": { + "type": "integer" + } + } + }, "internal_api.AboutResponse": { "type": "object", "properties": { @@ -4161,6 +4422,17 @@ const docTemplate = `{ } } }, + "internal_api.CountStats": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, "internal_api.CreateMasterRequest": { "type": "object", "required": [ @@ -4182,6 +4454,41 @@ const docTemplate = `{ } } }, + "internal_api.DashboardSummaryResponse": { + "type": "object", + "properties": { + "keys": { + "$ref": "#/definitions/internal_api.CountStats" + }, + "latency": { + "$ref": "#/definitions/internal_api.LatencyStats" + }, + "masters": { + "$ref": "#/definitions/internal_api.CountStats" + }, + "period": { + "type": "string" + }, + "provider_keys": { + "$ref": "#/definitions/internal_api.ProviderKeyStats" + }, + "requests": { + "$ref": "#/definitions/internal_api.RequestStats" + }, + "tokens": { + "$ref": "#/definitions/internal_api.TokenStats" + }, + "top_models": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_api.TopModelStat" + } + }, + "updated_at": { + "type": "integer" + } + } + }, "internal_api.DeleteLogsRequest": { "type": "object", "properties": { @@ -4217,6 +4524,10 @@ const docTemplate = `{ "description": "For group_by=day", "type": "string" }, + "hour": { + "description": "For group_by=hour", + "type": "string" + }, "model": { "description": "For group_by=model", "type": "string" @@ -4284,6 +4595,14 @@ const docTemplate = `{ } } }, + "internal_api.LatencyStats": { + "type": "object", + "properties": { + "avg_ms": { + "type": "number" + } + } + }, "internal_api.ListLogsResponse": { "type": "object", "properties": { @@ -4460,6 +4779,20 @@ const docTemplate = `{ } } }, + "internal_api.MasterRealtimeSummaryView": { + "type": "object", + "properties": { + "master_id": { + "type": "integer" + }, + "qps": { + "type": "integer" + }, + "rate_limited": { + "type": "boolean" + } + } + }, "internal_api.MasterRealtimeView": { "type": "object", "properties": { @@ -4633,6 +4966,23 @@ const docTemplate = `{ } } }, + "internal_api.ProviderKeyStats": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "auto_disabled": { + "type": "integer" + }, + "suspended": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, "internal_api.ProviderUsageAgg": { "type": "object", "properties": { @@ -4653,6 +5003,23 @@ const docTemplate = `{ } } }, + "internal_api.RequestStats": { + "type": "object", + "properties": { + "error_rate": { + "type": "number" + }, + "failed": { + "type": "integer" + }, + "success": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, "internal_api.StatusResponse": { "type": "object", "properties": { @@ -4670,6 +5037,43 @@ const docTemplate = `{ } } }, + "internal_api.SystemRealtimeView": { + "type": "object", + "properties": { + "by_master": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_api.MasterRealtimeSummaryView" + } + }, + "qps": { + "type": "integer" + }, + "rate_limited_count": { + "type": "integer" + }, + "rpm": { + "type": "integer" + }, + "updated_at": { + "type": "integer" + } + } + }, + "internal_api.TokenStats": { + "type": "object", + "properties": { + "input": { + "type": "integer" + }, + "output": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, "internal_api.TokenView": { "type": "object", "properties": { @@ -4741,6 +5145,20 @@ const docTemplate = `{ } } }, + "internal_api.TopModelStat": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "requests": { + "type": "integer" + }, + "tokens": { + "type": "integer" + } + } + }, "internal_api.UpdateAccessRequest": { "type": "object", "properties": { @@ -4896,6 +5314,31 @@ const docTemplate = `{ } } }, + "internal_api.apiKeyStatsFlushEntry": { + "type": "object", + "properties": { + "api_key_id": { + "type": "integer" + }, + "requests": { + "type": "integer" + }, + "success_requests": { + "type": "integer" + } + } + }, + "internal_api.apiKeyStatsFlushRequest": { + "type": "object", + "properties": { + "keys": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_api.apiKeyStatsFlushEntry" + } + } + } + }, "internal_api.refreshModelRegistryRequest": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 6b0bd11..57b8d68 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -45,7 +45,7 @@ "AdminAuth": [] } ], - "description": "List API keys", + "description": "List API keys with optional filters", "produces": [ "application/json" ], @@ -71,6 +71,12 @@ "description": "filter by group_id", "name": "group_id", "in": "query" + }, + { + "type": "string", + "description": "filter by status (active, suspended, auto_disabled, manual_disabled)", + "name": "status", + "in": "query" } ], "responses": { @@ -356,6 +362,37 @@ } } }, + "/admin/apikey-stats/summary": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "Aggregate APIKey success/failure stats across all provider groups", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "APIKey stats summary (admin)", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.APIKeyStatsSummaryResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + } + }, "/admin/bindings": { "get": { "security": [ @@ -674,6 +711,63 @@ } } }, + "/admin/dashboard/summary": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "Returns aggregated metrics for dashboard display including requests, tokens, latency, masters, keys, and provider keys statistics", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Dashboard summary", + "parameters": [ + { + "type": "string", + "description": "time period: today, week, month, all", + "name": "period", + "in": "query" + }, + { + "type": "integer", + "description": "unix seconds", + "name": "since", + "in": "query" + }, + { + "type": "integer", + "description": "unix seconds", + "name": "until", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.DashboardSummaryResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + } + }, "/admin/features": { "get": { "security": [ @@ -710,7 +804,7 @@ "AdminAuth": [] } ], - "description": "Updates selected feature flags (meta:features). Values are stored as strings. Example: dp_claude_cross_upstream controls whether /v1/messages can route to OpenAI/compatible and Google-family providers.", + "description": "Updates selected feature flags (meta:features). Values are stored as strings.", "consumes": [ "application/json" ], @@ -1005,7 +1099,7 @@ "AdminAuth": [] } ], - "description": "Aggregate log stats with basic filtering. Use group_by param for grouped statistics (model/day/month). Without group_by returns LogStatsResponse; with group_by returns GroupedStatsResponse.", + "description": "Aggregate log stats with basic filtering. Use group_by param for grouped statistics (model/day/month/hour). Without group_by returns LogStatsResponse; with group_by returns GroupedStatsResponse.", "produces": [ "application/json" ], @@ -1030,10 +1124,11 @@ "enum": [ "model", "day", - "month" + "month", + "hour" ], "type": "string", - "description": "group by dimension: model, day, month. Returns GroupedStatsResponse when specified.", + "description": "group by dimension: model, day, month, hour. Returns GroupedStatsResponse when specified.", "name": "group_by", "in": "query" } @@ -2770,6 +2865,37 @@ } } }, + "/admin/realtime": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "Return aggregated realtime counters across all masters", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "System-level realtime stats (admin)", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.SystemRealtimeView" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + } + }, "/admin/stats": { "get": { "security": [ @@ -2892,6 +3018,52 @@ } } }, + "/internal/apikey-stats/flush": { + "post": { + "description": "Internal endpoint for flushing accumulated APIKey stats from DP to CP database", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "internal" + ], + "summary": "Flush API key stats", + "parameters": [ + { + "description": "Stats to flush", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_api.apiKeyStatsFlushRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + } + }, "/internal/stats/flush": { "post": { "description": "Internal endpoint for flushing accumulated key usage stats from DP to CP database", @@ -3567,6 +3739,12 @@ "github_com_ez-api_ez-api_internal_dto.APIKeyDTO": { "type": "object", "properties": { + "access_token": { + "type": "string" + }, + "account_id": { + "type": "string" + }, "api_key": { "type": "string" }, @@ -3579,9 +3757,18 @@ "ban_until": { "type": "string" }, + "expires_at": { + "type": "string" + }, "group_id": { "type": "integer" }, + "project_id": { + "type": "string" + }, + "refresh_token": { + "type": "string" + }, "status": { "type": "string" }, @@ -3660,6 +3847,9 @@ "google_project": { "type": "string" }, + "headers_profile": { + "type": "string" + }, "models": { "type": "array", "items": { @@ -3669,6 +3859,9 @@ "name": { "type": "string" }, + "static_headers": { + "type": "string" + }, "status": { "type": "string" }, @@ -3680,6 +3873,12 @@ "github_com_ez-api_ez-api_internal_model.APIKey": { "type": "object", "properties": { + "access_token": { + "type": "string" + }, + "account_id": { + "type": "string" + }, "api_key": { "type": "string" }, @@ -3698,15 +3897,36 @@ "deletedAt": { "$ref": "#/definitions/gorm.DeletedAt" }, + "expires_at": { + "type": "string" + }, + "failure_rate": { + "type": "number" + }, + "failure_requests": { + "type": "integer" + }, "group_id": { "type": "integer" }, "id": { "type": "integer" }, + "project_id": { + "type": "string" + }, "status": { "type": "string" }, + "success_rate": { + "type": "number" + }, + "success_requests": { + "type": "integer" + }, + "total_requests": { + "type": "integer" + }, "updatedAt": { "type": "string" }, @@ -3905,12 +4125,21 @@ "deletedAt": { "$ref": "#/definitions/gorm.DeletedAt" }, + "failure_rate": { + "type": "number" + }, + "failure_requests": { + "type": "integer" + }, "google_location": { "type": "string" }, "google_project": { "type": "string" }, + "headers_profile": { + "type": "string" + }, "id": { "type": "integer" }, @@ -3921,9 +4150,21 @@ "name": { "type": "string" }, + "static_headers": { + "type": "string" + }, "status": { "type": "string" }, + "success_rate": { + "type": "number" + }, + "success_requests": { + "type": "integer" + }, + "total_requests": { + "type": "integer" + }, "type": { "description": "openai, anthropic, gemini", "type": "string" @@ -4040,6 +4281,26 @@ } } }, + "internal_api.APIKeyStatsSummaryResponse": { + "type": "object", + "properties": { + "failure_rate": { + "type": "number" + }, + "failure_requests": { + "type": "integer" + }, + "success_rate": { + "type": "number" + }, + "success_requests": { + "type": "integer" + }, + "total_requests": { + "type": "integer" + } + } + }, "internal_api.AboutResponse": { "type": "object", "properties": { @@ -4155,6 +4416,17 @@ } } }, + "internal_api.CountStats": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, "internal_api.CreateMasterRequest": { "type": "object", "required": [ @@ -4176,6 +4448,41 @@ } } }, + "internal_api.DashboardSummaryResponse": { + "type": "object", + "properties": { + "keys": { + "$ref": "#/definitions/internal_api.CountStats" + }, + "latency": { + "$ref": "#/definitions/internal_api.LatencyStats" + }, + "masters": { + "$ref": "#/definitions/internal_api.CountStats" + }, + "period": { + "type": "string" + }, + "provider_keys": { + "$ref": "#/definitions/internal_api.ProviderKeyStats" + }, + "requests": { + "$ref": "#/definitions/internal_api.RequestStats" + }, + "tokens": { + "$ref": "#/definitions/internal_api.TokenStats" + }, + "top_models": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_api.TopModelStat" + } + }, + "updated_at": { + "type": "integer" + } + } + }, "internal_api.DeleteLogsRequest": { "type": "object", "properties": { @@ -4211,6 +4518,10 @@ "description": "For group_by=day", "type": "string" }, + "hour": { + "description": "For group_by=hour", + "type": "string" + }, "model": { "description": "For group_by=model", "type": "string" @@ -4278,6 +4589,14 @@ } } }, + "internal_api.LatencyStats": { + "type": "object", + "properties": { + "avg_ms": { + "type": "number" + } + } + }, "internal_api.ListLogsResponse": { "type": "object", "properties": { @@ -4454,6 +4773,20 @@ } } }, + "internal_api.MasterRealtimeSummaryView": { + "type": "object", + "properties": { + "master_id": { + "type": "integer" + }, + "qps": { + "type": "integer" + }, + "rate_limited": { + "type": "boolean" + } + } + }, "internal_api.MasterRealtimeView": { "type": "object", "properties": { @@ -4627,6 +4960,23 @@ } } }, + "internal_api.ProviderKeyStats": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "auto_disabled": { + "type": "integer" + }, + "suspended": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, "internal_api.ProviderUsageAgg": { "type": "object", "properties": { @@ -4647,6 +4997,23 @@ } } }, + "internal_api.RequestStats": { + "type": "object", + "properties": { + "error_rate": { + "type": "number" + }, + "failed": { + "type": "integer" + }, + "success": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, "internal_api.StatusResponse": { "type": "object", "properties": { @@ -4664,6 +5031,43 @@ } } }, + "internal_api.SystemRealtimeView": { + "type": "object", + "properties": { + "by_master": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_api.MasterRealtimeSummaryView" + } + }, + "qps": { + "type": "integer" + }, + "rate_limited_count": { + "type": "integer" + }, + "rpm": { + "type": "integer" + }, + "updated_at": { + "type": "integer" + } + } + }, + "internal_api.TokenStats": { + "type": "object", + "properties": { + "input": { + "type": "integer" + }, + "output": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, "internal_api.TokenView": { "type": "object", "properties": { @@ -4735,6 +5139,20 @@ } } }, + "internal_api.TopModelStat": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "requests": { + "type": "integer" + }, + "tokens": { + "type": "integer" + } + } + }, "internal_api.UpdateAccessRequest": { "type": "object", "properties": { @@ -4890,6 +5308,31 @@ } } }, + "internal_api.apiKeyStatsFlushEntry": { + "type": "object", + "properties": { + "api_key_id": { + "type": "integer" + }, + "requests": { + "type": "integer" + }, + "success_requests": { + "type": "integer" + } + } + }, + "internal_api.apiKeyStatsFlushRequest": { + "type": "object", + "properties": { + "keys": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_api.apiKeyStatsFlushEntry" + } + } + } + }, "internal_api.refreshModelRegistryRequest": { "type": "object", "properties": { @@ -4999,4 +5442,4 @@ "in": "header" } } -} +} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 263c199..1df6513 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -5,6 +5,10 @@ definitions: type: object github_com_ez-api_ez-api_internal_dto.APIKeyDTO: properties: + access_token: + type: string + account_id: + type: string api_key: type: string auto_ban: @@ -13,8 +17,14 @@ definitions: type: string ban_until: type: string + expires_at: + type: string group_id: type: integer + project_id: + type: string + refresh_token: + type: string status: type: string weight: @@ -66,12 +76,16 @@ definitions: type: string google_project: type: string + headers_profile: + type: string models: items: type: string type: array name: type: string + static_headers: + type: string status: type: string type: @@ -79,6 +93,10 @@ definitions: type: object github_com_ez-api_ez-api_internal_model.APIKey: properties: + access_token: + type: string + account_id: + type: string api_key: type: string auto_ban: @@ -91,12 +109,26 @@ definitions: type: string deletedAt: $ref: '#/definitions/gorm.DeletedAt' + expires_at: + type: string + failure_rate: + type: number + failure_requests: + type: integer group_id: type: integer id: type: integer + project_id: + type: string status: type: string + success_rate: + type: number + success_requests: + type: integer + total_requests: + type: integer updatedAt: type: string weight: @@ -228,10 +260,16 @@ definitions: type: string deletedAt: $ref: '#/definitions/gorm.DeletedAt' + failure_rate: + type: number + failure_requests: + type: integer google_location: type: string google_project: type: string + headers_profile: + type: string id: type: integer models: @@ -239,8 +277,16 @@ definitions: type: string name: type: string + static_headers: + type: string status: type: string + success_rate: + type: number + success_requests: + type: integer + total_requests: + type: integer type: description: openai, anthropic, gemini type: string @@ -317,6 +363,19 @@ definitions: description: Valid is true if Time is not NULL type: boolean type: object + internal_api.APIKeyStatsSummaryResponse: + properties: + failure_rate: + type: number + failure_requests: + type: integer + success_rate: + type: number + success_requests: + type: integer + total_requests: + type: integer + type: object internal_api.AboutResponse: properties: description: @@ -393,6 +452,13 @@ definitions: id: type: integer type: object + internal_api.CountStats: + properties: + active: + type: integer + total: + type: integer + type: object internal_api.CreateMasterRequest: properties: global_qps: @@ -407,6 +473,29 @@ definitions: - group - name type: object + internal_api.DashboardSummaryResponse: + properties: + keys: + $ref: '#/definitions/internal_api.CountStats' + latency: + $ref: '#/definitions/internal_api.LatencyStats' + masters: + $ref: '#/definitions/internal_api.CountStats' + period: + type: string + provider_keys: + $ref: '#/definitions/internal_api.ProviderKeyStats' + requests: + $ref: '#/definitions/internal_api.RequestStats' + tokens: + $ref: '#/definitions/internal_api.TokenStats' + top_models: + items: + $ref: '#/definitions/internal_api.TopModelStat' + type: array + updated_at: + type: integer + type: object internal_api.DeleteLogsRequest: properties: before: @@ -430,6 +519,9 @@ definitions: date: description: For group_by=day type: string + hour: + description: For group_by=hour + type: string model: description: For group_by=model type: string @@ -474,6 +566,11 @@ definitions: tokens: type: integer type: object + internal_api.LatencyStats: + properties: + avg_ms: + type: number + type: object internal_api.ListLogsResponse: properties: items: @@ -590,6 +687,15 @@ definitions: tokens_out: type: integer type: object + internal_api.MasterRealtimeSummaryView: + properties: + master_id: + type: integer + qps: + type: integer + rate_limited: + type: boolean + type: object internal_api.MasterRealtimeView: properties: qps: @@ -703,6 +809,17 @@ definitions: user_agent: type: string type: object + internal_api.ProviderKeyStats: + properties: + active: + type: integer + auto_disabled: + type: integer + suspended: + type: integer + total: + type: integer + type: object internal_api.ProviderUsageAgg: properties: provider_id: @@ -716,6 +833,17 @@ definitions: tokens: type: integer type: object + internal_api.RequestStats: + properties: + error_rate: + type: number + failed: + type: integer + success: + type: integer + total: + type: integer + type: object internal_api.StatusResponse: properties: status: @@ -728,6 +856,30 @@ definitions: example: 0.1.0 type: string type: object + internal_api.SystemRealtimeView: + properties: + by_master: + items: + $ref: '#/definitions/internal_api.MasterRealtimeSummaryView' + type: array + qps: + type: integer + rate_limited_count: + type: integer + rpm: + type: integer + updated_at: + type: integer + type: object + internal_api.TokenStats: + properties: + input: + type: integer + output: + type: integer + total: + type: integer + type: object internal_api.TokenView: properties: allow_ips: @@ -775,6 +927,15 @@ definitions: used_tokens: type: integer type: object + internal_api.TopModelStat: + properties: + model: + type: string + requests: + type: integer + tokens: + type: integer + type: object internal_api.UpdateAccessRequest: properties: default_namespace: @@ -884,6 +1045,22 @@ definitions: example: 1703505600 type: integer type: object + internal_api.apiKeyStatsFlushEntry: + properties: + api_key_id: + type: integer + requests: + type: integer + success_requests: + type: integer + type: object + internal_api.apiKeyStatsFlushRequest: + properties: + keys: + items: + $ref: '#/definitions/internal_api.apiKeyStatsFlushEntry' + type: array + type: object internal_api.refreshModelRegistryRequest: properties: ref: @@ -974,7 +1151,7 @@ paths: - Public /admin/api-keys: get: - description: List API keys + description: List API keys with optional filters parameters: - description: page (1-based) in: query @@ -988,6 +1165,10 @@ paths: in: query name: group_id type: integer + - description: filter by status (active, suspended, auto_disabled, manual_disabled) + in: query + name: status + type: string produces: - application/json responses: @@ -1174,6 +1355,25 @@ paths: summary: Batch api keys tags: - admin + /admin/apikey-stats/summary: + get: + description: Aggregate APIKey success/failure stats across all provider groups + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_api.APIKeyStatsSummaryResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/gin.H' + security: + - AdminAuth: [] + summary: APIKey stats summary (admin) + tags: + - admin /admin/bindings: get: description: List all configured bindings @@ -1377,6 +1577,43 @@ paths: summary: Batch bindings tags: - admin + /admin/dashboard/summary: + get: + description: Returns aggregated metrics for dashboard display including requests, + tokens, latency, masters, keys, and provider keys statistics + parameters: + - description: 'time period: today, week, month, all' + in: query + name: period + type: string + - description: unix seconds + in: query + name: since + type: integer + - description: unix seconds + in: query + name: until + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_api.DashboardSummaryResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/gin.H' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/gin.H' + security: + - AdminAuth: [] + summary: Dashboard summary + tags: + - admin /admin/features: get: description: Returns all feature flags stored in Redis (meta:features) @@ -1400,8 +1637,7 @@ paths: consumes: - application/json description: Updates selected feature flags (meta:features). Values are stored - as strings. Example: dp_claude_cross_upstream controls whether /v1/messages - can route to OpenAI/compatible and Google-family providers. + as strings. parameters: - description: Feature map in: body @@ -1589,7 +1825,7 @@ paths: /admin/logs/stats: get: description: Aggregate log stats with basic filtering. Use group_by param for - grouped statistics (model/day/month). Without group_by returns LogStatsResponse; + grouped statistics (model/day/month/hour). Without group_by returns LogStatsResponse; with group_by returns GroupedStatsResponse. parameters: - description: unix seconds @@ -1600,12 +1836,13 @@ paths: in: query name: until type: integer - - description: 'group by dimension: model, day, month. Returns GroupedStatsResponse + - description: 'group by dimension: model, day, month, hour. Returns GroupedStatsResponse when specified.' enum: - model - day - month + - hour in: query name: group_by type: string @@ -2719,6 +2956,25 @@ paths: summary: Update provider group tags: - admin + /admin/realtime: + get: + description: Return aggregated realtime counters across all masters + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_api.SystemRealtimeView' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/gin.H' + security: + - AdminAuth: [] + summary: System-level realtime stats (admin) + tags: + - admin /admin/stats: get: description: Aggregate request stats across all masters @@ -2801,6 +3057,37 @@ paths: summary: Get current identity tags: - auth + /internal/apikey-stats/flush: + post: + consumes: + - application/json + description: Internal endpoint for flushing accumulated APIKey stats from DP + to CP database + parameters: + - description: Stats to flush + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_api.apiKeyStatsFlushRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/gin.H' + "400": + description: Bad Request + schema: + $ref: '#/definitions/gin.H' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/gin.H' + summary: Flush API key stats + tags: + - internal /internal/stats/flush: post: consumes: diff --git a/internal/api/api_key_handler.go b/internal/api/api_key_handler.go index 7b5ea20..302dde1 100644 --- a/internal/api/api_key_handler.go +++ b/internal/api/api_key_handler.go @@ -92,13 +92,14 @@ func (h *Handler) CreateAPIKey(c *gin.Context) { // ListAPIKeys godoc // @Summary List API keys -// @Description List API keys +// @Description List API keys with optional filters // @Tags admin // @Produce json // @Security AdminAuth -// @Param page query int false "page (1-based)" -// @Param limit query int false "limit (default 50, max 200)" -// @Param group_id query int false "filter by group_id" +// @Param page query int false "page (1-based)" +// @Param limit query int false "limit (default 50, max 200)" +// @Param group_id query int false "filter by group_id" +// @Param status query string false "filter by status (active, suspended, auto_disabled, manual_disabled)" // @Success 200 {array} model.APIKey // @Failure 500 {object} gin.H // @Router /admin/api-keys [get] @@ -108,6 +109,9 @@ func (h *Handler) ListAPIKeys(c *gin.Context) { if groupID := strings.TrimSpace(c.Query("group_id")); groupID != "" { q = q.Where("group_id = ?", groupID) } + if status := strings.TrimSpace(c.Query("status")); status != "" { + q = q.Where("status = ?", status) + } query := parseListQuery(c) q = applyListPagination(q, query) if err := q.Find(&keys).Error; err != nil { diff --git a/internal/api/dashboard_handler.go b/internal/api/dashboard_handler.go new file mode 100644 index 0000000..e2cc1f5 --- /dev/null +++ b/internal/api/dashboard_handler.go @@ -0,0 +1,271 @@ +package api + +import ( + "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) +} + +// 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"` +} + +// 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"` + 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, all" +// @Param since query int false "unix seconds" +// @Param until query int false "unix seconds" +// @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 + } + + // 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, + }) + } + + 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, + UpdatedAt: time.Now().UTC().Unix(), + }) +} diff --git a/internal/api/log_handler.go b/internal/api/log_handler.go index 8fb0d29..d84a72b 100644 --- a/internal/api/log_handler.go +++ b/internal/api/log_handler.go @@ -233,6 +233,8 @@ type GroupedStatsItem struct { Date string `json:"date,omitempty"` // For group_by=month Month string `json:"month,omitempty"` + // For group_by=hour + Hour string `json:"hour,omitempty"` Count int64 `json:"count"` TokensIn int64 `json:"tokens_in"` @@ -346,13 +348,13 @@ func (h *Handler) deleteLogsBefore(cutoff time.Time, keyID uint, modelName strin // LogStats godoc // @Summary Log stats (admin) -// @Description Aggregate log stats with basic filtering. Use group_by param for grouped statistics (model/day/month). Without group_by returns LogStatsResponse; with group_by returns GroupedStatsResponse. +// @Description Aggregate log stats with basic filtering. Use group_by param for grouped statistics (model/day/month/hour). Without group_by returns LogStatsResponse; with group_by returns GroupedStatsResponse. // @Tags admin // @Produce json // @Security AdminAuth // @Param since query int false "unix seconds" // @Param until query int false "unix seconds" -// @Param group_by query string false "group by dimension: model, day, month. Returns GroupedStatsResponse when specified." Enums(model, day, month) +// @Param group_by query string false "group by dimension: model, day, month, hour. Returns GroupedStatsResponse when specified." Enums(model, day, month, hour) // @Success 200 {object} LogStatsResponse "Default aggregated stats (when group_by is not specified)" // @Success 200 {object} GroupedStatsResponse "Grouped stats (when group_by is specified)" // @Failure 500 {object} gin.H @@ -377,6 +379,9 @@ func (h *Handler) LogStats(c *gin.Context) { case "month": h.logStatsByMonth(c, q) return + case "hour": + h.logStatsByHour(c, q) + return } // Default: aggregated stats (backward compatible) @@ -508,6 +513,37 @@ func (h *Handler) logStatsByMonth(c *gin.Context, q *gorm.DB) { c.JSON(http.StatusOK, GroupedStatsResponse{Items: items}) } +// logStatsByHour handles group_by=hour +func (h *Handler) logStatsByHour(c *gin.Context, q *gorm.DB) { + type hourStats struct { + Hour string + Cnt int64 + TokensIn int64 + TokensOut int64 + AvgLatencyMs float64 + } + var rows []hourStats + // PostgreSQL DATE_TRUNC for hour-level aggregation + if err := q.Select(`DATE_TRUNC('hour', created_at) as hour, COUNT(*) as cnt, COALESCE(SUM(tokens_in),0) as tokens_in, COALESCE(SUM(tokens_out),0) as tokens_out, COALESCE(AVG(latency_ms),0) as avg_latency_ms`). + Group("hour"). + Order("hour ASC"). + Scan(&rows).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to aggregate by hour", "details": err.Error()}) + return + } + items := make([]GroupedStatsItem, 0, len(rows)) + for _, r := range rows { + items = append(items, GroupedStatsItem{ + Hour: r.Hour, + Count: r.Cnt, + TokensIn: r.TokensIn, + TokensOut: r.TokensOut, + AvgLatencyMs: r.AvgLatencyMs, + }) + } + c.JSON(http.StatusOK, GroupedStatsResponse{Items: items}) +} + // ListSelfLogs godoc // @Summary List logs (master) // @Description List request logs for the authenticated master diff --git a/internal/api/realtime_handler.go b/internal/api/realtime_handler.go index a3261b8..0ff59cf 100644 --- a/internal/api/realtime_handler.go +++ b/internal/api/realtime_handler.go @@ -112,3 +112,73 @@ func (h *MasterHandler) GetSelfRealtime(c *gin.Context) { } c.JSON(http.StatusOK, toMasterRealtimeView(stats)) } + +// SystemRealtimeView represents system-level realtime statistics +type SystemRealtimeView struct { + QPS int64 `json:"qps"` + RPM int64 `json:"rpm"` + RateLimitedCount int64 `json:"rate_limited_count"` + ByMaster []MasterRealtimeSummaryView `json:"by_master"` + UpdatedAt *int64 `json:"updated_at,omitempty"` +} + +// MasterRealtimeSummaryView is a brief summary of a master's realtime stats +type MasterRealtimeSummaryView struct { + MasterID uint `json:"master_id"` + QPS int64 `json:"qps"` + RateLimited bool `json:"rate_limited"` +} + +// GetAdminRealtime godoc +// @Summary System-level realtime stats (admin) +// @Description Return aggregated realtime counters across all masters +// @Tags admin +// @Produce json +// @Security AdminAuth +// @Success 200 {object} SystemRealtimeView +// @Failure 500 {object} gin.H +// @Router /admin/realtime [get] +func (h *AdminHandler) GetAdminRealtime(c *gin.Context) { + if h.statsService == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "stats service not configured"}) + return + } + + // Get all active master IDs + var masterIDs []uint + if err := h.db.Model(&model.Master{}). + Where("status = ?", "active"). + Pluck("id", &masterIDs).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load masters", "details": err.Error()}) + return + } + + stats, err := h.statsService.GetSystemRealtimeSnapshot(c.Request.Context(), masterIDs) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load realtime stats", "details": err.Error()}) + return + } + + var updatedAt *int64 + if stats.UpdatedAt != nil { + sec := stats.UpdatedAt.Unix() + updatedAt = &sec + } + + byMaster := make([]MasterRealtimeSummaryView, 0, len(stats.ByMaster)) + for _, m := range stats.ByMaster { + byMaster = append(byMaster, MasterRealtimeSummaryView{ + MasterID: m.MasterID, + QPS: m.QPS, + RateLimited: m.RateLimited, + }) + } + + c.JSON(http.StatusOK, SystemRealtimeView{ + QPS: stats.TotalQPS, + RPM: stats.TotalRPM, + RateLimitedCount: stats.RateLimitedCount, + ByMaster: byMaster, + UpdatedAt: updatedAt, + }) +} diff --git a/internal/service/stats.go b/internal/service/stats.go index c647f74..ca1648b 100644 --- a/internal/service/stats.go +++ b/internal/service/stats.go @@ -153,3 +153,69 @@ func readCmdTime(cmd *redis.StringCmd) *time.Time { tm := time.Unix(v, 0).UTC() return &tm } + +// SystemRealtimeSnapshot contains aggregated realtime stats across all masters +type SystemRealtimeSnapshot struct { + TotalQPS int64 `json:"qps"` + TotalRPM int64 `json:"rpm"` + RateLimitedCount int64 `json:"rate_limited_count"` + ByMaster []MasterRealtimeSummary `json:"by_master"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// MasterRealtimeSummary is a brief summary of a master's realtime stats +type MasterRealtimeSummary struct { + MasterID uint `json:"master_id"` + QPS int64 `json:"qps"` + RateLimited bool `json:"rate_limited"` +} + +// GetSystemRealtimeSnapshot aggregates realtime stats across all masters +func (s *StatsService) GetSystemRealtimeSnapshot(ctx context.Context, masterIDs []uint) (SystemRealtimeSnapshot, error) { + if s == nil || s.rdb == nil { + return SystemRealtimeSnapshot{}, fmt.Errorf("redis client is required") + } + if ctx == nil { + ctx = context.Background() + } + + var totalQPS int64 + var rateLimitedCount int64 + byMaster := make([]MasterRealtimeSummary, 0, len(masterIDs)) + var latestUpdatedAt *time.Time + + for _, masterID := range masterIDs { + snapshot, err := s.GetMasterRealtimeSnapshot(ctx, masterID) + if err != nil { + continue // Skip masters with errors + } + + totalQPS += snapshot.QPS + if snapshot.RateLimited { + rateLimitedCount++ + } + + byMaster = append(byMaster, MasterRealtimeSummary{ + MasterID: masterID, + QPS: snapshot.QPS, + RateLimited: snapshot.RateLimited, + }) + + if snapshot.UpdatedAt != nil { + if latestUpdatedAt == nil || snapshot.UpdatedAt.After(*latestUpdatedAt) { + latestUpdatedAt = snapshot.UpdatedAt + } + } + } + + // Estimate RPM as QPS * 60 (since we're measuring requests per second) + totalRPM := totalQPS * 60 + + return SystemRealtimeSnapshot{ + TotalQPS: totalQPS, + TotalRPM: totalRPM, + RateLimitedCount: rateLimitedCount, + ByMaster: byMaster, + UpdatedAt: latestUpdatedAt, + }, nil +}