feat(admin): 优化后台运维与任务管理

完善平台、基准模型、定价规则和实时负载的紧凑查询与滚动展示,并固定关键操作列。

新增管理员任务记录、批量计费结算和用户组限流、额度及模型权限维护能力,同时补充任务脱敏、查询索引与 OpenAPI 契约。

验证:pnpm openapi、Go 全量测试、pnpm lint、pnpm test、pnpm build、gofmt 与差异格式检查均通过。
This commit is contained in:
2026-07-25 01:32:49 +08:00
parent b29d539d49
commit de10875439
38 changed files with 5015 additions and 425 deletions
+584
View File
@@ -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": {
+388
View File
@@ -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"`
}
+3
View File
@@ -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)
}
}
}
+385
View File
@@ -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
View File
@@ -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
View File
@@ -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 });
}
+2
View File
@@ -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[];
+3 -1
View File
@@ -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。' },
];
+32 -25
View File
@@ -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,
};
}
+36 -5
View File
@@ -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']) {
+65 -59
View File
@@ -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']);
});
});
+205 -24
View File
@@ -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"> RPMTPM</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;
}
+45 -1
View File
@@ -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
View File
@@ -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') ?? '',
+78
View File
@@ -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
View File
@@ -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
View File
@@ -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;