feat(admin): 优化后台运维与任务管理
完善平台、基准模型、定价规则和实时负载的紧凑查询与滚动展示,并固定关键操作列。 新增管理员任务记录、批量计费结算和用户组限流、额度及模型权限维护能力,同时补充任务脱敏、查询索引与 OpenAPI 契约。 验证:pnpm openapi、Go 全量测试、pnpm lint、pnpm test、pnpm build、gofmt 与差异格式检查均通过。
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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: 管理端返回网关租户列表。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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;
|
||||
+69
-7
@@ -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<PageKey>(initialRoute.activePage);
|
||||
const [adminSection, setAdminSection] = useState<AdminSection>(initialRoute.adminSection);
|
||||
const [adminTaskQuery, setAdminTaskQuery] = useState<AdminTaskQuery>(initialRoute.adminTaskQuery);
|
||||
const [workspaceSection, setWorkspaceSection] = useState<WorkspaceSection>(initialRoute.workspaceSection);
|
||||
const [workspaceTaskQuery, setWorkspaceTaskQuery] = useState<WorkspaceTaskQuery>(initialRoute.workspaceTaskQuery);
|
||||
const [workspaceTransactionQuery, setWorkspaceTransactionQuery] = useState<WorkspaceTransactionQuery>(() => defaultWorkspaceTransactionQuery());
|
||||
@@ -258,6 +265,10 @@ export function App() {
|
||||
const [taskResult, setTaskResult] = useState<GatewayTask | null>(null);
|
||||
const [tasks, setTasks] = useState<GatewayTask[]>([]);
|
||||
const [taskTotal, setTaskTotal] = useState(0);
|
||||
const [adminTasks, setAdminTasks] = useState<AdminGatewayTask[]>([]);
|
||||
const [adminTaskTotal, setAdminTaskTotal] = useState(0);
|
||||
const [adminTasksUpdatedAt, setAdminTasksUpdatedAt] = useState<number | null>(null);
|
||||
const [adminTaskState, setAdminTaskState] = useState<LoadState>('idle');
|
||||
const [walletAccounts, setWalletAccounts] = useState<GatewayWalletAccount[]>([]);
|
||||
const [walletTransactions, setWalletTransactions] = useState<GatewayWalletTransaction[]>([]);
|
||||
const [walletTransactionTotal, setWalletTransactionTotal] = useState(0);
|
||||
@@ -279,6 +290,8 @@ export function App() {
|
||||
const loadingDataKeysRef = useRef(new Set<DataKey>());
|
||||
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<ConsoleData>(() => ({
|
||||
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 ? (
|
||||
<AdminPage
|
||||
token={token}
|
||||
token={token}
|
||||
adminTaskQuery={adminTaskQuery}
|
||||
adminTaskTotal={adminTaskTotal}
|
||||
adminTasksUpdatedAt={adminTasksUpdatedAt}
|
||||
adminTaskState={adminTaskState}
|
||||
data={data}
|
||||
operationMessage={coreMessage}
|
||||
section={adminSection}
|
||||
@@ -1447,6 +1505,8 @@ export function App() {
|
||||
onRechargeUserWalletBalance={rechargeUserWallet}
|
||||
onSaveUserGroup={saveUserGroup}
|
||||
onClearOperationMessage={() => 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:
|
||||
|
||||
+38
-1
@@ -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<ListResponse<GatewayTask>>(`/api/workspace/tasks?${search.toString()}`, { token });
|
||||
}
|
||||
|
||||
export async function listAdminTasks(token: string, query: AdminTaskQuery): Promise<ListResponse<AdminGatewayTask>> {
|
||||
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<ListResponse<AdminGatewayTask>>(`/api/admin/tasks?${search.toString()}`, { token });
|
||||
}
|
||||
|
||||
export async function getAdminTask(token: string, taskId: string, sensitive: 'masked' | 'full' = 'masked'): Promise<AdminGatewayTask> {
|
||||
return request<AdminGatewayTask>(`/api/admin/tasks/${taskId}?sensitive=${sensitive}`, { token });
|
||||
}
|
||||
|
||||
export async function listAdminTaskParamPreprocessing(
|
||||
token: string,
|
||||
taskId: string,
|
||||
sensitive: 'masked' | 'full' = 'masked',
|
||||
): Promise<ListResponse<GatewayTaskParamPreprocessingLog>> {
|
||||
return request<ListResponse<GatewayTaskParamPreprocessingLog>>(
|
||||
`/api/admin/tasks/${taskId}/param-preprocessing?sensitive=${sensitive}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export async function getWalletSummary(token: string): Promise<WalletSummaryResponse> {
|
||||
return request<WalletSummaryResponse>('/api/workspace/wallet', { token });
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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。' },
|
||||
];
|
||||
|
||||
@@ -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: <Boxes size={15} /> },
|
||||
{ value: 'platforms', label: '平台管理', icon: <ServerCog size={15} /> },
|
||||
{ value: 'realtimeLoad', label: '实时负载', icon: <Gauge size={15} /> },
|
||||
{ value: 'tasks', label: '任务记录', icon: <ListChecks size={15} /> },
|
||||
{ value: 'billingSettlements', label: '计费结算', icon: <ReceiptText size={15} /> },
|
||||
{ value: 'tenants', label: '租户', icon: <Building2 size={15} /> },
|
||||
{ value: 'users', label: '用户', icon: <UsersRound size={15} /> },
|
||||
{ value: 'userGroups', label: '用户组', icon: <UsersRound size={15} /> },
|
||||
{ value: 'accessRules', label: '模型权限', icon: <KeyRound size={15} /> },
|
||||
{ value: 'systemSettings', label: '系统设置', icon: <Settings size={15} /> },
|
||||
{ value: 'auditLogs', label: '审计日志', icon: <History size={15} /> },
|
||||
] 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<void>;
|
||||
@@ -94,6 +99,8 @@ export function AdminPage(props: {
|
||||
onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise<void>;
|
||||
onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise<void>;
|
||||
onClearOperationMessage: () => void;
|
||||
onAdminTaskQueryChange: (value: AdminTaskQuery) => void;
|
||||
onRefreshAdminTasks: () => Promise<void>;
|
||||
onSectionChange: (value: AdminSection) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -132,31 +139,17 @@ export function AdminPage(props: {
|
||||
/>
|
||||
)}
|
||||
{props.section === 'runtime' && (
|
||||
<div className="pageStack">
|
||||
<BillingSettlementsPanel token={props.token} />
|
||||
<RuntimePoliciesPanel
|
||||
message={props.operationMessage}
|
||||
runnerPolicy={props.data.runnerPolicy}
|
||||
runtimePolicySets={props.data.runtimePolicySets}
|
||||
state={props.state}
|
||||
onDeleteRuntimePolicySet={props.onDeleteRuntimePolicySet}
|
||||
onSaveRunnerPolicy={props.onSaveRunnerPolicy}
|
||||
onSaveRuntimePolicySet={props.onSaveRuntimePolicySet}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{props.section === 'accessRules' && (
|
||||
<AccessRulesPanel
|
||||
accessRules={props.data.accessRules}
|
||||
baseModels={props.data.baseModels}
|
||||
<RuntimePoliciesPanel
|
||||
message={props.operationMessage}
|
||||
platformModels={props.data.models}
|
||||
platforms={props.data.platforms}
|
||||
runnerPolicy={props.data.runnerPolicy}
|
||||
runtimePolicySets={props.data.runtimePolicySets}
|
||||
state={props.state}
|
||||
userGroups={props.data.userGroups}
|
||||
onBatchAccessRules={props.onBatchAccessRules}
|
||||
onDeleteRuntimePolicySet={props.onDeleteRuntimePolicySet}
|
||||
onSaveRunnerPolicy={props.onSaveRunnerPolicy}
|
||||
onSaveRuntimePolicySet={props.onSaveRuntimePolicySet}
|
||||
/>
|
||||
)}
|
||||
{props.section === 'billingSettlements' && <BillingSettlementsPanel token={props.token} />}
|
||||
{props.section === 'pricing' && (
|
||||
<PricingRulesPanel
|
||||
message={props.operationMessage}
|
||||
@@ -190,6 +183,18 @@ export function AdminPage(props: {
|
||||
onRestoreRuntimeModel={props.onRestoreRuntimeModel}
|
||||
/>
|
||||
)}
|
||||
{props.section === 'tasks' && (
|
||||
<AdminTasksPanel
|
||||
data={props.data}
|
||||
query={props.adminTaskQuery}
|
||||
state={props.adminTaskState}
|
||||
token={props.token}
|
||||
total={props.adminTaskTotal}
|
||||
updatedAt={props.adminTasksUpdatedAt}
|
||||
onQueryChange={props.onAdminTaskQueryChange}
|
||||
onRefresh={props.onRefreshAdminTasks}
|
||||
/>
|
||||
)}
|
||||
{props.section === 'tenants' && <TenantsPanel {...identityPanelProps(props)} />}
|
||||
{props.section === 'users' && <UsersPanel {...identityPanelProps(props)} />}
|
||||
{props.section === 'userGroups' && <UserGroupsPanel {...identityPanelProps(props)} />}
|
||||
@@ -225,6 +230,7 @@ function identityPanelProps(props: {
|
||||
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
|
||||
onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise<void>;
|
||||
onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise<void>;
|
||||
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
||||
}) {
|
||||
return {
|
||||
data: props.data,
|
||||
@@ -237,6 +243,7 @@ function identityPanelProps(props: {
|
||||
onSaveUser: props.onSaveUser,
|
||||
onRechargeUserWalletBalance: props.onRechargeUserWalletBalance,
|
||||
onSaveUserGroup: props.onSaveUserGroup,
|
||||
onBatchAccessRules: props.onBatchAccessRules,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void>; 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<void>;
|
||||
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 (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
@@ -940,6 +953,14 @@ function TaskRecord(props: { task: GatewayTask; token: string; onCopyRequestId:
|
||||
</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
{props.admin && (
|
||||
<TableCell className="taskRecordAdminIdentityCell">
|
||||
<span className="taskRecordPrimaryCell">
|
||||
<strong>{adminContext?.user?.displayName || adminContext?.user?.username || adminContext?.user?.id || props.task.userId || '-'}</strong>
|
||||
<small>{adminContext?.tenant?.name || adminContext?.tenant?.tenantKey || adminContext?.tenant?.id || props.task.tenantKey || '无租户'}</small>
|
||||
</span>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<span className="taskRecordRequestLine">
|
||||
<code title={props.task.requestId || '-'}>{props.task.requestId || '-'}</code>
|
||||
@@ -956,22 +977,32 @@ function TaskRecord(props: { task: GatewayTask; token: string; onCopyRequestId:
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell><Badge variant={badgeVariant}>{props.task.status}</Badge></TableCell>
|
||||
{props.admin && <TableCell>{props.task.runMode || '-'}</TableCell>}
|
||||
<TableCell className="taskRecordModelCell">
|
||||
<span className="taskRecordPrimaryCell">
|
||||
<strong>{calledModel || '-'}</strong>
|
||||
{resolvedModel && resolvedModel !== calledModel && <small>{resolvedModel}</small>}
|
||||
</span>
|
||||
</TableCell>
|
||||
{props.admin && (
|
||||
<TableCell>
|
||||
<span className="taskRecordPrimaryCell">
|
||||
<strong>{adminContext?.latestPlatform?.name || '未分配'}</strong>
|
||||
<small>{adminContext?.latestPlatform?.provider || '-'}</small>
|
||||
</span>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="taskRecordAttemptCell">
|
||||
<TaskAttemptChain task={props.task} />
|
||||
</TableCell>
|
||||
<TableCell className="taskRecordParamCell">
|
||||
<TaskParamConversionCell task={props.task} token={props.token} />
|
||||
<TaskParamConversionCell task={props.task} token={props.token} loader={props.onLoadParamPreprocessing} />
|
||||
</TableCell>
|
||||
<TableCell>{props.task.modelType || '-'}</TableCell>
|
||||
<TableCell>{props.task.apiKeyName || props.task.apiKeyPrefix || props.task.apiKeyId || '-'}</TableCell>
|
||||
<TableCell className="taskRecordTokenCell">{tokenUsage}</TableCell>
|
||||
<TableCell>{chargeText}</TableCell>
|
||||
{props.admin && <TableCell>{props.task.billingStatus || '-'}</TableCell>}
|
||||
<TableCell>{formatDuration(taskDurationMs(props.task))}</TableCell>
|
||||
<TableCell>{formatDateTime(props.task.createdAt)}</TableCell>
|
||||
<TableCell>
|
||||
@@ -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<GatewayTaskParamPreprocessingLog[] | null>(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 ?? [];
|
||||
|
||||
@@ -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<void>;
|
||||
}) {
|
||||
const [selectedGroupId, setSelectedGroupId] = useState(props.userGroups[0]?.id ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedGroupId && props.userGroups[0]?.id) {
|
||||
setSelectedGroupId(props.userGroups[0].id);
|
||||
}
|
||||
}, [props.userGroups, selectedGroupId]);
|
||||
|
||||
return (
|
||||
<div className="pageStack">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div>
|
||||
<CardTitle>用户组访问权限</CardTitle>
|
||||
<p className="mutedText">数据仍写入统一权限规则表;这里按用户组维护专属使用和不允许使用的平台/模型。</p>
|
||||
</div>
|
||||
<Badge variant="secondary">{props.accessRules.length} 条规则</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{props.message && <p className="formMessage">{props.message}</p>}
|
||||
<div className="accessGroupToolbar">
|
||||
<Label>
|
||||
用户组
|
||||
<Select size="sm" value={selectedGroupId} onChange={(event) => setSelectedGroupId(event.target.value)}>
|
||||
{props.userGroups.map((group) => <option value={group.id} key={group.id}>{group.name}</option>)}
|
||||
</Select>
|
||||
</Label>
|
||||
<div className="accessGroupHint">
|
||||
<ShieldCheck size={15} />
|
||||
<span>未配置规则时默认放行;拒绝规则优先于专属规则。</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedGroupId ? (
|
||||
<AccessPermissionEditor
|
||||
accessRules={props.accessRules}
|
||||
metadataMode="user_group_permission_tree"
|
||||
platformModels={props.platformModels}
|
||||
platforms={props.platforms}
|
||||
state={props.state}
|
||||
subjectId={selectedGroupId}
|
||||
subjectType="user_group"
|
||||
onBatchAccessRules={props.onBatchAccessRules}
|
||||
/>
|
||||
) : (
|
||||
<div className="emptyState">
|
||||
<strong>暂无用户组</strong>
|
||||
<span>请先创建用户组,再维护平台和模型权限。</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
}) {
|
||||
const [localMessage, setLocalMessage] = useState('');
|
||||
const [pageJump, setPageJump] = useState(String(props.query.page));
|
||||
const [detail, setDetail] = useState<AdminGatewayTask | null>(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>(() => 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<AdminTaskQuery>) {
|
||||
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<AdminTaskAdvancedFilters>) {
|
||||
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 (
|
||||
<section className="pageStack">
|
||||
<Card className="shTableViewportCard adminTaskRecordViewport">
|
||||
<CardContent className="shTableViewportPanel">
|
||||
{localMessage && <p className="formMessage">{localMessage}</p>}
|
||||
<TableViewportLayout>
|
||||
<TableToolbar className="adminTaskFilters">
|
||||
<Label className="adminTaskSearchLabel">
|
||||
搜索
|
||||
<span className="taskRecordSearchBox">
|
||||
<Search size={15} />
|
||||
<Input
|
||||
value={query.query}
|
||||
placeholder="任务、用户、租户、模型、平台、RequestID"
|
||||
onChange={(event) => updateQuery({ query: event.target.value })}
|
||||
/>
|
||||
</span>
|
||||
</Label>
|
||||
<Label className="adminTaskRangeLabel">
|
||||
创建时间
|
||||
<DateTimeRangePicker
|
||||
from={query.createdFrom}
|
||||
fromPlaceholder="开始日期"
|
||||
to={query.createdTo}
|
||||
toPlaceholder="结束日期"
|
||||
onChange={(value) => updateQuery({ createdFrom: value.from, createdTo: value.to })}
|
||||
/>
|
||||
</Label>
|
||||
<div className="adminTaskToolbarActions">
|
||||
<Button type="button" variant="outline" size="sm" onClick={openAdvancedFilters}>
|
||||
<SlidersHorizontal size={14} />
|
||||
高级筛选{advancedFilterCount ? `(${advancedFilterCount})` : ''}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" disabled={!hasActiveFilters} onClick={resetFilters}>
|
||||
<RotateCcw size={14} />
|
||||
重置
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={() => void props.onRefresh()}>
|
||||
<RefreshCw size={14} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</TableToolbar>
|
||||
|
||||
<div className="adminTaskResultSummary">
|
||||
<span>匹配 {props.total} 条任务</span>
|
||||
<small>最近刷新:{formatAdminTaskUpdatedAt(props.updatedAt)}</small>
|
||||
</div>
|
||||
|
||||
{tasks.length ? (
|
||||
<Table className="shTableViewport adminTaskRecordTable" density="compact">
|
||||
<TableRow className="shTableHeader">
|
||||
<TableHead>任务</TableHead>
|
||||
<TableHead>用户 / 租户</TableHead>
|
||||
<TableHead>RequestID</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>运行模式</TableHead>
|
||||
<TableHead>模型</TableHead>
|
||||
<TableHead>最终平台</TableHead>
|
||||
<TableHead>尝试链路</TableHead>
|
||||
<TableHead>参数转换</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>API Key</TableHead>
|
||||
<TableHead>Token</TableHead>
|
||||
<TableHead>扣费</TableHead>
|
||||
<TableHead>计费状态</TableHead>
|
||||
<TableHead>耗时</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead>原始 JSON</TableHead>
|
||||
</TableRow>
|
||||
{tasks.map((task) => (
|
||||
<TaskRecord
|
||||
admin
|
||||
key={task.id}
|
||||
task={task}
|
||||
token={props.token}
|
||||
onCopyRequestId={copyTaskRequestId}
|
||||
onLoadParamPreprocessing={listAdminTaskParamPreprocessing}
|
||||
onOpenJson={openDetail}
|
||||
/>
|
||||
))}
|
||||
</Table>
|
||||
) : (
|
||||
<div className="emptyState">
|
||||
<strong>{hasActiveFilters ? '没有匹配的任务' : '暂无任务'}</strong>
|
||||
<span>{hasActiveFilters ? '调整筛选条件后再试。' : '任务创建后会在这里显示。'}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TableFooter>
|
||||
<div className="shTableFooterGroup">
|
||||
<Label>
|
||||
每页
|
||||
<Select
|
||||
size="sm"
|
||||
value={String(query.pageSize)}
|
||||
onChange={(event) => props.onQueryChange({ ...query, page: 1, pageSize: Number(event.target.value) })}
|
||||
>
|
||||
{taskPageSizeOptions.map((option) => <option key={option} value={option}>{option} 条</option>)}
|
||||
</Select>
|
||||
</Label>
|
||||
<span>共 {props.total} 条 · {pageStart}-{pageEnd}</span>
|
||||
</div>
|
||||
<form
|
||||
className="shTablePageJump"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submitPageJump();
|
||||
}}
|
||||
>
|
||||
<span>第 {currentPage} / {totalPages} 页</span>
|
||||
<span>跳至</span>
|
||||
<Input
|
||||
aria-label="跳转页码"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
size="xs"
|
||||
type="number"
|
||||
value={pageJump}
|
||||
onChange={(event) => setPageJump(event.target.value)}
|
||||
/>
|
||||
<span>页</span>
|
||||
<Button type="submit" variant="outline" size="xs" disabled={totalPages <= 1}>跳转</Button>
|
||||
</form>
|
||||
<TablePageActions>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => props.onQueryChange({ ...query, page: Math.max(1, currentPage - 1) })}
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => props.onQueryChange({ ...query, page: Math.min(totalPages, currentPage + 1) })}
|
||||
>
|
||||
下一页
|
||||
<ChevronRight size={14} />
|
||||
</Button>
|
||||
</TablePageActions>
|
||||
</TableFooter>
|
||||
</TableViewportLayout>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<FormDialog
|
||||
ariaLabel="管理员任务高级筛选"
|
||||
bodyClassName="adminTaskAdvancedDialogBody"
|
||||
className="adminTaskAdvancedDialog"
|
||||
closeLabel="关闭"
|
||||
footer={(
|
||||
<>
|
||||
<Button type="button" variant="outline" disabled={!adminTaskAdvancedFilterCount(advancedFilterDraft)} onClick={clearAdvancedFilters}>
|
||||
<RotateCcw size={15} />
|
||||
清空高级条件
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<SlidersHorizontal size={15} />
|
||||
应用筛选
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
open={advancedFilterOpen}
|
||||
title="高级筛选"
|
||||
onClose={() => setAdvancedFilterOpen(false)}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
applyAdvancedFilters();
|
||||
}}
|
||||
>
|
||||
<FilterSelect
|
||||
label="租户"
|
||||
value={advancedFilterDraft.tenantId}
|
||||
onChange={(value) => updateAdvancedFilter({ tenantId: value, userId: '' })}
|
||||
>
|
||||
{tenants.map((tenant) => <option value={tenant.id} key={tenant.id}>{tenant.name || tenant.tenantKey}</option>)}
|
||||
</FilterSelect>
|
||||
<FilterSelect label="用户" value={advancedFilterDraft.userId} onChange={(value) => updateAdvancedFilter({ userId: value })}>
|
||||
{users.filter((user) => !advancedFilterDraft.tenantId || user.gatewayTenantId === advancedFilterDraft.tenantId).map((user) => (
|
||||
<option value={user.id} key={user.id}>{adminUserLabel(user)}</option>
|
||||
))}
|
||||
</FilterSelect>
|
||||
<FilterSelect label="用户组" value={advancedFilterDraft.userGroupId} onChange={(value) => updateAdvancedFilter({ userGroupId: value })}>
|
||||
{props.data.userGroups.map((group) => <option value={group.id} key={group.id}>{group.name || group.groupKey}</option>)}
|
||||
</FilterSelect>
|
||||
<FilterSelect label="状态" value={advancedFilterDraft.status} onChange={(value) => updateAdvancedFilter({ status: value })}>
|
||||
{taskStatuses.map((status) => <option value={status} key={status}>{status}</option>)}
|
||||
</FilterSelect>
|
||||
<FilterSelect label="平台" value={advancedFilterDraft.platformId} onChange={(value) => updateAdvancedFilter({ platformId: value })}>
|
||||
{platforms.map((platform) => <option value={platform.id} key={platform.id}>{platform.internalName || platform.name}</option>)}
|
||||
</FilterSelect>
|
||||
<FilterSelect label="模型" value={advancedFilterDraft.model} onChange={(value) => updateAdvancedFilter({ model: value })}>
|
||||
{models.map((model) => <option value={model} key={model}>{model}</option>)}
|
||||
</FilterSelect>
|
||||
<FilterSelect label="类型" value={advancedFilterDraft.modelType} onChange={(value) => updateAdvancedFilter({ modelType: value })}>
|
||||
{modelTypes.map((modelType) => <option value={modelType} key={modelType}>{modelType}</option>)}
|
||||
</FilterSelect>
|
||||
<FilterSelect label="运行模式" value={advancedFilterDraft.runMode} onChange={(value) => updateAdvancedFilter({ runMode: value })}>
|
||||
<option value="production">production</option>
|
||||
<option value="simulation">simulation</option>
|
||||
</FilterSelect>
|
||||
<FilterSelect label="计费状态" value={advancedFilterDraft.billingStatus} onChange={(value) => updateAdvancedFilter({ billingStatus: value })}>
|
||||
{billingStatuses.map((status) => <option value={status} key={status}>{status}</option>)}
|
||||
</FilterSelect>
|
||||
<Label>
|
||||
API Key
|
||||
<Input
|
||||
value={advancedFilterDraft.apiKey}
|
||||
placeholder="名称、ID 或前缀"
|
||||
onChange={(event) => updateAdvancedFilter({ apiKey: event.target.value })}
|
||||
/>
|
||||
</Label>
|
||||
</FormDialog>
|
||||
|
||||
<FormDialog
|
||||
ariaLabel="查看管理员任务原始 JSON"
|
||||
bodyClassName="taskJsonDialogBody"
|
||||
className="taskJsonDialog"
|
||||
footer={(
|
||||
<>
|
||||
{detailSensitive === 'masked' && (
|
||||
<Button type="button" variant="outline" size="sm" disabled={!detail || detailLoading} onClick={() => setRevealConfirmOpen(true)}>
|
||||
查看完整敏感字段
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" size="sm" onClick={() => setDetail(null)}>关闭</Button>
|
||||
</>
|
||||
)}
|
||||
open={Boolean(detail)}
|
||||
title={detailSensitive === 'full' ? '任务原始 JSON(完整数据)' : '任务原始 JSON(默认脱敏)'}
|
||||
onClose={() => setDetail(null)}
|
||||
onSubmit={(event) => event.preventDefault()}
|
||||
>
|
||||
{detailError && <p className="formError">{detailError}</p>}
|
||||
{detailLoading && !detail ? <p className="mutedText">正在加载任务详情…</p> : (
|
||||
<pre className="taskJsonPreview">{detail ? JSON.stringify(detail, null, 2) : ''}</pre>
|
||||
)}
|
||||
</FormDialog>
|
||||
<ConfirmDialog
|
||||
confirmLabel="查看完整数据"
|
||||
description="完整数据可能包含用户输入、授权头或第三方返回内容,仅应在排障所需时查看。"
|
||||
loading={detailLoading}
|
||||
open={revealConfirmOpen}
|
||||
title="确认查看未脱敏任务详情?"
|
||||
onCancel={() => setRevealConfirmOpen(false)}
|
||||
onConfirm={revealSensitiveDetail}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterSelect(props: {
|
||||
children: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Label>
|
||||
{props.label}
|
||||
<Select value={props.value || 'all'} onChange={(event) => props.onChange(event.target.value === 'all' ? '' : event.target.value)}>
|
||||
<option value="all">全部</option>
|
||||
{props.children}
|
||||
</Select>
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
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<AdminTaskAdvancedFilters>) {
|
||||
const keys: Array<keyof AdminTaskAdvancedFilters> = [
|
||||
'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),
|
||||
};
|
||||
}
|
||||
@@ -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<void>;
|
||||
}) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [filterDialogOpen, setFilterDialogOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState('');
|
||||
const [form, setForm] = useState<ModelForm>(() => 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 (
|
||||
<div className="pageStack">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>基准模型库</CardTitle>
|
||||
<Badge variant="secondary">{props.baseModels.length}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="providerToolbar">
|
||||
<p>默认模型来自原 server-main integration-platform,保留模型类型、能力、图标、计费和限流元数据。</p>
|
||||
<div className="inlineActions">
|
||||
<Button type="button" variant="outline" disabled={!systemModelCount || props.state === 'loading'} onClick={() => setPendingResetAll(true)}>
|
||||
<RotateCcw size={15} />
|
||||
<Card className="compactAdminTableCard">
|
||||
<CardContent className="compactAdminTableContent">
|
||||
<div className="compactAdminToolbar catalogCompactToolbar">
|
||||
<span className="platformSearchBox compactAdminSearch">
|
||||
<Search size={15} />
|
||||
<Input
|
||||
aria-label="搜索基准模型"
|
||||
value={query}
|
||||
placeholder="模型名称、调用名、Provider 模型名、Canonical Key 或别名"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</span>
|
||||
<div className="compactAdminToolbarActions">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setFilterDialogOpen(true)}>
|
||||
<SlidersHorizontal size={14} />
|
||||
高级筛选{advancedFilterCount ? `(${advancedFilterCount})` : ''}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" disabled={!filterActive} onClick={resetFilters}>
|
||||
<RotateCcw size={14} />
|
||||
重置
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" disabled={!systemModelCount || props.state === 'loading'} onClick={() => setPendingResetAll(true)}>
|
||||
<RotateCcw size={14} />
|
||||
重置所有
|
||||
</Button>
|
||||
<Button type="button" onClick={openCreateDialog}>
|
||||
<Button type="button" size="sm" onClick={openCreateDialog}>
|
||||
<Plus size={15} />
|
||||
新增模型
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modelCatalogFilters">
|
||||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索模型名称 / canonical key" />
|
||||
<Select value={providerFilter} onChange={(event) => setProviderFilter(event.target.value)}>
|
||||
<option value="all">全部厂商</option>
|
||||
{providerOptions.map((item) => <option value={item} key={item}>{providerNames.get(item) ?? item}</option>)}
|
||||
</Select>
|
||||
<Select value={typeFilter} onChange={(event) => setTypeFilter(event.target.value)}>
|
||||
<option value="all">全部能力</option>
|
||||
{typeOptions.map((item) => <option value={item} key={item}>{item}</option>)}
|
||||
</Select>
|
||||
<Select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}>
|
||||
<option value="active">仅 Active</option>
|
||||
<option value="deprecated">仅 Deprecated</option>
|
||||
<option value="hidden">仅 Hidden</option>
|
||||
<option value="all">全部状态</option>
|
||||
</Select>
|
||||
</div>
|
||||
{(props.message || localError) && <p className="formMessage">{localError || props.message}</p>}
|
||||
<section className="baseModelGrid">
|
||||
{filteredModels.map((model) => (
|
||||
<ModelCard
|
||||
key={model.id}
|
||||
model={model}
|
||||
pricingRuleSetName={pricingRuleSetName(model.pricingRuleSetId, props.pricingRuleSets)}
|
||||
providerName={providerNames.get(model.providerKey)}
|
||||
runtimePolicySetName={runtimePolicySetName(model.runtimePolicySetId, props.runtimePolicySets)}
|
||||
onDelete={() => setPendingDeleteModel(model)}
|
||||
onEdit={() => editModel(model)}
|
||||
onReset={() => setPendingResetModel(model)}
|
||||
/>
|
||||
))}
|
||||
{!filteredModels.length && (
|
||||
<div className="emptyState">
|
||||
<strong>没有匹配的基准模型</strong>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="baseModelGrid">
|
||||
{filteredModels.map((model) => (
|
||||
<ModelCard
|
||||
key={model.id}
|
||||
model={model}
|
||||
pricingRuleSetName={pricingRuleSetName(model.pricingRuleSetId, props.pricingRuleSets)}
|
||||
providerName={providerNames.get(model.providerKey)}
|
||||
runtimePolicySetName={runtimePolicySetName(model.runtimePolicySetId, props.runtimePolicySets)}
|
||||
onDelete={() => setPendingDeleteModel(model)}
|
||||
onEdit={() => editModel(model)}
|
||||
onReset={() => setPendingResetModel(model)}
|
||||
/>
|
||||
))}
|
||||
{!filteredModels.length && (
|
||||
<Card>
|
||||
<CardContent className="emptyState">
|
||||
<strong>没有匹配的基准模型</strong>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FormDialog
|
||||
ariaLabel="基准模型高级筛选"
|
||||
className="compactFilterDialog"
|
||||
footer={(
|
||||
<>
|
||||
<Button type="submit">完成</Button>
|
||||
<Button type="button" variant="outline" onClick={() => {
|
||||
setProviderFilter('all');
|
||||
setTypeFilter('all');
|
||||
setStatusFilter('active');
|
||||
}}>
|
||||
重置高级条件
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
open={filterDialogOpen}
|
||||
title="高级筛选"
|
||||
onClose={() => setFilterDialogOpen(false)}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setFilterDialogOpen(false);
|
||||
}}
|
||||
>
|
||||
<Label>Provider<Select value={providerFilter} onChange={(event) => setProviderFilter(event.target.value)}>
|
||||
<option value="all">全部 Provider</option>
|
||||
{providerOptions.map((item) => <option value={item} key={item}>{providerNames.get(item) ?? item}</option>)}
|
||||
</Select></Label>
|
||||
<Label>模型能力<Select value={typeFilter} onChange={(event) => setTypeFilter(event.target.value)}>
|
||||
<option value="all">全部能力</option>
|
||||
{typeOptions.map((item) => <option value={item} key={item}>{item}</option>)}
|
||||
</Select></Label>
|
||||
<Label>状态<Select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}>
|
||||
<option value="active">仅 Active</option>
|
||||
<option value="deprecated">仅 Deprecated</option>
|
||||
<option value="hidden">仅 Hidden</option>
|
||||
<option value="all">全部状态</option>
|
||||
</Select></Label>
|
||||
</FormDialog>
|
||||
|
||||
<FormDialog
|
||||
ariaLabel={editingId ? '编辑基准模型' : '新增基准模型'}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { retrySettlementBatch } from './BillingSettlementsPanel';
|
||||
|
||||
describe('billing settlement batch processing', () => {
|
||||
it('continues after individual failures and reports failed settlement IDs', async () => {
|
||||
const active = new Set<string>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<BillingSettlement[]>([]);
|
||||
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<BillingSettlement | null>(null);
|
||||
const [batchConfirming, setBatchConfirming] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => 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 (
|
||||
<div className="pageStack">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="identityHeaderTitle">
|
||||
<div className="iconBox"><ReceiptText size={17} /></div>
|
||||
<div>
|
||||
<CardTitle>计费结算</CardTitle>
|
||||
<p className="mutedText">任务结果与账务状态相互独立;异常记录可审计重试,不会重新调用上游。</p>
|
||||
</div>
|
||||
<Badge variant={exceptions ? 'warning' : 'secondary'}>{exceptions ? `${exceptions} 条异常` : `${items.length} 条`}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="tableActions">
|
||||
<section className="pageStack">
|
||||
<Card className="compactAdminTableCard">
|
||||
<CardContent className="compactAdminTableContent">
|
||||
<div className="compactAdminToolbar billingCompactToolbar">
|
||||
<span className="platformSearchBox compactAdminSearch">
|
||||
<Search size={15} />
|
||||
<Input
|
||||
aria-label="搜索计费结算"
|
||||
value={query}
|
||||
placeholder="任务 ID、结算 ID、动作或异常原因"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</span>
|
||||
<Select size="sm" aria-label="结算状态" value={status} onChange={(event) => setStatus(event.target.value)}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="pending">待处理</option>
|
||||
@@ -70,43 +158,76 @@ export function BillingSettlementsPanel(props: { token: string }) {
|
||||
<option value="manual_review">人工复核</option>
|
||||
<option value="completed">已完成</option>
|
||||
</Select>
|
||||
<Button type="button" size="sm" variant="outline" disabled={loading} onClick={() => void load()}>
|
||||
<RefreshCw size={14} />{loading ? '刷新中' : '刷新'}
|
||||
</Button>
|
||||
<div className="compactAdminToolbarActions">
|
||||
<Button type="button" size="sm" disabled={!selectedItems.length || busy} onClick={() => setBatchConfirming(true)}>
|
||||
<RotateCcw size={14} />
|
||||
{batchRetrying ? '结算中' : `批量结算${selectedItems.length ? `(${selectedItems.length})` : ''}`}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" disabled={loading || busy} onClick={() => void load()}>
|
||||
<RefreshCw size={14} />{loading ? '刷新中' : '刷新'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{message && <p className="formMessage">{message}</p>}
|
||||
{filteredItems.length ? (
|
||||
<div className="billingSettlementTableViewport">
|
||||
<Table density="compact" className="identityDataTable billingSettlementTable">
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<Checkbox
|
||||
aria-label="选择当前筛选结果中的全部可处理结算"
|
||||
checked={allVisibleSelected ? true : someVisibleSelected ? 'indeterminate' : false}
|
||||
disabled={!visibleRetryableItems.length || busy}
|
||||
onCheckedChange={(checked) => toggleVisible(checked === true)}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>任务 / 动作</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>金额</TableHead>
|
||||
<TableHead>尝试</TableHead>
|
||||
<TableHead>异常分类</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
{filteredItems.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
aria-label={`选择结算 ${shortId(item.id)}`}
|
||||
checked={selectedIds.has(item.id)}
|
||||
disabled={!canRetry(item) || busy}
|
||||
onCheckedChange={(checked) => toggleItem(item.id, checked === true)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="identityTableName">
|
||||
<strong>{shortId(item.taskId)}</strong>
|
||||
<small>{item.action === 'settle' ? '扣费' : '释放冻结'} · {shortId(item.id)}</small>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell><Badge variant={statusVariant(item.status)}>{statusLabel(item.status)}</Badge></TableCell>
|
||||
<TableCell>{formatAmount(item.amount)} {item.currency}</TableCell>
|
||||
<TableCell>{item.attempts} / 20</TableCell>
|
||||
<TableCell>{item.lastErrorCode || item.manualReviewReason || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{canRetry(item) ? (
|
||||
<Button type="button" size="xs" variant="outline" disabled={busy} onClick={() => setConfirming(item)}>
|
||||
<RotateCcw size={13} />{retrying === item.id ? '提交中' : retryActionLabel(item)}
|
||||
</Button>
|
||||
) : '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="emptyState">
|
||||
<AlertTriangle size={18} />
|
||||
<strong>{loading ? '正在加载结算队列' : '暂无匹配的结算记录'}</strong>
|
||||
<span>可调整搜索或状态筛选查看其他记录。</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{items.length ? (
|
||||
<Table density="compact" className="identityDataTable">
|
||||
<TableRow>
|
||||
<TableHead>任务 / 动作</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>金额</TableHead>
|
||||
<TableHead>尝试</TableHead>
|
||||
<TableHead>异常分类</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
{items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell><span className="identityTableName"><strong>{shortId(item.taskId)}</strong><small>{item.action === 'settle' ? '扣费' : '释放冻结'} · {shortId(item.id)}</small></span></TableCell>
|
||||
<TableCell><Badge variant={statusVariant(item.status)}>{statusLabel(item.status)}</Badge></TableCell>
|
||||
<TableCell>{formatAmount(item.amount)} {item.currency}</TableCell>
|
||||
<TableCell>{item.attempts} / 20</TableCell>
|
||||
<TableCell>{item.lastErrorCode || item.manualReviewReason || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{(item.status === 'retryable_failed' || item.status === 'manual_review') ? (
|
||||
<Button type="button" size="xs" variant="outline" disabled={retrying === item.id} onClick={() => setConfirming(item)}>
|
||||
<RotateCcw size={13} />{retrying === item.id ? '提交中' : retryActionLabel(item)}
|
||||
</Button>
|
||||
) : '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
) : (
|
||||
<Card><CardContent className="emptyState"><AlertTriangle size={18} /><strong>{loading ? '正在加载结算队列' : '暂无结算记录'}</strong><span>可切换状态筛选,查看待处理与人工复核记录。</span></CardContent></Card>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirming)}
|
||||
loading={Boolean(confirming && retrying === confirming.id)}
|
||||
@@ -120,10 +241,42 @@ export function BillingSettlementsPanel(props: { token: string }) {
|
||||
void retry(item).finally(() => setConfirming(null));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={batchConfirming}
|
||||
loading={batchRetrying}
|
||||
title={`批量结算 ${selectedItems.length} 条记录?`}
|
||||
description="系统会按记录动作逐条执行结算或释放冻结,并复用现有审计流程;只处理等待重试或人工复核记录,不会重新调用上游。"
|
||||
confirmLabel="确认批量结算"
|
||||
onCancel={() => setBatchConfirming(false)}
|
||||
onConfirm={() => void retrySelected().finally(() => setBatchConfirming(false))}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function canRetry(item: BillingSettlement) {
|
||||
return item.status === 'retryable_failed' || item.status === 'manual_review';
|
||||
}
|
||||
|
||||
export async function retrySettlementBatch<T extends { id: string }>(
|
||||
items: T[],
|
||||
retryOne: (item: T) => Promise<unknown>,
|
||||
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<string, string>)[status] ?? status;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
billingDiscountFactor: string;
|
||||
billingDiscountPolicy: Record<string, unknown>;
|
||||
rateLimitPolicyJson: string;
|
||||
quotaPolicyJson: string;
|
||||
rateLimitRules: UserGroupRateLimitRuleForm[];
|
||||
rateLimitPolicyExtra: Record<string, unknown>;
|
||||
quotaPolicyEntries: UserGroupQuotaEntryForm[];
|
||||
metadataJson: string;
|
||||
status: string;
|
||||
};
|
||||
@@ -427,6 +442,7 @@ export function UserGroupsPanel(props: IdentityPanelProps) {
|
||||
const [form, setForm] = useState<UserGroupForm>(() => defaultUserGroupForm());
|
||||
const [localError, setLocalError] = useState('');
|
||||
const [pendingDeleteGroup, setPendingDeleteGroup] = useState<UserGroup | null>(null);
|
||||
const [permissionGroup, setPermissionGroup] = useState<UserGroup | null>(null);
|
||||
|
||||
function openCreateDialog() {
|
||||
setEditingId('');
|
||||
@@ -488,20 +504,32 @@ export function UserGroupsPanel(props: IdentityPanelProps) {
|
||||
<TableHead>优先级</TableHead>
|
||||
<TableHead>折扣</TableHead>
|
||||
<TableHead>限流</TableHead>
|
||||
<TableHead>额度</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
{props.data.userGroups.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell><IdentityName title={group.name} subtitle={group.groupKey} /></TableCell>
|
||||
<TableCell>{group.source}</TableCell>
|
||||
<TableCell>{group.priority}</TableCell>
|
||||
<TableCell>{discountSummary(group)}</TableCell>
|
||||
<TableCell>{policyKeys(group.rateLimitPolicy).join(', ') || '未设置'}</TableCell>
|
||||
<TableCell><Badge variant={group.status === 'active' ? 'success' : 'secondary'}>{group.status}</Badge></TableCell>
|
||||
<TableCell><TableActions onEdit={() => editGroup(group)} onDelete={() => setPendingDeleteGroup(group)} /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{props.data.userGroups.map((group) => {
|
||||
const permissionSummary = countAccessPermissionRules(props.data.accessRules, 'user_group', group.id);
|
||||
return (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell><IdentityName title={group.name} subtitle={group.groupKey} /></TableCell>
|
||||
<TableCell>{group.source}</TableCell>
|
||||
<TableCell>{group.priority}</TableCell>
|
||||
<TableCell>{discountSummary(group)}</TableCell>
|
||||
<TableCell><PolicySummary parts={rateLimitPolicySummary(group.rateLimitPolicy)} /></TableCell>
|
||||
<TableCell><PolicySummary parts={quotaPolicySummary(group.quotaPolicy)} /></TableCell>
|
||||
<TableCell><Badge variant={group.status === 'active' ? 'success' : 'secondary'}>{group.status}</Badge></TableCell>
|
||||
<TableCell>
|
||||
<TableActions
|
||||
permissionSummary={permissionRuleSummary(permissionSummary)}
|
||||
onPermissions={() => setPermissionGroup(group)}
|
||||
onEdit={() => editGroup(group)}
|
||||
onDelete={() => setPendingDeleteGroup(group)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</Table>
|
||||
) : (
|
||||
<EmptyIdentity title="暂无用户组" description="可先新增默认用户组,用来配置折扣和并发控制。" />
|
||||
@@ -509,7 +537,7 @@ export function UserGroupsPanel(props: IdentityPanelProps) {
|
||||
|
||||
<FormDialog
|
||||
ariaLabel={editingId ? '编辑用户组' : '新增用户组'}
|
||||
className="identityDialog"
|
||||
className="identityDialog userGroupDialog"
|
||||
eyebrow={editingId ? 'Edit User Group' : 'New User Group'}
|
||||
footer={<DialogFooter editing={Boolean(editingId)} loading={props.state === 'loading'} onCancel={closeDialog} />}
|
||||
open={dialogOpen}
|
||||
@@ -525,10 +553,44 @@ export function UserGroupsPanel(props: IdentityPanelProps) {
|
||||
<Label className="spanTwo">描述<Input size="sm" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Label>
|
||||
<Label>充值折扣系数<Input size="sm" value={form.rechargeDiscountFactor} inputMode="decimal" placeholder="1 = 不打折,0.95 = 95 折" onChange={(event) => setForm({ ...form, rechargeDiscountFactor: event.target.value })} /></Label>
|
||||
<Label>计费折扣系数<Input size="sm" value={form.billingDiscountFactor} inputMode="decimal" placeholder="1 = 不打折,0.95 = 95 折" onChange={(event) => setForm({ ...form, billingDiscountFactor: event.target.value })} /></Label>
|
||||
<JsonField label="限流策略 JSON" value={form.rateLimitPolicyJson} onChange={(value) => setForm({ ...form, rateLimitPolicyJson: value })} />
|
||||
<JsonField label="额度策略 JSON" value={form.quotaPolicyJson} onChange={(value) => setForm({ ...form, quotaPolicyJson: value })} />
|
||||
<UserGroupRateLimitEditor value={form.rateLimitRules} onChange={(rateLimitRules) => setForm({ ...form, rateLimitRules })} />
|
||||
<UserGroupQuotaPolicyEditor value={form.quotaPolicyEntries} onChange={(quotaPolicyEntries) => setForm({ ...form, quotaPolicyEntries })} />
|
||||
<JsonField label="元数据 JSON" value={form.metadataJson} onChange={(value) => setForm({ ...form, metadataJson: value })} />
|
||||
</FormDialog>
|
||||
<FormDialog
|
||||
ariaLabel={permissionGroup ? `${permissionGroup.name} 模型权限` : '用户组模型权限'}
|
||||
bodyClassName="userGroupPermissionDialogBody"
|
||||
className="userGroupPermissionDialog"
|
||||
eyebrow="Model Permission"
|
||||
footer={<Button type="button" onClick={() => setPermissionGroup(null)}>完成</Button>}
|
||||
open={Boolean(permissionGroup)}
|
||||
title={permissionGroup ? `${permissionGroup.name} · 模型权限` : '模型权限'}
|
||||
onClose={() => setPermissionGroup(null)}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setPermissionGroup(null);
|
||||
}}
|
||||
>
|
||||
{permissionGroup && (
|
||||
<>
|
||||
<div className="accessGroupHint userGroupPermissionHint">
|
||||
<ShieldCheck size={15} />
|
||||
<span>未配置规则时默认放行;拒绝规则优先于专属规则,修改后立即生效。</span>
|
||||
</div>
|
||||
<AccessPermissionEditor
|
||||
key={permissionGroup.id}
|
||||
accessRules={props.data.accessRules}
|
||||
metadataMode="user_group_permission_tree"
|
||||
platformModels={props.data.models}
|
||||
platforms={props.data.platforms}
|
||||
state={props.state}
|
||||
subjectId={permissionGroup.id}
|
||||
subjectType="user_group"
|
||||
onBatchAccessRules={props.onBatchAccessRules}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</FormDialog>
|
||||
<ConfirmDialog
|
||||
confirmLabel="删除用户组"
|
||||
description="删除后租户和用户上引用该用户组的默认策略会失效。"
|
||||
@@ -553,6 +615,7 @@ type IdentityPanelProps = {
|
||||
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
|
||||
onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise<void>;
|
||||
onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise<void>;
|
||||
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
||||
};
|
||||
|
||||
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 (
|
||||
<span className="tableActions">
|
||||
{props.onWallet && <Button type="button" variant="outline" size="icon" title="余额" onClick={props.onWallet}><CircleDollarSign size={14} /></Button>}
|
||||
{props.onPermissions && (
|
||||
<Button type="button" variant="outline" size="xs" title={props.permissionSummary || '模型权限'} onClick={props.onPermissions}>
|
||||
<KeyRound size={13} />权限
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" size="icon" title="修改" onClick={props.onEdit}><Pencil size={14} /></Button>
|
||||
<Button type="button" variant="destructive" size="icon" title="删除" onClick={props.onDelete}><Trash2 size={14} /></Button>
|
||||
</span>
|
||||
@@ -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<string, unknown>) {
|
||||
if (!value) return [];
|
||||
return Object.keys(value).slice(0, 3);
|
||||
function PolicySummary(props: { parts: string[] }) {
|
||||
if (!props.parts.length) return <span className="mutedText">未设置</span>;
|
||||
return (
|
||||
<span className="identityTableName userGroupPolicySummary">
|
||||
<strong title={props.parts.join(' / ')}>{props.parts.slice(0, 2).join(' · ')}</strong>
|
||||
{props.parts.length > 2 && <small>{props.parts.slice(2).join(' · ')}</small>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function permissionRuleSummary(summary: ReturnType<typeof countAccessPermissionRules>) {
|
||||
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) {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<section className="pageStack">
|
||||
<ScreenMessage message={validationMessage} variant="error" onClose={() => setValidationMessage('')} />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div>
|
||||
<CardTitle>平台管理</CardTitle>
|
||||
<p className="mutedText">配置平台授权、Base URL、平台内重试与限流策略,并选择接入全部或部分基准模型。</p>
|
||||
</div>
|
||||
<Button type="button" onClick={openCreateDialog}>
|
||||
<Plus size={15} />
|
||||
添加平台
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="platformViewTabs">
|
||||
<button type="button" data-active={viewMode === 'platforms'} onClick={() => setViewMode('platforms')}>平台视图</button>
|
||||
<button type="button" data-active={viewMode === 'models'} onClick={() => setViewMode('models')}>模型视图</button>
|
||||
<Card className="compactAdminTableCard">
|
||||
<CardContent className="compactAdminTableContent">
|
||||
<div className="compactAdminToolbar platformCompactToolbar">
|
||||
<div className="platformViewTabs">
|
||||
<button type="button" data-active={viewMode === 'platforms'} onClick={() => setViewMode('platforms')}>平台</button>
|
||||
<button type="button" data-active={viewMode === 'models'} onClick={() => setViewMode('models')}>模型</button>
|
||||
</div>
|
||||
<span className="platformSearchBox compactAdminSearch">
|
||||
<Search size={15} />
|
||||
<Input
|
||||
aria-label={viewMode === 'platforms' ? '搜索平台' : '搜索模型'}
|
||||
value={viewMode === 'platforms' ? platformQuery : modelQuery}
|
||||
placeholder={viewMode === 'platforms' ? '平台名、Key、Provider、Base URL 或绑定模型' : '模型名称、别名、类型或平台名称'}
|
||||
onChange={(event) => {
|
||||
if (viewMode === 'platforms') setPlatformQuery(event.target.value);
|
||||
else setModelQuery(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<div className="compactAdminToolbarActions">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setFilterDialogOpen(true)}>
|
||||
<SlidersHorizontal size={14} />
|
||||
高级筛选{currentAdvancedFilterCount ? `(${currentAdvancedFilterCount})` : ''}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" disabled={!currentFilterActive} onClick={resetCurrentFilters}>
|
||||
<RotateCcw size={14} />
|
||||
重置
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={openCreateDialog}>
|
||||
<Plus size={15} />
|
||||
添加平台
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === 'platforms' ? (
|
||||
<PlatformTable
|
||||
now={now}
|
||||
platformModelCount={platformModelCount}
|
||||
platforms={props.platforms}
|
||||
providerMap={providerMap}
|
||||
pricingRuleSets={props.pricingRuleSets}
|
||||
togglingPlatformId={togglingPlatformId}
|
||||
onDelete={setPendingDeletePlatform}
|
||||
onCreate={openCreateDialog}
|
||||
onEdit={openEditDialog}
|
||||
onToggleStatus={togglePlatformStatus}
|
||||
/>
|
||||
<section className="platformFilteredView">
|
||||
<PlatformTable
|
||||
filtered={platformFilterActive}
|
||||
now={now}
|
||||
platformModelCount={platformModelCount}
|
||||
platforms={filteredPlatforms}
|
||||
providerMap={providerMap}
|
||||
pricingRuleSets={props.pricingRuleSets}
|
||||
togglingPlatformId={togglingPlatformId}
|
||||
onDelete={setPendingDeletePlatform}
|
||||
onCreate={openCreateDialog}
|
||||
onEdit={openEditDialog}
|
||||
onToggleStatus={togglePlatformStatus}
|
||||
/>
|
||||
</section>
|
||||
) : (
|
||||
<PlatformModelTable
|
||||
baseModels={props.baseModels}
|
||||
modelQuery={modelQuery}
|
||||
filterActive={modelFilterActive}
|
||||
models={filteredPlatformModels}
|
||||
platformId={selectedModelPlatformId}
|
||||
platformMap={platformMap}
|
||||
platforms={props.platforms}
|
||||
providerMap={providerMap}
|
||||
onModelQueryChange={setModelQuery}
|
||||
now={now}
|
||||
onPlatformChange={setSelectedPlatformId}
|
||||
/>
|
||||
)}
|
||||
{props.message && <p className="formMessage">{props.message}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<FormDialog
|
||||
ariaLabel="平台管理高级筛选"
|
||||
className="compactFilterDialog"
|
||||
closeLabel="关闭"
|
||||
footer={(
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!currentAdvancedFilterCount}
|
||||
onClick={() => {
|
||||
if (viewMode === 'platforms') {
|
||||
setPlatformProvider('');
|
||||
setPlatformStatus('');
|
||||
} else {
|
||||
setSelectedPlatformId('');
|
||||
setModelType('');
|
||||
setModelStatus('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={15} />
|
||||
清空高级条件
|
||||
</Button>
|
||||
<Button type="submit">完成</Button>
|
||||
</>
|
||||
)}
|
||||
open={filterDialogOpen}
|
||||
title={viewMode === 'platforms' ? '平台高级筛选' : '模型高级筛选'}
|
||||
onClose={() => setFilterDialogOpen(false)}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setFilterDialogOpen(false);
|
||||
}}
|
||||
>
|
||||
{viewMode === 'platforms' ? (
|
||||
<>
|
||||
<Label>
|
||||
Provider
|
||||
<Select value={platformProvider || 'all'} onChange={(event) => setPlatformProvider(event.target.value === 'all' ? '' : event.target.value)}>
|
||||
<option value="all">全部 Provider</option>
|
||||
{platformProviderOptions.map((provider) => <option value={provider} key={provider}>{providerMap.get(provider)?.displayName ?? provider}</option>)}
|
||||
</Select>
|
||||
</Label>
|
||||
<Label>
|
||||
状态
|
||||
<Select value={platformStatus || 'all'} onChange={(event) => setPlatformStatus(event.target.value === 'all' ? '' : event.target.value)}>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="enabled">已启用</option>
|
||||
<option value="disabled">已禁用</option>
|
||||
<option value="cooldown">冷却中</option>
|
||||
</Select>
|
||||
</Label>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Label>
|
||||
平台
|
||||
<Select value={selectedModelPlatformId} onChange={(event) => setSelectedPlatformId(event.target.value)}>
|
||||
<option value="">全部平台</option>
|
||||
{props.platforms.map((platform) => (
|
||||
<option value={platform.id} key={platform.id}>{platformDisplayName(platform)}</option>
|
||||
))}
|
||||
</Select>
|
||||
</Label>
|
||||
<Label>
|
||||
模型类型
|
||||
<Select value={modelType || 'all'} onChange={(event) => setModelType(event.target.value === 'all' ? '' : event.target.value)}>
|
||||
<option value="all">全部类型</option>
|
||||
{modelTypeOptions.map((type) => <option value={type} key={type}>{type}</option>)}
|
||||
</Select>
|
||||
</Label>
|
||||
<Label>
|
||||
状态
|
||||
<Select value={modelStatus || 'all'} onChange={(event) => setModelStatus(event.target.value === 'all' ? '' : event.target.value)}>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="enabled">已启用</option>
|
||||
<option value="disabled">已禁用</option>
|
||||
<option value="cooldown">冷却中</option>
|
||||
</Select>
|
||||
</Label>
|
||||
</>
|
||||
)}
|
||||
</FormDialog>
|
||||
|
||||
<FormDialog
|
||||
bodyClassName="platformDialogBody"
|
||||
className="platformDialog"
|
||||
@@ -423,6 +574,7 @@ function ModelBindingPolicy(props: { form: PlatformWizardForm; onChange: (value:
|
||||
}
|
||||
|
||||
function PlatformTable(props: {
|
||||
filtered: boolean;
|
||||
now: number;
|
||||
platformModelCount: Map<string, number>;
|
||||
platforms: IntegrationPlatform[];
|
||||
@@ -435,6 +587,9 @@ function PlatformTable(props: {
|
||||
onToggleStatus: (platform: IntegrationPlatform, status: 'enabled' | 'disabled') => void;
|
||||
}) {
|
||||
if (!props.platforms.length) {
|
||||
if (props.filtered) {
|
||||
return <EmptyState title="没有匹配的平台" description="调整平台关键词、Provider 或状态筛选后再试。" />;
|
||||
}
|
||||
return (
|
||||
<div className="platformEmptyState">
|
||||
<div className="platformEmptyIcon"><ServerCog size={24} /></div>
|
||||
@@ -448,7 +603,7 @@ function PlatformTable(props: {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Table className="platformDataTable">
|
||||
<Table className="shTableViewport platformDataTable platformManagementTableViewport">
|
||||
<TableRow className="shTableHeader">
|
||||
<TableHead>平台</TableHead>
|
||||
<TableHead>Provider</TableHead>
|
||||
@@ -457,12 +612,14 @@ function PlatformTable(props: {
|
||||
<TableHead>限流</TableHead>
|
||||
<TableHead>模型数</TableHead>
|
||||
<TableHead>运行</TableHead>
|
||||
<TableHead>优先级</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
{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: {
|
||||
<Badge variant={isEnabled ? 'success' : 'destructive'}>{isEnabled ? '已启用' : '已禁用'}</Badge>
|
||||
)}
|
||||
</strong>
|
||||
<small>{platformCooldownMs > 0 ? `剩余 ${formatCooldownRemaining(platformCooldownMs)}` : runtime}</small>
|
||||
{platformCooldownMs > 0 && <small>剩余 {formatCooldownRemaining(platformCooldownMs)}</small>}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="platformTableName">
|
||||
<strong>{platform.priority}</strong>
|
||||
{platform.effectivePriority !== undefined && platform.effectivePriority !== platform.priority && (
|
||||
<small>当前 {platform.effectivePriority}</small>
|
||||
)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="platformTableName">
|
||||
<strong title={runtimeDescription}>{runtimeDescription}</strong>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -534,36 +704,15 @@ function PlatformTable(props: {
|
||||
|
||||
function PlatformModelTable(props: {
|
||||
baseModels: BaseModelCatalogItem[];
|
||||
modelQuery: string;
|
||||
filterActive: boolean;
|
||||
models: PlatformModel[];
|
||||
platformId: string;
|
||||
platformMap: Map<string, IntegrationPlatform>;
|
||||
platforms: IntegrationPlatform[];
|
||||
providerMap: Map<string, CatalogProvider>;
|
||||
now: number;
|
||||
onModelQueryChange: (value: string) => void;
|
||||
onPlatformChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="platformModelView">
|
||||
<div className="platformModelToolbar">
|
||||
<Label>
|
||||
平台
|
||||
<Select value={props.platformId} onChange={(event) => props.onPlatformChange(event.target.value)}>
|
||||
{props.platforms.map((platform) => (
|
||||
<option value={platform.id} key={platform.id}>{platformDisplayName(platform)}</option>
|
||||
))}
|
||||
</Select>
|
||||
</Label>
|
||||
<Label>
|
||||
搜索模型
|
||||
<Input
|
||||
value={props.modelQuery}
|
||||
placeholder="模型名称、别名、类型或平台名称"
|
||||
onChange={(event) => props.onModelQueryChange(event.target.value)}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
{!props.platforms.length ? (
|
||||
<EmptyState title="暂无平台" description="请先添加平台,再查看对应平台模型。" />
|
||||
) : (
|
||||
@@ -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<string, string[]>();
|
||||
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']) {
|
||||
|
||||
@@ -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 (
|
||||
<div className="pageStack">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>定价规则</CardTitle>
|
||||
<Badge variant="secondary">{props.pricingRuleSets.length}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="providerToolbar">
|
||||
<p>规则集可包含文本、图像、视频等多个计价条目,平台或平台模型绑定规则集后再通过折扣率整体调价。</p>
|
||||
<Button type="button" onClick={openCreateDialog}><Plus size={15} />新增规则</Button>
|
||||
</div>
|
||||
<div className="modelCatalogFilters">
|
||||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索规则名称 / key" />
|
||||
<Card className="compactAdminTableCard">
|
||||
<CardContent className="compactAdminTableContent">
|
||||
<div className="compactAdminToolbar catalogCompactToolbar">
|
||||
<span className="platformSearchBox compactAdminSearch">
|
||||
<Search size={15} />
|
||||
<Input
|
||||
aria-label="搜索定价规则"
|
||||
value={query}
|
||||
placeholder="规则名称、Key 或说明"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</span>
|
||||
<div className="compactAdminToolbarActions">
|
||||
<Button type="button" variant="outline" size="sm" disabled={!query} onClick={() => setQuery('')}>
|
||||
<RotateCcw size={14} />
|
||||
重置
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={openCreateDialog}><Plus size={15} />新增规则</Button>
|
||||
</div>
|
||||
</div>
|
||||
{(props.message || localError) && <p className="formMessage">{localError || props.message}</p>}
|
||||
<section className="pricingRuleGrid">
|
||||
{filtered.map((ruleSet) => (
|
||||
<article className="pricingRuleCard" key={ruleSet.id}>
|
||||
<header>
|
||||
<div>
|
||||
<strong>{ruleSet.name}</strong>
|
||||
<span>{ruleSet.ruleSetKey}</span>
|
||||
</div>
|
||||
<Badge variant={ruleSet.status === 'active' ? 'success' : 'secondary'}>{ruleSet.status}</Badge>
|
||||
</header>
|
||||
<p>{ruleSet.description || '未填写说明'}</p>
|
||||
<div className="providerCatalogMeta">
|
||||
<span>{ruleSet.category}</span>
|
||||
<span>{ruleSet.currency}</span>
|
||||
<span>{pricingRuleSummaries(ruleSet).length} 条计价规则</span>
|
||||
</div>
|
||||
<div className="pricingRuleItems">
|
||||
{pricingRuleSummaries(ruleSet).map((item) => (
|
||||
<span key={item.label}>
|
||||
<strong>{pricingRuleSummaryIcon(item.kind)}{item.label}</strong>
|
||||
<em>{item.value}</em>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="providerCatalogActions">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => editRuleSet(ruleSet)}><Pencil size={14} />修改</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={isDefaultRuleSet(ruleSet)}
|
||||
title={isDefaultRuleSet(ruleSet) ? '默认计价规则不能删除' : undefined}
|
||||
onClick={() => setPendingDeleteRuleSet(ruleSet)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{!filtered.length && (
|
||||
<div className="emptyState"><strong>暂无匹配的定价规则</strong></div>
|
||||
)}
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="pricingRuleGrid">
|
||||
{filtered.map((ruleSet) => (
|
||||
<article className="pricingRuleCard" key={ruleSet.id}>
|
||||
<header>
|
||||
<div>
|
||||
<strong>{ruleSet.name}</strong>
|
||||
<span>{ruleSet.ruleSetKey}</span>
|
||||
</div>
|
||||
<Badge variant={ruleSet.status === 'active' ? 'success' : 'secondary'}>{ruleSet.status}</Badge>
|
||||
</header>
|
||||
<p>{ruleSet.description || '未填写说明'}</p>
|
||||
<div className="providerCatalogMeta">
|
||||
<span>{ruleSet.category}</span>
|
||||
<span>{ruleSet.currency}</span>
|
||||
<span>{pricingRuleSummaries(ruleSet).length} 条计价规则</span>
|
||||
</div>
|
||||
<div className="pricingRuleItems">
|
||||
{pricingRuleSummaries(ruleSet).map((item) => (
|
||||
<span key={item.label}>
|
||||
<strong>{pricingRuleSummaryIcon(item.kind)}{item.label}</strong>
|
||||
<em>{item.value}</em>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="providerCatalogActions">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => editRuleSet(ruleSet)}><Pencil size={14} />修改</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={isDefaultRuleSet(ruleSet)}
|
||||
title={isDefaultRuleSet(ruleSet) ? '默认计价规则不能删除' : undefined}
|
||||
onClick={() => setPendingDeleteRuleSet(ruleSet)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{!filtered.length && (
|
||||
<Card><CardContent className="emptyState"><strong>暂无定价规则</strong></CardContent></Card>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<FormDialog
|
||||
ariaLabel={editingId ? '编辑定价规则' : '新增定价规则'}
|
||||
bodyClassName="pricingRuleFormBody"
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { IntegrationPlatform, ModelRateLimitStatus } from '@easyai-ai-gateway/contracts';
|
||||
import { filterAndSortRealtimeLoad } from './RealtimeLoadPanel';
|
||||
|
||||
function status(input: Partial<ModelRateLimitStatus> & Pick<ModelRateLimitStatus, 'platformModelId' | 'platformId' | 'displayName'>): 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<string, IntegrationPlatform>([
|
||||
['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']);
|
||||
});
|
||||
});
|
||||
@@ -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<string | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [platformId, setPlatformId] = useState('');
|
||||
const [modelType, setModelType] = useState('');
|
||||
const [runtimeState, setRuntimeState] = useState('');
|
||||
const [sortField, setSortField] = useState<RealtimeLoadSortField>('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 (
|
||||
<section className="pageStack">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div>
|
||||
<CardTitle>实时负载</CardTitle>
|
||||
<p className="mutedText">按平台模型查看实时 RPM、TPM、并发、排队、冷却和综合满载率。</p>
|
||||
<Card className="compactAdminTableCard">
|
||||
<CardContent className="compactAdminTableContent">
|
||||
<div className="compactAdminToolbar realtimeCompactToolbar">
|
||||
<span className="platformSearchBox compactAdminSearch">
|
||||
<Search size={15} />
|
||||
<Input
|
||||
aria-label="搜索实时负载"
|
||||
value={query}
|
||||
placeholder="平台、模型、Provider 或模型类型"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</span>
|
||||
<Select
|
||||
aria-label="平台筛选"
|
||||
value={platformId || 'all'}
|
||||
onChange={(event) => setPlatformId(event.target.value === 'all' ? '' : event.target.value)}
|
||||
>
|
||||
<option value="all">全部平台</option>
|
||||
{props.platforms.map((platform) => <option value={platform.id} key={platform.id}>{platformDisplayName(platform)}</option>)}
|
||||
</Select>
|
||||
<Select
|
||||
aria-label="实时负载排序"
|
||||
value={sortField}
|
||||
onChange={(event) => setSortField(event.target.value as RealtimeLoadSortField)}
|
||||
>
|
||||
<option value="load">满载率</option>
|
||||
<option value="queued">排队数</option>
|
||||
<option value="concurrent">并发</option>
|
||||
<option value="tpm">TPM</option>
|
||||
<option value="rpm">RPM</option>
|
||||
<option value="priority">平台优先级</option>
|
||||
</Select>
|
||||
<div className="compactAdminToolbarActions">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setFilterDialogOpen(true)}>
|
||||
<SlidersHorizontal size={14} />
|
||||
高级筛选{advancedFilterCount ? `(${advancedFilterCount})` : ''}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" disabled={!hasActiveSettings} onClick={resetFilters}>
|
||||
<RotateCcw size={14} />
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RateLimitStatusTable
|
||||
filtered={hasActiveFilters}
|
||||
now={now}
|
||||
platformMap={platformMap}
|
||||
statuses={props.modelRateLimits}
|
||||
updatedAt={props.modelRateLimitsUpdatedAt}
|
||||
statuses={filteredStatuses}
|
||||
onAdjustPriority={openPriorityDialog}
|
||||
onRestoreRuntimeModel={restoreRuntimeModel}
|
||||
restoreSavingId={restoreSavingId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FormDialog
|
||||
ariaLabel="实时负载高级筛选"
|
||||
className="compactFilterDialog"
|
||||
closeLabel="关闭"
|
||||
footer={(
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!advancedFilterCount}
|
||||
onClick={() => {
|
||||
setModelType('');
|
||||
setRuntimeState('');
|
||||
setSortDirection('desc');
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={15} />
|
||||
清空高级条件
|
||||
</Button>
|
||||
<Button type="submit">完成</Button>
|
||||
</>
|
||||
)}
|
||||
open={filterDialogOpen}
|
||||
title="实时负载高级筛选"
|
||||
onClose={() => setFilterDialogOpen(false)}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setFilterDialogOpen(false);
|
||||
}}
|
||||
>
|
||||
<Label>
|
||||
模型类型
|
||||
<Select value={modelType || 'all'} onChange={(event) => setModelType(event.target.value === 'all' ? '' : event.target.value)}>
|
||||
<option value="all">全部类型</option>
|
||||
{modelTypeOptions.map((type) => <option value={type} key={type}>{type}</option>)}
|
||||
</Select>
|
||||
</Label>
|
||||
<Label>
|
||||
运行状态
|
||||
<Select value={runtimeState || 'all'} onChange={(event) => setRuntimeState(event.target.value === 'all' ? '' : event.target.value)}>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="normal">正常</option>
|
||||
<option value="high">高负载 ≥ 80%</option>
|
||||
<option value="queued">有排队</option>
|
||||
<option value="attention">冷却 / 停用</option>
|
||||
</Select>
|
||||
</Label>
|
||||
<Label>
|
||||
排序方向
|
||||
<Select value={sortDirection} onChange={(event) => setSortDirection(event.target.value as 'asc' | 'desc')}>
|
||||
<option value="desc">降序</option>
|
||||
<option value="asc">升序</option>
|
||||
</Select>
|
||||
</Label>
|
||||
</FormDialog>
|
||||
<PlatformPriorityDialog
|
||||
dialog={priorityDialog}
|
||||
error={priorityError}
|
||||
@@ -118,26 +247,27 @@ type PriorityDialogState = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
type RealtimeLoadSortField = 'load' | 'queued' | 'concurrent' | 'tpm' | 'rpm' | 'priority';
|
||||
|
||||
function RateLimitStatusTable(props: {
|
||||
filtered: boolean;
|
||||
statuses: ModelRateLimitStatus[];
|
||||
platformMap: Map<string, IntegrationPlatform>;
|
||||
now: number;
|
||||
updatedAt: number | null;
|
||||
onAdjustPriority: (status: ModelRateLimitStatus, platform: IntegrationPlatform | undefined) => void;
|
||||
onRestoreRuntimeModel: (platformModelId: string) => Promise<void>;
|
||||
restoreSavingId: string | null;
|
||||
}) {
|
||||
if (!props.statuses.length) {
|
||||
if (props.filtered) {
|
||||
return <EmptyState title="没有匹配的实时负载" description="调整平台、模型、运行状态或关键词后再试。" />;
|
||||
}
|
||||
return <EmptyState title="暂无实时负载" description="模型产生请求后会在这里显示实时 RPM、TPM 和并发窗口。" />;
|
||||
}
|
||||
return (
|
||||
<section className="platformLimitView">
|
||||
<div className="platformLimitHeader">
|
||||
<span><Gauge size={15} />按综合满载率从高到低排序</span>
|
||||
<small>每 3 秒刷新一次;TPM 显示已结算 + 预占;最新更新时间 {formatTimeOfDay(props.updatedAt)}。</small>
|
||||
</div>
|
||||
<div className="platformLimitTableViewport">
|
||||
<Table className="platformDataTable platformLimitTable">
|
||||
<Table className="platformDataTable platformLimitTable" density="compact">
|
||||
<TableRow className="shTableHeader">
|
||||
<TableHead>模型</TableHead>
|
||||
<TableHead>平台</TableHead>
|
||||
@@ -195,6 +325,64 @@ function RateLimitStatusTable(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export function filterAndSortRealtimeLoad(
|
||||
statuses: ModelRateLimitStatus[],
|
||||
platformMap: Map<string, IntegrationPlatform>,
|
||||
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);
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
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<UserGroupRateLimitRuleForm>) {
|
||||
props.onChange(props.value.map((item) => item.id === id ? { ...item, ...patch } : item));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="userGroupPolicyEditor spanTwo">
|
||||
<header className="userGroupPolicyHeader">
|
||||
<div>
|
||||
<strong>限流策略</strong>
|
||||
<span>按指标设置独立上限;留空表示不配置该规则。</span>
|
||||
</div>
|
||||
<Button type="button" size="xs" variant="outline" onClick={() => props.onChange([
|
||||
...props.value,
|
||||
createRateLimitRuleForm(nextAvailableMetric(props.value)),
|
||||
])}>
|
||||
<Plus size={13} />添加规则
|
||||
</Button>
|
||||
</header>
|
||||
<div className="userGroupPolicyTable userGroupRateLimitTable" role="table" aria-label="用户组限流策略">
|
||||
<div className="userGroupPolicyRow userGroupPolicyTableHead" role="row">
|
||||
<span role="columnheader">指标</span>
|
||||
<span role="columnheader">上限</span>
|
||||
<span role="columnheader">窗口(秒)</span>
|
||||
<span role="columnheader">租约 TTL(秒)</span>
|
||||
<span role="columnheader">消费方式</span>
|
||||
<span role="columnheader">操作</span>
|
||||
</div>
|
||||
{props.value.map((rule) => (
|
||||
<div className="userGroupPolicyRow" role="row" key={rule.id}>
|
||||
<Select aria-label="限流指标" size="sm" value={rule.metric} onChange={(event) => update(rule.id, { metric: event.target.value as RateLimitMetric })}>
|
||||
{rateLimitMetrics.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}
|
||||
</Select>
|
||||
<Input aria-label={`${metricLabel(rule.metric)}上限`} size="sm" inputMode="decimal" value={rule.limit} placeholder="不限" onChange={(event) => update(rule.id, { limit: event.target.value })} />
|
||||
<Input aria-label={`${metricLabel(rule.metric)}窗口秒数`} size="sm" inputMode="numeric" value={rule.windowSeconds} placeholder={rule.metric === 'concurrent' ? '-' : '60'} onChange={(event) => update(rule.id, { windowSeconds: event.target.value })} />
|
||||
<Input aria-label={`${metricLabel(rule.metric)}租约 TTL`} size="sm" inputMode="numeric" value={rule.leaseTtlSeconds} placeholder={rule.metric === 'concurrent' ? '120' : '-'} onChange={(event) => update(rule.id, { leaseTtlSeconds: event.target.value })} />
|
||||
<Select aria-label={`${metricLabel(rule.metric)}消费方式`} size="sm" value={rule.consume} onChange={(event) => update(rule.id, { consume: event.target.value })}>
|
||||
<option value="">默认</option>
|
||||
<option value="fixed_window">固定窗口</option>
|
||||
<option value="reserve_then_reconcile">预占后核销</option>
|
||||
{rule.consume && !['fixed_window', 'reserve_then_reconcile'].includes(rule.consume) && <option value={rule.consume}>{rule.consume}</option>}
|
||||
</Select>
|
||||
<Button type="button" aria-label={`删除${metricLabel(rule.metric)}规则`} size="xs" variant="ghost" onClick={() => props.onChange(props.value.filter((item) => item.id !== rule.id))}>
|
||||
<Trash2 size={13} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{!props.value.length && <div className="userGroupPolicyEmpty">未配置限流规则,当前用户组不追加组级限流。</div>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserGroupQuotaPolicyEditor(props: {
|
||||
value: UserGroupQuotaEntryForm[];
|
||||
onChange: (value: UserGroupQuotaEntryForm[]) => void;
|
||||
}) {
|
||||
function update(id: string, patch: Partial<UserGroupQuotaEntryForm>) {
|
||||
props.onChange(props.value.map((item) => item.id === id ? { ...item, ...patch } : item));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="userGroupPolicyEditor spanTwo">
|
||||
<header className="userGroupPolicyHeader">
|
||||
<div>
|
||||
<strong>额度策略</strong>
|
||||
<span>额度字段保持开放,可维护数字、文本、布尔值或 JSON。</span>
|
||||
</div>
|
||||
<Button type="button" size="xs" variant="outline" onClick={() => props.onChange([
|
||||
...props.value,
|
||||
createQuotaEntryForm(),
|
||||
])}>
|
||||
<Plus size={13} />添加额度
|
||||
</Button>
|
||||
</header>
|
||||
<div className="userGroupPolicyTable userGroupQuotaTable" role="table" aria-label="用户组额度策略">
|
||||
<div className="userGroupPolicyRow userGroupPolicyTableHead" role="row">
|
||||
<span role="columnheader">字段</span>
|
||||
<span role="columnheader">值类型</span>
|
||||
<span role="columnheader">值</span>
|
||||
<span role="columnheader">操作</span>
|
||||
</div>
|
||||
{props.value.map((entry) => (
|
||||
<div className="userGroupPolicyRow" role="row" key={entry.id}>
|
||||
<Input aria-label="额度字段" size="sm" value={entry.key} placeholder="例如 monthlyResource" onChange={(event) => update(entry.id, { key: event.target.value })} />
|
||||
<Select
|
||||
aria-label={`${entry.key || '额度'}值类型`}
|
||||
size="sm"
|
||||
value={entry.valueType}
|
||||
onChange={(event) => {
|
||||
const valueType = event.target.value as UserGroupQuotaEntryForm['valueType'];
|
||||
update(entry.id, { valueType, value: quotaValueForType(valueType, entry.value) });
|
||||
}}
|
||||
>
|
||||
<option value="number">数字</option>
|
||||
<option value="string">文本</option>
|
||||
<option value="boolean">布尔值</option>
|
||||
<option value="json">JSON</option>
|
||||
</Select>
|
||||
{entry.valueType === 'boolean' ? (
|
||||
<Select aria-label={`${entry.key || '额度'}值`} size="sm" value={entry.value} onChange={(event) => update(entry.id, { value: event.target.value })}>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
aria-label={`${entry.key || '额度'}值`}
|
||||
size="sm"
|
||||
inputMode={entry.valueType === 'number' ? 'decimal' : undefined}
|
||||
value={entry.value}
|
||||
placeholder={entry.valueType === 'json' ? '{"key":"value"}' : '请输入值'}
|
||||
onChange={(event) => update(entry.id, { value: event.target.value })}
|
||||
/>
|
||||
)}
|
||||
<Button type="button" aria-label={`删除额度字段 ${entry.key || '未命名'}`} size="xs" variant="ghost" onClick={() => props.onChange(props.value.filter((item) => item.id !== entry.id))}>
|
||||
<Trash2 size={13} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{!props.value.length && <div className="userGroupPolicyEmpty">未配置额度策略。</div>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
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<string, unknown>,
|
||||
): RateLimitPolicy | undefined {
|
||||
const metrics = new Set<string>();
|
||||
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<string, unknown>) {
|
||||
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<string, unknown> = {};
|
||||
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<string, unknown>) {
|
||||
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<string, string>)[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<UserGroupQuotaEntryForm, 'valueType' | 'value'> {
|
||||
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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+98
-7
@@ -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<AdminSection, string> = {
|
||||
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') ?? '',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+315
-22
@@ -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,
|
||||
|
||||
+20
-1
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user