From de1087543974b783036829a4d67fc66cfeda5998 Mon Sep 17 00:00:00 2001 From: wangbo Date: Sat, 25 Jul 2026 01:32:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E4=BC=98=E5=8C=96=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E8=BF=90=E7=BB=B4=E4=B8=8E=E4=BB=BB=E5=8A=A1=E7=AE=A1?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 完善平台、基准模型、定价规则和实时负载的紧凑查询与滚动展示,并固定关键操作列。 新增管理员任务记录、批量计费结算和用户组限流、额度及模型权限维护能力,同时补充任务脱敏、查询索引与 OpenAPI 契约。 验证:pnpm openapi、Go 全量测试、pnpm lint、pnpm test、pnpm build、gofmt 与差异格式检查均通过。 --- apps/api/docs/swagger.json | 584 ++++++++++++++++++ apps/api/docs/swagger.yaml | 388 ++++++++++++ .../internal/httpapi/admin_task_handlers.go | 213 +++++++ .../httpapi/admin_task_handlers_test.go | 22 + .../httpapi/admin_tasks_integration_test.go | 321 ++++++++++ .../httpapi/core_flow_integration_test.go | 58 ++ apps/api/internal/httpapi/openapi_models.go | 7 + apps/api/internal/httpapi/server.go | 3 + .../internal/store/admin_task_redaction.go | 94 +++ .../store/admin_task_redaction_test.go | 87 +++ apps/api/internal/store/admin_tasks.go | 385 ++++++++++++ .../0087_admin_task_query_indexes.sql | 22 + apps/web/src/App.tsx | 76 ++- apps/web/src/api.ts | 39 +- apps/web/src/app-state.ts | 2 + apps/web/src/navigation.ts | 4 +- apps/web/src/pages/AdminPage.tsx | 57 +- apps/web/src/pages/WorkspacePage.tsx | 41 +- apps/web/src/pages/admin/AccessRulesPanel.tsx | 79 --- .../src/pages/admin/AdminTasksPanel.test.ts | 33 + apps/web/src/pages/admin/AdminTasksPanel.tsx | 546 ++++++++++++++++ .../src/pages/admin/BaseModelCatalogPanel.tsx | 148 +++-- .../admin/BillingSettlementsPanel.test.ts | 26 + .../pages/admin/BillingSettlementsPanel.tsx | 259 ++++++-- .../pages/admin/IdentityManagementPanels.tsx | 143 ++++- .../admin/PlatformManagementPanel.test.ts | 33 + .../pages/admin/PlatformManagementPanel.tsx | 309 +++++++-- .../web/src/pages/admin/PricingRulesPanel.tsx | 124 ++-- .../src/pages/admin/RealtimeLoadPanel.test.ts | 72 +++ .../web/src/pages/admin/RealtimeLoadPanel.tsx | 229 ++++++- .../admin/UserGroupPolicyEditors.test.ts | 53 ++ .../pages/admin/UserGroupPolicyEditors.tsx | 368 +++++++++++ apps/web/src/routing.test.ts | 46 +- apps/web/src/routing.ts | 105 +++- apps/web/src/styles.css | 78 +++ apps/web/src/styles/pages.css | 337 +++++++++- apps/web/src/types.ts | 21 +- packages/contracts/src/index.ts | 28 + 38 files changed, 5015 insertions(+), 425 deletions(-) create mode 100644 apps/api/internal/httpapi/admin_task_handlers.go create mode 100644 apps/api/internal/httpapi/admin_task_handlers_test.go create mode 100644 apps/api/internal/httpapi/admin_tasks_integration_test.go create mode 100644 apps/api/internal/store/admin_task_redaction.go create mode 100644 apps/api/internal/store/admin_task_redaction_test.go create mode 100644 apps/api/internal/store/admin_tasks.go create mode 100644 apps/api/migrations/0087_admin_task_query_indexes.sql delete mode 100644 apps/web/src/pages/admin/AccessRulesPanel.tsx create mode 100644 apps/web/src/pages/admin/AdminTasksPanel.test.ts create mode 100644 apps/web/src/pages/admin/AdminTasksPanel.tsx create mode 100644 apps/web/src/pages/admin/BillingSettlementsPanel.test.ts create mode 100644 apps/web/src/pages/admin/PlatformManagementPanel.test.ts create mode 100644 apps/web/src/pages/admin/RealtimeLoadPanel.test.ts create mode 100644 apps/web/src/pages/admin/UserGroupPolicyEditors.test.ts create mode 100644 apps/web/src/pages/admin/UserGroupPolicyEditors.tsx diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index 5a94d68..2a466eb 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -3766,6 +3766,299 @@ } } }, + "/api/admin/tasks": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "跨租户分页查询任务;请求、结果和执行快照中的常见敏感字段默认脱敏。", + "produces": [ + "application/json" + ], + "tags": [ + "admin-tasks" + ], + "summary": "管理员列出任务", + "parameters": [ + { + "type": "string", + "description": "关键词,匹配任务、用户、租户、模型、平台和 API Key", + "name": "q", + "in": "query" + }, + { + "type": "string", + "description": "网关租户 ID", + "name": "tenantId", + "in": "query" + }, + { + "type": "string", + "description": "网关用户 ID", + "name": "userId", + "in": "query" + }, + { + "type": "string", + "description": "用户组 ID", + "name": "userGroupId", + "in": "query" + }, + { + "type": "string", + "description": "任务状态", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "执行平台 ID", + "name": "platformId", + "in": "query" + }, + { + "type": "string", + "description": "调用或实际模型名称", + "name": "model", + "in": "query" + }, + { + "type": "string", + "description": "模型类型", + "name": "modelType", + "in": "query" + }, + { + "type": "string", + "description": "运行模式", + "name": "runMode", + "in": "query" + }, + { + "type": "string", + "description": "计费状态", + "name": "billingStatus", + "in": "query" + }, + { + "type": "string", + "description": "API Key ID、名称或前缀", + "name": "apiKey", + "in": "query" + }, + { + "type": "string", + "description": "创建时间起点,支持 RFC3339 或日期格式", + "name": "createdFrom", + "in": "query" + }, + { + "type": "string", + "description": "创建时间终点,支持 RFC3339 或日期格式", + "name": "createdTo", + "in": "query" + }, + { + "type": "integer", + "default": 1, + "description": "页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "default": 50, + "description": "每页数量", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpapi.AdminTaskListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, + "/api/admin/tasks/{taskID}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "返回任意租户的任务详情;默认脱敏,sensitive=full 时返回完整存储内容。", + "produces": [ + "application/json" + ], + "tags": [ + "admin-tasks" + ], + "summary": "管理员获取任务详情", + "parameters": [ + { + "type": "string", + "description": "任务 ID", + "name": "taskID", + "in": "path", + "required": true + }, + { + "enum": [ + "masked", + "full" + ], + "type": "string", + "default": "masked", + "description": "敏感字段模式", + "name": "sensitive", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/store.AdminGatewayTask" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, + "/api/admin/tasks/{taskID}/param-preprocessing": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "返回任意租户任务的参数改写、校验或模板处理日志;默认脱敏。", + "produces": [ + "application/json" + ], + "tags": [ + "admin-tasks" + ], + "summary": "管理员获取任务参数预处理日志", + "parameters": [ + { + "type": "string", + "description": "任务 ID", + "name": "taskID", + "in": "path", + "required": true + }, + { + "enum": [ + "masked", + "full" + ], + "type": "string", + "default": "masked", + "description": "敏感字段模式", + "name": "sensitive", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpapi.TaskParamPreprocessingLogListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, "/api/admin/tenants": { "get": { "security": [ @@ -9451,6 +9744,29 @@ } } }, + "httpapi.AdminTaskListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/store.AdminGatewayTask" + } + }, + "page": { + "type": "integer", + "example": 1 + }, + "pageSize": { + "type": "integer", + "example": 50 + }, + "total": { + "type": "integer", + "example": 42 + } + } + }, "httpapi.AuditLogListResponse": { "type": "object", "properties": { @@ -12334,6 +12650,274 @@ } } }, + "store.AdminGatewayTask": { + "type": "object", + "properties": { + "adminContext": { + "$ref": "#/definitions/store.AdminTaskContext" + }, + "apiKeyId": { + "type": "string" + }, + "apiKeyName": { + "type": "string" + }, + "apiKeyPrefix": { + "type": "string" + }, + "asyncMode": { + "type": "boolean" + }, + "attemptCount": { + "type": "integer" + }, + "attempts": { + "type": "array", + "items": { + "$ref": "#/definitions/store.TaskAttempt" + } + }, + "billingCurrency": { + "type": "string" + }, + "billingSettledAt": { + "type": "string" + }, + "billingStatus": { + "type": "string" + }, + "billingSummary": { + "type": "object", + "additionalProperties": {} + }, + "billingUpdatedAt": { + "type": "string" + }, + "billingVersion": { + "type": "string" + }, + "billings": { + "type": "array", + "items": {} + }, + "cancellable": { + "type": "boolean" + }, + "compatibilityProtocol": { + "type": "string" + }, + "compatibilityPublicId": { + "type": "string" + }, + "compatibilitySourceProtocol": { + "type": "string" + }, + "compatibilitySubmitBody": { + "type": "object", + "additionalProperties": {} + }, + "compatibilitySubmitHeaders": { + "type": "object", + "additionalProperties": {} + }, + "compatibilitySubmitHttpStatus": { + "type": "integer" + }, + "conversationId": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "error": { + "type": "string" + }, + "errorCode": { + "type": "string" + }, + "errorMessage": { + "type": "string" + }, + "executionLeaseExpiresAt": { + "type": "string" + }, + "externalTaskId": { + "type": "string" + }, + "finalChargeAmount": { + "type": "number" + }, + "finishedAt": { + "type": "string" + }, + "gatewayTenantId": { + "type": "string" + }, + "gatewayUserId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "metrics": { + "type": "object", + "additionalProperties": {} + }, + "model": { + "type": "string" + }, + "modelType": { + "type": "string" + }, + "newMessageCount": { + "type": "integer" + }, + "pricingSnapshot": { + "type": "object", + "additionalProperties": {} + }, + "remoteTaskId": { + "type": "string" + }, + "request": { + "type": "object", + "additionalProperties": {} + }, + "requestFingerprint": { + "type": "string" + }, + "requestId": { + "type": "string" + }, + "requestedModel": { + "type": "string" + }, + "reservationAmount": { + "type": "number" + }, + "resolvedModel": { + "type": "string" + }, + "responseDurationMs": { + "type": "integer" + }, + "responseFinishedAt": { + "type": "string" + }, + "responseStartedAt": { + "type": "string" + }, + "result": { + "type": "object", + "additionalProperties": {} + }, + "riverJobId": { + "type": "integer" + }, + "runMode": { + "type": "string" + }, + "status": { + "type": "string" + }, + "submitted": { + "type": "boolean" + }, + "tenantId": { + "type": "string" + }, + "tenantKey": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "usage": { + "type": "object", + "additionalProperties": {} + }, + "userGroupId": { + "type": "string" + }, + "userGroupKey": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "userSource": { + "type": "string" + } + } + }, + "store.AdminTaskContext": { + "type": "object", + "properties": { + "latestPlatform": { + "$ref": "#/definitions/store.AdminTaskPlatformSummary" + }, + "tenant": { + "$ref": "#/definitions/store.AdminTaskTenantSummary" + }, + "user": { + "$ref": "#/definitions/store.AdminTaskUserSummary" + } + } + }, + "store.AdminTaskPlatformSummary": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "provider": { + "type": "string" + } + } + }, + "store.AdminTaskTenantSummary": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "tenantKey": { + "type": "string" + } + } + }, + "store.AdminTaskUserSummary": { + "type": "object", + "properties": { + "displayName": { + "type": "string" + }, + "email": { + "type": "string" + }, + "id": { + "type": "string" + }, + "source": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, "store.AuditLog": { "type": "object", "properties": { diff --git a/apps/api/docs/swagger.yaml b/apps/api/docs/swagger.yaml index 25a90eb..3b68a48 100644 --- a/apps/api/docs/swagger.yaml +++ b/apps/api/docs/swagger.yaml @@ -57,6 +57,22 @@ definitions: $ref: '#/definitions/store.AccessRule' type: array type: object + httpapi.AdminTaskListResponse: + properties: + items: + items: + $ref: '#/definitions/store.AdminGatewayTask' + type: array + page: + example: 1 + type: integer + pageSize: + example: 50 + type: integer + total: + example: 42 + type: integer + type: object httpapi.AuditLogListResponse: properties: items: @@ -2059,6 +2075,186 @@ definitions: status: type: string type: object + store.AdminGatewayTask: + properties: + adminContext: + $ref: '#/definitions/store.AdminTaskContext' + apiKeyId: + type: string + apiKeyName: + type: string + apiKeyPrefix: + type: string + asyncMode: + type: boolean + attemptCount: + type: integer + attempts: + items: + $ref: '#/definitions/store.TaskAttempt' + type: array + billingCurrency: + type: string + billingSettledAt: + type: string + billingStatus: + type: string + billingSummary: + additionalProperties: {} + type: object + billingUpdatedAt: + type: string + billingVersion: + type: string + billings: + items: {} + type: array + cancellable: + type: boolean + compatibilityProtocol: + type: string + compatibilityPublicId: + type: string + compatibilitySourceProtocol: + type: string + compatibilitySubmitBody: + additionalProperties: {} + type: object + compatibilitySubmitHeaders: + additionalProperties: {} + type: object + compatibilitySubmitHttpStatus: + type: integer + conversationId: + type: string + createdAt: + type: string + error: + type: string + errorCode: + type: string + errorMessage: + type: string + executionLeaseExpiresAt: + type: string + externalTaskId: + type: string + finalChargeAmount: + type: number + finishedAt: + type: string + gatewayTenantId: + type: string + gatewayUserId: + type: string + id: + type: string + kind: + type: string + message: + type: string + metrics: + additionalProperties: {} + type: object + model: + type: string + modelType: + type: string + newMessageCount: + type: integer + pricingSnapshot: + additionalProperties: {} + type: object + remoteTaskId: + type: string + request: + additionalProperties: {} + type: object + requestFingerprint: + type: string + requestId: + type: string + requestedModel: + type: string + reservationAmount: + type: number + resolvedModel: + type: string + responseDurationMs: + type: integer + responseFinishedAt: + type: string + responseStartedAt: + type: string + result: + additionalProperties: {} + type: object + riverJobId: + type: integer + runMode: + type: string + status: + type: string + submitted: + type: boolean + tenantId: + type: string + tenantKey: + type: string + updatedAt: + type: string + usage: + additionalProperties: {} + type: object + userGroupId: + type: string + userGroupKey: + type: string + userId: + type: string + userSource: + type: string + type: object + store.AdminTaskContext: + properties: + latestPlatform: + $ref: '#/definitions/store.AdminTaskPlatformSummary' + tenant: + $ref: '#/definitions/store.AdminTaskTenantSummary' + user: + $ref: '#/definitions/store.AdminTaskUserSummary' + type: object + store.AdminTaskPlatformSummary: + properties: + id: + type: string + name: + type: string + provider: + type: string + type: object + store.AdminTaskTenantSummary: + properties: + id: + type: string + name: + type: string + tenantKey: + type: string + type: object + store.AdminTaskUserSummary: + properties: + displayName: + type: string + email: + type: string + id: + type: string + source: + type: string + username: + type: string + type: object store.AuditLog: properties: action: @@ -6201,6 +6397,198 @@ paths: summary: 验证统一认证 Revision tags: - identity + /api/admin/tasks: + get: + description: 跨租户分页查询任务;请求、结果和执行快照中的常见敏感字段默认脱敏。 + parameters: + - description: 关键词,匹配任务、用户、租户、模型、平台和 API Key + in: query + name: q + type: string + - description: 网关租户 ID + in: query + name: tenantId + type: string + - description: 网关用户 ID + in: query + name: userId + type: string + - description: 用户组 ID + in: query + name: userGroupId + type: string + - description: 任务状态 + in: query + name: status + type: string + - description: 执行平台 ID + in: query + name: platformId + type: string + - description: 调用或实际模型名称 + in: query + name: model + type: string + - description: 模型类型 + in: query + name: modelType + type: string + - description: 运行模式 + in: query + name: runMode + type: string + - description: 计费状态 + in: query + name: billingStatus + type: string + - description: API Key ID、名称或前缀 + in: query + name: apiKey + type: string + - description: 创建时间起点,支持 RFC3339 或日期格式 + in: query + name: createdFrom + type: string + - description: 创建时间终点,支持 RFC3339 或日期格式 + in: query + name: createdTo + type: string + - default: 1 + description: 页码 + in: query + name: page + type: integer + - default: 50 + description: 每页数量 + in: query + name: pageSize + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/httpapi.AdminTaskListResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "403": + description: Forbidden + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + security: + - BearerAuth: [] + summary: 管理员列出任务 + tags: + - admin-tasks + /api/admin/tasks/{taskID}: + get: + description: 返回任意租户的任务详情;默认脱敏,sensitive=full 时返回完整存储内容。 + parameters: + - description: 任务 ID + in: path + name: taskID + required: true + type: string + - default: masked + description: 敏感字段模式 + enum: + - masked + - full + in: query + name: sensitive + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/store.AdminGatewayTask' + "400": + description: Bad Request + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "403": + description: Forbidden + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "404": + description: Not Found + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + security: + - BearerAuth: [] + summary: 管理员获取任务详情 + tags: + - admin-tasks + /api/admin/tasks/{taskID}/param-preprocessing: + get: + description: 返回任意租户任务的参数改写、校验或模板处理日志;默认脱敏。 + parameters: + - description: 任务 ID + in: path + name: taskID + required: true + type: string + - default: masked + description: 敏感字段模式 + enum: + - masked + - full + in: query + name: sensitive + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/httpapi.TaskParamPreprocessingLogListResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "403": + description: Forbidden + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "404": + description: Not Found + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + security: + - BearerAuth: [] + summary: 管理员获取任务参数预处理日志 + tags: + - admin-tasks /api/admin/tenants: get: description: 管理端返回网关租户列表。 diff --git a/apps/api/internal/httpapi/admin_task_handlers.go b/apps/api/internal/httpapi/admin_task_handlers.go new file mode 100644 index 0000000..bc89687 --- /dev/null +++ b/apps/api/internal/httpapi/admin_task_handlers.go @@ -0,0 +1,213 @@ +package httpapi + +import ( + "net/http" + "strings" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" + "github.com/google/uuid" +) + +// listAdminTasks godoc +// @Summary 管理员列出任务 +// @Description 跨租户分页查询任务;请求、结果和执行快照中的常见敏感字段默认脱敏。 +// @Tags admin-tasks +// @Produce json +// @Security BearerAuth +// @Param q query string false "关键词,匹配任务、用户、租户、模型、平台和 API Key" +// @Param tenantId query string false "网关租户 ID" +// @Param userId query string false "网关用户 ID" +// @Param userGroupId query string false "用户组 ID" +// @Param status query string false "任务状态" +// @Param platformId query string false "执行平台 ID" +// @Param model query string false "调用或实际模型名称" +// @Param modelType query string false "模型类型" +// @Param runMode query string false "运行模式" +// @Param billingStatus query string false "计费状态" +// @Param apiKey query string false "API Key ID、名称或前缀" +// @Param createdFrom query string false "创建时间起点,支持 RFC3339 或日期格式" +// @Param createdTo query string false "创建时间终点,支持 RFC3339 或日期格式" +// @Param page query int false "页码" default(1) +// @Param pageSize query int false "每页数量" default(50) +// @Success 200 {object} AdminTaskListResponse +// @Failure 400 {object} ErrorEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 500 {object} ErrorEnvelope +// @Router /api/admin/tasks [get] +func (s *Server) listAdminTasks(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + page, err := positiveQueryInt(query.Get("page"), 1) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid page") + return + } + pageSize, err := positiveQueryInt(query.Get("pageSize"), 50) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid pageSize") + return + } + createdFrom, err := parseTaskListTime(query.Get("createdFrom"), query.Get("from")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid createdFrom") + return + } + createdTo, err := parseTaskListTime(query.Get("createdTo"), query.Get("to")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid createdTo") + return + } + for name, value := range map[string]string{ + "tenantId": query.Get("tenantId"), + "userId": query.Get("userId"), + "userGroupId": query.Get("userGroupId"), + "platformId": query.Get("platformId"), + } { + if value != "" && !validUUID(value) { + writeError(w, http.StatusBadRequest, "invalid "+name) + return + } + } + + result, err := s.store.ListAdminTasks(r.Context(), store.AdminTaskListFilter{ + Query: firstNonEmpty(query.Get("q"), query.Get("query")), + GatewayTenant: query.Get("tenantId"), + GatewayUser: query.Get("userId"), + UserGroup: query.Get("userGroupId"), + Status: query.Get("status"), + Platform: query.Get("platformId"), + Model: query.Get("model"), + ModelType: query.Get("modelType"), + RunMode: query.Get("runMode"), + BillingStatus: query.Get("billingStatus"), + APIKey: query.Get("apiKey"), + CreatedFrom: createdFrom, + CreatedTo: createdTo, + Page: page, + PageSize: pageSize, + }) + if err != nil { + s.logger.Error("list admin tasks failed", "error", err) + writeError(w, http.StatusInternalServerError, "list admin tasks failed") + return + } + for index := range result.Items { + result.Items[index] = store.MaskAdminGatewayTask(result.Items[index]) + } + writeJSON(w, http.StatusOK, map[string]any{ + "items": result.Items, + "total": result.Total, + "page": result.Page, + "pageSize": result.PageSize, + }) +} + +// getAdminTask godoc +// @Summary 管理员获取任务详情 +// @Description 返回任意租户的任务详情;默认脱敏,sensitive=full 时返回完整存储内容。 +// @Tags admin-tasks +// @Produce json +// @Security BearerAuth +// @Param taskID path string true "任务 ID" +// @Param sensitive query string false "敏感字段模式" Enums(masked,full) default(masked) +// @Success 200 {object} store.AdminGatewayTask +// @Failure 400 {object} ErrorEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Failure 500 {object} ErrorEnvelope +// @Router /api/admin/tasks/{taskID} [get] +func (s *Server) getAdminTask(w http.ResponseWriter, r *http.Request) { + taskID := r.PathValue("taskID") + if !validUUID(taskID) { + writeError(w, http.StatusBadRequest, "invalid taskID") + return + } + full, ok := parseAdminTaskSensitiveMode(r.URL.Query().Get("sensitive")) + if !ok { + writeError(w, http.StatusBadRequest, "invalid sensitive mode") + return + } + task, err := s.store.GetAdminTask(r.Context(), taskID) + if err != nil { + if store.IsNotFound(err) { + writeError(w, http.StatusNotFound, "task not found") + return + } + s.logger.Error("get admin task failed", "taskID", taskID, "error", err) + writeError(w, http.StatusInternalServerError, "get admin task failed") + return + } + task.GatewayTask, err = s.hydrateTaskResult(r.Context(), task.GatewayTask) + if err != nil { + writeStoredBinaryResultError(w, err) + return + } + if !full { + task = store.MaskAdminGatewayTask(task) + } + writeJSON(w, http.StatusOK, task) +} + +// adminTaskParamPreprocessing godoc +// @Summary 管理员获取任务参数预处理日志 +// @Description 返回任意租户任务的参数改写、校验或模板处理日志;默认脱敏。 +// @Tags admin-tasks +// @Produce json +// @Security BearerAuth +// @Param taskID path string true "任务 ID" +// @Param sensitive query string false "敏感字段模式" Enums(masked,full) default(masked) +// @Success 200 {object} TaskParamPreprocessingLogListResponse +// @Failure 400 {object} ErrorEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Failure 500 {object} ErrorEnvelope +// @Router /api/admin/tasks/{taskID}/param-preprocessing [get] +func (s *Server) adminTaskParamPreprocessing(w http.ResponseWriter, r *http.Request) { + taskID := r.PathValue("taskID") + if !validUUID(taskID) { + writeError(w, http.StatusBadRequest, "invalid taskID") + return + } + full, ok := parseAdminTaskSensitiveMode(r.URL.Query().Get("sensitive")) + if !ok { + writeError(w, http.StatusBadRequest, "invalid sensitive mode") + return + } + if _, err := s.store.GetAdminTask(r.Context(), taskID); err != nil { + if store.IsNotFound(err) { + writeError(w, http.StatusNotFound, "task not found") + return + } + s.logger.Error("get admin task failed", "taskID", taskID, "error", err) + writeError(w, http.StatusInternalServerError, "get admin task failed") + return + } + items, err := s.store.ListTaskParamPreprocessingLogs(r.Context(), taskID) + if err != nil { + s.logger.Error("list admin task parameter preprocessing logs failed", "taskID", taskID, "error", err) + writeError(w, http.StatusInternalServerError, "list admin task parameter preprocessing logs failed") + return + } + if !full { + items = store.MaskTaskParamPreprocessingLogs(items) + } + writeJSON(w, http.StatusOK, map[string]any{"items": items}) +} + +func parseAdminTaskSensitiveMode(value string) (bool, bool) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "masked": + return false, true + case "full": + return true, true + default: + return false, false + } +} + +func validUUID(value string) bool { + _, err := uuid.Parse(strings.TrimSpace(value)) + return err == nil +} diff --git a/apps/api/internal/httpapi/admin_task_handlers_test.go b/apps/api/internal/httpapi/admin_task_handlers_test.go new file mode 100644 index 0000000..fb2b362 --- /dev/null +++ b/apps/api/internal/httpapi/admin_task_handlers_test.go @@ -0,0 +1,22 @@ +package httpapi + +import "testing" + +func TestParseAdminTaskSensitiveMode(t *testing.T) { + tests := []struct { + value string + full bool + accepted bool + }{ + {value: "", full: false, accepted: true}, + {value: "masked", full: false, accepted: true}, + {value: "FULL", full: true, accepted: true}, + {value: "invalid", full: false, accepted: false}, + } + for _, test := range tests { + full, accepted := parseAdminTaskSensitiveMode(test.value) + if full != test.full || accepted != test.accepted { + t.Fatalf("parseAdminTaskSensitiveMode(%q)=(%v,%v), want (%v,%v)", test.value, full, accepted, test.full, test.accepted) + } + } +} diff --git a/apps/api/internal/httpapi/admin_tasks_integration_test.go b/apps/api/internal/httpapi/admin_tasks_integration_test.go new file mode 100644 index 0000000..61693ee --- /dev/null +++ b/apps/api/internal/httpapi/admin_tasks_integration_test.go @@ -0,0 +1,321 @@ +package httpapi + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/config" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +func TestAdminTasksCrossTenantQueryAndRedaction(t *testing.T) { + databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL")) + if databaseURL == "" { + t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run the PostgreSQL integration flow") + } + + ctx := context.Background() + applyMigration(t, ctx, databaseURL) + db, err := store.Connect(ctx, databaseURL) + if err != nil { + t.Fatalf("connect store: %v", err) + } + defer db.Close() + + suffix := strconv.FormatInt(time.Now().UnixNano(), 10) + createTenant := func(key, name string) store.GatewayTenant { + t.Helper() + tenant, err := db.CreateTenant(ctx, store.GatewayTenantInput{ + TenantKey: key + "-" + suffix, + Name: name, + Source: "gateway", + Status: "active", + }) + if err != nil { + t.Fatalf("create tenant %s: %v", name, err) + } + return tenant + } + createUser := func(tenant store.GatewayTenant, prefix string, roles []string) store.GatewayUser { + t.Helper() + username := prefix + "_" + suffix + user, err := db.CreateGatewayUser(ctx, store.GatewayUserInput{ + UserKey: "gateway:" + username, + Username: username, + DisplayName: prefix + " display", + Email: username + "@example.com", + Source: "gateway", + GatewayTenantID: tenant.ID, + TenantKey: tenant.TenantKey, + Roles: roles, + Status: "active", + }) + if err != nil { + t.Fatalf("create gateway user %s: %v", prefix, err) + } + return user + } + authUser := func(user store.GatewayUser, apiKeyName string) *auth.User { + return &auth.User{ + ID: user.ID, + Username: user.Username, + Roles: user.Roles, + Source: user.Source, + GatewayUserID: user.ID, + GatewayTenantID: user.GatewayTenantID, + TenantKey: user.TenantKey, + APIKeyID: "key-" + apiKeyName + "-" + suffix, + APIKeyName: apiKeyName, + APIKeyPrefix: "sk-" + apiKeyName, + } + } + createTask := func(user *auth.User, model, secret string) store.GatewayTask { + t.Helper() + task, err := db.CreateTask(ctx, store.CreateTaskInput{ + Kind: "chat.completions", + Model: model, + RunMode: "simulation", + Request: map[string]any{ + "model": model, + "diagnostic": map[string]any{ + "apiKey": secret, + }, + }, + }, user) + if err != nil { + t.Fatalf("create task for %s: %v", user.Username, err) + } + return task + } + + tenantA := createTenant("admin-task-a", "Admin Task Tenant A") + tenantB := createTenant("admin-task-b", "Admin Task Tenant B") + operator := createUser(tenantA, "admin_task_operator", []string{"operator"}) + userA := createUser(tenantA, "admin_task_user_a", []string{"user"}) + userB := createUser(tenantB, "admin_task_user_b", []string{"user"}) + userAAuth := authUser(userA, "alpha-key") + userBAuth := authUser(userB, "bravo-key") + + modelA := "admin-task-model-a-" + suffix + modelB := "admin-task-model-b-" + suffix + taskA := createTask(userAAuth, modelA, "alpha-secret-"+suffix) + taskBSecret := "bravo-secret-" + suffix + taskB := createTask(userBAuth, modelB, taskBSecret) + + platform, err := db.CreatePlatform(ctx, store.CreatePlatformInput{ + Provider: "openai", + PlatformKey: "admin-task-platform-" + suffix, + Name: "Admin Task Platform " + suffix, + BaseURL: "https://example.invalid/v1", + AuthType: "bearer", + Credentials: map[string]any{"apiKey": "platform-secret-" + suffix}, + Status: "enabled", + }) + if err != nil { + t.Fatalf("create platform: %v", err) + } + attemptID, err := db.CreateTaskAttempt(ctx, store.CreateTaskAttemptInput{ + TaskID: taskB.ID, + AttemptNo: 1, + PlatformID: platform.ID, + QueueKey: "admin-task-integration-" + suffix, + Status: "succeeded", + Simulated: true, + }) + if err != nil { + t.Fatalf("create task attempt: %v", err) + } + if _, err := db.Pool().Exec(ctx, ` +UPDATE gateway_tasks +SET status = 'succeeded', + model_type = 'text_generate', + requested_model = $2, + resolved_model = $2, + request_id = $3, + billing_status = 'settled', + result = $4::jsonb, + finished_at = now(), + updated_at = now() +WHERE id = $1::uuid`, + taskB.ID, + modelB, + "request-admin-task-"+suffix, + `{"authorization":"Bearer result-secret","output":"ok"}`, + ); err != nil { + t.Fatalf("enrich task: %v", err) + } + if _, err := db.Pool().Exec(ctx, ` +UPDATE gateway_task_attempts +SET request_snapshot = $2::jsonb, + response_snapshot = $3::jsonb, + finished_at = now() +WHERE id = $1::uuid`, + attemptID, + `{"headers":{"authorization":"Bearer attempt-secret"}}`, + `{"cookie":"attempt-cookie","status":"ok"}`, + ); err != nil { + t.Fatalf("enrich task attempt: %v", err) + } + if _, err := db.Pool().Exec(ctx, ` +INSERT INTO gateway_task_param_preprocessing_logs ( + task_id, attempt_id, attempt_no, model_type, platform_id, client_id, + changed, change_count, actual_input, converted_output, changes, model_snapshot +) +VALUES ( + $1::uuid, $2::uuid, 1, 'text_generate', $3::uuid, $4, + true, 1, $5::jsonb, $6::jsonb, $7::jsonb, '{}'::jsonb +)`, + taskB.ID, + attemptID, + platform.ID, + "admin-task-client-"+suffix, + `{"password":"input-secret","prompt":"hello"}`, + `{"token":"converted-secret","prompt":"hello"}`, + `[{"path":"credentials.secret","before":"old-secret","after":"new-secret"}]`, + ); err != nil { + t.Fatalf("create parameter preprocessing log: %v", err) + } + + const jwtSecret = "admin-task-integration-jwt-secret" + handlerCtx, cancelHandler := context.WithCancel(ctx) + defer cancelHandler() + server := httptest.NewServer(NewServerWithContext(handlerCtx, config.Config{ + AppEnv: "test", + HTTPAddr: ":0", + DatabaseURL: databaseURL, + IdentityMode: "hybrid", + JWTSecret: jwtSecret, + CORSAllowedOrigin: "*", + }, db, slog.New(slog.NewTextHandler(io.Discard, nil)))) + defer server.Close() + + authenticator := auth.New(jwtSecret, "", "") + signToken := func(user *auth.User) string { + t.Helper() + token, err := authenticator.SignJWT(user, time.Hour) + if err != nil { + t.Fatalf("sign JWT for %s: %v", user.Username, err) + } + return token + } + operatorToken := signToken(&auth.User{ + ID: operator.ID, + Username: operator.Username, + Roles: operator.Roles, + Source: operator.Source, + GatewayUserID: operator.ID, + GatewayTenantID: operator.GatewayTenantID, + TenantKey: operator.TenantKey, + }) + userAToken := signToken(userAAuth) + + doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks", userAToken, nil, http.StatusForbidden, nil) + + query := url.Values{ + "q": {taskB.ID}, + "tenantId": {tenantB.ID}, + "userId": {userB.ID}, + "status": {"succeeded"}, + "platformId": {platform.ID}, + "model": {modelB}, + "modelType": {"text_generate"}, + "runMode": {"simulation"}, + "billingStatus": {"settled"}, + "apiKey": {"bravo-key"}, + "page": {"1"}, + "pageSize": {"10"}, + } + var listResponse struct { + Items []store.AdminGatewayTask `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + } + doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks?"+query.Encode(), operatorToken, nil, http.StatusOK, &listResponse) + if len(listResponse.Items) != 1 || listResponse.Total != 1 || listResponse.Page != 1 || listResponse.PageSize != 10 { + t.Fatalf("unexpected filtered admin task list: %+v", listResponse) + } + listed := listResponse.Items[0] + if listed.ID != taskB.ID || listed.AdminContext.User == nil || listed.AdminContext.User.ID != userB.ID || + listed.AdminContext.Tenant == nil || listed.AdminContext.Tenant.ID != tenantB.ID || + listed.AdminContext.LatestPlatform == nil || listed.AdminContext.LatestPlatform.ID != platform.ID { + t.Fatalf("admin task list should expose cross-tenant identity and platform summaries: %+v", listed) + } + if nestedString(listed.Request, "diagnostic", "apiKey") != "***" || + nestedString(listed.Result, "authorization") != "***" || + nestedString(listed.Attempts[0].RequestSnapshot, "headers", "authorization") != "***" { + t.Fatalf("admin task list should mask task and attempt secrets: %+v", listed) + } + + var maskedDetail store.AdminGatewayTask + doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks/"+taskB.ID, operatorToken, nil, http.StatusOK, &maskedDetail) + if nestedString(maskedDetail.Request, "diagnostic", "apiKey") != "***" { + t.Fatalf("admin task detail should be masked by default: %+v", maskedDetail.Request) + } + var fullDetail store.AdminGatewayTask + doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks/"+taskB.ID+"?sensitive=full", operatorToken, nil, http.StatusOK, &fullDetail) + if nestedString(fullDetail.Request, "diagnostic", "apiKey") != taskBSecret { + t.Fatalf("explicit full task detail should expose stored content: %+v", fullDetail.Request) + } + + var maskedLogs struct { + Items []store.TaskParamPreprocessingLog `json:"items"` + } + doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks/"+taskB.ID+"/param-preprocessing", operatorToken, nil, http.StatusOK, &maskedLogs) + if len(maskedLogs.Items) != 1 || + nestedString(maskedLogs.Items[0].ActualInput, "password") != "***" || + nestedString(maskedLogs.Items[0].ConvertedOutput, "token") != "***" { + t.Fatalf("admin task preprocessing logs should be masked by default: %+v", maskedLogs.Items) + } + var fullLogs struct { + Items []store.TaskParamPreprocessingLog `json:"items"` + } + doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks/"+taskB.ID+"/param-preprocessing?sensitive=full", operatorToken, nil, http.StatusOK, &fullLogs) + if len(fullLogs.Items) != 1 || + nestedString(fullLogs.Items[0].ActualInput, "password") != "input-secret" || + nestedString(fullLogs.Items[0].ConvertedOutput, "token") != "converted-secret" { + t.Fatalf("explicit full preprocessing logs should expose stored content: %+v", fullLogs.Items) + } + + var ownTasks struct { + Items []store.GatewayTask `json:"items"` + } + doJSON(t, server.URL, http.MethodGet, "/api/workspace/tasks?pageSize=100", userAToken, nil, http.StatusOK, &ownTasks) + if !gatewayTaskListHasID(ownTasks.Items, taskA.ID) || gatewayTaskListHasID(ownTasks.Items, taskB.ID) { + t.Fatalf("workspace task list must remain scoped to the current user: %+v", ownTasks.Items) + } + doJSON(t, server.URL, http.MethodGet, "/api/workspace/tasks/"+taskB.ID, userAToken, nil, http.StatusNotFound, nil) + doJSON(t, server.URL, http.MethodGet, "/api/workspace/tasks/"+taskB.ID+"/param-preprocessing", userAToken, nil, http.StatusNotFound, nil) +} + +func nestedString(value map[string]any, path ...string) string { + var current any = value + for _, key := range path { + object, ok := current.(map[string]any) + if !ok { + return "" + } + current = object[key] + } + result, _ := current.(string) + return result +} + +func gatewayTaskListHasID(items []store.GatewayTask, taskID string) bool { + for _, item := range items { + if item.ID == taskID { + return true + } + } + return false +} diff --git a/apps/api/internal/httpapi/core_flow_integration_test.go b/apps/api/internal/httpapi/core_flow_integration_test.go index 1eb15ac..73e5800 100644 --- a/apps/api/internal/httpapi/core_flow_integration_test.go +++ b/apps/api/internal/httpapi/core_flow_integration_test.go @@ -1695,6 +1695,64 @@ WHERE m.platform_id = $1::uuid if !taskListContains(workspaceTaskList.Items, taskResponse.Task.ID) || !taskListContains(workspaceTaskList.Items, pricingTask.Task.ID) { t.Fatalf("workspace task list should include persisted task records, got %+v", workspaceTaskList.Items) } + doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks", ordinaryLoginResponse.AccessToken, nil, http.StatusForbidden, nil) + if _, err := testPool.Exec(ctx, ` +UPDATE gateway_tasks +SET request = request || '{"diagnostic":{"apiKey":"private-admin-task-test-key"}}'::jsonb +WHERE id = $1::uuid`, taskResponse.Task.ID); err != nil { + t.Fatalf("seed admin task redaction payload: %v", err) + } + var adminTaskList struct { + Items []struct { + ID string `json:"id"` + Request map[string]any `json:"request"` + Context struct { + User struct { + ID string `json:"id"` + Username string `json:"username"` + } `json:"user"` + Tenant struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"tenant"` + } `json:"adminContext"` + } `json:"items"` + Total int `json:"total"` + } + adminTaskQuery := "/api/admin/tasks?q=" + taskResponse.Task.ID + + "&userId=" + smokeGatewayUserID + + "&status=succeeded" + + "&platformId=" + platform.ID + + "&model=" + defaultTextModel + + "&modelType=text_generate" + + "&runMode=simulation" + + "&billingStatus=settled" + + "&apiKey=smoke" + + "&pageSize=10" + doJSON(t, server.URL, http.MethodGet, adminTaskQuery, loginResponse.AccessToken, nil, http.StatusOK, &adminTaskList) + if len(adminTaskList.Items) != 1 || adminTaskList.Total != 1 || adminTaskList.Items[0].Context.User.Username != username { + t.Fatalf("admin task list should expose the matching task and identity summary: %+v", adminTaskList) + } + maskedDiagnostic, _ := adminTaskList.Items[0].Request["diagnostic"].(map[string]any) + if maskedDiagnostic["apiKey"] != "***" { + t.Fatalf("admin task list must mask nested secrets: %+v", adminTaskList.Items[0].Request) + } + var maskedAdminTask struct { + Request map[string]any `json:"request"` + } + doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks/"+taskResponse.Task.ID, loginResponse.AccessToken, nil, http.StatusOK, &maskedAdminTask) + maskedDiagnostic, _ = maskedAdminTask.Request["diagnostic"].(map[string]any) + if maskedDiagnostic["apiKey"] != "***" { + t.Fatalf("admin task detail must be masked by default: %+v", maskedAdminTask.Request) + } + var fullAdminTask struct { + Request map[string]any `json:"request"` + } + doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks/"+taskResponse.Task.ID+"?sensitive=full", loginResponse.AccessToken, nil, http.StatusOK, &fullAdminTask) + fullDiagnostic, _ := fullAdminTask.Request["diagnostic"].(map[string]any) + if fullDiagnostic["apiKey"] != "private-admin-task-test-key" { + t.Fatalf("explicit full admin task detail should return stored content: %+v", fullAdminTask.Request) + } req, err := http.NewRequest(http.MethodGet, server.URL+"/api/v1/tasks/"+pricingTask.Task.ID+"/events", nil) if err != nil { diff --git a/apps/api/internal/httpapi/openapi_models.go b/apps/api/internal/httpapi/openapi_models.go index 9141dd4..aa70b9b 100644 --- a/apps/api/internal/httpapi/openapi_models.go +++ b/apps/api/internal/httpapi/openapi_models.go @@ -189,6 +189,13 @@ type TaskListResponse struct { PageSize int `json:"pageSize" example:"50"` } +type AdminTaskListResponse struct { + Items []store.AdminGatewayTask `json:"items"` + Total int `json:"total" example:"42"` + Page int `json:"page" example:"1"` + PageSize int `json:"pageSize" example:"50"` +} + type TaskParamPreprocessingLogListResponse struct { Items []store.TaskParamPreprocessingLog `json:"items"` } diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 8254014..00efea0 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -184,6 +184,9 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor mux.Handle("POST /api/admin/access-rules/batch", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.batchAccessRules))) mux.Handle("PATCH /api/admin/access-rules/{ruleID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateAccessRule))) mux.Handle("DELETE /api/admin/access-rules/{ruleID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteAccessRule))) + mux.Handle("GET /api/admin/tasks", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listAdminTasks))) + mux.Handle("GET /api/admin/tasks/{taskID}", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getAdminTask))) + mux.Handle("GET /api/admin/tasks/{taskID}/param-preprocessing", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.adminTaskParamPreprocessing))) mux.Handle("GET /api/v1/api-keys", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeys))) mux.Handle("POST /api/v1/api-keys", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createAPIKey))) mux.Handle("GET /api/v1/api-keys/access-rules", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeyAccessRules))) diff --git a/apps/api/internal/store/admin_task_redaction.go b/apps/api/internal/store/admin_task_redaction.go new file mode 100644 index 0000000..a6e7e95 --- /dev/null +++ b/apps/api/internal/store/admin_task_redaction.go @@ -0,0 +1,94 @@ +package store + +import "strings" + +const maskedAdminTaskValue = "***" + +func MaskAdminGatewayTask(task AdminGatewayTask) AdminGatewayTask { + task.Request = maskSensitiveObject(task.Request) + task.Result = maskSensitiveObject(task.Result) + task.Billings = maskSensitiveArray(task.Billings) + task.Usage = maskSensitiveObject(task.Usage) + task.Metrics = maskSensitiveObject(task.Metrics) + task.BillingSummary = maskSensitiveObject(task.BillingSummary) + task.PricingSnapshot = maskSensitiveObject(task.PricingSnapshot) + task.CompatibilitySubmitHeaders = maskSensitiveObject(task.CompatibilitySubmitHeaders) + task.CompatibilitySubmitBody = maskSensitiveObject(task.CompatibilitySubmitBody) + task.Attempts = append([]TaskAttempt(nil), task.Attempts...) + for index := range task.Attempts { + task.Attempts[index].Usage = maskSensitiveObject(task.Attempts[index].Usage) + task.Attempts[index].Metrics = maskSensitiveObject(task.Attempts[index].Metrics) + task.Attempts[index].RequestSnapshot = maskSensitiveObject(task.Attempts[index].RequestSnapshot) + task.Attempts[index].ResponseSnapshot = maskSensitiveObject(task.Attempts[index].ResponseSnapshot) + task.Attempts[index].PricingSnapshot = maskSensitiveObject(task.Attempts[index].PricingSnapshot) + } + return task +} + +func MaskTaskParamPreprocessingLogs(items []TaskParamPreprocessingLog) []TaskParamPreprocessingLog { + masked := make([]TaskParamPreprocessingLog, len(items)) + for index, item := range items { + item.ActualInput = maskSensitiveObject(item.ActualInput) + item.ConvertedOutput = maskSensitiveObject(item.ConvertedOutput) + item.Changes = maskSensitiveArray(item.Changes) + item.ModelSnapshot = maskSensitiveObject(item.ModelSnapshot) + masked[index] = item + } + return masked +} + +func maskSensitiveObject(value map[string]any) map[string]any { + if value == nil { + return nil + } + masked, _ := maskSensitiveValue(value).(map[string]any) + return masked +} + +func maskSensitiveArray(value []any) []any { + if value == nil { + return nil + } + masked, _ := maskSensitiveValue(value).([]any) + return masked +} + +func maskSensitiveValue(value any) any { + switch typed := value.(type) { + case map[string]any: + masked := make(map[string]any, len(typed)) + for key, item := range typed { + if adminTaskSensitiveKey(key) { + masked[key] = maskedAdminTaskValue + continue + } + masked[key] = maskSensitiveValue(item) + } + return masked + case []any: + masked := make([]any, len(typed)) + for index, item := range typed { + masked[index] = maskSensitiveValue(item) + } + return masked + default: + return value + } +} + +func adminTaskSensitiveKey(key string) bool { + normalized := strings.NewReplacer("_", "", "-", "", ".", "", " ", "").Replace(strings.ToLower(strings.TrimSpace(key))) + switch normalized { + case "authorization", "proxyauthorization", "apikey", "xapikey", "accesstoken", "refreshtoken", + "idtoken", "token", "secret", "clientsecret", "password", "passwd", "cookie", "setcookie", + "credentials", "credential", "privatekey": + return true + default: + return strings.HasSuffix(normalized, "apikey") || + strings.HasSuffix(normalized, "accesstoken") || + strings.HasSuffix(normalized, "refreshtoken") || + strings.HasSuffix(normalized, "clientsecret") || + strings.HasSuffix(normalized, "password") || + strings.HasSuffix(normalized, "privatekey") + } +} diff --git a/apps/api/internal/store/admin_task_redaction_test.go b/apps/api/internal/store/admin_task_redaction_test.go new file mode 100644 index 0000000..21bc9b9 --- /dev/null +++ b/apps/api/internal/store/admin_task_redaction_test.go @@ -0,0 +1,87 @@ +package store + +import ( + "strings" + "testing" +) + +func TestMaskAdminGatewayTaskRecursivelyMasksSecretsWithoutMutatingSource(t *testing.T) { + source := AdminGatewayTask{ + GatewayTask: GatewayTask{ + Request: map[string]any{ + "model": "example", + "headers": map[string]any{ + "Authorization": "Bearer private", + "X-Api-Key": "secret-key", + }, + "usage": map[string]any{"input_tokens": float64(12)}, + }, + Result: map[string]any{ + "nested": []any{map[string]any{"password": "private-password"}}, + }, + Attempts: []TaskAttempt{{ + RequestSnapshot: map[string]any{"client_secret": "private-client-secret"}, + }}, + }, + } + + masked := MaskAdminGatewayTask(source) + headers := masked.Request["headers"].(map[string]any) + if headers["Authorization"] != maskedAdminTaskValue || headers["X-Api-Key"] != maskedAdminTaskValue { + t.Fatalf("sensitive headers were not masked: %#v", headers) + } + if masked.Request["usage"].(map[string]any)["input_tokens"] != float64(12) { + t.Fatalf("token usage must remain visible: %#v", masked.Request) + } + nested := masked.Result["nested"].([]any)[0].(map[string]any) + if nested["password"] != maskedAdminTaskValue { + t.Fatalf("nested password was not masked: %#v", nested) + } + if masked.Attempts[0].RequestSnapshot["client_secret"] != maskedAdminTaskValue { + t.Fatalf("attempt snapshot secret was not masked: %#v", masked.Attempts[0]) + } + if source.Request["headers"].(map[string]any)["Authorization"] != "Bearer private" { + t.Fatal("masking mutated the source request") + } + if source.Attempts[0].RequestSnapshot["client_secret"] != "private-client-secret" { + t.Fatal("masking mutated the source attempts") + } +} + +func TestBuildAdminTaskWhereUsesSharedPlaceholdersAndAllFilters(t *testing.T) { + where, args := buildAdminTaskWhere(AdminTaskListFilter{ + Query: "needle", + GatewayTenant: "00000000-0000-0000-0000-000000000001", + GatewayUser: "00000000-0000-0000-0000-000000000002", + UserGroup: "00000000-0000-0000-0000-000000000003", + Status: "failed", + Platform: "00000000-0000-0000-0000-000000000004", + Model: "model-a", + ModelType: "image_generate", + RunMode: "production", + BillingStatus: "settled", + APIKey: "ops", + }) + sql := strings.Join(where, "\n") + if strings.Contains(sql, "%!") || strings.Contains(sql, "$%d") { + t.Fatalf("SQL contains an unresolved placeholder: %s", sql) + } + if len(args) != 11 { + t.Fatalf("argument count=%d, want 11", len(args)) + } + if strings.Count(sql, "$1") < 10 { + t.Fatalf("keyword search should reuse one placeholder, got: %s", sql) + } + for _, fragment := range []string{ + "t.gateway_tenant_id", + "t.gateway_user_id", + "t.user_group_id", + "platform_attempt.platform_id", + "t.billing_status", + "t.api_key_prefix", + } { + if !strings.Contains(sql, fragment) { + t.Fatalf("SQL is missing %q: %s", fragment, sql) + } + } +} diff --git a/apps/api/internal/store/admin_tasks.go b/apps/api/internal/store/admin_tasks.go new file mode 100644 index 0000000..01c12a1 --- /dev/null +++ b/apps/api/internal/store/admin_tasks.go @@ -0,0 +1,385 @@ +package store + +import ( + "context" + "fmt" + "strings" + "time" +) + +type AdminTaskListFilter struct { + Query string + GatewayTenant string + GatewayUser string + UserGroup string + Status string + Platform string + Model string + ModelType string + RunMode string + BillingStatus string + APIKey string + CreatedFrom *time.Time + CreatedTo *time.Time + Page int + PageSize int +} + +type AdminTaskUserSummary struct { + ID string `json:"id"` + Username string `json:"username,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Email string `json:"email,omitempty"` + Source string `json:"source,omitempty"` +} + +type AdminTaskTenantSummary struct { + ID string `json:"id"` + TenantKey string `json:"tenantKey,omitempty"` + Name string `json:"name,omitempty"` +} + +type AdminTaskPlatformSummary struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Provider string `json:"provider,omitempty"` +} + +type AdminTaskContext struct { + User *AdminTaskUserSummary `json:"user,omitempty"` + Tenant *AdminTaskTenantSummary `json:"tenant,omitempty"` + LatestPlatform *AdminTaskPlatformSummary `json:"latestPlatform,omitempty"` +} + +type AdminGatewayTask struct { + GatewayTask + AdminContext AdminTaskContext `json:"adminContext"` +} + +type AdminTaskListResult struct { + Items []AdminGatewayTask + Total int + Page int + PageSize int +} + +func (s *Store) ListAdminTasks(ctx context.Context, filter AdminTaskListFilter) (AdminTaskListResult, error) { + page := filter.Page + if page <= 0 { + page = 1 + } + pageSize := filter.PageSize + if pageSize <= 0 { + pageSize = 50 + } + if pageSize > 100 { + pageSize = 100 + } + + where, args := buildAdminTaskWhere(filter) + var total int + if err := s.pool.QueryRow(ctx, ` +SELECT count(*) +FROM gateway_tasks t +WHERE `+strings.Join(where, "\n AND "), args...).Scan(&total); err != nil { + return AdminTaskListResult{}, err + } + + queryArgs := append([]any{}, args...) + queryArgs = append(queryArgs, pageSize, (page-1)*pageSize) + rows, err := s.pool.Query(ctx, ` +SELECT t.id::text +FROM gateway_tasks t +WHERE `+strings.Join(where, "\n AND ")+` +ORDER BY t.created_at DESC, t.id DESC +LIMIT $`+fmt.Sprint(len(args)+1)+` OFFSET $`+fmt.Sprint(len(args)+2), queryArgs...) + if err != nil { + return AdminTaskListResult{}, err + } + defer rows.Close() + + taskIDs := make([]string, 0, pageSize) + for rows.Next() { + var taskID string + if err := rows.Scan(&taskID); err != nil { + return AdminTaskListResult{}, err + } + taskIDs = append(taskIDs, taskID) + } + if err := rows.Err(); err != nil { + return AdminTaskListResult{}, err + } + + items, err := s.loadAdminTasksByIDs(ctx, taskIDs) + if err != nil { + return AdminTaskListResult{}, err + } + return AdminTaskListResult{ + Items: items, + Total: total, + Page: page, + PageSize: pageSize, + }, nil +} + +func (s *Store) GetAdminTask(ctx context.Context, taskID string) (AdminGatewayTask, error) { + task, err := s.GetTask(ctx, taskID) + if err != nil { + return AdminGatewayTask{}, err + } + contexts, err := s.loadAdminTaskContexts(ctx, []string{task.ID}) + if err != nil { + return AdminGatewayTask{}, err + } + return adminTaskWithContext(task, contexts[task.ID]), nil +} + +func buildAdminTaskWhere(filter AdminTaskListFilter) ([]string, []any) { + where := []string{"TRUE"} + args := make([]any, 0, 14) + add := func(clause string, value any) { + args = append(args, value) + placeholder := fmt.Sprintf("$%d", len(args)) + where = append(where, strings.ReplaceAll(clause, "$%d", placeholder)) + } + + if query := strings.TrimSpace(filter.Query); query != "" { + add(`( + t.id::text ILIKE $%d + OR COALESCE(t.request_id, '') ILIKE $%d + OR t.kind ILIKE $%d + OR t.model ILIKE $%d + OR COALESCE(t.requested_model, '') ILIKE $%d + OR COALESCE(t.resolved_model, '') ILIKE $%d + OR COALESCE(t.api_key_id, '') ILIKE $%d + OR COALESCE(t.api_key_name, '') ILIKE $%d + OR COALESCE(t.api_key_prefix, '') ILIKE $%d + OR COALESCE(t.model_type, '') ILIKE $%d + OR EXISTS ( + SELECT 1 + FROM gateway_users admin_user + WHERE admin_user.id = t.gateway_user_id + AND ( + admin_user.username ILIKE $%d + OR COALESCE(admin_user.display_name, '') ILIKE $%d + OR COALESCE(admin_user.email, '') ILIKE $%d + OR admin_user.user_key ILIKE $%d + ) + ) + OR EXISTS ( + SELECT 1 + FROM gateway_tenants admin_tenant + WHERE admin_tenant.id = t.gateway_tenant_id + AND ( + admin_tenant.name ILIKE $%d + OR admin_tenant.tenant_key ILIKE $%d + ) + ) + OR EXISTS ( + SELECT 1 + FROM gateway_task_attempts admin_attempt + LEFT JOIN integration_platforms admin_platform ON admin_platform.id = admin_attempt.platform_id + LEFT JOIN platform_models admin_model ON admin_model.id = admin_attempt.platform_model_id + WHERE admin_attempt.task_id = t.id + AND ( + COALESCE(admin_platform.name, '') ILIKE $%d + OR COALESCE(admin_platform.internal_name, '') ILIKE $%d + OR COALESCE(admin_platform.provider, '') ILIKE $%d + OR COALESCE(admin_model.model_name, '') ILIKE $%d + OR COALESCE(admin_model.provider_model_name, '') ILIKE $%d + OR COALESCE(admin_model.model_alias, '') ILIKE $%d + ) + ) + )`, "%"+query+"%") + } + if value := strings.TrimSpace(filter.GatewayTenant); value != "" { + add("t.gateway_tenant_id = $%d::uuid", value) + } + if value := strings.TrimSpace(filter.GatewayUser); value != "" { + add("t.gateway_user_id = $%d::uuid", value) + } + if value := strings.TrimSpace(filter.UserGroup); value != "" { + add("t.user_group_id = $%d::uuid", value) + } + if value := strings.TrimSpace(filter.Status); value != "" { + add("t.status = $%d", value) + } + if value := strings.TrimSpace(filter.Platform); value != "" { + add(`EXISTS ( + SELECT 1 + FROM gateway_task_attempts platform_attempt + WHERE platform_attempt.task_id = t.id + AND platform_attempt.platform_id = $%d::uuid + )`, value) + } + if value := strings.TrimSpace(filter.Model); value != "" { + add(`( + t.model = $%d + OR COALESCE(t.requested_model, '') = $%d + OR COALESCE(t.resolved_model, '') = $%d + OR EXISTS ( + SELECT 1 + FROM gateway_task_attempts model_attempt + LEFT JOIN platform_models selected_model ON selected_model.id = model_attempt.platform_model_id + WHERE model_attempt.task_id = t.id + AND ( + COALESCE(selected_model.model_name, '') = $%d + OR COALESCE(selected_model.provider_model_name, '') = $%d + OR COALESCE(selected_model.model_alias, '') = $%d + ) + ) + )`, value) + } + if value := strings.TrimSpace(filter.ModelType); value != "" { + add("COALESCE(t.model_type, '') = $%d", value) + } + if value := strings.TrimSpace(filter.RunMode); value != "" { + add("t.run_mode = $%d", value) + } + if value := strings.TrimSpace(filter.BillingStatus); value != "" { + add("t.billing_status = $%d", value) + } + if value := strings.TrimSpace(filter.APIKey); value != "" { + add(`( + COALESCE(t.api_key_id, '') ILIKE $%d + OR COALESCE(t.api_key_name, '') ILIKE $%d + OR COALESCE(t.api_key_prefix, '') ILIKE $%d + )`, "%"+value+"%") + } + if filter.CreatedFrom != nil { + add("t.created_at >= $%d::timestamptz", *filter.CreatedFrom) + } + if filter.CreatedTo != nil { + add("t.created_at <= $%d::timestamptz", *filter.CreatedTo) + } + return where, args +} + +func (s *Store) loadAdminTasksByIDs(ctx context.Context, taskIDs []string) ([]AdminGatewayTask, error) { + if len(taskIDs) == 0 { + return []AdminGatewayTask{}, nil + } + rows, err := s.pool.Query(ctx, ` +SELECT `+gatewayTaskColumns+` +FROM gateway_tasks +WHERE id::text = ANY($1)`, taskIDs) + if err != nil { + return nil, err + } + defer rows.Close() + + tasksByID := make(map[string]GatewayTask, len(taskIDs)) + tasks := make([]GatewayTask, 0, len(taskIDs)) + for rows.Next() { + task, err := scanGatewayTask(rows) + if err != nil { + return nil, err + } + tasks = append(tasks, task) + } + if err := rows.Err(); err != nil { + return nil, err + } + tasks, err = s.attachTaskAttempts(ctx, tasks) + if err != nil { + return nil, err + } + for _, task := range tasks { + tasksByID[task.ID] = task + } + contexts, err := s.loadAdminTaskContexts(ctx, taskIDs) + if err != nil { + return nil, err + } + + items := make([]AdminGatewayTask, 0, len(taskIDs)) + for _, taskID := range taskIDs { + task, ok := tasksByID[taskID] + if !ok { + continue + } + items = append(items, adminTaskWithContext(task, contexts[taskID])) + } + return items, nil +} + +func (s *Store) loadAdminTaskContexts(ctx context.Context, taskIDs []string) (map[string]AdminTaskContext, error) { + contexts := make(map[string]AdminTaskContext, len(taskIDs)) + if len(taskIDs) == 0 { + return contexts, nil + } + rows, err := s.pool.Query(ctx, ` +SELECT t.id::text, + COALESCE(u.id::text, ''), COALESCE(u.username, ''), COALESCE(u.display_name, ''), + COALESCE(u.email, ''), COALESCE(u.source, ''), + COALESCE(tenant.id::text, ''), COALESCE(tenant.tenant_key, ''), COALESCE(tenant.name, '') +FROM gateway_tasks t +LEFT JOIN gateway_users u ON u.id = t.gateway_user_id +LEFT JOIN gateway_tenants tenant ON tenant.id = t.gateway_tenant_id +WHERE t.id::text = ANY($1)`, taskIDs) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var taskID string + var user AdminTaskUserSummary + var tenant AdminTaskTenantSummary + if err := rows.Scan( + &taskID, + &user.ID, + &user.Username, + &user.DisplayName, + &user.Email, + &user.Source, + &tenant.ID, + &tenant.TenantKey, + &tenant.Name, + ); err != nil { + return nil, err + } + context := AdminTaskContext{} + if user.ID != "" { + context.User = &user + } + if tenant.ID != "" { + context.Tenant = &tenant + } + contexts[taskID] = context + } + return contexts, rows.Err() +} + +func adminTaskWithContext(task GatewayTask, context AdminTaskContext) AdminGatewayTask { + if context.User == nil { + userID := strings.TrimSpace(task.GatewayUserID) + if userID == "" { + userID = strings.TrimSpace(task.UserID) + } + if userID != "" { + context.User = &AdminTaskUserSummary{ID: userID, Source: task.UserSource} + } + } + if context.Tenant == nil { + tenantID := strings.TrimSpace(task.GatewayTenantID) + if tenantID == "" { + tenantID = strings.TrimSpace(task.TenantID) + } + if tenantID != "" || strings.TrimSpace(task.TenantKey) != "" { + context.Tenant = &AdminTaskTenantSummary{ID: tenantID, TenantKey: task.TenantKey} + } + } + for index := len(task.Attempts) - 1; index >= 0; index-- { + attempt := task.Attempts[index] + if strings.TrimSpace(attempt.PlatformID) == "" { + continue + } + context.LatestPlatform = &AdminTaskPlatformSummary{ + ID: attempt.PlatformID, + Name: attempt.PlatformName, + Provider: attempt.Provider, + } + break + } + return AdminGatewayTask{GatewayTask: task, AdminContext: context} +} diff --git a/apps/api/migrations/0087_admin_task_query_indexes.sql b/apps/api/migrations/0087_admin_task_query_indexes.sql new file mode 100644 index 0000000..55b288a --- /dev/null +++ b/apps/api/migrations/0087_admin_task_query_indexes.sql @@ -0,0 +1,22 @@ +-- easyai:migration:no-transaction +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_gateway_tasks_admin_created + ON gateway_tasks(created_at DESC, id DESC); + +-- easyai:migration:statement +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_gateway_tasks_admin_status_created + ON gateway_tasks(status, created_at DESC, id DESC); + +-- easyai:migration:statement +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_gateway_tasks_admin_user_created + ON gateway_tasks(gateway_user_id, created_at DESC, id DESC) + WHERE gateway_user_id IS NOT NULL; + +-- easyai:migration:statement +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_gateway_tasks_admin_tenant_created + ON gateway_tasks(gateway_tenant_id, created_at DESC, id DESC) + WHERE gateway_tenant_id IS NOT NULL; + +-- easyai:migration:statement +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_gateway_task_attempts_admin_platform_task + ON gateway_task_attempts(platform_id, task_id) + WHERE platform_id IS NOT NULL; diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index ef16cfb..9b9f99d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react'; import type { + AdminGatewayTask, BaseModelCatalogItem, CatalogProvider, ClientCustomizationSettings, @@ -67,6 +68,7 @@ import { getSecurityEventConnection, getWalletSummary, listAccessRules, + listAdminTasks, listAuditLogs, listApiKeyAccessRules, listApiKeyAssignableModels, @@ -141,7 +143,9 @@ import { ModelsPage } from './pages/ModelsPage'; import { PlaygroundPage } from './pages/PlaygroundPage'; import { WorkspacePage } from './pages/WorkspacePage'; import { + adminTaskQueryKey, parseAppRoute, + pathForAdminTaskQuery, pathForAdminSection, pathForApiDocSection, pathForPage, @@ -153,6 +157,7 @@ import { } from './routing'; import type { AdminSection, + AdminTaskQuery, ApiDocSection, ApiKeyForm, AuthMode, @@ -197,6 +202,7 @@ type DataKey = | 'tenants' | 'users' | 'userGroups' + | 'adminTasks' | 'tasks' | 'wallet' | 'walletTransactions' @@ -208,6 +214,7 @@ export function App() { const initialRoute = parseAppRoute(); const [activePage, setActivePage] = useState(initialRoute.activePage); const [adminSection, setAdminSection] = useState(initialRoute.adminSection); + const [adminTaskQuery, setAdminTaskQuery] = useState(initialRoute.adminTaskQuery); const [workspaceSection, setWorkspaceSection] = useState(initialRoute.workspaceSection); const [workspaceTaskQuery, setWorkspaceTaskQuery] = useState(initialRoute.workspaceTaskQuery); const [workspaceTransactionQuery, setWorkspaceTransactionQuery] = useState(() => defaultWorkspaceTransactionQuery()); @@ -258,6 +265,10 @@ export function App() { const [taskResult, setTaskResult] = useState(null); const [tasks, setTasks] = useState([]); const [taskTotal, setTaskTotal] = useState(0); + const [adminTasks, setAdminTasks] = useState([]); + const [adminTaskTotal, setAdminTaskTotal] = useState(0); + const [adminTasksUpdatedAt, setAdminTasksUpdatedAt] = useState(null); + const [adminTaskState, setAdminTaskState] = useState('idle'); const [walletAccounts, setWalletAccounts] = useState([]); const [walletTransactions, setWalletTransactions] = useState([]); const [walletTransactionTotal, setWalletTransactionTotal] = useState(0); @@ -279,6 +290,8 @@ export function App() { const loadingDataKeysRef = useRef(new Set()); const loadedTaskQueryKeyRef = useRef(''); const currentTaskQueryKeyRef = useRef(''); + const loadedAdminTaskQueryKeyRef = useRef(''); + const currentAdminTaskQueryKeyRef = useRef(''); const loadedTransactionQueryKeyRef = useRef(''); const currentTransactionQueryKeyRef = useRef(''); const { removeBaseModel, removeProvider, resetAllBaseModelsToDefault, resetBaseModelToDefault, saveBaseModel, saveProvider } = useCatalogOperations({ @@ -302,8 +315,10 @@ export function App() { token, }); const taskListRequestKey = workspaceTaskQueryKey(workspaceTaskQuery); + const adminTaskListRequestKey = adminTaskQueryKey(adminTaskQuery); const transactionListRequestKey = workspaceTransactionQueryKey(workspaceTransactionQuery); currentTaskQueryKeyRef.current = taskListRequestKey; + currentAdminTaskQueryKeyRef.current = adminTaskListRequestKey; currentTransactionQueryKeyRef.current = transactionListRequestKey; useEffect(() => { @@ -360,7 +375,7 @@ export function App() { }, []); useEffect(() => { void ensureRouteData(token); - }, [activePage, adminSection, taskListRequestKey, transactionListRequestKey, workspaceSection, token]); + }, [activePage, adminSection, adminTaskListRequestKey, taskListRequestKey, transactionListRequestKey, workspaceSection, token]); useEffect(() => { if (!token || activePage !== 'admin' || adminSection !== 'realtimeLoad') return undefined; const timer = window.setInterval(() => { @@ -409,6 +424,7 @@ export function App() { const data = useMemo(() => ({ accessRules, + adminTasks, auditLogs, apiKeys, baseModels, @@ -437,7 +453,7 @@ export function App() { users, walletAccounts, walletTransactions, - }), [accessRules, apiKeys, auditLogs, baseModels, clientCustomizationSettings, currentUser, currentUserGroups, fileStorageChannels, fileStorageSettings, modelCatalog, modelRateLimits, modelRateLimitsUpdatedAt, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, securityEventConnection, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions]); + }), [accessRules, adminTasks, apiKeys, auditLogs, baseModels, clientCustomizationSettings, currentUser, currentUserGroups, fileStorageChannels, fileStorageSettings, modelCatalog, modelRateLimits, modelRateLimitsUpdatedAt, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, securityEventConnection, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions]); async function refresh(nextToken = token) { await ensureRouteData(nextToken, true); @@ -452,6 +468,10 @@ export function App() { loadedDataKeysRef.current.delete('tasks'); loadingDataKeysRef.current.delete('tasks'); } + if (activePage === 'admin' && adminSection === 'tasks' && loadedAdminTaskQueryKeyRef.current !== adminTaskListRequestKey) { + loadedDataKeysRef.current.delete('adminTasks'); + loadingDataKeysRef.current.delete('adminTasks'); + } if (activePage === 'workspace' && workspaceSection === 'transactions' && loadedTransactionQueryKeyRef.current !== transactionListRequestKey) { loadedDataKeysRef.current.delete('walletTransactions'); loadingDataKeysRef.current.delete('walletTransactions'); @@ -590,6 +610,26 @@ export function App() { loadedTaskQueryKeyRef.current = requestKey; } return; + case 'adminTasks': + { + const requestKey = adminTaskListRequestKey; + setAdminTaskState('loading'); + try { + const response = await listAdminTasks(nextToken, adminTaskQuery); + if (requestKey !== currentAdminTaskQueryKeyRef.current) return; + setAdminTasks(response.items); + setAdminTaskTotal(response.total ?? response.items.length); + setAdminTasksUpdatedAt(Date.now()); + loadedAdminTaskQueryKeyRef.current = requestKey; + setAdminTaskState('ready'); + } catch (err) { + if (requestKey === currentAdminTaskQueryKeyRef.current) { + setAdminTaskState('error'); + } + throw err; + } + } + return; case 'wallet': { const response = await getWalletSummary(nextToken); setWalletAccounts(response.accounts); @@ -1219,6 +1259,10 @@ export function App() { setTaskResult(null); setTasks([]); setTaskTotal(0); + setAdminTasks([]); + setAdminTaskTotal(0); + setAdminTasksUpdatedAt(null); + setAdminTaskState('idle'); setWalletAccounts([]); setWalletTransactions([]); setWalletTransactionTotal(0); @@ -1270,12 +1314,13 @@ export function App() { } function currentRouteState(): AppRouteState { - return { activePage, adminSection, apiDocSection, playgroundMode, workspaceSection, workspaceTaskQuery }; + return { activePage, adminSection, adminTaskQuery, apiDocSection, playgroundMode, workspaceSection, workspaceTaskQuery }; } function applyRoute(route: AppRouteState) { setActivePage(route.activePage); setAdminSection(route.adminSection); + setAdminTaskQuery(route.adminTaskQuery); setApiDocSection(route.apiDocSection); setPlaygroundMode(route.playgroundMode); setWorkspaceSection(route.workspaceSection); @@ -1298,6 +1343,15 @@ export function App() { navigatePath(pathForAdminSection(section)); } + function navigateAdminTaskQuery(query: AdminTaskQuery) { + navigatePath(pathForAdminTaskQuery(query)); + } + + async function refreshAdminTasks() { + loadedDataKeysRef.current.delete('adminTasks'); + await ensureData(['adminTasks'], token, true); + } + function navigateWorkspaceSection(section: WorkspaceSection) { navigatePath(pathForWorkspaceSection(section)); } @@ -1405,7 +1459,11 @@ export function App() { {activePage === 'admin' && ( isAuthenticated ? ( setCoreMessage('')} + onAdminTaskQueryChange={navigateAdminTaskQuery} + onRefreshAdminTasks={refreshAdminTasks} onSectionChange={navigateAdminSection} /> ) : ( @@ -1647,22 +1707,24 @@ function dataKeysForRoute( return ['pricingRuleSets']; case 'runtime': return ['runtimePolicySets', 'runnerPolicy']; + case 'billingSettlements': + return []; case 'baseModels': return ['baseModels', 'providers', 'pricingRuleSets', 'runtimePolicySets']; case 'platforms': return ['platforms', 'models', 'providers', 'baseModels', 'pricingRuleSets', 'networkProxyConfig']; case 'realtimeLoad': return ['platforms', 'modelRateLimits']; + case 'tasks': + return ['adminTasks', 'tenants', 'users', 'userGroups', 'platforms', 'models']; case 'tenants': return ['tenants', 'userGroups']; case 'users': return ['users', 'tenants', 'userGroups']; case 'userGroups': - return ['userGroups']; + return ['userGroups', 'accessRules', 'platforms', 'models']; case 'auditLogs': return ['auditLogs']; - case 'accessRules': - return ['accessRules', 'userGroups', 'platforms', 'models']; case 'systemSettings': return ['clientCustomizationSettings', 'fileStorageSettings', 'fileStorageChannels', 'securityEventConnection']; default: diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 30ba323..4f96374 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1,4 +1,5 @@ import type { + AdminGatewayTask, AuthResponse, AuthUser, BaseModelCatalogItem, @@ -59,7 +60,7 @@ import type { WalletRechargeRequest, WalletSummaryResponse, } from '@easyai-ai-gateway/contracts'; -import type { PlatformCreateInput, PlatformModelBindingInput, WorkspaceTaskQuery } from './types'; +import type { AdminTaskQuery, PlatformCreateInput, PlatformModelBindingInput, WorkspaceTaskQuery } from './types'; const API_BASE = import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088'; export const OIDC_BROWSER_SESSION_CREDENTIAL = '__easyai_gateway_oidc_browser_session__'; @@ -967,6 +968,42 @@ export async function listTasks(token: string, query: WorkspaceTaskQuery): Promi return request>(`/api/workspace/tasks?${search.toString()}`, { token }); } +export async function listAdminTasks(token: string, query: AdminTaskQuery): Promise> { + const search = new URLSearchParams({ + page: String(query.page), + pageSize: String(query.pageSize), + }); + if (query.query) search.set('q', query.query); + if (query.tenantId) search.set('tenantId', query.tenantId); + if (query.userId) search.set('userId', query.userId); + if (query.userGroupId) search.set('userGroupId', query.userGroupId); + if (query.status) search.set('status', query.status); + if (query.platformId) search.set('platformId', query.platformId); + if (query.model) search.set('model', query.model); + if (query.modelType) search.set('modelType', query.modelType); + if (query.runMode) search.set('runMode', query.runMode); + if (query.billingStatus) search.set('billingStatus', query.billingStatus); + if (query.apiKey) search.set('apiKey', query.apiKey); + if (query.createdFrom) search.set('createdFrom', query.createdFrom); + if (query.createdTo) search.set('createdTo', query.createdTo); + return request>(`/api/admin/tasks?${search.toString()}`, { token }); +} + +export async function getAdminTask(token: string, taskId: string, sensitive: 'masked' | 'full' = 'masked'): Promise { + return request(`/api/admin/tasks/${taskId}?sensitive=${sensitive}`, { token }); +} + +export async function listAdminTaskParamPreprocessing( + token: string, + taskId: string, + sensitive: 'masked' | 'full' = 'masked', +): Promise> { + return request>( + `/api/admin/tasks/${taskId}/param-preprocessing?sensitive=${sensitive}`, + { token }, + ); +} + export async function getWalletSummary(token: string): Promise { return request('/api/workspace/wallet', { token }); } diff --git a/apps/web/src/app-state.ts b/apps/web/src/app-state.ts index 9ef928f..77301f7 100644 --- a/apps/web/src/app-state.ts +++ b/apps/web/src/app-state.ts @@ -1,4 +1,5 @@ import type { + AdminGatewayTask, AuthUser, BaseModelCatalogItem, CatalogProvider, @@ -29,6 +30,7 @@ import type { export interface ConsoleData { accessRules: GatewayAccessRule[]; + adminTasks: AdminGatewayTask[]; auditLogs: GatewayAuditLog[]; apiKeys: GatewayApiKey[]; baseModels: BaseModelCatalogItem[]; diff --git a/apps/web/src/navigation.ts b/apps/web/src/navigation.ts index c3fb4a8..b4798ed 100644 --- a/apps/web/src/navigation.ts +++ b/apps/web/src/navigation.ts @@ -27,7 +27,7 @@ export const primaryModules = [ title: '管理工作台', path: '/admin', description: '租户、用户、用户组、全局模型、平台、实时负载、重试、队列和回调 outbox。', - items: ['租户管理', '用户管理', '用户组策略', '全局模型', '实时负载'], + items: ['租户管理', '用户管理', '用户组策略', '全局模型', '实时负载', '任务记录'], }, { title: 'API 文档', @@ -51,6 +51,8 @@ export const adminPages = [ { title: '全局模型配置', path: '/admin/models/global', description: '基准模型库、能力 schema、基准定价和默认限流模板。' }, { title: '平台管理', path: '/admin/platforms', description: '平台 CRUD、凭证、默认折扣、平台模型、限流和重试策略。' }, { title: '实时负载', path: '/admin/realtime-load', description: '按平台模型查看实时 RPM、TPM、并发、排队和冷却状态。' }, + { title: '任务记录', path: '/admin/tasks', description: '跨租户查询任务、执行链路、参数转换、计费和原始详情。' }, + { title: '计费结算', path: '/admin/billing-settlements', description: '查询计费结算队列,批量处理等待重试和人工复核记录。' }, { title: '运行与队列', path: '/admin/runtime/queues', description: 'TPM/RPM 窗口、并发 lease、cooldown、任务恢复和队列积压。' }, { title: '回调与结算', path: '/admin/callbacks', description: '任务进度 callback outbox、结算 outbox、失败重试和手动 replay。' }, ]; diff --git a/apps/web/src/pages/AdminPage.tsx b/apps/web/src/pages/AdminPage.tsx index f3649fc..6c52eae 100644 --- a/apps/web/src/pages/AdminPage.tsx +++ b/apps/web/src/pages/AdminPage.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { Boxes, Building2, Gauge, History, KeyRound, Route, ServerCog, Settings, ShieldCheck, UsersRound, Workflow } from 'lucide-react'; +import { Boxes, Building2, Gauge, History, KeyRound, ListChecks, ReceiptText, Route, ServerCog, Settings, ShieldCheck, UsersRound, Workflow } from 'lucide-react'; import type { BaseModelUpsertRequest, CatalogProviderUpsertRequest, @@ -22,8 +22,8 @@ import type { ConsoleData, StatItem } from '../app-state'; import { EntityTable } from '../components/EntityTable'; import { StatGrid } from '../components/StatGrid'; import { Badge, Card, CardContent, CardHeader, CardTitle, ScreenMessage, Tabs } from '../components/ui'; -import type { AdminSection, LoadState, PlatformWithModelsInput } from '../types'; -import { AccessRulesPanel } from './admin/AccessRulesPanel'; +import type { AdminSection, AdminTaskQuery, LoadState, PlatformWithModelsInput } from '../types'; +import { AdminTasksPanel } from './admin/AdminTasksPanel'; import { AuditLogsPanel } from './admin/AuditLogsPanel'; import { BaseModelCatalogPanel } from './admin/BaseModelCatalogPanel'; import { BillingSettlementsPanel } from './admin/BillingSettlementsPanel'; @@ -43,10 +43,11 @@ const tabs = [ { value: 'baseModels', label: '基准模型库', icon: }, { value: 'platforms', label: '平台管理', icon: }, { value: 'realtimeLoad', label: '实时负载', icon: }, + { value: 'tasks', label: '任务记录', icon: }, + { value: 'billingSettlements', label: '计费结算', icon: }, { value: 'tenants', label: '租户', icon: }, { value: 'users', label: '用户', icon: }, { value: 'userGroups', label: '用户组', icon: }, - { value: 'accessRules', label: '模型权限', icon: }, { value: 'systemSettings', label: '系统设置', icon: }, { value: 'auditLogs', label: '审计日志', icon: }, ] satisfies Array<{ value: AdminSection; label: string; icon: ReactNode }>; @@ -56,6 +57,10 @@ export function AdminPage(props: { data: ConsoleData; operationMessage: string; section: AdminSection; + adminTaskQuery: AdminTaskQuery; + adminTaskTotal: number; + adminTasksUpdatedAt: number | null; + adminTaskState: LoadState; stats: StatItem[]; state: LoadState; onDeleteBaseModel: (baseModelId: string) => Promise; @@ -94,6 +99,8 @@ export function AdminPage(props: { onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise; onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise; onClearOperationMessage: () => void; + onAdminTaskQueryChange: (value: AdminTaskQuery) => void; + onRefreshAdminTasks: () => Promise; onSectionChange: (value: AdminSection) => void; }) { return ( @@ -132,31 +139,17 @@ export function AdminPage(props: { /> )} {props.section === 'runtime' && ( -
- - -
- )} - {props.section === 'accessRules' && ( - )} + {props.section === 'billingSettlements' && } {props.section === 'pricing' && ( )} + {props.section === 'tasks' && ( + + )} {props.section === 'tenants' && } {props.section === 'users' && } {props.section === 'userGroups' && } @@ -225,6 +230,7 @@ function identityPanelProps(props: { onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise; onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise; onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise; + onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise; }) { return { data: props.data, @@ -237,6 +243,7 @@ function identityPanelProps(props: { onSaveUser: props.onSaveUser, onRechargeUserWalletBalance: props.onRechargeUserWalletBalance, onSaveUserGroup: props.onSaveUserGroup, + onBatchAccessRules: props.onBatchAccessRules, }; } diff --git a/apps/web/src/pages/WorkspacePage.tsx b/apps/web/src/pages/WorkspacePage.tsx index 7f8dd71..419da7c 100644 --- a/apps/web/src/pages/WorkspacePage.tsx +++ b/apps/web/src/pages/WorkspacePage.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from 'react'; import { Popover as AntPopover } from 'antd'; import { ChevronLeft, ChevronRight, Copy, CreditCard, Eye, KeyRound, ListChecks, Plus, ReceiptText, RotateCcw, Search, ShieldCheck, SlidersHorizontal, Trash2, UserRound } from 'lucide-react'; -import type { GatewayAccessRuleBatchRequest, GatewayApiKey, GatewayApiKeyScopeUpdateRequest, GatewayTask, GatewayTaskParamPreprocessingLog, GatewayWalletAccount, GatewayWalletTransaction, IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts'; +import type { AdminGatewayTask, GatewayAccessRuleBatchRequest, GatewayApiKey, GatewayApiKeyScopeUpdateRequest, GatewayTask, GatewayTaskParamPreprocessingLog, GatewayWalletAccount, GatewayWalletTransaction, IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts'; import type { ConsoleData } from '../app-state'; import { EntityTable } from '../components/EntityTable'; import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Checkbox, ConfirmDialog, DateTimePicker, DateTimeRangePicker, FormDialog, Input, Label, Select, Table, TableCell, TableFooter, TableHead, TablePageActions, TableRow, TableToolbar, TableViewportLayout, Tabs } from '../components/ui'; @@ -922,13 +922,26 @@ function TaskPanel(props: { ); } -function TaskRecord(props: { task: GatewayTask; token: string; onCopyRequestId: (task: GatewayTask) => Promise; onOpenJson: (task: GatewayTask) => void }) { +export type TaskParamPreprocessingLoader = ( + token: string, + taskId: string, +) => Promise<{ items: GatewayTaskParamPreprocessingLog[] }>; + +export function TaskRecord(props: { + task: GatewayTask | AdminGatewayTask; + token: string; + admin?: boolean; + onCopyRequestId: (task: GatewayTask) => Promise; + onOpenJson: (task: GatewayTask) => void; + onLoadParamPreprocessing?: TaskParamPreprocessingLoader; +}) { const usage = props.task.usage ?? {}; const tokenUsage = formatTokenUsage(usage); const chargeText = props.task.finalChargeAmount !== undefined ? formatCellValue(props.task.finalChargeAmount) : '-'; const calledModel = taskCalledModelLabel(props.task); const resolvedModel = taskResolvedModelLabel(props.task); const badgeVariant = props.task.status === 'succeeded' ? 'success' : props.task.status === 'failed' ? 'destructive' : 'secondary'; + const adminContext = props.admin && 'adminContext' in props.task ? props.task.adminContext : undefined; return ( @@ -940,6 +953,14 @@ function TaskRecord(props: { task: GatewayTask; token: string; onCopyRequestId: + {props.admin && ( + + + {adminContext?.user?.displayName || adminContext?.user?.username || adminContext?.user?.id || props.task.userId || '-'} + {adminContext?.tenant?.name || adminContext?.tenant?.tenantKey || adminContext?.tenant?.id || props.task.tenantKey || '无租户'} + + + )} {props.task.requestId || '-'} @@ -956,22 +977,32 @@ function TaskRecord(props: { task: GatewayTask; token: string; onCopyRequestId: {props.task.status} + {props.admin && {props.task.runMode || '-'}} {calledModel || '-'} {resolvedModel && resolvedModel !== calledModel && {resolvedModel}} + {props.admin && ( + + + {adminContext?.latestPlatform?.name || '未分配'} + {adminContext?.latestPlatform?.provider || '-'} + + + )} - + {props.task.modelType || '-'} {props.task.apiKeyName || props.task.apiKeyPrefix || props.task.apiKeyId || '-'} {tokenUsage} {chargeText} + {props.admin && {props.task.billingStatus || '-'}} {formatDuration(taskDurationMs(props.task))} {formatDateTime(props.task.createdAt)} @@ -992,7 +1023,7 @@ type TaskParamConversionSummary = { capabilityPaths: string[]; }; -function TaskParamConversionCell(props: { task: GatewayTask; token: string }) { +function TaskParamConversionCell(props: { task: GatewayTask; token: string; loader?: TaskParamPreprocessingLoader }) { const summary = taskParamConversionSummary(props.task); const [open, setOpen] = useState(false); const [logs, setLogs] = useState(null); @@ -1015,7 +1046,7 @@ function TaskParamConversionCell(props: { task: GatewayTask; token: string }) { loadingRef.current = true; setLoadState('loading'); setError(''); - listTaskParamPreprocessing(props.token, props.task.id) + (props.loader ?? listTaskParamPreprocessing)(props.token, props.task.id) .then((response) => { if (cancelled) return; const items = response.items ?? []; diff --git a/apps/web/src/pages/admin/AccessRulesPanel.tsx b/apps/web/src/pages/admin/AccessRulesPanel.tsx deleted file mode 100644 index 65d639f..0000000 --- a/apps/web/src/pages/admin/AccessRulesPanel.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useEffect, useState } from 'react'; -import { ShieldCheck } from 'lucide-react'; -import type { - BaseModelCatalogItem, - GatewayAccessRule, - GatewayAccessRuleBatchRequest, - IntegrationPlatform, - PlatformModel, - UserGroup, -} from '@easyai-ai-gateway/contracts'; -import { Badge, Card, CardContent, CardHeader, CardTitle, Label, Select } from '../../components/ui'; -import type { LoadState } from '../../types'; -import { AccessPermissionEditor } from './AccessPermissionEditor'; - -export function AccessRulesPanel(props: { - accessRules: GatewayAccessRule[]; - baseModels: BaseModelCatalogItem[]; - message: string; - platformModels: PlatformModel[]; - platforms: IntegrationPlatform[]; - state: LoadState; - userGroups: UserGroup[]; - onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise; -}) { - const [selectedGroupId, setSelectedGroupId] = useState(props.userGroups[0]?.id ?? ''); - - useEffect(() => { - if (!selectedGroupId && props.userGroups[0]?.id) { - setSelectedGroupId(props.userGroups[0].id); - } - }, [props.userGroups, selectedGroupId]); - - return ( -
- - -
- 用户组访问权限 -

数据仍写入统一权限规则表;这里按用户组维护专属使用和不允许使用的平台/模型。

-
- {props.accessRules.length} 条规则 -
- - {props.message &&

{props.message}

} -
- -
- - 未配置规则时默认放行;拒绝规则优先于专属规则。 -
-
-
-
- - {selectedGroupId ? ( - - ) : ( -
- 暂无用户组 - 请先创建用户组,再维护平台和模型权限。 -
- )} -
- ); -} diff --git a/apps/web/src/pages/admin/AdminTasksPanel.test.ts b/apps/web/src/pages/admin/AdminTasksPanel.test.ts new file mode 100644 index 0000000..24884eb --- /dev/null +++ b/apps/web/src/pages/admin/AdminTasksPanel.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { adminTaskAdvancedFilterCount, adminTaskPagination } from './AdminTasksPanel'; + +describe('admin task pagination', () => { + it('keeps empty results on the first page', () => { + expect(adminTaskPagination(0, 8, 20)).toEqual({ + totalPages: 1, + currentPage: 1, + pageStart: 0, + pageEnd: 0, + }); + }); + + it('clamps an out-of-range page to the final partial page', () => { + expect(adminTaskPagination(41, 99, 20)).toEqual({ + totalPages: 3, + currentPage: 3, + pageStart: 41, + pageEnd: 41, + }); + }); +}); + +describe('admin task advanced filters', () => { + it('counts only filters moved into the advanced dialog', () => { + expect(adminTaskAdvancedFilterCount({ + tenantId: 'tenant-id', + status: 'failed', + model: '', + apiKey: 'ops-key', + })).toBe(3); + }); +}); diff --git a/apps/web/src/pages/admin/AdminTasksPanel.tsx b/apps/web/src/pages/admin/AdminTasksPanel.tsx new file mode 100644 index 0000000..3719f50 --- /dev/null +++ b/apps/web/src/pages/admin/AdminTasksPanel.tsx @@ -0,0 +1,546 @@ +import { useEffect, useMemo, useState, type ReactNode } from 'react'; +import { ChevronLeft, ChevronRight, RefreshCw, RotateCcw, Search, SlidersHorizontal } from 'lucide-react'; +import type { AdminGatewayTask, GatewayTask } from '@easyai-ai-gateway/contracts'; +import { getAdminTask, listAdminTaskParamPreprocessing } from '../../api'; +import { + Button, + Card, + CardContent, + ConfirmDialog, + DateTimeRangePicker, + FormDialog, + Input, + Label, + Select, + Table, + TableFooter, + TableHead, + TablePageActions, + TableRow, + TableToolbar, + TableViewportLayout, +} from '../../components/ui'; +import type { ConsoleData } from '../../app-state'; +import type { AdminTaskQuery, LoadState } from '../../types'; +import { TaskRecord } from '../WorkspacePage'; + +const taskPageSizeOptions = [10, 20, 50, 100]; +const taskStatuses = ['queued', 'submitting', 'running', 'succeeded', 'failed', 'cancelled']; +const billingStatuses = ['not_started', 'pending', 'processing', 'settled', 'released', 'retryable_failed', 'manual_review', 'not_required']; +type AdminTaskAdvancedFilters = Pick< + AdminTaskQuery, + 'tenantId' | 'userId' | 'userGroupId' | 'status' | 'platformId' | 'model' | 'modelType' | 'runMode' | 'billingStatus' | 'apiKey' +>; + +export function AdminTasksPanel(props: { + data: ConsoleData; + query: AdminTaskQuery; + state: LoadState; + token: string; + total: number; + updatedAt: number | null; + onQueryChange: (query: AdminTaskQuery) => void; + onRefresh: () => Promise; +}) { + const [localMessage, setLocalMessage] = useState(''); + const [pageJump, setPageJump] = useState(String(props.query.page)); + const [detail, setDetail] = useState(null); + const [detailSensitive, setDetailSensitive] = useState<'masked' | 'full'>('masked'); + const [detailLoading, setDetailLoading] = useState(false); + const [detailError, setDetailError] = useState(''); + const [revealConfirmOpen, setRevealConfirmOpen] = useState(false); + const [advancedFilterOpen, setAdvancedFilterOpen] = useState(false); + const [advancedFilterDraft, setAdvancedFilterDraft] = useState(() => adminTaskAdvancedFilters(props.query)); + const query = props.query; + const tasks = props.data.adminTasks; + const { totalPages, currentPage, pageStart, pageEnd } = adminTaskPagination(props.total, query.page, query.pageSize); + const hasActiveFilters = adminTaskHasActiveFilters(query); + const advancedFilterCount = adminTaskAdvancedFilterCount(query); + const users = useMemo( + () => props.data.users.slice().sort((a, b) => adminUserLabel(a).localeCompare(adminUserLabel(b))), + [props.data.users], + ); + const tenants = useMemo( + () => props.data.tenants.slice().sort((a, b) => (a.name || a.tenantKey).localeCompare(b.name || b.tenantKey)), + [props.data.tenants], + ); + const platforms = useMemo( + () => props.data.platforms.slice().sort((a, b) => (a.internalName || a.name).localeCompare(b.internalName || b.name)), + [props.data.platforms], + ); + const models = useMemo(() => { + const values = props.data.models + .flatMap((model) => [model.modelAlias, model.modelName, model.providerModelName]) + .filter((value): value is string => Boolean(value)); + return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b)); + }, [props.data.models]); + const modelTypes = useMemo(() => { + const values = [ + ...props.data.models.flatMap((model) => model.modelType), + ...tasks.map((task) => task.modelType || ''), + ].filter((value): value is string => Boolean(value)); + return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b)); + }, [props.data.models, tasks]); + + useEffect(() => { + if (query.page > totalPages) { + props.onQueryChange({ ...query, page: totalPages }); + } + }, [props.onQueryChange, query, totalPages]); + + useEffect(() => { + setPageJump(String(currentPage)); + }, [currentPage]); + + function updateQuery(patch: Partial) { + props.onQueryChange({ ...query, ...patch, page: patch.page ?? 1 }); + } + + function resetFilters() { + props.onQueryChange({ + query: '', + tenantId: '', + userId: '', + userGroupId: '', + status: '', + platformId: '', + model: '', + modelType: '', + runMode: '', + billingStatus: '', + apiKey: '', + createdFrom: '', + createdTo: '', + page: 1, + pageSize: query.pageSize, + }); + } + + function openAdvancedFilters() { + setAdvancedFilterDraft(adminTaskAdvancedFilters(query)); + setAdvancedFilterOpen(true); + } + + function updateAdvancedFilter(patch: Partial) { + setAdvancedFilterDraft((current) => ({ ...current, ...patch })); + } + + function clearAdvancedFilters() { + setAdvancedFilterDraft(emptyAdminTaskAdvancedFilters()); + } + + function applyAdvancedFilters() { + props.onQueryChange({ ...query, ...advancedFilterDraft, page: 1 }); + setAdvancedFilterOpen(false); + } + + async function copyTaskRequestId(task: GatewayTask) { + if (!task.requestId) return; + await navigator.clipboard.writeText(task.requestId); + setLocalMessage(`已复制 RequestID:${task.requestId}`); + } + + async function openDetail(task: GatewayTask) { + setDetailError(''); + setDetailLoading(true); + setDetailSensitive('masked'); + setDetail('adminContext' in task ? task as AdminGatewayTask : null); + try { + setDetail(await getAdminTask(props.token, task.id, 'masked')); + } catch (err) { + setDetailError(err instanceof Error ? err.message : '加载任务详情失败'); + } finally { + setDetailLoading(false); + } + } + + async function revealSensitiveDetail() { + if (!detail) return; + setRevealConfirmOpen(false); + setDetailLoading(true); + setDetailError(''); + try { + setDetail(await getAdminTask(props.token, detail.id, 'full')); + setDetailSensitive('full'); + } catch (err) { + setDetailError(err instanceof Error ? err.message : '加载完整任务详情失败'); + } finally { + setDetailLoading(false); + } + } + + function submitPageJump() { + const parsed = Number(pageJump); + if (!Number.isFinite(parsed) || parsed <= 0) { + setPageJump(String(currentPage)); + return; + } + props.onQueryChange({ ...query, page: Math.min(totalPages, Math.max(1, Math.floor(parsed))) }); + } + + return ( +
+ + + {localMessage &&

{localMessage}

} + + + + +
+ + + +
+
+ +
+ 匹配 {props.total} 条任务 + 最近刷新:{formatAdminTaskUpdatedAt(props.updatedAt)} +
+ + {tasks.length ? ( + + + 任务 + 用户 / 租户 + RequestID + 状态 + 运行模式 + 模型 + 最终平台 + 尝试链路 + 参数转换 + 类型 + API Key + Token + 扣费 + 计费状态 + 耗时 + 创建时间 + 原始 JSON + + {tasks.map((task) => ( + + ))} +
+ ) : ( +
+ {hasActiveFilters ? '没有匹配的任务' : '暂无任务'} + {hasActiveFilters ? '调整筛选条件后再试。' : '任务创建后会在这里显示。'} +
+ )} + + +
+ + 共 {props.total} 条 · {pageStart}-{pageEnd} +
+
{ + event.preventDefault(); + submitPageJump(); + }} + > + 第 {currentPage} / {totalPages} 页 + 跳至 + setPageJump(event.target.value)} + /> + + +
+ + + + +
+
+
+
+ + + + + + )} + open={advancedFilterOpen} + title="高级筛选" + onClose={() => setAdvancedFilterOpen(false)} + onSubmit={(event) => { + event.preventDefault(); + applyAdvancedFilters(); + }} + > + updateAdvancedFilter({ tenantId: value, userId: '' })} + > + {tenants.map((tenant) => )} + + updateAdvancedFilter({ userId: value })}> + {users.filter((user) => !advancedFilterDraft.tenantId || user.gatewayTenantId === advancedFilterDraft.tenantId).map((user) => ( + + ))} + + updateAdvancedFilter({ userGroupId: value })}> + {props.data.userGroups.map((group) => )} + + updateAdvancedFilter({ status: value })}> + {taskStatuses.map((status) => )} + + updateAdvancedFilter({ platformId: value })}> + {platforms.map((platform) => )} + + updateAdvancedFilter({ model: value })}> + {models.map((model) => )} + + updateAdvancedFilter({ modelType: value })}> + {modelTypes.map((modelType) => )} + + updateAdvancedFilter({ runMode: value })}> + + + + updateAdvancedFilter({ billingStatus: value })}> + {billingStatuses.map((status) => )} + + + + + + {detailSensitive === 'masked' && ( + + )} + + + )} + open={Boolean(detail)} + title={detailSensitive === 'full' ? '任务原始 JSON(完整数据)' : '任务原始 JSON(默认脱敏)'} + onClose={() => setDetail(null)} + onSubmit={(event) => event.preventDefault()} + > + {detailError &&

{detailError}

} + {detailLoading && !detail ?

正在加载任务详情…

: ( +
{detail ? JSON.stringify(detail, null, 2) : ''}
+ )} +
+ setRevealConfirmOpen(false)} + onConfirm={revealSensitiveDetail} + /> +
+ ); +} + +function FilterSelect(props: { + children: ReactNode; + label: string; + value: string; + onChange: (value: string) => void; +}) { + return ( + + ); +} + +function adminTaskHasActiveFilters(query: AdminTaskQuery) { + return Boolean( + query.query || + query.tenantId || + query.userId || + query.userGroupId || + query.status || + query.platformId || + query.model || + query.modelType || + query.runMode || + query.billingStatus || + query.apiKey || + query.createdFrom || + query.createdTo + ); +} + +function emptyAdminTaskAdvancedFilters(): AdminTaskAdvancedFilters { + return { + tenantId: '', + userId: '', + userGroupId: '', + status: '', + platformId: '', + model: '', + modelType: '', + runMode: '', + billingStatus: '', + apiKey: '', + }; +} + +function adminTaskAdvancedFilters(query: AdminTaskQuery): AdminTaskAdvancedFilters { + return { + tenantId: query.tenantId, + userId: query.userId, + userGroupId: query.userGroupId, + status: query.status, + platformId: query.platformId, + model: query.model, + modelType: query.modelType, + runMode: query.runMode, + billingStatus: query.billingStatus, + apiKey: query.apiKey, + }; +} + +export function adminTaskAdvancedFilterCount(query: Partial) { + const keys: Array = [ + 'tenantId', + 'userId', + 'userGroupId', + 'status', + 'platformId', + 'model', + 'modelType', + 'runMode', + 'billingStatus', + 'apiKey', + ]; + return keys.filter((key) => Boolean(query[key])).length; +} + +function adminUserLabel(user: ConsoleData['users'][number]) { + const primary = user.displayName || user.username || user.userKey; + return user.email ? `${primary} · ${user.email}` : primary; +} + +function formatAdminTaskUpdatedAt(value: number | null) { + if (!value) return '尚未刷新'; + return new Intl.DateTimeFormat('zh-CN', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).format(value); +} + +export function adminTaskPagination(total: number, page: number, pageSize: number) { + const safeTotal = Math.max(0, Math.floor(total)); + const safePageSize = Math.max(1, Math.floor(pageSize)); + const totalPages = Math.max(1, Math.ceil(safeTotal / safePageSize)); + const currentPage = Math.min(totalPages, Math.max(1, Math.floor(page))); + return { + totalPages, + currentPage, + pageStart: safeTotal ? Math.min((currentPage - 1) * safePageSize + 1, safeTotal) : 0, + pageEnd: Math.min(currentPage * safePageSize, safeTotal), + }; +} diff --git a/apps/web/src/pages/admin/BaseModelCatalogPanel.tsx b/apps/web/src/pages/admin/BaseModelCatalogPanel.tsx index 6f726cf..0955287 100644 --- a/apps/web/src/pages/admin/BaseModelCatalogPanel.tsx +++ b/apps/web/src/pages/admin/BaseModelCatalogPanel.tsx @@ -1,5 +1,5 @@ import { useMemo, useState, type FormEvent } from 'react'; -import { Pencil, Plus, RotateCcw, Trash2 } from 'lucide-react'; +import { Pencil, Plus, RotateCcw, Search, SlidersHorizontal, Trash2 } from 'lucide-react'; import type { BaseModelCatalogItem, BaseModelUpsertRequest, @@ -7,7 +7,7 @@ import type { PricingRuleSet, RuntimePolicySet, } from '@easyai-ai-gateway/contracts'; -import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, FormItem, Label, Input, Select, Textarea } from '../../components/ui'; +import { Badge, Button, Card, CardContent, ConfirmDialog, FormDialog, FormItem, Label, Input, Select, Textarea } from '../../components/ui'; import type { LoadState } from '../../types'; import { BaseModelCapabilityEditor } from './BaseModelCapabilityEditor'; import { @@ -90,6 +90,7 @@ export function BaseModelCatalogPanel(props: { onSaveBaseModel: (input: BaseModelUpsertRequest, baseModelId?: string) => Promise; }) { const [dialogOpen, setDialogOpen] = useState(false); + const [filterDialogOpen, setFilterDialogOpen] = useState(false); const [editingId, setEditingId] = useState(''); const [form, setForm] = useState(() => createEmptyForm()); const [localError, setLocalError] = useState(''); @@ -202,69 +203,114 @@ export function BaseModelCatalogPanel(props: { } } + function resetFilters() { + setQuery(''); + setProviderFilter('all'); + setTypeFilter('all'); + setStatusFilter('active'); + } + + const advancedFilterCount = [ + providerFilter !== 'all', + typeFilter !== 'all', + statusFilter !== 'active', + ].filter(Boolean).length; + const filterActive = Boolean(query) || advancedFilterCount > 0; + return (
- - - 基准模型库 - {props.baseModels.length} - - -
-

默认模型来自原 server-main integration-platform,保留模型类型、能力、图标、计费和限流元数据。

-
- + + -
-
- setQuery(event.target.value)} placeholder="搜索模型名称 / canonical key" /> - - - -
{(props.message || localError) &&

{localError || props.message}

} +
+ {filteredModels.map((model) => ( + setPendingDeleteModel(model)} + onEdit={() => editModel(model)} + onReset={() => setPendingResetModel(model)} + /> + ))} + {!filteredModels.length && ( +
+ 没有匹配的基准模型 +
+ )} +
-
- {filteredModels.map((model) => ( - setPendingDeleteModel(model)} - onEdit={() => editModel(model)} - onReset={() => setPendingResetModel(model)} - /> - ))} - {!filteredModels.length && ( - - - 没有匹配的基准模型 - - + + + + )} -
+ open={filterDialogOpen} + title="高级筛选" + onClose={() => setFilterDialogOpen(false)} + onSubmit={(event) => { + event.preventDefault(); + setFilterDialogOpen(false); + }} + > + + + + { + it('continues after individual failures and reports failed settlement IDs', async () => { + const active = new Set(); + let peakConcurrency = 0; + const retryOne = vi.fn(async (item: { id: string }) => { + active.add(item.id); + peakConcurrency = Math.max(peakConcurrency, active.size); + await Promise.resolve(); + active.delete(item.id); + if (item.id === 'failed') throw new Error('retry failed'); + }); + + const result = await retrySettlementBatch( + [{ id: 'first' }, { id: 'failed' }, { id: 'last' }], + retryOne, + 2, + ); + + expect(result).toEqual({ failedIds: ['failed'], succeeded: 2 }); + expect(retryOne).toHaveBeenCalledTimes(3); + expect(peakConcurrency).toBeLessThanOrEqual(2); + }); +}); diff --git a/apps/web/src/pages/admin/BillingSettlementsPanel.tsx b/apps/web/src/pages/admin/BillingSettlementsPanel.tsx index 17f5829..60eaae5 100644 --- a/apps/web/src/pages/admin/BillingSettlementsPanel.tsx +++ b/apps/web/src/pages/admin/BillingSettlementsPanel.tsx @@ -1,15 +1,32 @@ -import { useCallback, useEffect, useState } from 'react'; -import { AlertTriangle, ReceiptText, RefreshCw, RotateCcw } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { AlertTriangle, RefreshCw, RotateCcw, Search } from 'lucide-react'; import type { BillingSettlement } from '@easyai-ai-gateway/contracts'; import { listBillingSettlements, retryBillingSettlement } from '../../api'; -import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, Select, Table, TableCell, TableHead, TableRow } from '../../components/ui'; +import { + Badge, + Button, + Card, + CardContent, + Checkbox, + ConfirmDialog, + Input, + Select, + Table, + TableCell, + TableHead, + TableRow, +} from '../../components/ui'; export function BillingSettlementsPanel(props: { token: string }) { const [items, setItems] = useState([]); + const [query, setQuery] = useState(''); const [status, setStatus] = useState(''); const [loading, setLoading] = useState(false); const [retrying, setRetrying] = useState(''); + const [batchRetrying, setBatchRetrying] = useState(false); const [confirming, setConfirming] = useState(null); + const [batchConfirming, setBatchConfirming] = useState(false); + const [selectedIds, setSelectedIds] = useState>(() => new Set()); const [message, setMessage] = useState(''); const load = useCallback(async (signal?: AbortSignal) => { @@ -32,13 +49,44 @@ export function BillingSettlementsPanel(props: { token: string }) { return () => controller.abort(); }, [load]); + const filteredItems = useMemo(() => { + const keyword = query.trim().toLowerCase(); + if (!keyword) return items; + return items.filter((item) => [ + item.id, + item.taskId, + item.action, + item.status, + item.lastErrorCode, + item.manualReviewReason, + ].filter(Boolean).join(' ').toLowerCase().includes(keyword)); + }, [items, query]); + const retryableItems = useMemo(() => items.filter(canRetry), [items]); + const visibleRetryableItems = useMemo(() => filteredItems.filter(canRetry), [filteredItems]); + const selectedItems = useMemo( + () => retryableItems.filter((item) => selectedIds.has(item.id)), + [retryableItems, selectedIds], + ); + const allVisibleSelected = visibleRetryableItems.length > 0 + && visibleRetryableItems.every((item) => selectedIds.has(item.id)); + const someVisibleSelected = visibleRetryableItems.some((item) => selectedIds.has(item.id)); + const busy = batchRetrying || Boolean(retrying); + + useEffect(() => { + const retryableIds = new Set(retryableItems.map((item) => item.id)); + setSelectedIds((current) => { + const next = new Set(Array.from(current).filter((id) => retryableIds.has(id))); + return next.size === current.size ? current : next; + }); + }, [retryableItems]); + async function retry(item: BillingSettlement) { setRetrying(item.id); setMessage(''); try { await retryBillingSettlement(props.token, item.id); - setMessage(`结算 ${shortId(item.id)} 已重新进入队列`); await load(); + setMessage(`结算 ${shortId(item.id)} 已重新进入队列`); } catch (error) { setMessage(error instanceof Error ? error.message : '结算重试失败'); } finally { @@ -46,22 +94,62 @@ export function BillingSettlementsPanel(props: { token: string }) { } } - const exceptions = items.filter((item) => item.status === 'retryable_failed' || item.status === 'manual_review').length; + function toggleItem(itemId: string, checked: boolean) { + setSelectedIds((current) => { + const next = new Set(current); + if (checked) next.add(itemId); + else next.delete(itemId); + return next; + }); + } + + function toggleVisible(checked: boolean) { + setSelectedIds((current) => { + const next = new Set(current); + visibleRetryableItems.forEach((item) => { + if (checked) next.add(item.id); + else next.delete(item.id); + }); + return next; + }); + } + + async function retrySelected() { + if (!selectedItems.length) return; + setBatchRetrying(true); + setMessage(''); + const processingItems = [...selectedItems]; + try { + const { failedIds, succeeded } = await retrySettlementBatch( + processingItems, + (item) => retryBillingSettlement(props.token, item.id), + ); + setSelectedIds(new Set(failedIds)); + await load(); + setMessage(failedIds.length + ? `批量结算完成:成功 ${succeeded} 条,失败 ${failedIds.length} 条;失败记录已保留选择。` + : `已批量结算 ${succeeded} 条记录。`); + } catch (error) { + setMessage(error instanceof Error ? error.message : '批量结算处理失败'); + } finally { + setBatchRetrying(false); + } + } + return ( -
- - -
-
-
- 计费结算 -

任务结果与账务状态相互独立;异常记录可审计重试,不会重新调用上游。

-
- {exceptions ? `${exceptions} 条异常` : `${items.length} 条`} -
-
- -
+
+ + +
+ + + setQuery(event.target.value)} + /> + - +
+ + +
{message &&

{message}

} + {filteredItems.length ? ( +
+ + + + toggleVisible(checked === true)} + /> + + 任务 / 动作 + 状态 + 金额 + 尝试 + 异常分类 + 操作 + + {filteredItems.map((item) => ( + + + toggleItem(item.id, checked === true)} + /> + + + + {shortId(item.taskId)} + {item.action === 'settle' ? '扣费' : '释放冻结'} · {shortId(item.id)} + + + {statusLabel(item.status)} + {formatAmount(item.amount)} {item.currency} + {item.attempts} / 20 + {item.lastErrorCode || item.manualReviewReason || '-'} + + {canRetry(item) ? ( + + ) : '-'} + + + ))} +
+
+ ) : ( +
+ + {loading ? '正在加载结算队列' : '暂无匹配的结算记录'} + 可调整搜索或状态筛选查看其他记录。 +
+ )}
- {items.length ? ( - - - 任务 / 动作 - 状态 - 金额 - 尝试 - 异常分类 - 操作 - - {items.map((item) => ( - - {shortId(item.taskId)}{item.action === 'settle' ? '扣费' : '释放冻结'} · {shortId(item.id)} - {statusLabel(item.status)} - {formatAmount(item.amount)} {item.currency} - {item.attempts} / 20 - {item.lastErrorCode || item.manualReviewReason || '-'} - - {(item.status === 'retryable_failed' || item.status === 'manual_review') ? ( - - ) : '-'} - - - ))} -
- ) : ( - {loading ? '正在加载结算队列' : '暂无结算记录'}可切换状态筛选,查看待处理与人工复核记录。 - )} setConfirming(null)); }} /> -
+ setBatchConfirming(false)} + onConfirm={() => void retrySelected().finally(() => setBatchConfirming(false))} + /> + ); } +function canRetry(item: BillingSettlement) { + return item.status === 'retryable_failed' || item.status === 'manual_review'; +} + +export async function retrySettlementBatch( + items: T[], + retryOne: (item: T) => Promise, + concurrency = 5, +) { + const failedIds: string[] = []; + let succeeded = 0; + const batchSize = Math.max(1, Math.floor(concurrency)); + for (let index = 0; index < items.length; index += batchSize) { + const batch = items.slice(index, index + batchSize); + const results = await Promise.allSettled(batch.map(retryOne)); + results.forEach((result, resultIndex) => { + if (result.status === 'fulfilled') succeeded += 1; + else failedIds.push(batch[resultIndex].id); + }); + } + return { failedIds, succeeded }; +} + function statusLabel(status: string) { return ({ pending: '待处理', processing: '处理中', retryable_failed: '等待重试', manual_review: '人工复核', completed: '已完成' } as Record)[status] ?? status; } diff --git a/apps/web/src/pages/admin/IdentityManagementPanels.tsx b/apps/web/src/pages/admin/IdentityManagementPanels.tsx index 3844df0..3f5bd0c 100644 --- a/apps/web/src/pages/admin/IdentityManagementPanels.tsx +++ b/apps/web/src/pages/admin/IdentityManagementPanels.tsx @@ -1,8 +1,9 @@ import { useMemo, useState, type FormEvent, type ReactNode } from 'react'; -import { Building2, CircleDollarSign, KeyRound, Pencil, Plus, RotateCcw, Trash2, UserRound, UsersRound } from 'lucide-react'; +import { Building2, CircleDollarSign, KeyRound, Pencil, Plus, RotateCcw, ShieldCheck, Trash2, UserRound, UsersRound } from 'lucide-react'; import type { GatewayTenant, GatewayTenantUpsertRequest, + GatewayAccessRuleBatchRequest, GatewayUser, GatewayUserUpsertRequest, GatewayWalletAccount, @@ -31,6 +32,19 @@ import { } from '../../components/ui'; import type { ConsoleData } from '../../app-state'; import type { LoadState } from '../../types'; +import { AccessPermissionEditor, countAccessPermissionRules } from './AccessPermissionEditor'; +import { + quotaPolicyFromForm, + quotaPolicySummary, + quotaPolicyToForm, + rateLimitPolicyFromForm, + rateLimitPolicySummary, + rateLimitPolicyToForm, + UserGroupQuotaPolicyEditor, + UserGroupRateLimitEditor, + type UserGroupQuotaEntryForm, + type UserGroupRateLimitRuleForm, +} from './UserGroupPolicyEditors'; type TenantForm = { tenantKey: string; @@ -83,8 +97,9 @@ type UserGroupForm = { rechargeDiscountPolicy: Record; billingDiscountFactor: string; billingDiscountPolicy: Record; - rateLimitPolicyJson: string; - quotaPolicyJson: string; + rateLimitRules: UserGroupRateLimitRuleForm[]; + rateLimitPolicyExtra: Record; + quotaPolicyEntries: UserGroupQuotaEntryForm[]; metadataJson: string; status: string; }; @@ -427,6 +442,7 @@ export function UserGroupsPanel(props: IdentityPanelProps) { const [form, setForm] = useState(() => defaultUserGroupForm()); const [localError, setLocalError] = useState(''); const [pendingDeleteGroup, setPendingDeleteGroup] = useState(null); + const [permissionGroup, setPermissionGroup] = useState(null); function openCreateDialog() { setEditingId(''); @@ -488,20 +504,32 @@ export function UserGroupsPanel(props: IdentityPanelProps) { 优先级 折扣 限流 + 额度 状态 操作 - {props.data.userGroups.map((group) => ( - - - {group.source} - {group.priority} - {discountSummary(group)} - {policyKeys(group.rateLimitPolicy).join(', ') || '未设置'} - {group.status} - editGroup(group)} onDelete={() => setPendingDeleteGroup(group)} /> - - ))} + {props.data.userGroups.map((group) => { + const permissionSummary = countAccessPermissionRules(props.data.accessRules, 'user_group', group.id); + return ( + + + {group.source} + {group.priority} + {discountSummary(group)} + + + {group.status} + + setPermissionGroup(group)} + onEdit={() => editGroup(group)} + onDelete={() => setPendingDeleteGroup(group)} + /> + + + ); + })} ) : ( @@ -509,7 +537,7 @@ export function UserGroupsPanel(props: IdentityPanelProps) { } open={dialogOpen} @@ -525,10 +553,44 @@ export function UserGroupsPanel(props: IdentityPanelProps) { - setForm({ ...form, rateLimitPolicyJson: value })} /> - setForm({ ...form, quotaPolicyJson: value })} /> + setForm({ ...form, rateLimitRules })} /> + setForm({ ...form, quotaPolicyEntries })} /> setForm({ ...form, metadataJson: value })} /> + setPermissionGroup(null)}>完成} + open={Boolean(permissionGroup)} + title={permissionGroup ? `${permissionGroup.name} · 模型权限` : '模型权限'} + onClose={() => setPermissionGroup(null)} + onSubmit={(event) => { + event.preventDefault(); + setPermissionGroup(null); + }} + > + {permissionGroup && ( + <> +
+ + 未配置规则时默认放行;拒绝规则优先于专属规则,修改后立即生效。 +
+ + + )} +
Promise; onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise; onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise; + onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise; }; const defaultRechargeReason = '手动充值'; @@ -603,10 +666,21 @@ function IdentityName(props: { title: string; subtitle: string }) { ); } -function TableActions(props: { onDelete: () => void; onEdit: () => void; onWallet?: () => void }) { +function TableActions(props: { + onDelete: () => void; + onEdit: () => void; + onPermissions?: () => void; + onWallet?: () => void; + permissionSummary?: string; +}) { return ( {props.onWallet && } + {props.onPermissions && ( + + )} @@ -792,14 +866,16 @@ function defaultUserGroupForm(): UserGroupForm { rechargeDiscountPolicy: {}, billingDiscountFactor: '1', billingDiscountPolicy: {}, - rateLimitPolicyJson: '{"rules":[]}', - quotaPolicyJson: '{}', + rateLimitRules: [], + rateLimitPolicyExtra: {}, + quotaPolicyEntries: [], metadataJson: '{}', status: 'active', }; } function userGroupToForm(group: UserGroup): UserGroupForm { + const rateLimitPolicy = rateLimitPolicyToForm(group.rateLimitPolicy); return { groupKey: group.groupKey, name: group.name, @@ -810,8 +886,9 @@ function userGroupToForm(group: UserGroup): UserGroupForm { rechargeDiscountPolicy: group.rechargeDiscountPolicy ?? {}, billingDiscountFactor: discountFactorText(group.billingDiscountPolicy), billingDiscountPolicy: group.billingDiscountPolicy ?? {}, - rateLimitPolicyJson: stringifyJson(group.rateLimitPolicy), - quotaPolicyJson: stringifyJson(group.quotaPolicy), + rateLimitRules: rateLimitPolicy.rules, + rateLimitPolicyExtra: rateLimitPolicy.extra, + quotaPolicyEntries: quotaPolicyToForm(group.quotaPolicy), metadataJson: stringifyJson(group.metadata), status: group.status, }; @@ -826,8 +903,8 @@ function formToUserGroupPayload(form: UserGroupForm): UserGroupUpsertRequest { priority: Number(form.priority) || 100, rechargeDiscountPolicy: discountPolicyPayload(form.rechargeDiscountPolicy, form.rechargeDiscountFactor, '充值折扣系数'), billingDiscountPolicy: discountPolicyPayload(form.billingDiscountPolicy, form.billingDiscountFactor, '计费折扣系数'), - rateLimitPolicy: parseJsonObject(form.rateLimitPolicyJson, '限流策略 JSON'), - quotaPolicy: parseJsonObject(form.quotaPolicyJson, '额度策略 JSON'), + rateLimitPolicy: rateLimitPolicyFromForm(form.rateLimitRules, form.rateLimitPolicyExtra), + quotaPolicy: quotaPolicyFromForm(form.quotaPolicyEntries), metadata: parseJsonObject(form.metadataJson, '元数据 JSON'), status: form.status, }; @@ -928,9 +1005,21 @@ function trimNumber(value: number) { return value.toFixed(6).replace(/\.?0+$/, ''); } -function policyKeys(value?: Record) { - if (!value) return []; - return Object.keys(value).slice(0, 3); +function PolicySummary(props: { parts: string[] }) { + if (!props.parts.length) return 未设置; + return ( + + {props.parts.slice(0, 2).join(' · ')} + {props.parts.length > 2 && {props.parts.slice(2).join(' · ')}} + + ); +} + +function permissionRuleSummary(summary: ReturnType) { + const allow = summary.allow.platforms + summary.allow.models; + const deny = summary.deny.platforms + summary.deny.models; + if (!allow && !deny) return '模型权限:未配置,默认放行'; + return `模型权限:专属 ${allow} 条,拒绝 ${deny} 条`; } function stringifyJson(value: unknown) { diff --git a/apps/web/src/pages/admin/PlatformManagementPanel.test.ts b/apps/web/src/pages/admin/PlatformManagementPanel.test.ts new file mode 100644 index 0000000..c114abe --- /dev/null +++ b/apps/web/src/pages/admin/PlatformManagementPanel.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import type { IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts'; +import { filterAdminPlatforms } from './PlatformManagementPanel'; + +const platforms = [ + { id: 'platform-a', name: 'OpenAI Official', internalName: '主账号', platformKey: 'openai-main', provider: 'openai', baseUrl: 'https://api.openai.com', status: 'enabled' }, + { id: 'platform-b', name: 'Volces', platformKey: 'volces', provider: 'volces', baseUrl: 'https://ark.example.com', status: 'disabled' }, +] as IntegrationPlatform[]; + +const models = [ + { id: 'model-a', platformId: 'platform-a', modelName: 'gpt-image-2', providerModelName: 'gpt-image-2', modelAlias: 'image-pro', displayName: 'GPT Image 2', modelType: ['image_generate'], enabled: true }, + { id: 'model-b', platformId: 'platform-b', modelName: 'seedance', providerModelName: 'seedance-v2', displayName: 'Seedance', modelType: ['video_generate'], enabled: true }, +] as PlatformModel[]; + +describe('filterAdminPlatforms', () => { + it('matches a platform through its bound model name and alias', () => { + expect(filterAdminPlatforms(platforms, models, { + query: 'image-pro', + provider: '', + status: '', + now: Date.now(), + }).map((platform) => platform.id)).toEqual(['platform-a']); + }); + + it('combines provider and runtime status filters', () => { + expect(filterAdminPlatforms(platforms, models, { + query: '', + provider: 'volces', + status: 'disabled', + now: Date.now(), + }).map((platform) => platform.id)).toEqual(['platform-b']); + }); +}); diff --git a/apps/web/src/pages/admin/PlatformManagementPanel.tsx b/apps/web/src/pages/admin/PlatformManagementPanel.tsx index 714bf60..0cf21b3 100644 --- a/apps/web/src/pages/admin/PlatformManagementPanel.tsx +++ b/apps/web/src/pages/admin/PlatformManagementPanel.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react'; import { Boxes, CheckCircle2, Globe2, KeyRound, Pencil, Plus, RotateCcw, Search, ServerCog, ShieldCheck, SlidersHorizontal, Trash2, X } from 'lucide-react'; import type { BaseModelCatalogItem, CatalogProvider, IntegrationPlatform, PlatformModel, PricingRuleSet } from '@easyai-ai-gateway/contracts'; -import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, EmptyState, FormDialog, Input, Label, ScreenMessage, Select, Switch, Table, TableCell, TableHead, TableRow } from '../../components/ui'; +import { Badge, Button, Card, CardContent, ConfirmDialog, EmptyState, FormDialog, Input, Label, ScreenMessage, Select, Switch, Table, TableCell, TableHead, TableRow } from '../../components/ui'; import type { LoadState, PlatformWithModelsInput } from '../../types'; import { authTypes, @@ -40,8 +40,14 @@ export function PlatformManagementPanel(props: { const defaultProvider = props.providers[0]?.providerKey ?? props.baseModels[0]?.providerKey ?? ''; const [now, setNow] = useState(() => Date.now()); const [dialogOpen, setDialogOpen] = useState(false); + const [filterDialogOpen, setFilterDialogOpen] = useState(false); const [viewMode, setViewMode] = useState<'platforms' | 'models'>('platforms'); + const [platformQuery, setPlatformQuery] = useState(''); + const [platformProvider, setPlatformProvider] = useState(''); + const [platformStatus, setPlatformStatus] = useState(''); const [modelQuery, setModelQuery] = useState(''); + const [modelType, setModelType] = useState(''); + const [modelStatus, setModelStatus] = useState(''); const [selectedPlatformId, setSelectedPlatformId] = useState(''); const [validationMessage, setValidationMessage] = useState(''); const [globalProxyNoticeOpen, setGlobalProxyNoticeOpen] = useState(false); @@ -62,12 +68,36 @@ export function PlatformManagementPanel(props: { const availableModels = useMemo(() => props.baseModels.filter((item) => item.status !== 'hidden'), [props.baseModels]); const selectedModels = useMemo(() => selectedModelsForForm(props.baseModels, form), [form, props.baseModels]); const platformModelCount = useMemo(() => countModelsByPlatform(props.platformModels), [props.platformModels]); - const selectedModelPlatformId = selectedPlatformId || props.platforms[0]?.id || ''; + const selectedModelPlatformId = selectedPlatformId; + const platformFilterActive = Boolean(platformQuery || platformProvider || platformStatus); + const modelFilterActive = Boolean(selectedModelPlatformId || modelQuery || modelType || modelStatus); + const currentFilterActive = viewMode === 'platforms' ? platformFilterActive : modelFilterActive; + const currentAdvancedFilterCount = viewMode === 'platforms' + ? [platformProvider, platformStatus].filter(Boolean).length + : [selectedModelPlatformId, modelType, modelStatus].filter(Boolean).length; + const platformProviderOptions = useMemo( + () => Array.from(new Set(props.platforms.map((platform) => platform.provider).filter(Boolean))).sort((a, b) => a.localeCompare(b)), + [props.platforms], + ); + const modelTypeOptions = useMemo( + () => Array.from(new Set(props.platformModels.flatMap((model) => model.modelType))).sort((a, b) => a.localeCompare(b)), + [props.platformModels], + ); + const filteredPlatforms = useMemo( + () => filterAdminPlatforms(props.platforms, props.platformModels, { + query: platformQuery, + provider: platformProvider, + status: platformStatus, + now, + }), + [now, platformProvider, platformQuery, platformStatus, props.platformModels, props.platforms], + ); const filteredPlatformModels = useMemo(() => { const keyword = modelQuery.trim().toLowerCase(); return props.platformModels.filter((model) => { const matchesPlatform = !selectedModelPlatformId || model.platformId === selectedModelPlatformId; const platform = platformMap.get(model.platformId); + const cooldown = cooldownRemainingMs(model.cooldownUntil, now) > 0 || cooldownRemainingMs(platform?.cooldownUntil, now) > 0; const text = [ model.displayName, model.modelName, @@ -79,9 +109,14 @@ export function PlatformManagementPanel(props: { platform?.internalName, platform?.platformKey, ].filter(Boolean).join(' ').toLowerCase(); - return matchesPlatform && (!keyword || text.includes(keyword)); + const matchesStatus = !modelStatus || + (modelStatus === 'cooldown' ? cooldown : modelStatus === 'enabled' ? model.enabled : !model.enabled); + return matchesPlatform && + (!keyword || text.includes(keyword)) && + (!modelType || model.modelType.includes(modelType)) && + matchesStatus; }); - }, [modelQuery, platformMap, props.platformModels, selectedModelPlatformId]); + }, [modelQuery, modelStatus, modelType, now, platformMap, props.platformModels, selectedModelPlatformId]); useEffect(() => { const timer = window.setInterval(() => setNow(Date.now()), 1000); @@ -179,56 +214,172 @@ export function PlatformManagementPanel(props: { } } + function resetCurrentFilters() { + if (viewMode === 'platforms') { + setPlatformQuery(''); + setPlatformProvider(''); + setPlatformStatus(''); + return; + } + setSelectedPlatformId(''); + setModelQuery(''); + setModelType(''); + setModelStatus(''); + } + return (
setValidationMessage('')} /> - - -
- 平台管理 -

配置平台授权、Base URL、平台内重试与限流策略,并选择接入全部或部分基准模型。

-
- -
- -
- - + + +
+
+ + +
+ + + { + if (viewMode === 'platforms') setPlatformQuery(event.target.value); + else setModelQuery(event.target.value); + }} + /> + +
+ + + +
{viewMode === 'platforms' ? ( - +
+ +
) : ( )} {props.message &&

{props.message}

}
+ + + + + )} + open={filterDialogOpen} + title={viewMode === 'platforms' ? '平台高级筛选' : '模型高级筛选'} + onClose={() => setFilterDialogOpen(false)} + onSubmit={(event) => { + event.preventDefault(); + setFilterDialogOpen(false); + }} + > + {viewMode === 'platforms' ? ( + <> + + + + ) : ( + <> + + + + + )} + + ; platforms: IntegrationPlatform[]; @@ -435,6 +587,9 @@ function PlatformTable(props: { onToggleStatus: (platform: IntegrationPlatform, status: 'enabled' | 'disabled') => void; }) { if (!props.platforms.length) { + if (props.filtered) { + return ; + } return (
@@ -448,7 +603,7 @@ function PlatformTable(props: { ); } return ( - +
平台 Provider @@ -457,12 +612,14 @@ function PlatformTable(props: { 限流 模型数 运行 + 优先级 + 描述 操作 {props.platforms.map((platform) => { const pricing = platformPricingSummary(platform, props.pricingRuleSets); const rateLimit = platformRateLimitSummary(platform.rateLimitPolicy); - const runtime = platformRuntimeSummary(platform); + const runtimeDescription = platformRuntimeDescription(platform); const platformCooldownMs = cooldownRemainingMs(platform.cooldownUntil, props.now); const isEnabled = platform.status === 'enabled'; const isToggling = props.togglingPlatformId === platform.id; @@ -512,7 +669,20 @@ function PlatformTable(props: { {isEnabled ? '已启用' : '已禁用'} )} - {platformCooldownMs > 0 ? `剩余 ${formatCooldownRemaining(platformCooldownMs)}` : runtime} + {platformCooldownMs > 0 && 剩余 {formatCooldownRemaining(platformCooldownMs)}} + + + + + {platform.priority} + {platform.effectivePriority !== undefined && platform.effectivePriority !== platform.priority && ( + 当前 {platform.effectivePriority} + )} + + + + + {runtimeDescription} @@ -534,36 +704,15 @@ function PlatformTable(props: { function PlatformModelTable(props: { baseModels: BaseModelCatalogItem[]; - modelQuery: string; + filterActive: boolean; models: PlatformModel[]; - platformId: string; platformMap: Map; platforms: IntegrationPlatform[]; providerMap: Map; now: number; - onModelQueryChange: (value: string) => void; - onPlatformChange: (value: string) => void; }) { return (
-
- - -
{!props.platforms.length ? ( ) : ( @@ -1213,6 +1362,42 @@ function cooldownRemainingMs(cooldownUntil: string | undefined, now: number) { return Math.max(until - now, 0); } +export function filterAdminPlatforms( + platforms: IntegrationPlatform[], + models: PlatformModel[], + options: { query: string; provider: string; status: string; now: number }, +) { + const searchByPlatform = new Map(); + models.forEach((model) => { + const values = searchByPlatform.get(model.platformId) ?? []; + values.push(...[ + model.displayName, + model.modelName, + model.providerModelName, + model.modelAlias, + ...model.modelType, + ].filter((value): value is string => Boolean(value))); + searchByPlatform.set(model.platformId, values); + }); + const keyword = options.query.trim().toLowerCase(); + return platforms.filter((platform) => { + const cooldown = cooldownRemainingMs(platform.cooldownUntil, options.now) > 0; + const text = [ + platform.name, + platform.internalName, + platform.platformKey, + platform.provider, + platform.baseUrl, + ...(searchByPlatform.get(platform.id) ?? []), + ].filter(Boolean).join(' ').toLowerCase(); + const matchesStatus = !options.status || + (options.status === 'cooldown' ? cooldown : platform.status === options.status); + return (!keyword || text.includes(keyword)) && + (!options.provider || platform.provider === options.provider) && + matchesStatus; + }); +} + function formatCooldownRemaining(milliseconds: number) { const minutes = milliseconds / 60000; if (minutes >= 1) return `${trimNumber(Math.ceil(minutes * 10) / 10)} 分钟`; @@ -1220,11 +1405,11 @@ function formatCooldownRemaining(milliseconds: number) { return `${Math.max(seconds, 1)} 秒`; } -function platformRuntimeSummary(platform: IntegrationPlatform) { +function platformRuntimeDescription(platform: IntegrationPlatform) { const retryPolicy = platform.retryPolicy ?? {}; const retryEnabled = readBoolean(retryPolicy, 'enabled', true); const maxAttempts = readNumber(retryPolicy, 'maxAttempts') ?? 2; - return `优先级 ${platform.priority} · ${retryEnabled ? `同平台最多尝试 ${maxAttempts} 次` : '同平台不重试'} · ${proxyModeText(readNetworkProxyConfig(platform.config ?? {}).proxyMode)}`; + return `${retryEnabled ? `同平台最多尝试 ${maxAttempts} 次` : '同平台不重试'} · ${proxyModeText(readNetworkProxyConfig(platform.config ?? {}).proxyMode)}`; } function proxyModeText(mode: PlatformWizardForm['proxyMode']) { diff --git a/apps/web/src/pages/admin/PricingRulesPanel.tsx b/apps/web/src/pages/admin/PricingRulesPanel.tsx index d696ac3..a187774 100644 --- a/apps/web/src/pages/admin/PricingRulesPanel.tsx +++ b/apps/web/src/pages/admin/PricingRulesPanel.tsx @@ -1,7 +1,7 @@ import { useMemo, useState, type FormEvent } from 'react'; -import { FileText, Image, Pencil, Plus, RotateCcw, Trash2, Video } from 'lucide-react'; +import { FileText, Image, Pencil, Plus, RotateCcw, Search, Trash2, Video } from 'lucide-react'; import type { PricingRule, PricingRuleInput, PricingRuleSet, PricingRuleSetUpsertRequest } from '@easyai-ai-gateway/contracts'; -import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Select } from '../../components/ui'; +import { Badge, Button, Card, CardContent, ConfirmDialog, FormDialog, Input, Label, Select } from '../../components/ui'; import type { LoadState } from '../../types'; import { unitLabel } from './PricingEditorControls'; import { createDefaultPricingRules, KeyValueEditor, PricingRuleVisualEditor } from './PricingRuleVisualEditor'; @@ -92,68 +92,74 @@ export function PricingRulesPanel(props: { return (
- - - 定价规则 - {props.pricingRuleSets.length} - - -
-

规则集可包含文本、图像、视频等多个计价条目,平台或平台模型绑定规则集后再通过折扣率整体调价。

- -
-
- setQuery(event.target.value)} placeholder="搜索规则名称 / key" /> + + +
+ + + setQuery(event.target.value)} + /> + +
+ + +
{(props.message || localError) &&

{localError || props.message}

} +
+ {filtered.map((ruleSet) => ( +
+
+
+ {ruleSet.name} + {ruleSet.ruleSetKey} +
+ {ruleSet.status} +
+

{ruleSet.description || '未填写说明'}

+
+ {ruleSet.category} + {ruleSet.currency} + {pricingRuleSummaries(ruleSet).length} 条计价规则 +
+
+ {pricingRuleSummaries(ruleSet).map((item) => ( + + {pricingRuleSummaryIcon(item.kind)}{item.label} + {item.value} + + ))} +
+
+ + +
+
+ ))} + {!filtered.length && ( +
暂无匹配的定价规则
+ )} +
-
- {filtered.map((ruleSet) => ( -
-
-
- {ruleSet.name} - {ruleSet.ruleSetKey} -
- {ruleSet.status} -
-

{ruleSet.description || '未填写说明'}

-
- {ruleSet.category} - {ruleSet.currency} - {pricingRuleSummaries(ruleSet).length} 条计价规则 -
-
- {pricingRuleSummaries(ruleSet).map((item) => ( - - {pricingRuleSummaryIcon(item.kind)}{item.label} - {item.value} - - ))} -
-
- - -
-
- ))} - {!filtered.length && ( - 暂无定价规则 - )} -
- & Pick): ModelRateLimitStatus { + const { displayName, ...overrides } = input; + return { + platformName: input.platformId, + provider: 'provider', + platformStatus: 'enabled', + platformPriority: 10, + platformEffectivePriority: 10, + modelName: displayName, + displayName, + modelType: ['image_generate'], + enabled: true, + concurrent: { currentValue: 0, usedValue: 0, reservedValue: 0, limitValue: 10, limited: true, ratio: 0 }, + queuedTasks: 0, + rpm: { currentValue: 0, usedValue: 0, reservedValue: 0, limitValue: 100, limited: true, ratio: 0 }, + tpm: { currentValue: 0, usedValue: 0, reservedValue: 0, limitValue: 1000, limited: true, ratio: 0 }, + loadRatio: 0, + ...overrides, + }; +} + +describe('filterAndSortRealtimeLoad', () => { + const platforms = new Map([ + ['platform-a', { id: 'platform-a', name: 'Alpha', internalName: '主平台', platformKey: 'alpha', provider: 'provider', status: 'enabled' } as IntegrationPlatform], + ['platform-b', { id: 'platform-b', name: 'Beta', platformKey: 'beta', provider: 'provider', status: 'disabled' } as IntegrationPlatform], + ]); + const statuses = [ + status({ platformModelId: 'model-a', platformId: 'platform-a', displayName: 'Image A', loadRatio: 0.4, queuedTasks: 2 }), + status({ platformModelId: 'model-b', platformId: 'platform-b', displayName: 'Image B', loadRatio: 0.9, enabled: false, platformStatus: 'disabled' }), + ]; + + it('searches platform aliases and sorts by load descending', () => { + const result = filterAndSortRealtimeLoad(statuses, platforms, { + query: '平台', + platformId: '', + modelType: '', + runtimeState: '', + sortField: 'load', + sortDirection: 'desc', + now: Date.now(), + }); + expect(result.map((item) => item.platformModelId)).toEqual(['model-a']); + }); + + it('filters queued and attention states without changing source order', () => { + const queued = filterAndSortRealtimeLoad(statuses, platforms, { + query: '', + platformId: '', + modelType: 'image_generate', + runtimeState: 'queued', + sortField: 'queued', + sortDirection: 'desc', + now: Date.now(), + }); + const attention = filterAndSortRealtimeLoad(statuses, platforms, { + query: '', + platformId: '', + modelType: '', + runtimeState: 'attention', + sortField: 'load', + sortDirection: 'desc', + now: Date.now(), + }); + expect(queued.map((item) => item.platformModelId)).toEqual(['model-a']); + expect(attention.map((item) => item.platformModelId)).toEqual(['model-b']); + expect(statuses.map((item) => item.platformModelId)).toEqual(['model-a', 'model-b']); + }); +}); diff --git a/apps/web/src/pages/admin/RealtimeLoadPanel.tsx b/apps/web/src/pages/admin/RealtimeLoadPanel.tsx index fd25a33..72d4c94 100644 --- a/apps/web/src/pages/admin/RealtimeLoadPanel.tsx +++ b/apps/web/src/pages/admin/RealtimeLoadPanel.tsx @@ -1,8 +1,8 @@ import { useEffect, useMemo, useState, type FormEvent } from 'react'; import { Popover as AntPopover } from 'antd'; -import { CheckCircle2, Gauge, History, RotateCcw, SlidersHorizontal } from 'lucide-react'; +import { CheckCircle2, History, RotateCcw, Search, SlidersHorizontal } from 'lucide-react'; import type { IntegrationPlatform, ModelRateLimitStatus, PlatformDynamicPriorityUpdateRequest, PlatformPolicyEvent, PriorityDemotionRecord } from '@easyai-ai-gateway/contracts'; -import { Badge, Button, Card, CardContent, CardHeader, CardTitle, EmptyState, FormDialog, Input, Label, Table, TableCell, TableHead, TableRow } from '../../components/ui'; +import { Badge, Button, Card, CardContent, EmptyState, FormDialog, Input, Label, Select, Table, TableCell, TableHead, TableRow } from '../../components/ui'; export function RealtimeLoadPanel(props: { modelRateLimits: ModelRateLimitStatus[]; @@ -16,7 +16,37 @@ export function RealtimeLoadPanel(props: { const [priorityError, setPriorityError] = useState(''); const [prioritySaving, setPrioritySaving] = useState(false); const [restoreSavingId, setRestoreSavingId] = useState(null); + const [query, setQuery] = useState(''); + const [platformId, setPlatformId] = useState(''); + const [modelType, setModelType] = useState(''); + const [runtimeState, setRuntimeState] = useState(''); + const [sortField, setSortField] = useState('load'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); + const [filterDialogOpen, setFilterDialogOpen] = useState(false); const platformMap = useMemo(() => new Map(props.platforms.map((item) => [item.id, item])), [props.platforms]); + const modelTypeOptions = useMemo( + () => Array.from(new Set(props.modelRateLimits.flatMap((status) => status.modelType))).sort((a, b) => a.localeCompare(b)), + [props.modelRateLimits], + ); + const filteredStatuses = useMemo( + () => filterAndSortRealtimeLoad(props.modelRateLimits, platformMap, { + query, + platformId, + modelType, + runtimeState, + sortField, + sortDirection, + now, + }), + [modelType, now, platformId, platformMap, props.modelRateLimits, query, runtimeState, sortDirection, sortField], + ); + const hasActiveFilters = Boolean(query || platformId || modelType || runtimeState); + const advancedFilterCount = [ + modelType, + runtimeState, + sortDirection === 'desc' ? '' : sortDirection, + ].filter(Boolean).length; + const hasActiveSettings = Boolean(query || platformId || advancedFilterCount || sortField !== 'load'); useEffect(() => { const timer = window.setInterval(() => setNow(Date.now()), 1000); @@ -78,27 +108,126 @@ export function RealtimeLoadPanel(props: { } } + function resetFilters() { + setQuery(''); + setPlatformId(''); + setModelType(''); + setRuntimeState(''); + setSortField('load'); + setSortDirection('desc'); + } + return (
- - -
- 实时负载 -

按平台模型查看实时 RPM、TPM、并发、排队、冷却和综合满载率。

+ + +
+ + + setQuery(event.target.value)} + /> + + + +
+ + +
- -
+ + + + + )} + open={filterDialogOpen} + title="实时负载高级筛选" + onClose={() => setFilterDialogOpen(false)} + onSubmit={(event) => { + event.preventDefault(); + setFilterDialogOpen(false); + }} + > + + + + ; now: number; - updatedAt: number | null; onAdjustPriority: (status: ModelRateLimitStatus, platform: IntegrationPlatform | undefined) => void; onRestoreRuntimeModel: (platformModelId: string) => Promise; restoreSavingId: string | null; }) { if (!props.statuses.length) { + if (props.filtered) { + return ; + } return ; } return (
-
- 按综合满载率从高到低排序 - 每 3 秒刷新一次;TPM 显示已结算 + 预占;最新更新时间 {formatTimeOfDay(props.updatedAt)}。 -
-
+
模型 平台 @@ -195,6 +325,64 @@ function RateLimitStatusTable(props: { ); } +export function filterAndSortRealtimeLoad( + statuses: ModelRateLimitStatus[], + platformMap: Map, + options: { + query: string; + platformId: string; + modelType: string; + runtimeState: string; + sortField: RealtimeLoadSortField; + sortDirection: 'asc' | 'desc'; + now: number; + }, +) { + const keyword = options.query.trim().toLowerCase(); + const filtered = statuses.filter((status) => { + const platform = platformMap.get(status.platformId); + const text = [ + status.displayName, + status.modelName, + status.providerModelName, + status.modelAlias, + status.provider, + status.platformName, + platform?.name, + platform?.internalName, + platform?.platformKey, + ...status.modelType, + ].filter(Boolean).join(' ').toLowerCase(); + const cooling = cooldownRemainingMs(status.modelCooldownUntil, options.now) > 0 || + cooldownRemainingMs(status.platformCooldownUntil, options.now) > 0; + const disabled = !status.enabled || status.platformStatus !== 'enabled'; + const matchesRuntime = !options.runtimeState || + (options.runtimeState === 'high' && status.loadRatio >= 0.8) || + (options.runtimeState === 'queued' && status.queuedTasks > 0) || + (options.runtimeState === 'attention' && (cooling || disabled)) || + (options.runtimeState === 'normal' && !cooling && !disabled && status.loadRatio < 0.8 && status.queuedTasks <= 0); + return (!keyword || text.includes(keyword)) && + (!options.platformId || status.platformId === options.platformId) && + (!options.modelType || status.modelType.includes(options.modelType)) && + matchesRuntime; + }); + const direction = options.sortDirection === 'asc' ? 1 : -1; + return filtered.sort((left, right) => { + const difference = realtimeLoadSortValue(left, options.sortField) - realtimeLoadSortValue(right, options.sortField); + if (difference !== 0) return difference * direction; + return (left.displayName || left.modelName).localeCompare(right.displayName || right.modelName); + }); +} + +function realtimeLoadSortValue(status: ModelRateLimitStatus, field: RealtimeLoadSortField) { + if (field === 'queued') return status.queuedTasks; + if (field === 'concurrent') return status.concurrent.currentValue; + if (field === 'tpm') return status.tpm.currentValue; + if (field === 'rpm') return status.rpm.currentValue; + if (field === 'priority') return status.platformEffectivePriority; + return status.loadRatio; +} + function platformDisplayName(platform: IntegrationPlatform) { return platform.internalName?.trim() || platform.name; } @@ -452,13 +640,6 @@ function reservedMetricText(metric: ModelRateLimitStatus['rpm']) { return `已结算 ${formatLimit(metric.usedValue)} + 预占 ${formatLimit(metric.reservedValue)}`; } -function formatTimeOfDay(timestamp: number | null) { - if (!timestamp) return '暂无'; - const date = new Date(timestamp); - const pad = (value: number) => String(value).padStart(2, '0'); - return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; -} - function formatDateTime(value: string | undefined) { if (!value) return ''; const date = new Date(value); diff --git a/apps/web/src/pages/admin/UserGroupPolicyEditors.test.ts b/apps/web/src/pages/admin/UserGroupPolicyEditors.test.ts new file mode 100644 index 0000000..c4a03d9 --- /dev/null +++ b/apps/web/src/pages/admin/UserGroupPolicyEditors.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { + quotaPolicyFromForm, + quotaPolicySummary, + quotaPolicyToForm, + rateLimitPolicyFromForm, + rateLimitPolicySummary, + rateLimitPolicyToForm, +} from './UserGroupPolicyEditors'; + +describe('user group policy editors', () => { + it('round-trips canonical rate limits and preserves unknown top-level fields', () => { + const source = { + strategy: 'strict', + rules: [ + { metric: 'rpm' as const, limit: 60, windowSeconds: 60 }, + { metric: 'concurrent' as const, limit: 3, leaseTtlSeconds: 120 }, + ], + }; + const form = rateLimitPolicyToForm(source); + + expect(rateLimitPolicyFromForm(form.rules, form.extra)).toEqual(source); + expect(rateLimitPolicySummary(source)).toEqual(['RPM 60', '并发 3']); + }); + + it('rejects duplicate rate-limit metrics', () => { + const form = rateLimitPolicyToForm({ + rules: [ + { metric: 'rpm', limit: 60 }, + { metric: 'rpm', limit: 120 }, + ], + }); + + expect(() => rateLimitPolicyFromForm(form.rules, form.extra)).toThrow('不能重复'); + }); + + it('round-trips quota values without changing their types', () => { + const source = { + monthlyResource: 10000, + plan: 'vip', + enabled: true, + dimensions: { image: 20 }, + }; + const form = quotaPolicyToForm(source); + + expect(quotaPolicyFromForm(form)).toEqual(source); + expect(quotaPolicySummary(source)).toEqual([ + 'monthlyResource 10000', + 'plan vip', + 'enabled true', + ]); + }); +}); diff --git a/apps/web/src/pages/admin/UserGroupPolicyEditors.tsx b/apps/web/src/pages/admin/UserGroupPolicyEditors.tsx new file mode 100644 index 0000000..f1b48ec --- /dev/null +++ b/apps/web/src/pages/admin/UserGroupPolicyEditors.tsx @@ -0,0 +1,368 @@ +import { Plus, Trash2 } from 'lucide-react'; +import type { RateLimitMetric, RateLimitPolicy } from '@easyai-ai-gateway/contracts'; +import { Button, Input, Select } from '../../components/ui'; + +export type UserGroupRateLimitRuleForm = { + id: string; + metric: RateLimitMetric; + limit: string; + windowSeconds: string; + leaseTtlSeconds: string; + consume: string; + extra: Record; +}; + +export type UserGroupQuotaEntryForm = { + id: string; + key: string; + valueType: 'number' | 'string' | 'boolean' | 'json'; + value: string; +}; + +const rateLimitMetrics: Array<{ value: RateLimitMetric; label: string }> = [ + { value: 'rpm', label: 'RPM / 每分钟请求' }, + { value: 'rps', label: 'RPS / 每秒请求' }, + { value: 'tpm_total', label: 'TPM / 总 Token' }, + { value: 'tpm_input', label: '输入 TPM' }, + { value: 'tpm_output', label: '输出 TPM' }, + { value: 'concurrent', label: '并发请求' }, + { value: 'queue_size', label: '排队数量' }, +]; + +let policyEditorRowSequence = 0; + +export function UserGroupRateLimitEditor(props: { + value: UserGroupRateLimitRuleForm[]; + onChange: (value: UserGroupRateLimitRuleForm[]) => void; +}) { + function update(id: string, patch: Partial) { + props.onChange(props.value.map((item) => item.id === id ? { ...item, ...patch } : item)); + } + + return ( +
+
+
+ 限流策略 + 按指标设置独立上限;留空表示不配置该规则。 +
+ +
+
+
+ 指标 + 上限 + 窗口(秒) + 租约 TTL(秒) + 消费方式 + 操作 +
+ {props.value.map((rule) => ( +
+ + update(rule.id, { limit: event.target.value })} /> + update(rule.id, { windowSeconds: event.target.value })} /> + update(rule.id, { leaseTtlSeconds: event.target.value })} /> + + +
+ ))} + {!props.value.length &&
未配置限流规则,当前用户组不追加组级限流。
} +
+
+ ); +} + +export function UserGroupQuotaPolicyEditor(props: { + value: UserGroupQuotaEntryForm[]; + onChange: (value: UserGroupQuotaEntryForm[]) => void; +}) { + function update(id: string, patch: Partial) { + props.onChange(props.value.map((item) => item.id === id ? { ...item, ...patch } : item)); + } + + return ( +
+
+
+ 额度策略 + 额度字段保持开放,可维护数字、文本、布尔值或 JSON。 +
+ +
+
+
+ 字段 + 值类型 + + 操作 +
+ {props.value.map((entry) => ( +
+ update(entry.id, { key: event.target.value })} /> + + {entry.valueType === 'boolean' ? ( + + ) : ( + update(entry.id, { value: event.target.value })} + /> + )} + +
+ ))} + {!props.value.length &&
未配置额度策略。
} +
+
+ ); +} + +export function rateLimitPolicyToForm(policy?: RateLimitPolicy) { + const rules = Array.isArray(policy?.rules) ? policy.rules : []; + const extra = { ...(policy ?? {}) }; + delete extra.rules; + return { + extra, + rules: rules.map((rule) => { + const raw = rule as unknown as Record; + const ruleExtra = { ...raw }; + delete ruleExtra.metric; + delete ruleExtra.limit; + delete ruleExtra.windowSeconds; + delete ruleExtra.leaseTtlSeconds; + delete ruleExtra.consume; + return { + id: nextPolicyEditorRowId('rate'), + metric: normalizeMetric(rule.metric), + limit: numberText(rule.limit), + windowSeconds: numberText(rule.windowSeconds), + leaseTtlSeconds: numberText(rule.leaseTtlSeconds), + consume: typeof rule.consume === 'string' ? rule.consume : '', + extra: ruleExtra, + }; + }), + }; +} + +export function rateLimitPolicyFromForm( + rules: UserGroupRateLimitRuleForm[], + extra: Record, +): RateLimitPolicy | undefined { + const metrics = new Set(); + const normalizedRules = rules.flatMap((rule) => { + if (!rule.limit.trim()) return []; + if (metrics.has(rule.metric)) throw new Error(`限流指标 ${metricLabel(rule.metric)} 不能重复`); + metrics.add(rule.metric); + const limit = positiveNumber(rule.limit, `${metricLabel(rule.metric)}上限`); + const windowSeconds = optionalPositiveInteger(rule.windowSeconds, `${metricLabel(rule.metric)}窗口`); + const leaseTtlSeconds = optionalPositiveInteger(rule.leaseTtlSeconds, `${metricLabel(rule.metric)}租约 TTL`); + return [{ + ...rule.extra, + metric: rule.metric, + limit, + ...(windowSeconds ? { windowSeconds } : {}), + ...(leaseTtlSeconds ? { leaseTtlSeconds } : {}), + ...(rule.consume ? { consume: rule.consume } : {}), + }]; + }); + const policy = { + ...extra, + ...(normalizedRules.length ? { rules: normalizedRules } : {}), + }; + return Object.keys(policy).length ? policy : undefined; +} + +export function quotaPolicyToForm(policy?: Record) { + return Object.entries(policy ?? {}).map(([key, value]) => { + const serialized = serializeQuotaValue(value); + return { + id: nextPolicyEditorRowId('quota'), + key, + valueType: serialized.valueType, + value: serialized.value, + }; + }); +} + +export function quotaPolicyFromForm(entries: UserGroupQuotaEntryForm[]) { + const policy: Record = {}; + entries.forEach((entry) => { + const key = entry.key.trim(); + if (!key && !entry.value.trim()) return; + if (!key) throw new Error('额度字段不能为空'); + if (Object.prototype.hasOwnProperty.call(policy, key)) throw new Error(`额度字段 ${key} 不能重复`); + policy[key] = parseQuotaValue(entry); + }); + return Object.keys(policy).length ? policy : undefined; +} + +export function rateLimitPolicySummary(policy?: RateLimitPolicy) { + const rules = Array.isArray(policy?.rules) ? policy.rules : []; + const parts = rules + .filter((rule) => Number.isFinite(Number(rule.limit)) && Number(rule.limit) > 0) + .map((rule) => `${metricShortLabel(rule.metric)} ${compactNumber(Number(rule.limit))}`); + if (parts.length) return parts; + return Object.entries(policy ?? {}) + .filter(([key]) => key !== 'rules') + .slice(0, 3) + .map(([key, value]) => `${key} ${compactValue(value)}`); +} + +export function quotaPolicySummary(policy?: Record) { + return Object.entries(policy ?? {}).slice(0, 3).map(([key, value]) => `${key} ${compactValue(value)}`); +} + +function createRateLimitRuleForm(metric: RateLimitMetric): UserGroupRateLimitRuleForm { + return { + id: nextPolicyEditorRowId('rate'), + metric, + limit: '', + windowSeconds: metric === 'concurrent' ? '' : '60', + leaseTtlSeconds: metric === 'concurrent' ? '120' : '', + consume: '', + extra: {}, + }; +} + +function createQuotaEntryForm(): UserGroupQuotaEntryForm { + return { + id: nextPolicyEditorRowId('quota'), + key: '', + valueType: 'number', + value: '', + }; +} + +function nextAvailableMetric(rules: UserGroupRateLimitRuleForm[]) { + return rateLimitMetrics.find((item) => !rules.some((rule) => rule.metric === item.value))?.value ?? 'rpm'; +} + +function nextPolicyEditorRowId(prefix: string) { + policyEditorRowSequence += 1; + return `${prefix}-${policyEditorRowSequence}`; +} + +function normalizeMetric(value: string): RateLimitMetric { + return rateLimitMetrics.some((item) => item.value === value) ? value as RateLimitMetric : 'rpm'; +} + +function metricLabel(metric: string) { + return rateLimitMetrics.find((item) => item.value === metric)?.label ?? metric; +} + +function metricShortLabel(metric: string) { + return ({ + rpm: 'RPM', + rps: 'RPS', + tpm_total: 'TPM', + tpm_input: '输入 TPM', + tpm_output: '输出 TPM', + concurrent: '并发', + queue_size: '排队', + } as Record)[metric] ?? metric; +} + +function numberText(value: unknown) { + return typeof value === 'number' && Number.isFinite(value) ? String(value) : ''; +} + +function positiveNumber(value: string, label: string) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`${label}必须是大于 0 的数字`); + return parsed; +} + +function optionalPositiveInteger(value: string, label: string) { + if (!value.trim()) return undefined; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${label}必须是大于 0 的整数`); + return parsed; +} + +function serializeQuotaValue(value: unknown): Pick { + if (typeof value === 'number') return { valueType: 'number', value: String(value) }; + if (typeof value === 'boolean') return { valueType: 'boolean', value: String(value) }; + if (typeof value === 'string') return { valueType: 'string', value }; + return { valueType: 'json', value: JSON.stringify(value) ?? 'null' }; +} + +function parseQuotaValue(entry: UserGroupQuotaEntryForm) { + if (entry.valueType === 'string') return entry.value; + if (entry.valueType === 'boolean') return entry.value === 'true'; + if (entry.valueType === 'number') { + const value = Number(entry.value); + if (!Number.isFinite(value) || value < 0) throw new Error(`额度字段 ${entry.key} 必须是大于或等于 0 的数字`); + return value; + } + try { + return JSON.parse(entry.value) as unknown; + } catch { + throw new Error(`额度字段 ${entry.key} 必须是有效 JSON`); + } +} + +function quotaValueForType(valueType: UserGroupQuotaEntryForm['valueType'], current: string) { + if (valueType === 'boolean') return current === 'false' ? 'false' : 'true'; + if (valueType === 'number') return Number.isFinite(Number(current)) ? current : ''; + if (valueType === 'json') { + try { + JSON.parse(current); + return current; + } catch { + return '{}'; + } + } + return current; +} + +function compactNumber(value: number) { + return new Intl.NumberFormat('zh-CN', { notation: 'compact', maximumFractionDigits: 1 }).format(value); +} + +function compactValue(value: unknown) { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value); + const text = JSON.stringify(value) ?? String(value); + return text.length > 18 ? `${text.slice(0, 17)}…` : text; +} diff --git a/apps/web/src/routing.test.ts b/apps/web/src/routing.test.ts index c3ba621..1d10d56 100644 --- a/apps/web/src/routing.test.ts +++ b/apps/web/src/routing.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { parseAppRoute, pathForApiDocSection } from './routing'; +import { defaultAdminTaskQuery, parseAppRoute, pathForAdminSection, pathForAdminTaskQuery, pathForApiDocSection } from './routing'; describe('API documentation routes', () => { it.each([ @@ -35,3 +35,47 @@ describe('API documentation routes', () => { expect(parseAppRoute('/docs').apiDocSection).toBe('openApiOverview'); }); }); + +describe('admin task routes', () => { + it('round-trips the complete admin task query through the URL', () => { + const query = { + ...defaultAdminTaskQuery(), + query: 'request-123', + tenantId: 'tenant-id', + userId: 'user-id', + userGroupId: 'group-id', + status: 'failed', + platformId: 'platform-id', + model: 'model-a', + modelType: 'image_generate', + runMode: 'production', + billingStatus: 'settled', + apiKey: 'ops-key', + createdFrom: '2026-07-01T00:00:00Z', + createdTo: '2026-07-25T00:00:00Z', + page: 3, + pageSize: 50, + }; + const path = pathForAdminTaskQuery(query); + const parsed = parseAppRoute(path); + expect(parsed.adminSection).toBe('tasks'); + expect(parsed.adminTaskQuery).toEqual(query); + }); + + it('uses stable defaults for the admin task page', () => { + expect(parseAppRoute('/admin/tasks').adminTaskQuery).toEqual(defaultAdminTaskQuery()); + }); +}); + +describe('admin billing settlement route', () => { + it('maps billing settlements to an independent admin page', () => { + expect(pathForAdminSection('billingSettlements')).toBe('/admin/billing-settlements'); + expect(parseAppRoute('/admin/billing-settlements').adminSection).toBe('billingSettlements'); + }); +}); + +describe('legacy admin access rule route', () => { + it('opens user groups because model permissions are configured from the group row', () => { + expect(parseAppRoute('/admin/access-rules').adminSection).toBe('userGroups'); + }); +}); diff --git a/apps/web/src/routing.ts b/apps/web/src/routing.ts index cf29a66..1417b9e 100644 --- a/apps/web/src/routing.ts +++ b/apps/web/src/routing.ts @@ -1,8 +1,9 @@ -import type { AdminSection, ApiDocSection, PageKey, PlaygroundMode, WorkspaceSection, WorkspaceTaskQuery } from './types'; +import type { AdminSection, AdminTaskQuery, ApiDocSection, PageKey, PlaygroundMode, WorkspaceSection, WorkspaceTaskQuery } from './types'; export interface AppRouteState { activePage: PageKey; adminSection: AdminSection; + adminTaskQuery: AdminTaskQuery; apiDocSection: ApiDocSection; playgroundMode: PlaygroundMode; workspaceSection: WorkspaceSection; @@ -12,6 +13,7 @@ export interface AppRouteState { export const defaultRouteState: AppRouteState = { activePage: 'home', adminSection: 'overview', + adminTaskQuery: defaultAdminTaskQuery(), apiDocSection: 'openApiOverview', playgroundMode: 'chat', workspaceSection: 'overview', @@ -31,14 +33,15 @@ const adminPaths: Record = { globalModels: '/admin/providers', baseModels: '/admin/base-models', pricing: '/admin/pricing', + billingSettlements: '/admin/billing-settlements', platforms: '/admin/platforms', realtimeLoad: '/admin/realtime-load', + tasks: '/admin/tasks', tenants: '/admin/tenants', users: '/admin/users', userGroups: '/admin/user-groups', auditLogs: '/admin/audit-logs', runtime: '/admin/runtime', - accessRules: '/admin/access-rules', systemSettings: '/admin/system-settings', }; @@ -82,19 +85,20 @@ export function parseAppRoute(input = currentLocationPath()): AppRouteState { const url = new URL(input, origin); const path = normalizePath(url.pathname); const workspaceTaskQuery = parseWorkspaceTaskQuery(url.searchParams); + const adminTaskQuery = parseAdminTaskQuery(url.searchParams); if (path === '/') return { ...defaultRouteState }; if (path.startsWith('/playground')) { - return { ...defaultRouteState, activePage: 'playground', playgroundMode: parsePlaygroundMode(path), workspaceTaskQuery }; + return { ...defaultRouteState, activePage: 'playground', playgroundMode: parsePlaygroundMode(path), workspaceTaskQuery, adminTaskQuery }; } - if (path === '/models') return { ...defaultRouteState, activePage: 'models', workspaceTaskQuery }; + if (path === '/models') return { ...defaultRouteState, activePage: 'models', workspaceTaskQuery, adminTaskQuery }; if (path.startsWith('/workspace')) { - return { ...defaultRouteState, activePage: 'workspace', workspaceSection: parseWorkspaceSection(path), workspaceTaskQuery }; + return { ...defaultRouteState, activePage: 'workspace', workspaceSection: parseWorkspaceSection(path), workspaceTaskQuery, adminTaskQuery }; } if (path.startsWith('/admin')) { - return { ...defaultRouteState, activePage: 'admin', adminSection: parseAdminSection(path), workspaceTaskQuery }; + return { ...defaultRouteState, activePage: 'admin', adminSection: parseAdminSection(path), workspaceTaskQuery, adminTaskQuery }; } if (path.startsWith('/docs')) { - return { ...defaultRouteState, activePage: 'docs', apiDocSection: parseDocSection(path), workspaceTaskQuery }; + return { ...defaultRouteState, activePage: 'docs', apiDocSection: parseDocSection(path), workspaceTaskQuery, adminTaskQuery }; } return { ...defaultRouteState }; } @@ -130,6 +134,28 @@ export function pathForWorkspaceTaskQuery(query: WorkspaceTaskQuery) { return `${workspacePaths.tasks}${suffix ? `?${suffix}` : ''}`; } +export function pathForAdminTaskQuery(query: AdminTaskQuery) { + const normalized = normalizeAdminTaskQuery(query); + const search = new URLSearchParams(); + if (normalized.query) search.set('q', normalized.query); + if (normalized.tenantId) search.set('tenantId', normalized.tenantId); + if (normalized.userId) search.set('userId', normalized.userId); + if (normalized.userGroupId) search.set('userGroupId', normalized.userGroupId); + if (normalized.status) search.set('status', normalized.status); + if (normalized.platformId) search.set('platformId', normalized.platformId); + if (normalized.model) search.set('model', normalized.model); + if (normalized.modelType) search.set('modelType', normalized.modelType); + if (normalized.runMode) search.set('runMode', normalized.runMode); + if (normalized.billingStatus) search.set('billingStatus', normalized.billingStatus); + if (normalized.apiKey) search.set('apiKey', normalized.apiKey); + if (normalized.createdFrom) search.set('createdFrom', normalized.createdFrom); + if (normalized.createdTo) search.set('createdTo', normalized.createdTo); + if (normalized.page !== 1) search.set('page', String(normalized.page)); + if (normalized.pageSize !== 20) search.set('pageSize', String(normalized.pageSize)); + const suffix = search.toString(); + return `${adminPaths.tasks}${suffix ? `?${suffix}` : ''}`; +} + export function pathForAdminSection(section: AdminSection) { return adminPaths[section] ?? adminPaths.overview; } @@ -150,6 +176,7 @@ function parseAdminSection(path: string): AdminSection { if (path === '/admin') return 'overview'; if (path === '/admin/models/global') return 'baseModels'; if (path === '/admin/usergroups') return 'userGroups'; + if (path === '/admin/access-rules') return 'userGroups'; return adminSections[path] ?? 'overview'; } @@ -207,6 +234,70 @@ export function workspaceTaskQueryKey(query: WorkspaceTaskQuery) { return JSON.stringify(normalized); } +export function defaultAdminTaskQuery(): AdminTaskQuery { + return { + query: '', + tenantId: '', + userId: '', + userGroupId: '', + status: '', + platformId: '', + model: '', + modelType: '', + runMode: '', + billingStatus: '', + apiKey: '', + createdFrom: '', + createdTo: '', + page: 1, + pageSize: 20, + }; +} + +export function normalizeAdminTaskQuery(query: AdminTaskQuery): AdminTaskQuery { + return { + query: query.query.trim(), + tenantId: query.tenantId.trim(), + userId: query.userId.trim(), + userGroupId: query.userGroupId.trim(), + status: query.status.trim(), + platformId: query.platformId.trim(), + model: query.model.trim(), + modelType: query.modelType.trim(), + runMode: query.runMode.trim(), + billingStatus: query.billingStatus.trim(), + apiKey: query.apiKey.trim(), + createdFrom: query.createdFrom.trim(), + createdTo: query.createdTo.trim(), + page: positiveInt(query.page, 1), + pageSize: Math.min(100, Math.max(1, positiveInt(query.pageSize, 20))), + }; +} + +export function adminTaskQueryKey(query: AdminTaskQuery) { + return JSON.stringify(normalizeAdminTaskQuery(query)); +} + +function parseAdminTaskQuery(search: URLSearchParams): AdminTaskQuery { + return normalizeAdminTaskQuery({ + query: search.get('q') ?? search.get('query') ?? '', + tenantId: search.get('tenantId') ?? '', + userId: search.get('userId') ?? '', + userGroupId: search.get('userGroupId') ?? '', + status: search.get('status') ?? '', + platformId: search.get('platformId') ?? '', + model: search.get('model') ?? '', + modelType: search.get('modelType') ?? '', + runMode: search.get('runMode') ?? '', + billingStatus: search.get('billingStatus') ?? '', + apiKey: search.get('apiKey') ?? '', + createdFrom: search.get('createdFrom') ?? search.get('from') ?? '', + createdTo: search.get('createdTo') ?? search.get('to') ?? '', + page: Number(search.get('page') ?? 1), + pageSize: Number(search.get('pageSize') ?? 20), + }); +} + function parseWorkspaceTaskQuery(search: URLSearchParams): WorkspaceTaskQuery { return normalizeWorkspaceTaskQuery({ query: search.get('q') ?? search.get('query') ?? '', diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 2bc4d4a..9d7eae0 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -239,6 +239,62 @@ strong { margin-bottom: -52px; } +.adminTaskRecordViewport { + --sh-table-viewport-gap: 10px; + --sh-table-viewport-height: calc(100dvh - 120px); +} + +.adminTaskFilters { + grid-template-columns: minmax(300px, 1.35fr) minmax(330px, 1fr) auto; + gap: 10px; +} + +.adminTaskSearchLabel, +.adminTaskRangeLabel { + min-width: 0; +} + +.adminTaskToolbarActions { + display: flex; + align-items: end; + justify-content: flex-end; + gap: 8px; + min-height: 32px; +} + +.adminTaskAdvancedDialog { + width: min(820px, 100%); +} + +.adminTaskAdvancedDialogBody { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.adminTaskResultSummary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + color: var(--text-normal); + font-size: var(--font-size-sm); +} + +.adminTaskResultSummary small { + color: var(--text-soft); + font-size: var(--font-size-xs); +} + +.adminTaskRecordTable .shTableRow { + grid-template-columns: minmax(190px, 0.9fr) minmax(180px, 0.8fr) minmax(210px, 1fr) minmax(90px, 0.4fr) minmax(100px, 0.45fr) minmax(250px, 1.2fr) minmax(150px, 0.65fr) minmax(104px, 0.42fr) minmax(118px, 0.44fr) minmax(126px, 0.55fr) minmax(145px, 0.62fr) minmax(150px, 0.62fr) minmax(82px, 0.36fr) minmax(130px, 0.55fr) minmax(98px, 0.42fr) minmax(150px, 0.66fr) minmax(130px, 0.54fr); + min-width: 2513px; + align-items: start; +} + +.taskRecordAdminIdentityCell { + overflow: visible; + white-space: normal; +} + .taskRecordSearchBox { display: grid; grid-template-columns: 18px minmax(0, 1fr); @@ -974,6 +1030,14 @@ strong { } @media (max-width: 980px) { + .adminTaskFilters { + grid-template-columns: 1fr; + } + + .adminTaskToolbarActions { + justify-content: flex-start; + } + .contentShell { width: 100%; } @@ -1027,6 +1091,20 @@ strong { } @media (max-width: 560px) { + .adminTaskToolbarActions { + align-items: stretch; + flex-direction: column; + } + + .adminTaskAdvancedDialogBody { + grid-template-columns: 1fr; + } + + .adminTaskResultSummary { + align-items: flex-start; + flex-direction: column; + } + .walletMetricGrid { grid-template-columns: 1fr; } diff --git a/apps/web/src/styles/pages.css b/apps/web/src/styles/pages.css index fead9df..34c4b73 100644 --- a/apps/web/src/styles/pages.css +++ b/apps/web/src/styles/pages.css @@ -737,8 +737,21 @@ } .groupTable .shTableRow { - grid-template-columns: minmax(210px, 1.25fr) minmax(100px, 0.55fr) minmax(150px, 0.8fr) minmax(150px, 0.8fr) minmax(100px, 0.55fr) minmax(88px, 0.5fr) minmax(86px, 0.5fr); - min-width: 980px; + grid-template-columns: minmax(190px, 1.15fr) minmax(90px, 0.5fr) minmax(82px, 0.42fr) minmax(145px, 0.8fr) minmax(180px, 1fr) minmax(180px, 1fr) minmax(82px, 0.42fr) minmax(154px, 0.78fr); + min-width: 1305px; +} + +.groupTable .shTableRow > :last-child { + position: sticky; + right: 0; + z-index: 1; + background: var(--surface); + box-shadow: -8px 0 12px -12px rgba(16, 24, 40, 0.45); +} + +.groupTable > .shTableRow:first-child > :last-child { + z-index: 2; + background: var(--surface-subtle); } .auditLogTable .shTableRow { @@ -774,6 +787,106 @@ width: min(820px, 100%); } +.userGroupDialog { + width: min(1160px, 100%); +} + +.userGroupPermissionDialog { + width: min(1280px, 100%); + max-height: calc(100dvh - 32px); +} + +.userGroupPermissionDialogBody { + grid-template-columns: minmax(0, 1fr); +} + +.userGroupPermissionDialog .accessTreeBox { + min-height: min(520px, calc(100dvh - 300px)); + max-height: calc(100dvh - 300px); +} + +.userGroupPolicyEditor { + display: grid; + min-width: 0; + gap: 8px; + padding: 10px; + border: 1px solid var(--border-subtle); + border-radius: 9px; + background: var(--surface-subtle); +} + +.userGroupPolicyHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.userGroupPolicyHeader > div { + display: grid; + gap: 2px; +} + +.userGroupPolicyHeader strong { + color: var(--text-strong); + font-size: var(--font-size-sm); +} + +.userGroupPolicyHeader span { + color: var(--muted-foreground); + font-size: var(--font-size-xs); +} + +.userGroupPolicyTable { + min-width: 0; + overflow-x: auto; + border: 1px solid var(--border-subtle); + border-radius: 8px; + background: #fff; +} + +.userGroupPolicyRow { + display: grid; + align-items: center; + gap: 6px; + min-width: 850px; + padding: 6px 8px; + border-bottom: 1px solid var(--border-subtle); +} + +.userGroupRateLimitTable .userGroupPolicyRow { + grid-template-columns: minmax(170px, 1.15fr) minmax(100px, 0.65fr) minmax(112px, 0.65fr) minmax(120px, 0.72fr) minmax(156px, 0.9fr) 42px; +} + +.userGroupQuotaTable .userGroupPolicyRow { + min-width: 620px; + grid-template-columns: minmax(180px, 0.9fr) minmax(120px, 0.55fr) minmax(240px, 1.2fr) 42px; +} + +.userGroupPolicyRow:last-child { + border-bottom: 0; +} + +.userGroupPolicyTableHead { + min-height: 34px; + background: var(--surface-subtle); + color: var(--muted-foreground); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); +} + +.userGroupPolicyEmpty { + padding: 16px; + color: var(--muted-foreground); + font-size: var(--font-size-xs); + text-align: center; +} + +.userGroupPolicySummary strong, +.userGroupPolicySummary small { + max-width: 100%; +} + .formMessage { margin-top: 12px; color: var(--muted-foreground); @@ -1023,15 +1136,175 @@ box-shadow: 0 1px 2px rgba(16, 24, 40, 0.07); } +.compactAdminTableContent { + display: grid; + gap: 10px; + padding: 10px; +} + +.compactAdminToolbar { + display: grid; + align-items: center; + gap: 8px; +} + +.platformCompactToolbar { + grid-template-columns: auto minmax(300px, 1fr) auto; +} + +.realtimeCompactToolbar { + grid-template-columns: minmax(280px, 1fr) minmax(150px, 0.45fr) minmax(120px, 0.35fr) auto; +} + +.catalogCompactToolbar { + grid-template-columns: minmax(320px, 1fr) auto; +} + +.billingCompactToolbar { + grid-template-columns: minmax(280px, 1fr) minmax(150px, 220px) auto; +} + +.compactAdminToolbar .platformViewTabs { + margin: 0; + padding: 2px; +} + +.compactAdminToolbar .platformViewTabs button { + min-height: 28px; + padding: 0 10px; +} + +.compactAdminSearch { + width: 100%; +} + +.compactAdminToolbarActions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.compactFilterDialog { + width: min(680px, 100%); +} + +.billingSettlementTableViewport { + min-width: 0; + overflow-x: auto; +} + +.billingSettlementTable { + min-width: 860px; +} + +.billingSettlementTable .shTableRow { + grid-template-columns: 38px minmax(180px, 1.15fr) minmax(104px, 0.55fr) minmax(118px, 0.65fr) minmax(72px, 0.35fr) minmax(180px, 1fr) minmax(110px, 0.55fr); +} + +.billingSettlementTable .shTableHead:first-child, +.billingSettlementTable .shTableCell:first-child { + justify-content: center; +} + +.compactAdminTableCard .platformFilteredView, +.compactAdminTableCard .platformModelView { + gap: 0; +} + +.compactAdminTableCard .platformLimitView { + max-height: calc(100dvh - 150px); + grid-template-rows: minmax(0, 1fr); + gap: 0; +} + +.compactAdminTableCard .platformLimitTableViewport { + max-height: calc(100dvh - 160px); +} + +.compactAdminTableCard .platformManagementTableViewport { + max-height: calc(100dvh - 160px); +} + +.platformFilteredView, +.platformModelView { + display: grid; + gap: 10px; +} + +.platformFilterToolbar, +.realtimeLoadToolbar { + display: grid; + grid-template-columns: minmax(280px, 1.4fr) repeat(2, minmax(150px, 0.6fr)) auto; + align-items: end; + gap: 10px; + padding: 10px; + border: 1px solid var(--border-subtle); + border-radius: 10px; + background: var(--surface-subtle); +} + +.platformSearchBox { + display: grid; + grid-template-columns: 18px minmax(0, 1fr); + align-items: center; + gap: 8px; + min-height: 32px; + padding-left: 10px; + border: 1px solid var(--input); + border-radius: var(--control-radius); + background: var(--surface); +} + +.platformSearchBox svg { + color: var(--text-soft); +} + +.platformSearchBox .shInput { + border: 0; + box-shadow: none; +} + +.platformFilterSummary, +.realtimeLoadSummary { + color: var(--muted-foreground); + font-size: var(--font-size-xs); +} + .platformDataTable { border: 1px solid var(--border-subtle); border-radius: 10px; background: #fff; } +.platformManagementTableViewport { + min-width: 0; + min-height: 0; + max-height: calc(100dvh - 220px); + overflow: auto; + overscroll-behavior: contain; +} + +.platformManagementTableViewport .shTableHeader { + z-index: 2; +} + +.platformManagementTableViewport .shTableRow > :last-child { + position: sticky; + right: 0; + z-index: 1; + background: #fff; + box-shadow: -8px 0 12px -12px rgba(16, 24, 40, 0.45); +} + +.platformManagementTableViewport .shTableHeader > :last-child { + z-index: 3; + background: var(--surface-subtle); +} + .platformDataTable .shTableRow { - grid-template-columns: minmax(210px, 1.3fr) minmax(120px, 0.7fr) minmax(150px, 0.9fr) minmax(92px, 0.45fr) minmax(160px, 0.9fr) minmax(76px, 0.4fr) minmax(150px, 0.85fr) minmax(86px, 0.45fr); - min-width: 1040px; + grid-template-columns: minmax(210px, 1.3fr) minmax(120px, 0.7fr) minmax(150px, 0.9fr) minmax(92px, 0.45fr) minmax(160px, 0.9fr) minmax(70px, 0.35fr) minmax(130px, 0.7fr) minmax(82px, 0.4fr) minmax(190px, 1fr) minmax(86px, 0.45fr); + min-width: 1290px; } .platformModelView .platformDataTable .shTableRow { @@ -1100,11 +1373,6 @@ min-height: 30px; } -.platformModelView { - display: grid; - gap: 12px; -} - .platformLimitView { display: grid; grid-template-rows: auto minmax(0, 1fr); @@ -1165,13 +1433,17 @@ .platformLimitTable .shTableCell { display: grid; align-content: center; - min-height: 68px; - padding-right: 10px; - padding-left: 10px; + min-height: 48px; + padding: 8px; } .platformLimitTable .shTableHead { - min-height: 74px; + min-height: 50px; +} + +.platformLimitTable .platformTableName, +.platformLimitTable .rateMetricCell { + gap: 1px; } .platformLimitMetricHead { @@ -1203,9 +1475,10 @@ .platformRuntimeStatusCell { display: grid; + grid-template-columns: minmax(0, 1fr) auto; min-width: 0; - gap: 7px; - align-content: start; + align-items: center; + gap: 4px; } .platformRestoreButton { @@ -1272,17 +1545,18 @@ .platformPriorityCell { display: grid; + grid-template-columns: minmax(0, 1fr) auto; width: 100%; min-width: 0; - gap: 7px; - align-content: start; + align-items: center; + gap: 4px; } .platformPriorityCell .rateMetricCell small { - overflow: visible; - line-height: 1.35; - text-overflow: clip; - white-space: normal; + overflow: hidden; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; } .priorityDemotionTrigger { @@ -1409,7 +1683,8 @@ .platformModelToolbar { display: grid; - grid-template-columns: minmax(220px, 0.6fr) minmax(260px, 1fr); + grid-template-columns: minmax(180px, 0.6fr) minmax(240px, 1fr) minmax(150px, 0.55fr) minmax(150px, 0.55fr) auto; + align-items: end; gap: 12px; padding: 12px; border: 1px solid var(--border-subtle); @@ -1417,6 +1692,11 @@ background: var(--surface-subtle); } +.realtimeLoadToolbar { + grid-template-columns: minmax(260px, 1.3fr) repeat(4, minmax(140px, 0.55fr)) auto auto; + margin-bottom: 8px; +} + .platformEmptyState { display: grid; min-height: 260px; @@ -2407,6 +2687,17 @@ } @media (max-width: 860px) { + .platformCompactToolbar, + .realtimeCompactToolbar, + .catalogCompactToolbar, + .billingCompactToolbar { + grid-template-columns: 1fr; + } + + .compactAdminToolbarActions { + justify-content: flex-start; + } + .runnerPolicyHeader { align-items: flex-start; flex-direction: column; @@ -2453,6 +2744,8 @@ .platformModelChoices, .platformModelRow, .platformModelToolbar, + .platformFilterToolbar, + .realtimeLoadToolbar, .runtimePolicyGrid, .fileStorageGrid, .fileStorageSettingsCard, diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index ba2e870..1cc4e35 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -40,14 +40,15 @@ export type AdminSection = | 'globalModels' | 'baseModels' | 'pricing' + | 'billingSettlements' | 'platforms' | 'realtimeLoad' + | 'tasks' | 'tenants' | 'users' | 'userGroups' | 'auditLogs' | 'runtime' - | 'accessRules' | 'systemSettings'; export interface LoginForm { @@ -95,6 +96,24 @@ export interface WorkspaceTaskQuery { pageSize: number; } +export interface AdminTaskQuery { + query: string; + tenantId: string; + userId: string; + userGroupId: string; + status: string; + platformId: string; + model: string; + modelType: string; + runMode: string; + billingStatus: string; + apiKey: string; + createdFrom: string; + createdTo: string; + page: number; + pageSize: number; +} + export interface WorkspaceTransactionQuery { query: string; createdFrom: string; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 05e33b7..8eb4fc8 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1188,6 +1188,34 @@ export interface GatewayTask { updatedAt: string; } +export interface AdminTaskUserSummary { + id: string; + username?: string; + displayName?: string; + email?: string; + source?: string; +} + +export interface AdminTaskTenantSummary { + id: string; + tenantKey?: string; + name?: string; +} + +export interface AdminTaskPlatformSummary { + id: string; + name?: string; + provider?: string; +} + +export interface AdminGatewayTask extends GatewayTask { + adminContext: { + user?: AdminTaskUserSummary; + tenant?: AdminTaskTenantSummary; + latestPlatform?: AdminTaskPlatformSummary; + }; +} + export interface GatewayTaskParamPreprocessingLog { id: string; taskId: string;