diff --git a/docs/api.md b/docs/api.md index 3365904..40f01ac 100644 --- a/docs/api.md +++ b/docs/api.md @@ -371,6 +371,49 @@ curl "http://localhost:8080/admin/logs/stats?group_by=minute&since=1704150000&un } ``` +#### 流量图表统计 +`GET /admin/logs/stats/traffic-chart` + +**参数**: +| 参数 | 类型 | 说明 | 约束 | +| :--- | :--- | :--- | :--- | +| `granularity` | string | 时间粒度:`hour` / `minute` | `minute` 必须提供 `since` + `until` 且跨度 ≤ 6 小时 | +| `since` | int | 起始时间 (Unix 秒) | 默认 24 小时前 | +| `until` | int | 结束时间 (Unix 秒) | 默认当前 | +| `top_n` | int | Top 模型数量 | 1-20,默认 5,其余聚合为 `other` | + +**响应示例**: +```json +{ + "granularity": "hour", + "since": 1735689600, + "until": 1735776000, + "x": { + "labels": ["2025-01-01T00:00:00Z", "2025-01-01T01:00:00Z"], + "timestamps": [1735689600, 1735693200], + "totals": { + "data": [2050, 1800], + "tokens_in": [82000, 70000], + "tokens_out": [128000, 110000] + } + }, + "series": [ + { + "name": "gpt-4-turbo", + "data": [1200, 900], + "tokens_in": [50000, 38000], + "tokens_out": [80000, 62000] + }, + { + "name": "other", + "data": [850, 900], + "tokens_in": [32000, 32000], + "tokens_out": [48000, 48000] + } + ] +} +``` + ### 6.2 API Key 状态筛选 `GET /admin/api-keys?status=active|suspended|auto_disabled|manual_disabled` diff --git a/docs/docs.go b/docs/docs.go index 613648b..8db874d 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1308,6 +1308,272 @@ const docTemplate = `{ } } }, + "/admin/ip-bans": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "List all global IP/CIDR ban rules", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin", + "ip-bans" + ], + "summary": "List IP bans", + "parameters": [ + { + "type": "string", + "description": "Filter by status (active, expired)", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_api.IPBanView" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + }, + "post": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "Create a new global IP/CIDR ban rule", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin", + "ip-bans" + ], + "summary": "Create an IP ban", + "parameters": [ + { + "description": "IP Ban Info", + "name": "ban", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_api.CreateIPBanRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/internal_api.IPBanView" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + } + }, + "/admin/ip-bans/{id}": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "Get a single global IP/CIDR ban rule by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin", + "ip-bans" + ], + "summary": "Get an IP ban", + "parameters": [ + { + "type": "integer", + "description": "IP Ban ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.IPBanView" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + }, + "put": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "Update a global IP/CIDR ban rule", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin", + "ip-bans" + ], + "summary": "Update an IP ban", + "parameters": [ + { + "type": "integer", + "description": "IP Ban ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "IP Ban Update", + "name": "ban", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_api.UpdateIPBanRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.IPBanView" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + }, + "delete": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "Delete a global IP/CIDR ban rule", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin", + "ip-bans" + ], + "summary": "Delete an IP ban", + "parameters": [ + { + "type": "integer", + "description": "IP Ban ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + } + }, "/admin/keys/{id}/access": { "get": { "security": [ @@ -1623,7 +1889,7 @@ const docTemplate = `{ "AdminAuth": [] } ], - "description": "Get time × model aggregated data for stacked traffic charts. Returns time buckets with per-model breakdown.", + "description": "Get time × model aggregated data for stacked traffic charts. Returns a shared time axis under ` + "`" + `x` + "`" + ` and per-model series arrays aligned to that axis. Models outside top_n are aggregated under the series name \"other\".", "produces": [ "application/json" ], @@ -3528,7 +3794,7 @@ const docTemplate = `{ "MasterAuth": [] } ], - "description": "Returns the identity of the authenticated user based on the Authorization header.\nSupports Admin Token, Master Key, and Child Key (API Key) authentication.\n\nResponse varies by token type:\n- Admin Token: {\"type\": \"admin\", \"role\": \"admin\"}\n- Master Key: {\"type\": \"master\", \"id\": 1, \"name\": \"...\", ...}\n- Child Key: {\"type\": \"key\", \"id\": 5, \"master_id\": 1, \"issued_by\": \"master\", ...}", + "description": "Returns complete identity and realtime statistics of the authenticated user.\nSupports Admin Token, Master Key, and Child Key (API Key) authentication.\nThis endpoint is designed for frontend initialization - call once after login\nand store the response for subsequent use.\n\n**Response varies by token type:**\n\n**Admin Token:**\n- type: \"admin\"\n- role: \"admin\"\n- permissions: [\"*\"] (full access)\n\n**Master Key:**\n- type: \"master\"\n- Basic info: id, name, group, namespaces, status, epoch, max_child_keys, global_qps\n- Timestamps: created_at, updated_at\n- Realtime stats: requests, tokens, qps, qps_limit, rate_limited\n\n**Child Key (API Key):**\n- type: \"key\"\n- Basic info: id, master_id, master_name, group, scopes, namespaces, status\n- Security: issued_at_epoch, issued_by, allow_ips, deny_ips, expires_at\n- Model limits: model_limits, model_limits_enabled\n- Quota: quota_limit, quota_used, quota_reset_at, quota_reset_type\n- Usage stats: request_count, used_tokens, last_accessed_at\n- Realtime stats: requests, tokens, qps, qps_limit, rate_limited\n\n**Error responses:**\n- 401: authorization header required\n- 401: invalid authorization header format\n- 401: invalid token\n- 401: token is not active\n- 401: token has expired\n- 401: token has been revoked\n- 401: master is not active", "produces": [ "application/json" ], @@ -5163,6 +5429,23 @@ const docTemplate = `{ } } }, + "internal_api.CreateIPBanRequest": { + "type": "object", + "required": [ + "cidr" + ], + "properties": { + "cidr": { + "type": "string" + }, + "expires_at": { + "type": "integer" + }, + "reason": { + "type": "string" + } + } + }, "internal_api.CreateMasterRequest": { "type": "object", "required": [ @@ -5314,6 +5597,38 @@ const docTemplate = `{ } } }, + "internal_api.IPBanView": { + "type": "object", + "properties": { + "cidr": { + "type": "string" + }, + "created_at": { + "type": "integer" + }, + "created_by": { + "type": "string" + }, + "expires_at": { + "type": "integer" + }, + "hit_count": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "reason": { + "type": "string" + }, + "status": { + "type": "string" + }, + "updated_at": { + "type": "integer" + } + } + }, "internal_api.IssueChildKeyRequest": { "type": "object", "properties": { @@ -5938,42 +6253,36 @@ const docTemplate = `{ } } }, - "internal_api.TrafficBucket": { + "internal_api.TrafficChartAxis": { "type": "object", "properties": { - "breakdown": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/internal_api.TrafficMetrics" + "labels": { + "type": "array", + "items": { + "type": "string" } }, - "time": { - "type": "string" + "timestamps": { + "type": "array", + "items": { + "type": "integer" + } }, - "timestamp": { - "type": "integer" - }, - "total": { - "$ref": "#/definitions/internal_api.TrafficMetrics" + "totals": { + "$ref": "#/definitions/internal_api.TrafficTotals" } } }, "internal_api.TrafficChartResponse": { "type": "object", "properties": { - "buckets": { - "type": "array", - "items": { - "$ref": "#/definitions/internal_api.TrafficBucket" - } - }, "granularity": { "type": "string" }, - "models": { + "series": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/internal_api.TrafficSeries" } }, "since": { @@ -5981,20 +6290,58 @@ const docTemplate = `{ }, "until": { "type": "integer" + }, + "x": { + "$ref": "#/definitions/internal_api.TrafficChartAxis" } } }, - "internal_api.TrafficMetrics": { + "internal_api.TrafficSeries": { "type": "object", "properties": { - "count": { - "type": "integer" + "data": { + "type": "array", + "items": { + "type": "integer" + } + }, + "name": { + "type": "string" }, "tokens_in": { - "type": "integer" + "type": "array", + "items": { + "type": "integer" + } }, "tokens_out": { - "type": "integer" + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "internal_api.TrafficTotals": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "integer" + } + }, + "tokens_in": { + "type": "array", + "items": { + "type": "integer" + } + }, + "tokens_out": { + "type": "array", + "items": { + "type": "integer" + } } } }, @@ -6054,6 +6401,20 @@ const docTemplate = `{ } } }, + "internal_api.UpdateIPBanRequest": { + "type": "object", + "properties": { + "expires_at": { + "$ref": "#/definitions/internal_api.optionalInt64" + }, + "reason": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, "internal_api.UpdateMasterRequest": { "type": "object", "properties": { @@ -6115,9 +6476,49 @@ const docTemplate = `{ } } }, + "internal_api.WhoamiRealtimeView": { + "type": "object", + "properties": { + "qps": { + "description": "Current QPS", + "type": "integer", + "example": 5 + }, + "qps_limit": { + "description": "QPS limit", + "type": "integer", + "example": 100 + }, + "rate_limited": { + "description": "Whether currently rate limited", + "type": "boolean", + "example": false + }, + "requests": { + "description": "Total requests", + "type": "integer", + "example": 100 + }, + "tokens": { + "description": "Total tokens used", + "type": "integer", + "example": 50000 + }, + "updated_at": { + "description": "Last updated timestamp", + "type": "integer", + "example": 1703505600 + } + } + }, "internal_api.WhoamiResponse": { "type": "object", "properties": { + "allow_ips": { + "description": "IP whitelist (for diagnostics)", + "type": "string", + "example": "" + }, "created_at": { "type": "integer", "example": 1703505600 @@ -6126,10 +6527,20 @@ const docTemplate = `{ "type": "string", "example": "default" }, + "deny_ips": { + "description": "IP blacklist (for diagnostics)", + "type": "string", + "example": "" + }, "epoch": { "type": "integer", "example": 1 }, + "expires_at": { + "description": "Expiration timestamp (0 = never)", + "type": "integer", + "example": 0 + }, "global_qps": { "type": "integer", "example": 100 @@ -6151,15 +6562,35 @@ const docTemplate = `{ "type": "string", "example": "master" }, + "last_accessed_at": { + "description": "Last access timestamp", + "type": "integer", + "example": 0 + }, "master_id": { "description": "Key fields (only present when type is \"key\")", "type": "integer", "example": 1 }, + "master_name": { + "description": "Parent master name (for display)", + "type": "string", + "example": "tenant-a" + }, "max_child_keys": { "type": "integer", "example": 5 }, + "model_limits": { + "description": "Model restrictions", + "type": "string", + "example": "gpt-4,claude" + }, + "model_limits_enabled": { + "description": "Whether model limits are active", + "type": "boolean", + "example": false + }, "name": { "type": "string", "example": "tenant-a" @@ -6168,6 +6599,46 @@ const docTemplate = `{ "type": "string", "example": "default,ns1" }, + "permissions": { + "description": "Admin permissions (always [\"*\"])", + "type": "array", + "items": { + "type": "string" + } + }, + "quota_limit": { + "description": "Token quota limit (-1 = unlimited)", + "type": "integer", + "example": -1 + }, + "quota_reset_at": { + "description": "Quota reset timestamp", + "type": "integer", + "example": 0 + }, + "quota_reset_type": { + "description": "Quota reset type", + "type": "string", + "example": "monthly" + }, + "quota_used": { + "description": "Token quota used", + "type": "integer", + "example": 0 + }, + "realtime": { + "description": "Realtime stats (for master and key types)", + "allOf": [ + { + "$ref": "#/definitions/internal_api.WhoamiRealtimeView" + } + ] + }, + "request_count": { + "description": "Total request count (from DB)", + "type": "integer", + "example": 0 + }, "role": { "description": "Admin fields (only present when type is \"admin\")", "type": "string", @@ -6189,6 +6660,11 @@ const docTemplate = `{ "updated_at": { "type": "integer", "example": 1703505600 + }, + "used_tokens": { + "description": "Total tokens used (from DB)", + "type": "integer", + "example": 0 } } }, @@ -6217,6 +6693,18 @@ const docTemplate = `{ } } }, + "internal_api.optionalInt64": { + "type": "object", + "properties": { + "set": { + "type": "boolean" + }, + "value": { + "type": "integer", + "format": "int64" + } + } + }, "internal_api.refreshModelRegistryRequest": { "type": "object", "properties": { diff --git a/docs/feat/admin-panel-dashboard-feat/admin-panel-dashboard-feat.md b/docs/feat/admin-panel-dashboard-feat/admin-panel-dashboard-feat.md index e5e7b62..ea4b4ed 100644 --- a/docs/feat/admin-panel-dashboard-feat/admin-panel-dashboard-feat.md +++ b/docs/feat/admin-panel-dashboard-feat/admin-panel-dashboard-feat.md @@ -176,17 +176,33 @@ "granularity": "hour", "since": 1735689600, "until": 1735776000, - "models": ["gpt-4-turbo", "claude-3.5-sonnet", "llama-3-70b", "other"], - "buckets": [ + "x": { + "labels": ["2025-01-01T00:00:00Z", "2025-01-01T01:00:00Z"], + "timestamps": [1735689600, 1735693200], + "totals": { + "data": [2050, 1800], + "tokens_in": [82000, 70000], + "tokens_out": [128000, 110000] + } + }, + "series": [ { - "time": "2025-01-01T00:00:00Z", - "timestamp": 1735689600, - "breakdown": { - "gpt-4-turbo": { "count": 1200, "tokens_in": 50000, "tokens_out": 80000 }, - "claude-3.5-sonnet": { "count": 800, "tokens_in": 30000, "tokens_out": 45000 }, - "other": { "count": 50, "tokens_in": 2000, "tokens_out": 3000 } - }, - "total": { "count": 2050, "tokens_in": 82000, "tokens_out": 128000 } + "name": "gpt-4-turbo", + "data": [1200, 900], + "tokens_in": [50000, 38000], + "tokens_out": [80000, 62000] + }, + { + "name": "claude-3.5-sonnet", + "data": [800, 700], + "tokens_in": [30000, 26000], + "tokens_out": [45000, 40000] + }, + { + "name": "other", + "data": [50, 200], + "tokens_in": [2000, 6000], + "tokens_out": [3000, 8000] } ] } @@ -196,11 +212,11 @@ | 图表类型 | 数据映射 | |----------|----------| -| X 轴 | `buckets[i].time` (格式化为 00:00, 04:00 等) | +| X 轴 | `x.labels` (格式化为 00:00, 04:00 等) | | Y 轴 | 请求数量 | -| 堆叠系列 | `models` 数组,每个模型一个系列 | -| 系列数据 | `buckets[i].breakdown[model].count` | -| 总量标签 | `buckets[i].total.count` | +| 堆叠系列 | `series` 数组,每个模型一个系列 | +| 系列数据 | `series[i].data` | +| 总量标签 | `x.totals.data` | **时间范围选择器映射**: @@ -226,7 +242,7 @@ | 功能点 | 用户操作 | 前端行为 | 后端请求 | 成功反馈 | 异常处理 | 风险/边界 | |--------|----------|----------|----------|----------|----------|-----------| -| 图表加载 | 页面加载 | 请求默认 24H 图表 | `GET /admin/logs/stats/traffic-chart` | 渲染堆叠柱状图 | 请求失败 → 显示错误提示 | `buckets` 为空 → 显示 No Data | +| 图表加载 | 页面加载 | 请求默认 24H 图表 | `GET /admin/logs/stats/traffic-chart` | 渲染堆叠柱状图 | 请求失败 → 显示错误提示 | `series` 为空或 `x.labels` 为空 → 显示 No Data | | 时间粒度切换 | 选择分钟级范围 | 自动切换 granularity=minute | `GET /admin/logs/stats/traffic-chart` | 图表更细粒度 | 400 → 回退到 hour 并提示 | minute 范围 ≤ 6h | | Top 模型限制 | 修改显示数量 | 约束 top_n ≤ 20 | `GET /admin/logs/stats/traffic-chart` | 图表更新 | 400 → 自动回退到 20 | 模型过多时显示 "other" | diff --git a/docs/swagger.json b/docs/swagger.json index a2ab2c8..1e61947 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1302,6 +1302,272 @@ } } }, + "/admin/ip-bans": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "List all global IP/CIDR ban rules", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin", + "ip-bans" + ], + "summary": "List IP bans", + "parameters": [ + { + "type": "string", + "description": "Filter by status (active, expired)", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_api.IPBanView" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + }, + "post": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "Create a new global IP/CIDR ban rule", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin", + "ip-bans" + ], + "summary": "Create an IP ban", + "parameters": [ + { + "description": "IP Ban Info", + "name": "ban", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_api.CreateIPBanRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/internal_api.IPBanView" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + } + }, + "/admin/ip-bans/{id}": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "Get a single global IP/CIDR ban rule by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin", + "ip-bans" + ], + "summary": "Get an IP ban", + "parameters": [ + { + "type": "integer", + "description": "IP Ban ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.IPBanView" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + }, + "put": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "Update a global IP/CIDR ban rule", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin", + "ip-bans" + ], + "summary": "Update an IP ban", + "parameters": [ + { + "type": "integer", + "description": "IP Ban ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "IP Ban Update", + "name": "ban", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_api.UpdateIPBanRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.IPBanView" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + }, + "delete": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "Delete a global IP/CIDR ban rule", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin", + "ip-bans" + ], + "summary": "Delete an IP ban", + "parameters": [ + { + "type": "integer", + "description": "IP Ban ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + } + }, "/admin/keys/{id}/access": { "get": { "security": [ @@ -1617,7 +1883,7 @@ "AdminAuth": [] } ], - "description": "Get time × model aggregated data for stacked traffic charts. Returns time buckets with per-model breakdown.", + "description": "Get time × model aggregated data for stacked traffic charts. Returns a shared time axis under `x` and per-model series arrays aligned to that axis. Models outside top_n are aggregated under the series name \"other\".", "produces": [ "application/json" ], @@ -3522,7 +3788,7 @@ "MasterAuth": [] } ], - "description": "Returns the identity of the authenticated user based on the Authorization header.\nSupports Admin Token, Master Key, and Child Key (API Key) authentication.\n\nResponse varies by token type:\n- Admin Token: {\"type\": \"admin\", \"role\": \"admin\"}\n- Master Key: {\"type\": \"master\", \"id\": 1, \"name\": \"...\", ...}\n- Child Key: {\"type\": \"key\", \"id\": 5, \"master_id\": 1, \"issued_by\": \"master\", ...}", + "description": "Returns complete identity and realtime statistics of the authenticated user.\nSupports Admin Token, Master Key, and Child Key (API Key) authentication.\nThis endpoint is designed for frontend initialization - call once after login\nand store the response for subsequent use.\n\n**Response varies by token type:**\n\n**Admin Token:**\n- type: \"admin\"\n- role: \"admin\"\n- permissions: [\"*\"] (full access)\n\n**Master Key:**\n- type: \"master\"\n- Basic info: id, name, group, namespaces, status, epoch, max_child_keys, global_qps\n- Timestamps: created_at, updated_at\n- Realtime stats: requests, tokens, qps, qps_limit, rate_limited\n\n**Child Key (API Key):**\n- type: \"key\"\n- Basic info: id, master_id, master_name, group, scopes, namespaces, status\n- Security: issued_at_epoch, issued_by, allow_ips, deny_ips, expires_at\n- Model limits: model_limits, model_limits_enabled\n- Quota: quota_limit, quota_used, quota_reset_at, quota_reset_type\n- Usage stats: request_count, used_tokens, last_accessed_at\n- Realtime stats: requests, tokens, qps, qps_limit, rate_limited\n\n**Error responses:**\n- 401: authorization header required\n- 401: invalid authorization header format\n- 401: invalid token\n- 401: token is not active\n- 401: token has expired\n- 401: token has been revoked\n- 401: master is not active", "produces": [ "application/json" ], @@ -5157,6 +5423,23 @@ } } }, + "internal_api.CreateIPBanRequest": { + "type": "object", + "required": [ + "cidr" + ], + "properties": { + "cidr": { + "type": "string" + }, + "expires_at": { + "type": "integer" + }, + "reason": { + "type": "string" + } + } + }, "internal_api.CreateMasterRequest": { "type": "object", "required": [ @@ -5308,6 +5591,38 @@ } } }, + "internal_api.IPBanView": { + "type": "object", + "properties": { + "cidr": { + "type": "string" + }, + "created_at": { + "type": "integer" + }, + "created_by": { + "type": "string" + }, + "expires_at": { + "type": "integer" + }, + "hit_count": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "reason": { + "type": "string" + }, + "status": { + "type": "string" + }, + "updated_at": { + "type": "integer" + } + } + }, "internal_api.IssueChildKeyRequest": { "type": "object", "properties": { @@ -5932,42 +6247,36 @@ } } }, - "internal_api.TrafficBucket": { + "internal_api.TrafficChartAxis": { "type": "object", "properties": { - "breakdown": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/internal_api.TrafficMetrics" + "labels": { + "type": "array", + "items": { + "type": "string" } }, - "time": { - "type": "string" + "timestamps": { + "type": "array", + "items": { + "type": "integer" + } }, - "timestamp": { - "type": "integer" - }, - "total": { - "$ref": "#/definitions/internal_api.TrafficMetrics" + "totals": { + "$ref": "#/definitions/internal_api.TrafficTotals" } } }, "internal_api.TrafficChartResponse": { "type": "object", "properties": { - "buckets": { - "type": "array", - "items": { - "$ref": "#/definitions/internal_api.TrafficBucket" - } - }, "granularity": { "type": "string" }, - "models": { + "series": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/internal_api.TrafficSeries" } }, "since": { @@ -5975,20 +6284,58 @@ }, "until": { "type": "integer" + }, + "x": { + "$ref": "#/definitions/internal_api.TrafficChartAxis" } } }, - "internal_api.TrafficMetrics": { + "internal_api.TrafficSeries": { "type": "object", "properties": { - "count": { - "type": "integer" + "data": { + "type": "array", + "items": { + "type": "integer" + } + }, + "name": { + "type": "string" }, "tokens_in": { - "type": "integer" + "type": "array", + "items": { + "type": "integer" + } }, "tokens_out": { - "type": "integer" + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "internal_api.TrafficTotals": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "integer" + } + }, + "tokens_in": { + "type": "array", + "items": { + "type": "integer" + } + }, + "tokens_out": { + "type": "array", + "items": { + "type": "integer" + } } } }, @@ -6048,6 +6395,20 @@ } } }, + "internal_api.UpdateIPBanRequest": { + "type": "object", + "properties": { + "expires_at": { + "$ref": "#/definitions/internal_api.optionalInt64" + }, + "reason": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, "internal_api.UpdateMasterRequest": { "type": "object", "properties": { @@ -6109,9 +6470,49 @@ } } }, + "internal_api.WhoamiRealtimeView": { + "type": "object", + "properties": { + "qps": { + "description": "Current QPS", + "type": "integer", + "example": 5 + }, + "qps_limit": { + "description": "QPS limit", + "type": "integer", + "example": 100 + }, + "rate_limited": { + "description": "Whether currently rate limited", + "type": "boolean", + "example": false + }, + "requests": { + "description": "Total requests", + "type": "integer", + "example": 100 + }, + "tokens": { + "description": "Total tokens used", + "type": "integer", + "example": 50000 + }, + "updated_at": { + "description": "Last updated timestamp", + "type": "integer", + "example": 1703505600 + } + } + }, "internal_api.WhoamiResponse": { "type": "object", "properties": { + "allow_ips": { + "description": "IP whitelist (for diagnostics)", + "type": "string", + "example": "" + }, "created_at": { "type": "integer", "example": 1703505600 @@ -6120,10 +6521,20 @@ "type": "string", "example": "default" }, + "deny_ips": { + "description": "IP blacklist (for diagnostics)", + "type": "string", + "example": "" + }, "epoch": { "type": "integer", "example": 1 }, + "expires_at": { + "description": "Expiration timestamp (0 = never)", + "type": "integer", + "example": 0 + }, "global_qps": { "type": "integer", "example": 100 @@ -6145,15 +6556,35 @@ "type": "string", "example": "master" }, + "last_accessed_at": { + "description": "Last access timestamp", + "type": "integer", + "example": 0 + }, "master_id": { "description": "Key fields (only present when type is \"key\")", "type": "integer", "example": 1 }, + "master_name": { + "description": "Parent master name (for display)", + "type": "string", + "example": "tenant-a" + }, "max_child_keys": { "type": "integer", "example": 5 }, + "model_limits": { + "description": "Model restrictions", + "type": "string", + "example": "gpt-4,claude" + }, + "model_limits_enabled": { + "description": "Whether model limits are active", + "type": "boolean", + "example": false + }, "name": { "type": "string", "example": "tenant-a" @@ -6162,6 +6593,46 @@ "type": "string", "example": "default,ns1" }, + "permissions": { + "description": "Admin permissions (always [\"*\"])", + "type": "array", + "items": { + "type": "string" + } + }, + "quota_limit": { + "description": "Token quota limit (-1 = unlimited)", + "type": "integer", + "example": -1 + }, + "quota_reset_at": { + "description": "Quota reset timestamp", + "type": "integer", + "example": 0 + }, + "quota_reset_type": { + "description": "Quota reset type", + "type": "string", + "example": "monthly" + }, + "quota_used": { + "description": "Token quota used", + "type": "integer", + "example": 0 + }, + "realtime": { + "description": "Realtime stats (for master and key types)", + "allOf": [ + { + "$ref": "#/definitions/internal_api.WhoamiRealtimeView" + } + ] + }, + "request_count": { + "description": "Total request count (from DB)", + "type": "integer", + "example": 0 + }, "role": { "description": "Admin fields (only present when type is \"admin\")", "type": "string", @@ -6183,6 +6654,11 @@ "updated_at": { "type": "integer", "example": 1703505600 + }, + "used_tokens": { + "description": "Total tokens used (from DB)", + "type": "integer", + "example": 0 } } }, @@ -6211,6 +6687,18 @@ } } }, + "internal_api.optionalInt64": { + "type": "object", + "properties": { + "set": { + "type": "boolean" + }, + "value": { + "type": "integer", + "format": "int64" + } + } + }, "internal_api.refreshModelRegistryRequest": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 08a0550..83831d7 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -562,6 +562,17 @@ definitions: - title - type type: object + internal_api.CreateIPBanRequest: + properties: + cidr: + type: string + expires_at: + type: integer + reason: + type: string + required: + - cidr + type: object internal_api.CreateMasterRequest: properties: global_qps: @@ -661,6 +672,27 @@ definitions: $ref: '#/definitions/internal_api.GroupedStatsItem' type: array type: object + internal_api.IPBanView: + properties: + cidr: + type: string + created_at: + type: integer + created_by: + type: string + expires_at: + type: integer + hit_count: + type: integer + id: + type: integer + reason: + type: string + status: + type: string + updated_at: + type: integer + type: object internal_api.IssueChildKeyRequest: properties: allow_ips: @@ -1070,44 +1102,65 @@ definitions: tokens: type: integer type: object - internal_api.TrafficBucket: + internal_api.TrafficChartAxis: properties: - breakdown: - additionalProperties: - $ref: '#/definitions/internal_api.TrafficMetrics' - type: object - time: - type: string - timestamp: - type: integer - total: - $ref: '#/definitions/internal_api.TrafficMetrics' + labels: + items: + type: string + type: array + timestamps: + items: + type: integer + type: array + totals: + $ref: '#/definitions/internal_api.TrafficTotals' type: object internal_api.TrafficChartResponse: properties: - buckets: - items: - $ref: '#/definitions/internal_api.TrafficBucket' - type: array granularity: type: string - models: + series: items: - type: string + $ref: '#/definitions/internal_api.TrafficSeries' type: array since: type: integer until: type: integer + x: + $ref: '#/definitions/internal_api.TrafficChartAxis' type: object - internal_api.TrafficMetrics: + internal_api.TrafficSeries: properties: - count: - type: integer + data: + items: + type: integer + type: array + name: + type: string tokens_in: - type: integer + items: + type: integer + type: array tokens_out: - type: integer + items: + type: integer + type: array + type: object + internal_api.TrafficTotals: + properties: + data: + items: + type: integer + type: array + tokens_in: + items: + type: integer + type: array + tokens_out: + items: + type: integer + type: array type: object internal_api.TrendInfo: properties: @@ -1146,6 +1199,15 @@ definitions: min_tpm_tokens_1m: type: integer type: object + internal_api.UpdateIPBanRequest: + properties: + expires_at: + $ref: '#/definitions/internal_api.optionalInt64' + reason: + type: string + status: + type: string + type: object internal_api.UpdateMasterRequest: properties: global_qps: @@ -1186,17 +1248,56 @@ definitions: description: active/suspended type: string type: object + internal_api.WhoamiRealtimeView: + properties: + qps: + description: Current QPS + example: 5 + type: integer + qps_limit: + description: QPS limit + example: 100 + type: integer + rate_limited: + description: Whether currently rate limited + example: false + type: boolean + requests: + description: Total requests + example: 100 + type: integer + tokens: + description: Total tokens used + example: 50000 + type: integer + updated_at: + description: Last updated timestamp + example: 1703505600 + type: integer + type: object internal_api.WhoamiResponse: properties: + allow_ips: + description: IP whitelist (for diagnostics) + example: "" + type: string created_at: example: 1703505600 type: integer default_namespace: example: default type: string + deny_ips: + description: IP blacklist (for diagnostics) + example: "" + type: string epoch: example: 1 type: integer + expires_at: + description: Expiration timestamp (0 = never) + example: 0 + type: integer global_qps: example: 100 type: integer @@ -1213,19 +1314,64 @@ definitions: issued_by: example: master type: string + last_accessed_at: + description: Last access timestamp + example: 0 + type: integer master_id: description: Key fields (only present when type is "key") example: 1 type: integer + master_name: + description: Parent master name (for display) + example: tenant-a + type: string max_child_keys: example: 5 type: integer + model_limits: + description: Model restrictions + example: gpt-4,claude + type: string + model_limits_enabled: + description: Whether model limits are active + example: false + type: boolean name: example: tenant-a type: string namespaces: example: default,ns1 type: string + permissions: + description: Admin permissions (always ["*"]) + items: + type: string + type: array + quota_limit: + description: Token quota limit (-1 = unlimited) + example: -1 + type: integer + quota_reset_at: + description: Quota reset timestamp + example: 0 + type: integer + quota_reset_type: + description: Quota reset type + example: monthly + type: string + quota_used: + description: Token quota used + example: 0 + type: integer + realtime: + allOf: + - $ref: '#/definitions/internal_api.WhoamiRealtimeView' + description: Realtime stats (for master and key types) + request_count: + description: Total request count (from DB) + example: 0 + type: integer role: description: Admin fields (only present when type is "admin") example: admin @@ -1243,6 +1389,10 @@ definitions: updated_at: example: 1703505600 type: integer + used_tokens: + description: Total tokens used (from DB) + example: 0 + type: integer type: object internal_api.apiKeyStatsFlushEntry: properties: @@ -1260,6 +1410,14 @@ definitions: $ref: '#/definitions/internal_api.apiKeyStatsFlushEntry' type: array type: object + internal_api.optionalInt64: + properties: + set: + type: boolean + value: + format: int64 + type: integer + type: object internal_api.refreshModelRegistryRequest: properties: ref: @@ -2197,6 +2355,177 @@ paths: summary: Update feature flags tags: - admin + /admin/ip-bans: + get: + consumes: + - application/json + description: List all global IP/CIDR ban rules + parameters: + - description: Filter by status (active, expired) + in: query + name: status + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/internal_api.IPBanView' + type: array + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/gin.H' + security: + - AdminAuth: [] + summary: List IP bans + tags: + - admin + - ip-bans + post: + consumes: + - application/json + description: Create a new global IP/CIDR ban rule + parameters: + - description: IP Ban Info + in: body + name: ban + required: true + schema: + $ref: '#/definitions/internal_api.CreateIPBanRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/internal_api.IPBanView' + "400": + description: Bad Request + schema: + $ref: '#/definitions/gin.H' + "409": + description: Conflict + schema: + $ref: '#/definitions/gin.H' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/gin.H' + security: + - AdminAuth: [] + summary: Create an IP ban + tags: + - admin + - ip-bans + /admin/ip-bans/{id}: + delete: + consumes: + - application/json + description: Delete a global IP/CIDR ban rule + parameters: + - description: IP Ban ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "204": + description: No Content + "404": + description: Not Found + schema: + $ref: '#/definitions/gin.H' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/gin.H' + security: + - AdminAuth: [] + summary: Delete an IP ban + tags: + - admin + - ip-bans + get: + consumes: + - application/json + description: Get a single global IP/CIDR ban rule by ID + parameters: + - description: IP Ban ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_api.IPBanView' + "404": + description: Not Found + schema: + $ref: '#/definitions/gin.H' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/gin.H' + security: + - AdminAuth: [] + summary: Get an IP ban + tags: + - admin + - ip-bans + put: + consumes: + - application/json + description: Update a global IP/CIDR ban rule + parameters: + - description: IP Ban ID + in: path + name: id + required: true + type: integer + - description: IP Ban Update + in: body + name: ban + required: true + schema: + $ref: '#/definitions/internal_api.UpdateIPBanRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_api.IPBanView' + "400": + description: Bad Request + schema: + $ref: '#/definitions/gin.H' + "404": + description: Not Found + schema: + $ref: '#/definitions/gin.H' + "409": + description: Conflict + schema: + $ref: '#/definitions/gin.H' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/gin.H' + security: + - AdminAuth: [] + summary: Update an IP ban + tags: + - admin + - ip-bans /admin/keys/{id}/access: get: description: Returns key default_namespace and namespaces @@ -2403,7 +2732,8 @@ paths: /admin/logs/stats/traffic-chart: get: description: Get time × model aggregated data for stacked traffic charts. Returns - time buckets with per-model breakdown. + a shared time axis under `x` and per-model series arrays aligned to that axis. + Models outside top_n are aggregated under the series name "other". parameters: - description: 'Time granularity: hour (default) or minute' enum: @@ -3615,13 +3945,41 @@ paths: /auth/whoami: get: description: |- - Returns the identity of the authenticated user based on the Authorization header. + Returns complete identity and realtime statistics of the authenticated user. Supports Admin Token, Master Key, and Child Key (API Key) authentication. + This endpoint is designed for frontend initialization - call once after login + and store the response for subsequent use. - Response varies by token type: - - Admin Token: {"type": "admin", "role": "admin"} - - Master Key: {"type": "master", "id": 1, "name": "...", ...} - - Child Key: {"type": "key", "id": 5, "master_id": 1, "issued_by": "master", ...} + **Response varies by token type:** + + **Admin Token:** + - type: "admin" + - role: "admin" + - permissions: ["*"] (full access) + + **Master Key:** + - type: "master" + - Basic info: id, name, group, namespaces, status, epoch, max_child_keys, global_qps + - Timestamps: created_at, updated_at + - Realtime stats: requests, tokens, qps, qps_limit, rate_limited + + **Child Key (API Key):** + - type: "key" + - Basic info: id, master_id, master_name, group, scopes, namespaces, status + - Security: issued_at_epoch, issued_by, allow_ips, deny_ips, expires_at + - Model limits: model_limits, model_limits_enabled + - Quota: quota_limit, quota_used, quota_reset_at, quota_reset_type + - Usage stats: request_count, used_tokens, last_accessed_at + - Realtime stats: requests, tokens, qps, qps_limit, rate_limited + + **Error responses:** + - 401: authorization header required + - 401: invalid authorization header format + - 401: invalid token + - 401: token is not active + - 401: token has expired + - 401: token has been revoked + - 401: master is not active produces: - application/json responses: diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index b8f4071..e5df9f2 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -1183,7 +1183,7 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "description": "time period: today, week, month, all", + "description": "time period: today, week, month, last7d, last30d, all", "name": "period", "in": "query" }, @@ -1198,6 +1198,12 @@ const docTemplate = `{ "description": "unix seconds", "name": "until", "in": "query" + }, + { + "type": "boolean", + "description": "include trend data comparing to previous period", + "name": "include_trends", + "in": "query" } ], "responses": { @@ -1302,6 +1308,272 @@ const docTemplate = `{ } } }, + "/admin/ip-bans": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "List all global IP/CIDR ban rules", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin", + "ip-bans" + ], + "summary": "List IP bans", + "parameters": [ + { + "type": "string", + "description": "Filter by status (active, expired)", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_api.IPBanView" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + }, + "post": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "Create a new global IP/CIDR ban rule", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin", + "ip-bans" + ], + "summary": "Create an IP ban", + "parameters": [ + { + "description": "IP Ban Info", + "name": "ban", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_api.CreateIPBanRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/internal_api.IPBanView" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + } + }, + "/admin/ip-bans/{id}": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "Get a single global IP/CIDR ban rule by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin", + "ip-bans" + ], + "summary": "Get an IP ban", + "parameters": [ + { + "type": "integer", + "description": "IP Ban ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.IPBanView" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + }, + "put": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "Update a global IP/CIDR ban rule", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin", + "ip-bans" + ], + "summary": "Update an IP ban", + "parameters": [ + { + "type": "integer", + "description": "IP Ban ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "IP Ban Update", + "name": "ban", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_api.UpdateIPBanRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.IPBanView" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + }, + "delete": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "Delete a global IP/CIDR ban rule", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin", + "ip-bans" + ], + "summary": "Delete an IP ban", + "parameters": [ + { + "type": "integer", + "description": "IP Ban ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + } + }, "/admin/keys/{id}/access": { "get": { "security": [ @@ -1617,7 +1889,7 @@ const docTemplate = `{ "AdminAuth": [] } ], - "description": "Get time × model aggregated data for stacked traffic charts. Returns time buckets with per-model breakdown.", + "description": "Get time × model aggregated data for stacked traffic charts. Returns a shared time axis under ` + "`" + `x` + "`" + ` and per-model series arrays aligned to that axis. Models outside top_n are aggregated under the series name \"other\".", "produces": [ "application/json" ], @@ -3522,7 +3794,7 @@ const docTemplate = `{ "MasterAuth": [] } ], - "description": "Returns the identity of the authenticated user based on the Authorization header.\nSupports Admin Token, Master Key, and Child Key (API Key) authentication.\n\nResponse varies by token type:\n- Admin Token: {\"type\": \"admin\", \"role\": \"admin\"}\n- Master Key: {\"type\": \"master\", \"id\": 1, \"name\": \"...\", ...}\n- Child Key: {\"type\": \"key\", \"id\": 5, \"master_id\": 1, \"issued_by\": \"master\", ...}", + "description": "Returns complete identity and realtime statistics of the authenticated user.\nSupports Admin Token, Master Key, and Child Key (API Key) authentication.\nThis endpoint is designed for frontend initialization - call once after login\nand store the response for subsequent use.\n\n**Response varies by token type:**\n\n**Admin Token:**\n- type: \"admin\"\n- role: \"admin\"\n- permissions: [\"*\"] (full access)\n\n**Master Key:**\n- type: \"master\"\n- Basic info: id, name, group, namespaces, status, epoch, max_child_keys, global_qps\n- Timestamps: created_at, updated_at\n- Realtime stats: requests, tokens, qps, qps_limit, rate_limited\n\n**Child Key (API Key):**\n- type: \"key\"\n- Basic info: id, master_id, master_name, group, scopes, namespaces, status\n- Security: issued_at_epoch, issued_by, allow_ips, deny_ips, expires_at\n- Model limits: model_limits, model_limits_enabled\n- Quota: quota_limit, quota_used, quota_reset_at, quota_reset_type\n- Usage stats: request_count, used_tokens, last_accessed_at\n- Realtime stats: requests, tokens, qps, qps_limit, rate_limited\n\n**Error responses:**\n- 401: authorization header required\n- 401: invalid authorization header format\n- 401: invalid token\n- 401: token is not active\n- 401: token has expired\n- 401: token has been revoked\n- 401: master is not active", "produces": [ "application/json" ], @@ -5157,6 +5429,23 @@ const docTemplate = `{ } } }, + "internal_api.CreateIPBanRequest": { + "type": "object", + "required": [ + "cidr" + ], + "properties": { + "cidr": { + "type": "string" + }, + "expires_at": { + "type": "integer" + }, + "reason": { + "type": "string" + } + } + }, "internal_api.CreateMasterRequest": { "type": "object", "required": [ @@ -5208,11 +5497,36 @@ const docTemplate = `{ "$ref": "#/definitions/internal_api.TopModelStat" } }, + "trends": { + "description": "Only present when include_trends=true", + "allOf": [ + { + "$ref": "#/definitions/internal_api.DashboardTrends" + } + ] + }, "updated_at": { "type": "integer" } } }, + "internal_api.DashboardTrends": { + "type": "object", + "properties": { + "error_rate": { + "$ref": "#/definitions/internal_api.TrendInfo" + }, + "latency": { + "$ref": "#/definitions/internal_api.TrendInfo" + }, + "requests": { + "$ref": "#/definitions/internal_api.TrendInfo" + }, + "tokens": { + "$ref": "#/definitions/internal_api.TrendInfo" + } + } + }, "internal_api.DeleteLogsRequest": { "type": "object", "properties": { @@ -5283,6 +5597,38 @@ const docTemplate = `{ } } }, + "internal_api.IPBanView": { + "type": "object", + "properties": { + "cidr": { + "type": "string" + }, + "created_at": { + "type": "integer" + }, + "created_by": { + "type": "string" + }, + "expires_at": { + "type": "integer" + }, + "hit_count": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "reason": { + "type": "string" + }, + "status": { + "type": "string" + }, + "updated_at": { + "type": "integer" + } + } + }, "internal_api.IssueChildKeyRequest": { "type": "object", "properties": { @@ -5907,42 +6253,36 @@ const docTemplate = `{ } } }, - "internal_api.TrafficBucket": { + "internal_api.TrafficChartAxis": { "type": "object", "properties": { - "breakdown": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/internal_api.TrafficMetrics" + "labels": { + "type": "array", + "items": { + "type": "string" } }, - "time": { - "type": "string" + "timestamps": { + "type": "array", + "items": { + "type": "integer" + } }, - "timestamp": { - "type": "integer" - }, - "total": { - "$ref": "#/definitions/internal_api.TrafficMetrics" + "totals": { + "$ref": "#/definitions/internal_api.TrafficTotals" } } }, "internal_api.TrafficChartResponse": { "type": "object", "properties": { - "buckets": { - "type": "array", - "items": { - "$ref": "#/definitions/internal_api.TrafficBucket" - } - }, "granularity": { "type": "string" }, - "models": { + "series": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/internal_api.TrafficSeries" } }, "since": { @@ -5950,20 +6290,71 @@ const docTemplate = `{ }, "until": { "type": "integer" + }, + "x": { + "$ref": "#/definitions/internal_api.TrafficChartAxis" } } }, - "internal_api.TrafficMetrics": { + "internal_api.TrafficSeries": { "type": "object", "properties": { - "count": { - "type": "integer" + "data": { + "type": "array", + "items": { + "type": "integer" + } + }, + "name": { + "type": "string" }, "tokens_in": { - "type": "integer" + "type": "array", + "items": { + "type": "integer" + } }, "tokens_out": { - "type": "integer" + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "internal_api.TrafficTotals": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "integer" + } + }, + "tokens_in": { + "type": "array", + "items": { + "type": "integer" + } + }, + "tokens_out": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "internal_api.TrendInfo": { + "type": "object", + "properties": { + "delta": { + "description": "Percentage change from previous period (nil if no baseline)", + "type": "number" + }, + "direction": { + "description": "\"up\", \"down\", \"stable\", or \"new\" (no baseline)", + "type": "string" } } }, @@ -6010,6 +6401,20 @@ const docTemplate = `{ } } }, + "internal_api.UpdateIPBanRequest": { + "type": "object", + "properties": { + "expires_at": { + "$ref": "#/definitions/internal_api.optionalInt64" + }, + "reason": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, "internal_api.UpdateMasterRequest": { "type": "object", "properties": { @@ -6071,9 +6476,49 @@ const docTemplate = `{ } } }, + "internal_api.WhoamiRealtimeView": { + "type": "object", + "properties": { + "qps": { + "description": "Current QPS", + "type": "integer", + "example": 5 + }, + "qps_limit": { + "description": "QPS limit", + "type": "integer", + "example": 100 + }, + "rate_limited": { + "description": "Whether currently rate limited", + "type": "boolean", + "example": false + }, + "requests": { + "description": "Total requests", + "type": "integer", + "example": 100 + }, + "tokens": { + "description": "Total tokens used", + "type": "integer", + "example": 50000 + }, + "updated_at": { + "description": "Last updated timestamp", + "type": "integer", + "example": 1703505600 + } + } + }, "internal_api.WhoamiResponse": { "type": "object", "properties": { + "allow_ips": { + "description": "IP whitelist (for diagnostics)", + "type": "string", + "example": "" + }, "created_at": { "type": "integer", "example": 1703505600 @@ -6082,10 +6527,20 @@ const docTemplate = `{ "type": "string", "example": "default" }, + "deny_ips": { + "description": "IP blacklist (for diagnostics)", + "type": "string", + "example": "" + }, "epoch": { "type": "integer", "example": 1 }, + "expires_at": { + "description": "Expiration timestamp (0 = never)", + "type": "integer", + "example": 0 + }, "global_qps": { "type": "integer", "example": 100 @@ -6107,15 +6562,35 @@ const docTemplate = `{ "type": "string", "example": "master" }, + "last_accessed_at": { + "description": "Last access timestamp", + "type": "integer", + "example": 0 + }, "master_id": { "description": "Key fields (only present when type is \"key\")", "type": "integer", "example": 1 }, + "master_name": { + "description": "Parent master name (for display)", + "type": "string", + "example": "tenant-a" + }, "max_child_keys": { "type": "integer", "example": 5 }, + "model_limits": { + "description": "Model restrictions", + "type": "string", + "example": "gpt-4,claude" + }, + "model_limits_enabled": { + "description": "Whether model limits are active", + "type": "boolean", + "example": false + }, "name": { "type": "string", "example": "tenant-a" @@ -6124,6 +6599,46 @@ const docTemplate = `{ "type": "string", "example": "default,ns1" }, + "permissions": { + "description": "Admin permissions (always [\"*\"])", + "type": "array", + "items": { + "type": "string" + } + }, + "quota_limit": { + "description": "Token quota limit (-1 = unlimited)", + "type": "integer", + "example": -1 + }, + "quota_reset_at": { + "description": "Quota reset timestamp", + "type": "integer", + "example": 0 + }, + "quota_reset_type": { + "description": "Quota reset type", + "type": "string", + "example": "monthly" + }, + "quota_used": { + "description": "Token quota used", + "type": "integer", + "example": 0 + }, + "realtime": { + "description": "Realtime stats (for master and key types)", + "allOf": [ + { + "$ref": "#/definitions/internal_api.WhoamiRealtimeView" + } + ] + }, + "request_count": { + "description": "Total request count (from DB)", + "type": "integer", + "example": 0 + }, "role": { "description": "Admin fields (only present when type is \"admin\")", "type": "string", @@ -6145,6 +6660,11 @@ const docTemplate = `{ "updated_at": { "type": "integer", "example": 1703505600 + }, + "used_tokens": { + "description": "Total tokens used (from DB)", + "type": "integer", + "example": 0 } } }, @@ -6173,6 +6693,18 @@ const docTemplate = `{ } } }, + "internal_api.optionalInt64": { + "type": "object", + "properties": { + "set": { + "type": "boolean" + }, + "value": { + "type": "integer", + "format": "int64" + } + } + }, "internal_api.refreshModelRegistryRequest": { "type": "object", "properties": { diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index a20aab1..1e61947 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -1177,7 +1177,7 @@ "parameters": [ { "type": "string", - "description": "time period: today, week, month, all", + "description": "time period: today, week, month, last7d, last30d, all", "name": "period", "in": "query" }, @@ -1192,6 +1192,12 @@ "description": "unix seconds", "name": "until", "in": "query" + }, + { + "type": "boolean", + "description": "include trend data comparing to previous period", + "name": "include_trends", + "in": "query" } ], "responses": { @@ -1296,6 +1302,272 @@ } } }, + "/admin/ip-bans": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "List all global IP/CIDR ban rules", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin", + "ip-bans" + ], + "summary": "List IP bans", + "parameters": [ + { + "type": "string", + "description": "Filter by status (active, expired)", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_api.IPBanView" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + }, + "post": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "Create a new global IP/CIDR ban rule", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin", + "ip-bans" + ], + "summary": "Create an IP ban", + "parameters": [ + { + "description": "IP Ban Info", + "name": "ban", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_api.CreateIPBanRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/internal_api.IPBanView" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + } + }, + "/admin/ip-bans/{id}": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "Get a single global IP/CIDR ban rule by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin", + "ip-bans" + ], + "summary": "Get an IP ban", + "parameters": [ + { + "type": "integer", + "description": "IP Ban ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.IPBanView" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + }, + "put": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "Update a global IP/CIDR ban rule", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin", + "ip-bans" + ], + "summary": "Update an IP ban", + "parameters": [ + { + "type": "integer", + "description": "IP Ban ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "IP Ban Update", + "name": "ban", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_api.UpdateIPBanRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.IPBanView" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + }, + "delete": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "Delete a global IP/CIDR ban rule", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin", + "ip-bans" + ], + "summary": "Delete an IP ban", + "parameters": [ + { + "type": "integer", + "description": "IP Ban ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + } + } + }, "/admin/keys/{id}/access": { "get": { "security": [ @@ -1611,7 +1883,7 @@ "AdminAuth": [] } ], - "description": "Get time × model aggregated data for stacked traffic charts. Returns time buckets with per-model breakdown.", + "description": "Get time × model aggregated data for stacked traffic charts. Returns a shared time axis under `x` and per-model series arrays aligned to that axis. Models outside top_n are aggregated under the series name \"other\".", "produces": [ "application/json" ], @@ -3516,7 +3788,7 @@ "MasterAuth": [] } ], - "description": "Returns the identity of the authenticated user based on the Authorization header.\nSupports Admin Token, Master Key, and Child Key (API Key) authentication.\n\nResponse varies by token type:\n- Admin Token: {\"type\": \"admin\", \"role\": \"admin\"}\n- Master Key: {\"type\": \"master\", \"id\": 1, \"name\": \"...\", ...}\n- Child Key: {\"type\": \"key\", \"id\": 5, \"master_id\": 1, \"issued_by\": \"master\", ...}", + "description": "Returns complete identity and realtime statistics of the authenticated user.\nSupports Admin Token, Master Key, and Child Key (API Key) authentication.\nThis endpoint is designed for frontend initialization - call once after login\nand store the response for subsequent use.\n\n**Response varies by token type:**\n\n**Admin Token:**\n- type: \"admin\"\n- role: \"admin\"\n- permissions: [\"*\"] (full access)\n\n**Master Key:**\n- type: \"master\"\n- Basic info: id, name, group, namespaces, status, epoch, max_child_keys, global_qps\n- Timestamps: created_at, updated_at\n- Realtime stats: requests, tokens, qps, qps_limit, rate_limited\n\n**Child Key (API Key):**\n- type: \"key\"\n- Basic info: id, master_id, master_name, group, scopes, namespaces, status\n- Security: issued_at_epoch, issued_by, allow_ips, deny_ips, expires_at\n- Model limits: model_limits, model_limits_enabled\n- Quota: quota_limit, quota_used, quota_reset_at, quota_reset_type\n- Usage stats: request_count, used_tokens, last_accessed_at\n- Realtime stats: requests, tokens, qps, qps_limit, rate_limited\n\n**Error responses:**\n- 401: authorization header required\n- 401: invalid authorization header format\n- 401: invalid token\n- 401: token is not active\n- 401: token has expired\n- 401: token has been revoked\n- 401: master is not active", "produces": [ "application/json" ], @@ -5151,6 +5423,23 @@ } } }, + "internal_api.CreateIPBanRequest": { + "type": "object", + "required": [ + "cidr" + ], + "properties": { + "cidr": { + "type": "string" + }, + "expires_at": { + "type": "integer" + }, + "reason": { + "type": "string" + } + } + }, "internal_api.CreateMasterRequest": { "type": "object", "required": [ @@ -5202,11 +5491,36 @@ "$ref": "#/definitions/internal_api.TopModelStat" } }, + "trends": { + "description": "Only present when include_trends=true", + "allOf": [ + { + "$ref": "#/definitions/internal_api.DashboardTrends" + } + ] + }, "updated_at": { "type": "integer" } } }, + "internal_api.DashboardTrends": { + "type": "object", + "properties": { + "error_rate": { + "$ref": "#/definitions/internal_api.TrendInfo" + }, + "latency": { + "$ref": "#/definitions/internal_api.TrendInfo" + }, + "requests": { + "$ref": "#/definitions/internal_api.TrendInfo" + }, + "tokens": { + "$ref": "#/definitions/internal_api.TrendInfo" + } + } + }, "internal_api.DeleteLogsRequest": { "type": "object", "properties": { @@ -5277,6 +5591,38 @@ } } }, + "internal_api.IPBanView": { + "type": "object", + "properties": { + "cidr": { + "type": "string" + }, + "created_at": { + "type": "integer" + }, + "created_by": { + "type": "string" + }, + "expires_at": { + "type": "integer" + }, + "hit_count": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "reason": { + "type": "string" + }, + "status": { + "type": "string" + }, + "updated_at": { + "type": "integer" + } + } + }, "internal_api.IssueChildKeyRequest": { "type": "object", "properties": { @@ -5901,42 +6247,36 @@ } } }, - "internal_api.TrafficBucket": { + "internal_api.TrafficChartAxis": { "type": "object", "properties": { - "breakdown": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/internal_api.TrafficMetrics" + "labels": { + "type": "array", + "items": { + "type": "string" } }, - "time": { - "type": "string" + "timestamps": { + "type": "array", + "items": { + "type": "integer" + } }, - "timestamp": { - "type": "integer" - }, - "total": { - "$ref": "#/definitions/internal_api.TrafficMetrics" + "totals": { + "$ref": "#/definitions/internal_api.TrafficTotals" } } }, "internal_api.TrafficChartResponse": { "type": "object", "properties": { - "buckets": { - "type": "array", - "items": { - "$ref": "#/definitions/internal_api.TrafficBucket" - } - }, "granularity": { "type": "string" }, - "models": { + "series": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/internal_api.TrafficSeries" } }, "since": { @@ -5944,20 +6284,71 @@ }, "until": { "type": "integer" + }, + "x": { + "$ref": "#/definitions/internal_api.TrafficChartAxis" } } }, - "internal_api.TrafficMetrics": { + "internal_api.TrafficSeries": { "type": "object", "properties": { - "count": { - "type": "integer" + "data": { + "type": "array", + "items": { + "type": "integer" + } + }, + "name": { + "type": "string" }, "tokens_in": { - "type": "integer" + "type": "array", + "items": { + "type": "integer" + } }, "tokens_out": { - "type": "integer" + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "internal_api.TrafficTotals": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "integer" + } + }, + "tokens_in": { + "type": "array", + "items": { + "type": "integer" + } + }, + "tokens_out": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "internal_api.TrendInfo": { + "type": "object", + "properties": { + "delta": { + "description": "Percentage change from previous period (nil if no baseline)", + "type": "number" + }, + "direction": { + "description": "\"up\", \"down\", \"stable\", or \"new\" (no baseline)", + "type": "string" } } }, @@ -6004,6 +6395,20 @@ } } }, + "internal_api.UpdateIPBanRequest": { + "type": "object", + "properties": { + "expires_at": { + "$ref": "#/definitions/internal_api.optionalInt64" + }, + "reason": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, "internal_api.UpdateMasterRequest": { "type": "object", "properties": { @@ -6065,9 +6470,49 @@ } } }, + "internal_api.WhoamiRealtimeView": { + "type": "object", + "properties": { + "qps": { + "description": "Current QPS", + "type": "integer", + "example": 5 + }, + "qps_limit": { + "description": "QPS limit", + "type": "integer", + "example": 100 + }, + "rate_limited": { + "description": "Whether currently rate limited", + "type": "boolean", + "example": false + }, + "requests": { + "description": "Total requests", + "type": "integer", + "example": 100 + }, + "tokens": { + "description": "Total tokens used", + "type": "integer", + "example": 50000 + }, + "updated_at": { + "description": "Last updated timestamp", + "type": "integer", + "example": 1703505600 + } + } + }, "internal_api.WhoamiResponse": { "type": "object", "properties": { + "allow_ips": { + "description": "IP whitelist (for diagnostics)", + "type": "string", + "example": "" + }, "created_at": { "type": "integer", "example": 1703505600 @@ -6076,10 +6521,20 @@ "type": "string", "example": "default" }, + "deny_ips": { + "description": "IP blacklist (for diagnostics)", + "type": "string", + "example": "" + }, "epoch": { "type": "integer", "example": 1 }, + "expires_at": { + "description": "Expiration timestamp (0 = never)", + "type": "integer", + "example": 0 + }, "global_qps": { "type": "integer", "example": 100 @@ -6101,15 +6556,35 @@ "type": "string", "example": "master" }, + "last_accessed_at": { + "description": "Last access timestamp", + "type": "integer", + "example": 0 + }, "master_id": { "description": "Key fields (only present when type is \"key\")", "type": "integer", "example": 1 }, + "master_name": { + "description": "Parent master name (for display)", + "type": "string", + "example": "tenant-a" + }, "max_child_keys": { "type": "integer", "example": 5 }, + "model_limits": { + "description": "Model restrictions", + "type": "string", + "example": "gpt-4,claude" + }, + "model_limits_enabled": { + "description": "Whether model limits are active", + "type": "boolean", + "example": false + }, "name": { "type": "string", "example": "tenant-a" @@ -6118,6 +6593,46 @@ "type": "string", "example": "default,ns1" }, + "permissions": { + "description": "Admin permissions (always [\"*\"])", + "type": "array", + "items": { + "type": "string" + } + }, + "quota_limit": { + "description": "Token quota limit (-1 = unlimited)", + "type": "integer", + "example": -1 + }, + "quota_reset_at": { + "description": "Quota reset timestamp", + "type": "integer", + "example": 0 + }, + "quota_reset_type": { + "description": "Quota reset type", + "type": "string", + "example": "monthly" + }, + "quota_used": { + "description": "Token quota used", + "type": "integer", + "example": 0 + }, + "realtime": { + "description": "Realtime stats (for master and key types)", + "allOf": [ + { + "$ref": "#/definitions/internal_api.WhoamiRealtimeView" + } + ] + }, + "request_count": { + "description": "Total request count (from DB)", + "type": "integer", + "example": 0 + }, "role": { "description": "Admin fields (only present when type is \"admin\")", "type": "string", @@ -6139,6 +6654,11 @@ "updated_at": { "type": "integer", "example": 1703505600 + }, + "used_tokens": { + "description": "Total tokens used (from DB)", + "type": "integer", + "example": 0 } } }, @@ -6167,6 +6687,18 @@ } } }, + "internal_api.optionalInt64": { + "type": "object", + "properties": { + "set": { + "type": "boolean" + }, + "value": { + "type": "integer", + "format": "int64" + } + } + }, "internal_api.refreshModelRegistryRequest": { "type": "object", "properties": { diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index 349413b..83831d7 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -562,6 +562,17 @@ definitions: - title - type type: object + internal_api.CreateIPBanRequest: + properties: + cidr: + type: string + expires_at: + type: integer + reason: + type: string + required: + - cidr + type: object internal_api.CreateMasterRequest: properties: global_qps: @@ -596,9 +607,24 @@ definitions: items: $ref: '#/definitions/internal_api.TopModelStat' type: array + trends: + allOf: + - $ref: '#/definitions/internal_api.DashboardTrends' + description: Only present when include_trends=true updated_at: type: integer type: object + internal_api.DashboardTrends: + properties: + error_rate: + $ref: '#/definitions/internal_api.TrendInfo' + latency: + $ref: '#/definitions/internal_api.TrendInfo' + requests: + $ref: '#/definitions/internal_api.TrendInfo' + tokens: + $ref: '#/definitions/internal_api.TrendInfo' + type: object internal_api.DeleteLogsRequest: properties: before: @@ -646,6 +672,27 @@ definitions: $ref: '#/definitions/internal_api.GroupedStatsItem' type: array type: object + internal_api.IPBanView: + properties: + cidr: + type: string + created_at: + type: integer + created_by: + type: string + expires_at: + type: integer + hit_count: + type: integer + id: + type: integer + reason: + type: string + status: + type: string + updated_at: + type: integer + type: object internal_api.IssueChildKeyRequest: properties: allow_ips: @@ -1055,44 +1102,74 @@ definitions: tokens: type: integer type: object - internal_api.TrafficBucket: + internal_api.TrafficChartAxis: properties: - breakdown: - additionalProperties: - $ref: '#/definitions/internal_api.TrafficMetrics' - type: object - time: - type: string - timestamp: - type: integer - total: - $ref: '#/definitions/internal_api.TrafficMetrics' + labels: + items: + type: string + type: array + timestamps: + items: + type: integer + type: array + totals: + $ref: '#/definitions/internal_api.TrafficTotals' type: object internal_api.TrafficChartResponse: properties: - buckets: - items: - $ref: '#/definitions/internal_api.TrafficBucket' - type: array granularity: type: string - models: + series: items: - type: string + $ref: '#/definitions/internal_api.TrafficSeries' type: array since: type: integer until: type: integer + x: + $ref: '#/definitions/internal_api.TrafficChartAxis' type: object - internal_api.TrafficMetrics: + internal_api.TrafficSeries: properties: - count: - type: integer + data: + items: + type: integer + type: array + name: + type: string tokens_in: - type: integer + items: + type: integer + type: array tokens_out: - type: integer + items: + type: integer + type: array + type: object + internal_api.TrafficTotals: + properties: + data: + items: + type: integer + type: array + tokens_in: + items: + type: integer + type: array + tokens_out: + items: + type: integer + type: array + type: object + internal_api.TrendInfo: + properties: + delta: + description: Percentage change from previous period (nil if no baseline) + type: number + direction: + description: '"up", "down", "stable", or "new" (no baseline)' + type: string type: object internal_api.UpdateAccessRequest: properties: @@ -1122,6 +1199,15 @@ definitions: min_tpm_tokens_1m: type: integer type: object + internal_api.UpdateIPBanRequest: + properties: + expires_at: + $ref: '#/definitions/internal_api.optionalInt64' + reason: + type: string + status: + type: string + type: object internal_api.UpdateMasterRequest: properties: global_qps: @@ -1162,17 +1248,56 @@ definitions: description: active/suspended type: string type: object + internal_api.WhoamiRealtimeView: + properties: + qps: + description: Current QPS + example: 5 + type: integer + qps_limit: + description: QPS limit + example: 100 + type: integer + rate_limited: + description: Whether currently rate limited + example: false + type: boolean + requests: + description: Total requests + example: 100 + type: integer + tokens: + description: Total tokens used + example: 50000 + type: integer + updated_at: + description: Last updated timestamp + example: 1703505600 + type: integer + type: object internal_api.WhoamiResponse: properties: + allow_ips: + description: IP whitelist (for diagnostics) + example: "" + type: string created_at: example: 1703505600 type: integer default_namespace: example: default type: string + deny_ips: + description: IP blacklist (for diagnostics) + example: "" + type: string epoch: example: 1 type: integer + expires_at: + description: Expiration timestamp (0 = never) + example: 0 + type: integer global_qps: example: 100 type: integer @@ -1189,19 +1314,64 @@ definitions: issued_by: example: master type: string + last_accessed_at: + description: Last access timestamp + example: 0 + type: integer master_id: description: Key fields (only present when type is "key") example: 1 type: integer + master_name: + description: Parent master name (for display) + example: tenant-a + type: string max_child_keys: example: 5 type: integer + model_limits: + description: Model restrictions + example: gpt-4,claude + type: string + model_limits_enabled: + description: Whether model limits are active + example: false + type: boolean name: example: tenant-a type: string namespaces: example: default,ns1 type: string + permissions: + description: Admin permissions (always ["*"]) + items: + type: string + type: array + quota_limit: + description: Token quota limit (-1 = unlimited) + example: -1 + type: integer + quota_reset_at: + description: Quota reset timestamp + example: 0 + type: integer + quota_reset_type: + description: Quota reset type + example: monthly + type: string + quota_used: + description: Token quota used + example: 0 + type: integer + realtime: + allOf: + - $ref: '#/definitions/internal_api.WhoamiRealtimeView' + description: Realtime stats (for master and key types) + request_count: + description: Total request count (from DB) + example: 0 + type: integer role: description: Admin fields (only present when type is "admin") example: admin @@ -1219,6 +1389,10 @@ definitions: updated_at: example: 1703505600 type: integer + used_tokens: + description: Total tokens used (from DB) + example: 0 + type: integer type: object internal_api.apiKeyStatsFlushEntry: properties: @@ -1236,6 +1410,14 @@ definitions: $ref: '#/definitions/internal_api.apiKeyStatsFlushEntry' type: array type: object + internal_api.optionalInt64: + properties: + set: + type: boolean + value: + format: int64 + type: integer + type: object internal_api.refreshModelRegistryRequest: properties: ref: @@ -2086,7 +2268,7 @@ paths: 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' + - description: 'time period: today, week, month, last7d, last30d, all' in: query name: period type: string @@ -2098,6 +2280,10 @@ paths: in: query name: until type: integer + - description: include trend data comparing to previous period + in: query + name: include_trends + type: boolean produces: - application/json responses: @@ -2169,6 +2355,177 @@ paths: summary: Update feature flags tags: - admin + /admin/ip-bans: + get: + consumes: + - application/json + description: List all global IP/CIDR ban rules + parameters: + - description: Filter by status (active, expired) + in: query + name: status + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/internal_api.IPBanView' + type: array + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/gin.H' + security: + - AdminAuth: [] + summary: List IP bans + tags: + - admin + - ip-bans + post: + consumes: + - application/json + description: Create a new global IP/CIDR ban rule + parameters: + - description: IP Ban Info + in: body + name: ban + required: true + schema: + $ref: '#/definitions/internal_api.CreateIPBanRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/internal_api.IPBanView' + "400": + description: Bad Request + schema: + $ref: '#/definitions/gin.H' + "409": + description: Conflict + schema: + $ref: '#/definitions/gin.H' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/gin.H' + security: + - AdminAuth: [] + summary: Create an IP ban + tags: + - admin + - ip-bans + /admin/ip-bans/{id}: + delete: + consumes: + - application/json + description: Delete a global IP/CIDR ban rule + parameters: + - description: IP Ban ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "204": + description: No Content + "404": + description: Not Found + schema: + $ref: '#/definitions/gin.H' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/gin.H' + security: + - AdminAuth: [] + summary: Delete an IP ban + tags: + - admin + - ip-bans + get: + consumes: + - application/json + description: Get a single global IP/CIDR ban rule by ID + parameters: + - description: IP Ban ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_api.IPBanView' + "404": + description: Not Found + schema: + $ref: '#/definitions/gin.H' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/gin.H' + security: + - AdminAuth: [] + summary: Get an IP ban + tags: + - admin + - ip-bans + put: + consumes: + - application/json + description: Update a global IP/CIDR ban rule + parameters: + - description: IP Ban ID + in: path + name: id + required: true + type: integer + - description: IP Ban Update + in: body + name: ban + required: true + schema: + $ref: '#/definitions/internal_api.UpdateIPBanRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_api.IPBanView' + "400": + description: Bad Request + schema: + $ref: '#/definitions/gin.H' + "404": + description: Not Found + schema: + $ref: '#/definitions/gin.H' + "409": + description: Conflict + schema: + $ref: '#/definitions/gin.H' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/gin.H' + security: + - AdminAuth: [] + summary: Update an IP ban + tags: + - admin + - ip-bans /admin/keys/{id}/access: get: description: Returns key default_namespace and namespaces @@ -2375,7 +2732,8 @@ paths: /admin/logs/stats/traffic-chart: get: description: Get time × model aggregated data for stacked traffic charts. Returns - time buckets with per-model breakdown. + a shared time axis under `x` and per-model series arrays aligned to that axis. + Models outside top_n are aggregated under the series name "other". parameters: - description: 'Time granularity: hour (default) or minute' enum: @@ -3587,13 +3945,41 @@ paths: /auth/whoami: get: description: |- - Returns the identity of the authenticated user based on the Authorization header. + Returns complete identity and realtime statistics of the authenticated user. Supports Admin Token, Master Key, and Child Key (API Key) authentication. + This endpoint is designed for frontend initialization - call once after login + and store the response for subsequent use. - Response varies by token type: - - Admin Token: {"type": "admin", "role": "admin"} - - Master Key: {"type": "master", "id": 1, "name": "...", ...} - - Child Key: {"type": "key", "id": 5, "master_id": 1, "issued_by": "master", ...} + **Response varies by token type:** + + **Admin Token:** + - type: "admin" + - role: "admin" + - permissions: ["*"] (full access) + + **Master Key:** + - type: "master" + - Basic info: id, name, group, namespaces, status, epoch, max_child_keys, global_qps + - Timestamps: created_at, updated_at + - Realtime stats: requests, tokens, qps, qps_limit, rate_limited + + **Child Key (API Key):** + - type: "key" + - Basic info: id, master_id, master_name, group, scopes, namespaces, status + - Security: issued_at_epoch, issued_by, allow_ips, deny_ips, expires_at + - Model limits: model_limits, model_limits_enabled + - Quota: quota_limit, quota_used, quota_reset_at, quota_reset_type + - Usage stats: request_count, used_tokens, last_accessed_at + - Realtime stats: requests, tokens, qps, qps_limit, rate_limited + + **Error responses:** + - 401: authorization header required + - 401: invalid authorization header format + - 401: invalid token + - 401: token is not active + - 401: token has expired + - 401: token has been revoked + - 401: master is not active produces: - application/json responses: diff --git a/internal/api/log_handler.go b/internal/api/log_handler.go index 0c0a32a..41bc6b8 100644 --- a/internal/api/log_handler.go +++ b/internal/api/log_handler.go @@ -609,34 +609,35 @@ 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"` +// TrafficSeries contains the metrics for a model aligned to the shared time axis. +type TrafficSeries struct { + Name string `json:"name"` + Data []int64 `json:"data"` + TokensIn []int64 `json:"tokens_in"` + TokensOut []int64 `json:"tokens_out"` } -// ModelMetricsMap is a map from model name to TrafficMetrics. -// Keys are model names (e.g. "gpt-4", "claude-3-opus") or "other" for aggregated remaining models. -// Example: {"gpt-4": {"count": 10, "tokens_in": 1000, "tokens_out": 500}, "other": {"count": 3, "tokens_in": 200, "tokens_out": 100}} -type ModelMetricsMap map[string]TrafficMetrics +// TrafficTotals contains aggregated totals aligned to the shared time axis. +type TrafficTotals struct { + Data []int64 `json:"data"` + 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 is a map from model name (e.g. "gpt-4", "claude-3-opus", "other") to its metrics - Breakdown ModelMetricsMap `json:"breakdown"` - Total TrafficMetrics `json:"total"` +// TrafficChartAxis defines the shared time axis for chart data. +type TrafficChartAxis struct { + Labels []string `json:"labels"` + Timestamps []int64 `json:"timestamps"` + Totals TrafficTotals `json:"totals"` } // 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"` + Granularity string `json:"granularity"` + Since int64 `json:"since"` + Until int64 `json:"until"` + X TrafficChartAxis `json:"x"` + Series []TrafficSeries `json:"series"` } const ( @@ -644,9 +645,121 @@ const ( maxTrafficTopN = 20 ) +type trafficBucketRow struct { + Bucket time.Time + ModelName string + Cnt int64 + TokensIn int64 + TokensOut int64 +} + +func buildTrafficChartSeriesResponse(rows []trafficBucketRow, topN int, granularity string, sinceTime, untilTime time.Time) TrafficChartResponse { + bucketLabels := make(map[int64]string) + bucketOrder := make([]int64, 0) + for _, r := range rows { + ts := r.Bucket.Unix() + if _, exists := bucketLabels[ts]; !exists { + bucketLabels[ts] = r.Bucket.UTC().Format(time.RFC3339) + bucketOrder = append(bucketOrder, ts) + } + } + + modelCounts := make(map[string]int64) + for _, r := range rows { + modelCounts[r.ModelName] += r.Cnt + } + + type modelCount struct { + name string + count int64 + } + modelList := make([]modelCount, 0, len(modelCounts)) + for name, cnt := range modelCounts { + modelList = append(modelList, modelCount{name, cnt}) + } + 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, topN) + seriesNames := make([]string, 0, topN+1) + for i := 0; i < len(modelList) && i < topN; i++ { + topModels[modelList[i].name] = true + seriesNames = append(seriesNames, modelList[i].name) + } + if len(modelList) > topN { + seriesNames = append(seriesNames, "other") + } + + bucketIndex := make(map[int64]int, len(bucketOrder)) + labels := make([]string, len(bucketOrder)) + timestamps := make([]int64, len(bucketOrder)) + for i, ts := range bucketOrder { + bucketIndex[ts] = i + labels[i] = bucketLabels[ts] + timestamps[i] = ts + } + + series := make([]TrafficSeries, len(seriesNames)) + seriesIndex := make(map[string]int, len(seriesNames)) + for i, name := range seriesNames { + series[i] = TrafficSeries{ + Name: name, + Data: make([]int64, len(bucketOrder)), + TokensIn: make([]int64, len(bucketOrder)), + TokensOut: make([]int64, len(bucketOrder)), + } + seriesIndex[name] = i + } + + totals := TrafficTotals{ + Data: make([]int64, len(bucketOrder)), + TokensIn: make([]int64, len(bucketOrder)), + TokensOut: make([]int64, len(bucketOrder)), + } + + for _, r := range rows { + ts := r.Bucket.Unix() + idx, ok := bucketIndex[ts] + if !ok { + continue + } + + modelKey := r.ModelName + if !topModels[modelKey] { + modelKey = "other" + } + if seriesIdx, exists := seriesIndex[modelKey]; exists { + series[seriesIdx].Data[idx] += r.Cnt + series[seriesIdx].TokensIn[idx] += r.TokensIn + series[seriesIdx].TokensOut[idx] += r.TokensOut + } + + totals.Data[idx] += r.Cnt + totals.TokensIn[idx] += r.TokensIn + totals.TokensOut[idx] += r.TokensOut + } + + return TrafficChartResponse{ + Granularity: granularity, + Since: sinceTime.Unix(), + Until: untilTime.Unix(), + X: TrafficChartAxis{ + Labels: labels, + Timestamps: timestamps, + Totals: totals, + }, + Series: series, + } +} + // GetTrafficChart godoc // @Summary Traffic chart data (admin) -// @Description Get time × model aggregated data for stacked traffic charts. Returns time buckets with per-model breakdown. The 'breakdown' field in each bucket is a map where keys are model names (e.g. "gpt-4", "claude-3-opus") and values are TrafficMetrics objects. Models outside top_n are aggregated under the key "other". Example: {"gpt-4": {"count": 10, "tokens_in": 1000, "tokens_out": 500}, "other": {"count": 3, "tokens_in": 200, "tokens_out": 100}} +// @Description Get time × model aggregated data for stacked traffic charts. Returns a shared time axis under `x` and per-model series arrays aligned to that axis. Models outside top_n are aggregated under the series name "other". // @Tags admin // @Produce json // @Security AdminAuth @@ -731,14 +844,7 @@ func (h *Handler) GetTrafficChart(c *gin.Context) { truncFunc = "DATE_TRUNC('hour', created_at)" } - type bucketModelStats struct { - Bucket time.Time - ModelName string - Cnt int64 - TokensIn int64 - TokensOut int64 - } - var rows []bucketModelStats + var rows []trafficBucketRow 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"). @@ -748,88 +854,7 @@ func (h *Handler) GetTrafficChart(c *gin.Context) { 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, - }) + c.JSON(http.StatusOK, buildTrafficChartSeriesResponse(rows, topN, granularity, sinceTime, untilTime)) } // ListSelfLogs godoc diff --git a/internal/api/log_handler_test.go b/internal/api/log_handler_test.go index d019f58..9607220 100644 --- a/internal/api/log_handler_test.go +++ b/internal/api/log_handler_test.go @@ -355,6 +355,83 @@ func TestLogStats_DefaultBehavior(t *testing.T) { } } +func TestBuildTrafficChartSeriesResponse(t *testing.T) { + bucket1 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + bucket2 := time.Date(2025, 1, 1, 1, 0, 0, 0, time.UTC) + + rows := []trafficBucketRow{ + {Bucket: bucket1, ModelName: "a", Cnt: 5, TokensIn: 10, TokensOut: 20}, + {Bucket: bucket1, ModelName: "b", Cnt: 3, TokensIn: 6, TokensOut: 12}, + {Bucket: bucket2, ModelName: "a", Cnt: 2, TokensIn: 4, TokensOut: 8}, + {Bucket: bucket2, ModelName: "c", Cnt: 8, TokensIn: 16, TokensOut: 32}, + } + + resp := buildTrafficChartSeriesResponse(rows, 2, "hour", bucket1, bucket2) + + if len(resp.X.Labels) != 2 || len(resp.X.Timestamps) != 2 { + t.Fatalf("expected 2 time buckets, got labels=%d timestamps=%d", len(resp.X.Labels), len(resp.X.Timestamps)) + } + if resp.X.Labels[0] != bucket1.Format(time.RFC3339) || resp.X.Labels[1] != bucket2.Format(time.RFC3339) { + t.Fatalf("unexpected labels: %+v", resp.X.Labels) + } + if resp.X.Timestamps[0] != bucket1.Unix() || resp.X.Timestamps[1] != bucket2.Unix() { + t.Fatalf("unexpected timestamps: %+v", resp.X.Timestamps) + } + if resp.X.Totals.Data[0] != 8 || resp.X.Totals.Data[1] != 10 { + t.Fatalf("unexpected totals data: %+v", resp.X.Totals.Data) + } + if resp.X.Totals.TokensIn[0] != 16 || resp.X.Totals.TokensIn[1] != 20 { + t.Fatalf("unexpected totals tokens_in: %+v", resp.X.Totals.TokensIn) + } + if resp.X.Totals.TokensOut[0] != 32 || resp.X.Totals.TokensOut[1] != 40 { + t.Fatalf("unexpected totals tokens_out: %+v", resp.X.Totals.TokensOut) + } + + seriesByName := make(map[string]TrafficSeries, len(resp.Series)) + for _, s := range resp.Series { + seriesByName[s.Name] = s + } + + for _, name := range []string{"a", "c", "other"} { + if _, ok := seriesByName[name]; !ok { + t.Fatalf("missing series %q", name) + } + } + + aSeries := seriesByName["a"] + if aSeries.Data[0] != 5 || aSeries.Data[1] != 2 { + t.Fatalf("unexpected series a data: %+v", aSeries.Data) + } + if aSeries.TokensIn[0] != 10 || aSeries.TokensIn[1] != 4 { + t.Fatalf("unexpected series a tokens_in: %+v", aSeries.TokensIn) + } + if aSeries.TokensOut[0] != 20 || aSeries.TokensOut[1] != 8 { + t.Fatalf("unexpected series a tokens_out: %+v", aSeries.TokensOut) + } + + cSeries := seriesByName["c"] + if cSeries.Data[0] != 0 || cSeries.Data[1] != 8 { + t.Fatalf("unexpected series c data: %+v", cSeries.Data) + } + if cSeries.TokensIn[0] != 0 || cSeries.TokensIn[1] != 16 { + t.Fatalf("unexpected series c tokens_in: %+v", cSeries.TokensIn) + } + if cSeries.TokensOut[0] != 0 || cSeries.TokensOut[1] != 32 { + t.Fatalf("unexpected series c tokens_out: %+v", cSeries.TokensOut) + } + + otherSeries := seriesByName["other"] + if otherSeries.Data[0] != 3 || otherSeries.Data[1] != 0 { + t.Fatalf("unexpected series other data: %+v", otherSeries.Data) + } + if otherSeries.TokensIn[0] != 6 || otherSeries.TokensIn[1] != 0 { + t.Fatalf("unexpected series other tokens_in: %+v", otherSeries.TokensIn) + } + if otherSeries.TokensOut[0] != 12 || otherSeries.TokensOut[1] != 0 { + t.Fatalf("unexpected series other tokens_out: %+v", otherSeries.TokensOut) + } +} + func TestTrafficChart_TopNOtherAggregation(t *testing.T) { // Skip test when running with SQLite (no DATE_TRUNC support) // This test requires PostgreSQL for time truncation functions