Compare commits
10
Commits
219bbf1836
...
992c322858
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
992c322858 | ||
|
|
b36dc3fe28 | ||
|
|
66947cd543 | ||
|
|
f091089365 | ||
|
|
e7239c24b6 | ||
|
|
2c60af0216 | ||
|
|
c494e9576e | ||
|
|
c55e60b3b3 | ||
|
|
a0616eaebf | ||
|
|
a5419f9a15 |
@@ -0,0 +1,64 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/heicode/manager/model"
|
||||
)
|
||||
|
||||
// 客户端遥测(#24)管理员后台只读视图:列表 + 聚合。遥测仅诊断、隔离于计费,载荷无用户内容
|
||||
// (仅枚举/哈希/计数/脱敏栈帧);device_id(client_id)经 user_id 可关联账号,故仅管理员可读。
|
||||
|
||||
func telemetryQueryFilterFromContext(c *gin.Context) model.TelemetryQueryFilter {
|
||||
userId, _ := strconv.Atoi(strings.TrimSpace(c.Query("user_id")))
|
||||
start, _ := strconv.ParseInt(strings.TrimSpace(c.Query("start_timestamp")), 10, 64)
|
||||
end, _ := strconv.ParseInt(strings.TrimSpace(c.Query("end_timestamp")), 10, 64)
|
||||
return model.TelemetryQueryFilter{
|
||||
UserId: userId,
|
||||
ClientId: strings.TrimSpace(c.Query("client_id")),
|
||||
Platform: strings.TrimSpace(c.Query("platform")),
|
||||
AppVersion: strings.TrimSpace(c.Query("app_version")),
|
||||
ErrorCategory: strings.TrimSpace(c.Query("error_category")),
|
||||
ErrorCode: strings.TrimSpace(c.Query("error_code")),
|
||||
StartReceivedAt: start,
|
||||
EndReceivedAt: end,
|
||||
}
|
||||
}
|
||||
|
||||
// AdminListTelemetryEvents: GET /api/telemetry/events — 分页列表(最新在前),支持
|
||||
// user_id/client_id/platform/app_version/error_category/error_code + 时间范围过滤。
|
||||
func AdminListTelemetryEvents(c *gin.Context) {
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
events, total, err := model.ListTelemetryEvents(telemetryQueryFilterFromContext(c), pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
pageInfo.SetTotal(int(total))
|
||||
pageInfo.SetItems(events)
|
||||
common.ApiSuccess(c, pageInfo)
|
||||
}
|
||||
|
||||
// AdminAggregateTelemetryEvents: GET /api/telemetry/aggregate — 按维度聚合计数。
|
||||
// dimension 默认 error_category(白名单:error_category/error_code/platform/app_version/
|
||||
// os_version/arch/stack_hash);返回各桶 {key,count,users},busiest first。过滤同列表。
|
||||
func AdminAggregateTelemetryEvents(c *gin.Context) {
|
||||
dimension := strings.TrimSpace(c.Query("dimension"))
|
||||
if dimension == "" {
|
||||
dimension = "error_category"
|
||||
}
|
||||
limit, _ := strconv.Atoi(strings.TrimSpace(c.Query("limit")))
|
||||
buckets, err := model.AggregateTelemetryEvents(telemetryQueryFilterFromContext(c), dimension, limit)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"dimension": dimension,
|
||||
"buckets": buckets,
|
||||
})
|
||||
}
|
||||
@@ -487,6 +487,10 @@ var agentCallbackEventRequiredFields = map[string][]string{
|
||||
"swarm.completed": {},
|
||||
"swarm.failed": {"reason"},
|
||||
"swarm.stopped": {},
|
||||
// #60 A.5:用户全部 run 停机时 Swarm 恰好发一次,payload {user_id, secret_ref(azkv://)}。
|
||||
// 控制面生命周期事件,HM 据此吊销该用户 swarm 模型 key。最小必填集避免误拒;user_id 缺失
|
||||
// 时回退到 deployment 上下文。
|
||||
"swarm.pool_terminated": {},
|
||||
"artifact.created": {"artifact_id"},
|
||||
"timeline.updated": {"title"},
|
||||
"sk_tool.called": {"tool_name", "tool_invocation_id"},
|
||||
@@ -519,6 +523,7 @@ var agentCallbackEventCategories = map[string]string{
|
||||
"swarm.completed": "swarm_lifecycle",
|
||||
"swarm.failed": "swarm_lifecycle",
|
||||
"swarm.stopped": "swarm_lifecycle",
|
||||
"swarm.pool_terminated": "swarm_lifecycle",
|
||||
"artifact.created": "artifact",
|
||||
"timeline.updated": "timeline",
|
||||
"sk_tool.called": "sk",
|
||||
@@ -660,6 +665,26 @@ func persistAgentApprovalFromCallback(payload agentCallbackEnvelope, record agen
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleSwarmPoolTerminated 处理 #60 A.5 吊销握手:收到 swarm.pool_terminated 时,
|
||||
// 删除该用户的 swarm 模型 key(KV 密文 + token)。user_id 优先取 payload,缺失时回退到
|
||||
// deployment 上下文。仅在事件首次入库(inserted)时调用,天然幂等。
|
||||
func handleSwarmPoolTerminated(payload agentCallbackEnvelope, record agentDeploymentRecord) {
|
||||
if payload.EventType != "swarm.pool_terminated" {
|
||||
return
|
||||
}
|
||||
userID := strings.TrimSpace(callbackStringValue(payload.Payload, "user_id"))
|
||||
if userID == "" {
|
||||
userID = strings.TrimSpace(record.Plan.UserContext.UserID)
|
||||
}
|
||||
uid, err := strconv.Atoi(userID)
|
||||
if err != nil || uid <= 0 {
|
||||
common.SysLog("swarm.pool_terminated: missing/invalid user_id; skip swarm model key revocation")
|
||||
return
|
||||
}
|
||||
revokeSwarmModelKey(uid)
|
||||
common.SysLog(fmt.Sprintf("swarm.pool_terminated: revoked swarm model key for user %d", uid))
|
||||
}
|
||||
|
||||
func AgentReceiveRuntimeEventCallback(c *gin.Context) {
|
||||
rawBody, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
|
||||
if err != nil {
|
||||
@@ -748,6 +773,7 @@ func AgentReceiveRuntimeEventCallback(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
record, _ = applyAgentCallbackDeploymentState(payload, record)
|
||||
handleSwarmPoolTerminated(payload, record)
|
||||
recordAgentAuditEvent(agentEvent{
|
||||
EventID: "evt_" + common.GetUUID()[:12],
|
||||
Event: "callback." + payload.EventType,
|
||||
|
||||
@@ -1097,6 +1097,20 @@ func createAgentDeploymentFromPlan(c *gin.Context, plan agentOrchestrationPlan,
|
||||
plan.Metadata.RuntimeMode = agentRuntimeModeAgent
|
||||
}
|
||||
|
||||
// #60 模型 key 注入(方案A):swarm 模式下,若 billing_context 未自带 secret_ref,
|
||||
// 则为认证用户 mint/复用 per-user sk- 写入 Key Vault,把 azkv:// 引用放进
|
||||
// billing_context.secret_ref 下发给 Swarm(对接 agent_swarm PR#43)。Key Vault
|
||||
// 未配置/不可达时降级:记日志、secret_ref 留空,不阻断 create(联调前可用)。
|
||||
if plan.Metadata.RuntimeMode == agentRuntimeModeSwarm && strings.TrimSpace(plan.BillingContext.SecretRef) == "" {
|
||||
if uid := c.GetInt("id"); uid > 0 {
|
||||
if secretRef, err := provisionSwarmModelKey(uid); err != nil {
|
||||
common.SysLog("provisionSwarmModelKey (swarm create): " + err.Error())
|
||||
} else {
|
||||
plan.BillingContext.SecretRef = secretRef
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
now := agentNow()
|
||||
deploymentID := "dep_" + common.GetUUID()[:12]
|
||||
record := agentDeploymentRecord{
|
||||
@@ -1166,6 +1180,12 @@ func createAgentDeployment(c *gin.Context, enforceUserScope bool) {
|
||||
}
|
||||
|
||||
func AgentCreateUserSwarm(c *gin.Context) {
|
||||
// 订阅套餐 gate:蜂群按套餐开通(管理员在套餐编辑里逐个设 SwarmEnabled,不硬编码 tier)。
|
||||
// 普通用户须有任一活跃套餐开通蜂群;管理员(role>=admin)绕过,便于测试/运维。
|
||||
if c.GetInt("role") < common.RoleAdminUser && !model.GetUserSwarmEnabled(c.GetInt("id")) {
|
||||
agentError(c, "POLICY_REJECTED", "当前订阅套餐未开通蜂群(swarm);请升级套餐或联系管理员")
|
||||
return
|
||||
}
|
||||
record, ok := createAgentDeploymentRecord(c, true, agentRuntimeModeSwarm)
|
||||
if !ok {
|
||||
return
|
||||
|
||||
@@ -1008,6 +1008,9 @@ func TestAgentUserSwarmsAdapterCreatesScopedDeployment(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Set("id", 7)
|
||||
// 蜂群订阅 gate(PR#70):普通用户须有开通蜂群的活跃套餐;admin 绕过(便于测试/运维)。
|
||||
// 本用例聚焦 adapter 的用户作用域,非 gate 本身(gate 见 TestGetUserSwarmEnabled),走 admin 旁路。
|
||||
ctx.Set("role", common.RoleAdminUser)
|
||||
ctx.Set("group", "development")
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/swarms", strings.NewReader(string(body)))
|
||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -526,6 +526,29 @@ func agentRuntimeStopPath(cfg agentRuntimeConfig, runtimeDeploymentID string) st
|
||||
return strings.ReplaceAll(path, "{deployment_id}", url.PathEscape(runtimeDeploymentID))
|
||||
}
|
||||
|
||||
// agentRuntimeSwarmInputPath / agentRuntimeSwarmResultPath 构造 swarm 运行时的追加输入 / 结果
|
||||
// 端点(agent_swarm PR#41 runtime-contract:POST/GET …/{deployment_id}/{input,result})。默认沿用
|
||||
// create-path 的 /api/agent/swarm/deployments 基路径,可经 SWARM_RUNTIME_{INPUT,RESULT}_PATH 覆盖。
|
||||
func agentRuntimeSwarmInputPath(cfg agentRuntimeConfig, runtimeDeploymentID string) string {
|
||||
return swarmRuntimeDeploymentSubPath(cfg, runtimeDeploymentID, "input", "SWARM_RUNTIME_INPUT_PATH")
|
||||
}
|
||||
|
||||
func agentRuntimeSwarmResultPath(cfg agentRuntimeConfig, runtimeDeploymentID string) string {
|
||||
return swarmRuntimeDeploymentSubPath(cfg, runtimeDeploymentID, "result", "SWARM_RUNTIME_RESULT_PATH")
|
||||
}
|
||||
|
||||
func swarmRuntimeDeploymentSubPath(cfg agentRuntimeConfig, runtimeDeploymentID, sub, envKey string) string {
|
||||
path := strings.TrimSpace(common.GetEnvOrDefaultString(envKey, ""))
|
||||
if path == "" {
|
||||
base := strings.TrimRight(strings.TrimSpace(cfg.CreatePath), "/")
|
||||
if base == "" {
|
||||
base = "/api/agent/swarm/deployments"
|
||||
}
|
||||
path = base + "/{deployment_id}/" + sub
|
||||
}
|
||||
return strings.ReplaceAll(path, "{deployment_id}", url.PathEscape(strings.TrimSpace(runtimeDeploymentID)))
|
||||
}
|
||||
|
||||
func agentRuntimeStopPathForRecord(cfg agentRuntimeConfig, record agentDeploymentRecord) string {
|
||||
// {deployment_id} falls back to the swarm id so sub-agile-style paths still
|
||||
// resolve for records that only persisted a runtime swarm id.
|
||||
|
||||
@@ -216,7 +216,90 @@ func HeicodeGetSwarmStatus(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, swarmDeploymentView(dep))
|
||||
// 详情视图在状态摘要上补 HM 自有数据(#66 里程碑07/08):resource_grants(脱敏)/budget 上限/
|
||||
// 控制面审计。这些不依赖 swarm 运行时,故 list(轻量)不带、仅 detail 带。
|
||||
// usage/used_*/cost_by_phase 待 #60(计费 sk-)+ swarm metrics,本批不臆造。
|
||||
detail := swarmDeploymentView(dep)
|
||||
detail["resource_grants"] = swarmResourceGrantsView(dep.PlanJSON)
|
||||
detail["budget"] = swarmBudgetView(dep.PlanJSON)
|
||||
detail["audit_logs"] = swarmAuditView(dep.DeploymentID)
|
||||
common.ApiSuccess(c, detail)
|
||||
}
|
||||
|
||||
// swarmPlanForView 解析 plan_json 的 budget + resource_grants(详情视图用,复用既有类型)。
|
||||
func swarmPlanForView(planJSON string) (agentBudget, []agentResourceGrant) {
|
||||
if strings.TrimSpace(planJSON) == "" {
|
||||
return agentBudget{}, nil
|
||||
}
|
||||
var plan struct {
|
||||
Budget agentBudget `json:"budget"`
|
||||
ResourceGrants []agentResourceGrant `json:"resource_grants"`
|
||||
}
|
||||
if err := common.UnmarshalJsonStr(planJSON, &plan); err != nil {
|
||||
return agentBudget{}, nil
|
||||
}
|
||||
return plan.Budget, plan.ResourceGrants
|
||||
}
|
||||
|
||||
// swarmResourceGrantsView:resource_grants 脱敏视图(#66 里程碑07,只读)。
|
||||
// **安全红线**:绝不下发 secret_ref 值,只用 has_secret 标有无。
|
||||
func swarmResourceGrantsView(planJSON string) []gin.H {
|
||||
_, grants := swarmPlanForView(planJSON)
|
||||
out := make([]gin.H, 0, len(grants))
|
||||
for _, g := range grants {
|
||||
out = append(out, gin.H{
|
||||
"grant_id": g.GrantID,
|
||||
"resource_id": g.ResourceID,
|
||||
"resource_type": g.ResourceType,
|
||||
"binding_scope": g.BindingScope,
|
||||
"target_role": g.TargetRole,
|
||||
"permission_scope": g.PermissionScope,
|
||||
"status": g.Status,
|
||||
"has_secret": strings.TrimSpace(g.SecretRef) != "",
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// swarmBudgetView:预算上限(#66 里程碑08)。used_* 待 #60 计费 + swarm metrics,本批不返(不臆造)。
|
||||
func swarmBudgetView(planJSON string) gin.H {
|
||||
b, _ := swarmPlanForView(planJSON)
|
||||
return gin.H{
|
||||
"max_tokens": b.MaxTokens,
|
||||
"max_cost_usd": b.MaxCostUSD,
|
||||
"max_duration_sec": b.MaxDurationSec,
|
||||
}
|
||||
}
|
||||
|
||||
// swarmAuditView:HM 控制面部署审计(deployment 级,脱敏 + 截断)。
|
||||
// run 级 trace(prompt/model/tool/approval)是另一 scope,待 swarm `/audit` 代理(PR#41)。
|
||||
func swarmAuditView(deploymentID string) []gin.H {
|
||||
if model.DB == nil {
|
||||
return []gin.H{}
|
||||
}
|
||||
rows, err := model.ListAgentAuditEventsByDeployment(deploymentID)
|
||||
if err != nil || len(rows) == 0 {
|
||||
return []gin.H{}
|
||||
}
|
||||
const maxRows = 100
|
||||
if len(rows) > maxRows {
|
||||
rows = rows[:maxRows]
|
||||
}
|
||||
out := make([]gin.H, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
item := gin.H{
|
||||
"event": r.Event,
|
||||
"actor": r.Actor,
|
||||
"result": r.Result,
|
||||
"correlation_id": r.CorrelationID,
|
||||
"occurred_at": r.OccurredAt,
|
||||
}
|
||||
if strings.TrimSpace(r.DetailsJSON) != "" {
|
||||
item["details"] = sanitizeSwarmPayload(r.DetailsJSON) // 递归剔敏 + RedactText
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HeicodeListSwarmEvents: GET /api/heicode/swarms/:id/events?after=&limit= — 事件增量拉取。
|
||||
@@ -418,6 +501,167 @@ func callSwarmRuntimeStop(ctx context.Context, cfg agentRuntimeConfig, dep model
|
||||
}, nil
|
||||
}
|
||||
|
||||
// callSwarmRuntimeJSON 向 Swarm 运行时发起一次 JSON 请求并归一化 envelope(复用 stop 的解析 helper)。
|
||||
// 返回 data(envelope.data 或顶层)、HTTP 状态、错误。错误信息仅含 HTTP 状态 + 运行时响应体,
|
||||
// 绝不含调用方请求体(如 /input 的指令文本)。
|
||||
func callSwarmRuntimeJSON(ctx context.Context, cfg agentRuntimeConfig, method, endpoint string, body []byte, dep model.AgentDeployment) (map[string]any, int, error) {
|
||||
var reader io.Reader
|
||||
if len(body) > 0 {
|
||||
reader = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(body) > 0 {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(cfg.Token))
|
||||
if cid := strings.TrimSpace(dep.CorrelationID); cid != "" {
|
||||
req.Header.Set("X-Correlation-ID", cid)
|
||||
}
|
||||
client := &http.Client{Timeout: cfg.Timeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if readErr != nil {
|
||||
return nil, resp.StatusCode, readErr
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, resp.StatusCode, fmt.Errorf("runtime returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
var envelope map[string]any
|
||||
if len(respBody) > 0 {
|
||||
if err := common.Unmarshal(respBody, &envelope); err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
}
|
||||
if message := agentRuntimeEnvelopeError(envelope); message != "" {
|
||||
return nil, resp.StatusCode, errors.New(message)
|
||||
}
|
||||
return extractAgentRuntimeData(envelope), resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// HeicodeAppendSwarmInput: POST /api/heicode/swarms/:id/input — 追加用户输入(写路,代理到 Swarm)。
|
||||
// agent_swarm PR#41 runtime-contract:POST …/{deployment_id}/input,注入 source=user_append 任务
|
||||
// (终态 run 自动 reopen 为 running;stopped 拒绝)。**指令文本仅转发,绝不落 HM 日志/审计/事件**
|
||||
// (#40「原文不进事件流」+ #46「勿落日志」)。与 stop 一样需运行时开关 + base_url + 服务令牌,缺一即拒,
|
||||
// 绝不伪造受理。
|
||||
func HeicodeAppendSwarmInput(c *gin.Context) {
|
||||
dep, ok := findUserSwarmDeployment(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
cfg := agentRuntimeClientConfigForMode(agentRuntimeModeSwarm)
|
||||
if !cfg.Enabled || strings.TrimSpace(cfg.BaseURL) == "" || strings.TrimSpace(cfg.Token) == "" {
|
||||
agentError(c, "POLICY_REJECTED",
|
||||
"swarm input not enabled: requires SWARM_RUNTIME_ENABLED=true with SWARM_RUNTIME_BASE_URL and SWARM_RUNTIME_SERVICE_TOKEN")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Instruction string `json:"instruction"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
agentError(c, "POLICY_REJECTED", err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(body.Instruction) == "" {
|
||||
agentError(c, "POLICY_REJECTED", "instruction is required")
|
||||
return
|
||||
}
|
||||
runtimeID := firstNonEmpty(strings.TrimSpace(dep.RuntimeDeploymentID), strings.TrimSpace(dep.RuntimeSwarmID))
|
||||
if runtimeID == "" {
|
||||
agentError(c, "POLICY_REJECTED", "swarm run has no runtime deployment id yet")
|
||||
return
|
||||
}
|
||||
endpoint, err := agentRuntimeURL(cfg.BaseURL, agentRuntimeSwarmInputPath(cfg, runtimeID))
|
||||
if err != nil {
|
||||
agentError(c, "RUNTIME_UNAVAILABLE", err.Error())
|
||||
return
|
||||
}
|
||||
payload, err := common.Marshal(gin.H{
|
||||
"instruction": body.Instruction,
|
||||
"manager_deployment_id": dep.DeploymentID,
|
||||
})
|
||||
if err != nil {
|
||||
agentError(c, "POLICY_REJECTED", err.Error())
|
||||
return
|
||||
}
|
||||
data, _, err := callSwarmRuntimeJSON(c.Request.Context(), cfg, http.MethodPost, endpoint, payload, dep)
|
||||
if err != nil {
|
||||
// err 仅含 HTTP 状态 + 运行时响应体,不含指令文本。
|
||||
common.SysLog("HeicodeAppendSwarmInput: " + err.Error())
|
||||
agentError(c, "RUNTIME_UNAVAILABLE", "failed to append input at runtime: "+err.Error())
|
||||
return
|
||||
}
|
||||
// 审计:只记发生过一次 append,**不记指令文本**。
|
||||
recordAgentAuditEvent(agentEvent{
|
||||
EventID: "evt_" + common.GetUUID()[:12],
|
||||
Event: "swarm.input_appended",
|
||||
SchemaVersion: 1,
|
||||
UserID: dep.UserID,
|
||||
ChannelID: dep.ChannelID,
|
||||
BindingScope: dep.BindingScope,
|
||||
DeploymentID: dep.DeploymentID,
|
||||
CorrelationID: dep.CorrelationID,
|
||||
OccurredAt: agentNow(),
|
||||
}, "agent_swarm_query", dep.DeploymentID, agentRequestID(c), "ok")
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"deployment_id": dep.DeploymentID,
|
||||
"swarm_id": dep.RuntimeSwarmID,
|
||||
"accepted": true,
|
||||
"task_id": stringFromMap(data, "task_id"),
|
||||
"runtime_status": stringFromMap(data, "status", "runtime_status"),
|
||||
"note": "input appended; original instruction is not echoed into the event stream",
|
||||
})
|
||||
}
|
||||
|
||||
// HeicodeGetSwarmResult: GET /api/heicode/swarms/:id/result — 用户面结果(读路,代理到 Swarm)。
|
||||
// agent_swarm PR#41 runtime-contract:GET …/{deployment_id}/result →
|
||||
// {summary, deliverable, artifacts[], termination_reason, status};产物内容按各 artifact 的 uri
|
||||
// (git/runtime)取,非内联。透出前递归剔除敏感键(stripSensitiveKeys)兜底,绝不含 secret_ref 等。
|
||||
// 读路:需 base_url + 服务令牌(运行时可达),不强制 SWARM_RUNTIME_ENABLED(与派发开关解耦)。
|
||||
func HeicodeGetSwarmResult(c *gin.Context) {
|
||||
dep, ok := findUserSwarmDeployment(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
cfg := agentRuntimeClientConfigForMode(agentRuntimeModeSwarm)
|
||||
if strings.TrimSpace(cfg.BaseURL) == "" || strings.TrimSpace(cfg.Token) == "" {
|
||||
agentError(c, "POLICY_REJECTED",
|
||||
"swarm result not available: requires SWARM_RUNTIME_BASE_URL and SWARM_RUNTIME_SERVICE_TOKEN")
|
||||
return
|
||||
}
|
||||
runtimeID := firstNonEmpty(strings.TrimSpace(dep.RuntimeDeploymentID), strings.TrimSpace(dep.RuntimeSwarmID))
|
||||
if runtimeID == "" {
|
||||
agentError(c, "POLICY_REJECTED", "swarm run has no runtime deployment id yet")
|
||||
return
|
||||
}
|
||||
endpoint, err := agentRuntimeURL(cfg.BaseURL, agentRuntimeSwarmResultPath(cfg, runtimeID))
|
||||
if err != nil {
|
||||
agentError(c, "RUNTIME_UNAVAILABLE", err.Error())
|
||||
return
|
||||
}
|
||||
data, _, err := callSwarmRuntimeJSON(c.Request.Context(), cfg, http.MethodGet, endpoint, nil, dep)
|
||||
if err != nil {
|
||||
common.SysLog("HeicodeGetSwarmResult: " + err.Error())
|
||||
agentError(c, "RUNTIME_UNAVAILABLE", "failed to fetch swarm result at runtime: "+err.Error())
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"deployment_id": dep.DeploymentID,
|
||||
"swarm_id": dep.RuntimeSwarmID,
|
||||
"status": stringFromMap(data, "status"),
|
||||
"summary": stringFromMap(data, "summary"),
|
||||
"termination_reason": stringFromMap(data, "termination_reason"),
|
||||
"deliverable": stripSensitiveKeys(data["deliverable"]),
|
||||
"artifacts": stripSensitiveKeys(data["artifacts"]),
|
||||
})
|
||||
}
|
||||
|
||||
// swarmTerminalEventTypes 是 agent_swarm event-schema v1(#15)的运行终态事件。命中即可结束 SSE 流。
|
||||
var swarmTerminalEventTypes = map[string]bool{
|
||||
"swarm.completed": true,
|
||||
|
||||
@@ -198,3 +198,34 @@ func TestSwarmGoalSummary(t *testing.T) {
|
||||
require.LessOrEqual(t, len([]rune(out)), 201) // 200 + …
|
||||
require.True(t, strings.HasSuffix(out, "…"))
|
||||
}
|
||||
|
||||
// #66 里程碑07:resource_grants 脱敏视图 —— 绝不下发 secret_ref 值,只标 has_secret。
|
||||
func TestSwarmResourceGrantsView(t *testing.T) {
|
||||
plan := `{"resource_grants":[
|
||||
{"grant_id":"g1","resource_id":"r1","resource_type":"git","binding_scope":"git:repo","target_role":"impl","permission_scope":["read","write"],"status":"active","secret_ref":"azkv://heicode-kv.vault.azure.net/secrets/git-pat"},
|
||||
{"grant_id":"g2","resource_id":"r2","resource_type":"sk","status":"active"}
|
||||
]}`
|
||||
out := swarmResourceGrantsView(plan)
|
||||
require.Len(t, out, 2)
|
||||
require.Equal(t, "git", out[0]["resource_type"])
|
||||
require.Equal(t, true, out[0]["has_secret"])
|
||||
require.Equal(t, false, out[1]["has_secret"])
|
||||
b, _ := json.Marshal(out)
|
||||
require.NotContains(t, string(b), "secret_ref")
|
||||
require.NotContains(t, string(b), "azkv://")
|
||||
require.NotContains(t, string(b), "git-pat")
|
||||
require.Empty(t, swarmResourceGrantsView(""))
|
||||
require.Empty(t, swarmResourceGrantsView("bad-json"))
|
||||
}
|
||||
|
||||
// #66 里程碑08:budget 上限;used_* 本批不返。
|
||||
func TestSwarmBudgetView(t *testing.T) {
|
||||
b := swarmBudgetView(`{"budget":{"max_tokens":100000,"max_cost_usd":5.5,"max_duration_sec":3600}}`)
|
||||
require.EqualValues(t, 100000, b["max_tokens"])
|
||||
require.EqualValues(t, 5.5, b["max_cost_usd"])
|
||||
require.EqualValues(t, 3600, b["max_duration_sec"])
|
||||
_, hasUsed := b["used_model_cost"]
|
||||
require.False(t, hasUsed, "used_* 本批不应臆造")
|
||||
empty := swarmBudgetView("")
|
||||
require.EqualValues(t, 0, empty["max_tokens"])
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/heicode/manager/model"
|
||||
"github.com/heicode/manager/setting/ratio_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -244,6 +244,7 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
||||
"creem_product_id": req.Plan.CreemProductId,
|
||||
"max_purchase_per_user": req.Plan.MaxPurchasePerUser,
|
||||
"max_agents": req.Plan.MaxAgents,
|
||||
"swarm_enabled": req.Plan.SwarmEnabled,
|
||||
"total_amount": req.Plan.TotalAmount,
|
||||
"upgrade_group": req.Plan.UpgradeGroup,
|
||||
"quota_reset_period": req.Plan.QuotaResetPeriod,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/heicode/manager/model"
|
||||
)
|
||||
|
||||
// #46/#28:input/result 代理端点的运行时路径遵循 PR#41 契约 …/{deployment_id}/{input,result},
|
||||
// 默认沿用 create-path 基路径,可经 env 覆盖。
|
||||
func TestAgentRuntimeSwarmSubPaths(t *testing.T) {
|
||||
cfg := agentRuntimeConfig{CreatePath: "/api/agent/swarm/deployments"}
|
||||
require.Equal(t, "/api/agent/swarm/deployments/rt-9/input", agentRuntimeSwarmInputPath(cfg, "rt-9"))
|
||||
require.Equal(t, "/api/agent/swarm/deployments/rt-9/result", agentRuntimeSwarmResultPath(cfg, "rt-9"))
|
||||
|
||||
// 空 CreatePath -> 回退到 canonical 默认基路径。
|
||||
require.Equal(t, "/api/agent/swarm/deployments/x/input", agentRuntimeSwarmInputPath(agentRuntimeConfig{}, "x"))
|
||||
|
||||
// env 覆盖。
|
||||
t.Setenv("SWARM_RUNTIME_RESULT_PATH", "/api/swarms/{deployment_id}/result")
|
||||
require.Equal(t, "/api/swarms/rt-9/result", agentRuntimeSwarmResultPath(cfg, "rt-9"))
|
||||
}
|
||||
|
||||
// input 转发:命中契约路径/方法/鉴权,body 携带 instruction + manager_deployment_id,解析 envelope.data。
|
||||
func TestCallSwarmRuntimeJSON_InputForwards(t *testing.T) {
|
||||
var gotMethod, gotPath, gotAuth, gotCorr, gotBody string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod = r.Method
|
||||
gotPath = r.URL.Path
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotCorr = r.Header.Get("X-Correlation-ID")
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
gotBody = string(b)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true,"data":{"task_id":"task-append-1","status":"running"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := agentRuntimeConfig{Enabled: true, BaseURL: srv.URL, Token: "svc", CreatePath: "/api/agent/swarm/deployments", Timeout: 5 * time.Second}
|
||||
dep := model.AgentDeployment{DeploymentID: "dep-1", RuntimeDeploymentID: "rt-9", CorrelationID: "cor-3"}
|
||||
endpoint, err := agentRuntimeURL(cfg.BaseURL, agentRuntimeSwarmInputPath(cfg, dep.RuntimeDeploymentID))
|
||||
require.NoError(t, err)
|
||||
payload, err := common.Marshal(gin.H{"instruction": "refine the report", "manager_deployment_id": dep.DeploymentID})
|
||||
require.NoError(t, err)
|
||||
|
||||
data, status, err := callSwarmRuntimeJSON(context.Background(), cfg, http.MethodPost, endpoint, payload, dep)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, "task-append-1", stringFromMap(data, "task_id"))
|
||||
require.Equal(t, http.MethodPost, gotMethod)
|
||||
require.Equal(t, "/api/agent/swarm/deployments/rt-9/input", gotPath)
|
||||
require.Equal(t, "Bearer svc", gotAuth)
|
||||
require.Equal(t, "cor-3", gotCorr)
|
||||
require.Contains(t, gotBody, "refine the report")
|
||||
require.Contains(t, gotBody, "dep-1")
|
||||
}
|
||||
|
||||
// result 解析 + 透出脱敏:deliverable/artifacts 内若混入 secret_ref/token 等敏感键,视图须递归剔除。
|
||||
func TestCallSwarmRuntimeJSON_ResultParsesAndSanitizes(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodGet, r.Method)
|
||||
require.Equal(t, "/api/agent/swarm/deployments/rt-9/result", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true,"data":{"status":"completed","summary":"done","termination_reason":"completed","deliverable":{"text":"the answer","secret_ref":"azkv://heicode-kv/x"},"artifacts":[{"uri":"git://repo/a.patch","token":"leak"}]}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := agentRuntimeConfig{BaseURL: srv.URL, Token: "svc", CreatePath: "/api/agent/swarm/deployments", Timeout: 5 * time.Second}
|
||||
dep := model.AgentDeployment{DeploymentID: "dep-1", RuntimeDeploymentID: "rt-9"}
|
||||
endpoint, err := agentRuntimeURL(cfg.BaseURL, agentRuntimeSwarmResultPath(cfg, dep.RuntimeDeploymentID))
|
||||
require.NoError(t, err)
|
||||
|
||||
data, _, err := callSwarmRuntimeJSON(context.Background(), cfg, http.MethodGet, endpoint, nil, dep)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "completed", stringFromMap(data, "status"))
|
||||
require.Equal(t, "done", stringFromMap(data, "summary"))
|
||||
|
||||
deliverable, ok := stripSensitiveKeys(data["deliverable"]).(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "the answer", deliverable["text"])
|
||||
_, hasSecret := deliverable["secret_ref"]
|
||||
require.False(t, hasSecret, "secret_ref 必须从 deliverable 剔除")
|
||||
|
||||
artifacts, ok := stripSensitiveKeys(data["artifacts"]).([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, artifacts, 1)
|
||||
first := artifacts[0].(map[string]any)
|
||||
require.Equal(t, "git://repo/a.patch", first["uri"])
|
||||
_, hasToken := first["token"]
|
||||
require.False(t, hasToken, "token 必须从 artifact 剔除")
|
||||
}
|
||||
|
||||
// 运行时非 2xx → 返回错误(handler 据此回 RUNTIME_UNAVAILABLE,不伪造受理)。
|
||||
func TestCallSwarmRuntimeJSON_RuntimeError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte(`upstream down`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
cfg := agentRuntimeConfig{BaseURL: srv.URL, Token: "t", CreatePath: "/api/agent/swarm/deployments", Timeout: 5 * time.Second}
|
||||
endpoint, _ := agentRuntimeURL(cfg.BaseURL, agentRuntimeSwarmResultPath(cfg, "rt"))
|
||||
_, status, err := callSwarmRuntimeJSON(context.Background(), cfg, http.MethodGet, endpoint, nil, model.AgentDeployment{DeploymentID: "d", RuntimeDeploymentID: "rt"})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusBadGateway, status)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/heicode/manager/model"
|
||||
)
|
||||
|
||||
// #60 模型 key 注入(方案A) —— 对接 agent_swarm PR#43 定死的参数:
|
||||
//
|
||||
// - A.1 粒度/命名:HM 为每个用户 mint 一把 per-user `sk-`,跨该用户所有 swarm run 复用;
|
||||
// KV 密文名 = `swarm-model-key-<user_id>`。
|
||||
// - A.2 KV value:JSON `{"openai_api_key":"sk-..."}`,对齐 Swarm
|
||||
// `orchestrator/agent_launcher._extract_model_key` 的解析字段。
|
||||
// - A.4 OPENAI_API_BASE:Swarm 部署常量(=HM 网关 /v1),HM **不**经 billing_context 下发。
|
||||
// - A.5 吊销:事件驱动 —— 用户全部 run 被 stop 时 Swarm 恰好发一次 `swarm.pool_terminated`,
|
||||
// HM 据此删 KV 密文 + 删 token(见 agent_callback.go handleSwarmPoolTerminated)。
|
||||
//
|
||||
// create 时把 putSecret 返回的 azkv:// secret_ref 放进 billing_context.secret_ref 下发;
|
||||
// Swarm 侧凭 KV 读权限(A.3,运维授权 pending)解析后注入 agent 环境。
|
||||
// 明文 `sk-` 仅经 secret_ref 服务端解析,绝不入代码/日志/事件/argv。
|
||||
|
||||
func swarmModelKeyTokenName(userID int) string {
|
||||
return fmt.Sprintf("swarm:user:%d", userID)
|
||||
}
|
||||
|
||||
func swarmModelKeySecretName(userID int) string {
|
||||
return fmt.Sprintf("swarm-model-key-%d", userID)
|
||||
}
|
||||
|
||||
// getOrMintSwarmModelToken 返回该用户长存的 swarm 模型 token 的 `sk-` bearer。
|
||||
// 已存在(未软删)则复用(A.1:跨 run 复用、稳定 key),否则新建一把隐藏、无限额度、
|
||||
// 不自然过期的系统托管 token(计费直接走 User.Quota,与 mintAgentModelToken 一致)。
|
||||
func getOrMintSwarmModelToken(userID int) (string, error) {
|
||||
if userID <= 0 || model.DB == nil {
|
||||
return "", errors.New("invalid user for swarm model key")
|
||||
}
|
||||
name := swarmModelKeyTokenName(userID)
|
||||
var tok model.Token
|
||||
err := model.DB.Where("user_id = ? AND name = ?", userID, name).First(&tok).Error
|
||||
if err == nil && strings.TrimSpace(tok.Key) != "" {
|
||||
return "sk-" + tok.Key, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", err
|
||||
}
|
||||
rawKey, err := common.GenerateKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
now := common.GetTimestamp()
|
||||
tok = model.Token{
|
||||
UserId: userID,
|
||||
Name: name,
|
||||
Key: rawKey,
|
||||
Status: common.TokenStatusEnabled,
|
||||
CreatedTime: now,
|
||||
AccessedTime: now,
|
||||
ExpiredTime: -1, // never naturally
|
||||
UnlimitedQuota: true, // bills straight from User.Quota
|
||||
HideFromUserUI: true, // 系统托管令牌,不在用户令牌列表展示
|
||||
}
|
||||
if err := tok.Insert(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "sk-" + rawKey, nil
|
||||
}
|
||||
|
||||
// provisionSwarmModelKey 确保用户的 swarm 模型 key 已就位(token + KV 密文),返回 azkv:// secret_ref。
|
||||
// 仅在 Azure Key Vault 已配置时可用;未配置/不可达返回 error,由调用方按需降级
|
||||
// (联调前 secret_ref 可留空,不阻断 swarm create)。
|
||||
func provisionSwarmModelKey(userID int) (string, error) {
|
||||
store, err := newSecretStoreClientFromEnv()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
bearer, err := getOrMintSwarmModelToken(userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// A.2:KV value = JSON {"openai_api_key":"sk-..."}。putSecret 幂等(同名新建版本)。
|
||||
secretRef, err := store.putSecret(swarmModelKeySecretName(userID), map[string]any{
|
||||
"openai_api_key": bearer,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return secretRef, nil
|
||||
}
|
||||
|
||||
// revokeSwarmModelKey 吊销用户的 swarm 模型 key:删 KV 密文 + 软删 token(均尽力而为)。
|
||||
// 由 swarm.pool_terminated 回调触发(A.5)。token 软删后,下次 swarm create 会重新 mint 一把新 key。
|
||||
func revokeSwarmModelKey(userID int) {
|
||||
if userID <= 0 {
|
||||
return
|
||||
}
|
||||
if store, err := newSecretStoreClientFromEnv(); err == nil {
|
||||
if err := store.deleteSecret(store.secretRef(swarmModelKeySecretName(userID))); err != nil {
|
||||
common.SysLog("revokeSwarmModelKey delete KV secret: " + err.Error())
|
||||
}
|
||||
}
|
||||
if model.DB != nil {
|
||||
if err := model.DB.Where("user_id = ? AND name = ?", userID, swarmModelKeyTokenName(userID)).Delete(&model.Token{}).Error; err != nil {
|
||||
common.SysLog("revokeSwarmModelKey delete token: " + err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/heicode/manager/model"
|
||||
)
|
||||
|
||||
func setupSwarmModelKeyTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
common.UsingSQLite = true
|
||||
common.UsingMySQL = false
|
||||
common.UsingPostgreSQL = false
|
||||
common.RedisEnabled = false
|
||||
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
model.DB = db
|
||||
require.NoError(t, db.AutoMigrate(&model.Token{}))
|
||||
t.Cleanup(func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
model.DB = nil
|
||||
})
|
||||
}
|
||||
|
||||
func TestSwarmModelKeyNames(t *testing.T) {
|
||||
require.Equal(t, "swarm:user:42", swarmModelKeyTokenName(42))
|
||||
require.Equal(t, "swarm-model-key-42", swarmModelKeySecretName(42))
|
||||
}
|
||||
|
||||
func TestGetOrMintSwarmModelTokenReusesPerUser(t *testing.T) {
|
||||
setupSwarmModelKeyTestDB(t)
|
||||
|
||||
// A.1: first call mints, second call reuses the SAME sk- across runs.
|
||||
first, err := getOrMintSwarmModelToken(7001)
|
||||
require.NoError(t, err)
|
||||
require.True(t, strings.HasPrefix(first, "sk-"))
|
||||
second, err := getOrMintSwarmModelToken(7001)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, first, second, "per-user swarm key must be reused across runs")
|
||||
|
||||
// only one token persisted for the user, and it is hidden + system-managed.
|
||||
var toks []model.Token
|
||||
require.NoError(t, model.DB.Where("user_id = ?", 7001).Find(&toks).Error)
|
||||
require.Len(t, toks, 1)
|
||||
require.Equal(t, swarmModelKeyTokenName(7001), toks[0].Name)
|
||||
require.True(t, toks[0].HideFromUserUI)
|
||||
require.True(t, toks[0].UnlimitedQuota)
|
||||
require.EqualValues(t, -1, toks[0].ExpiredTime)
|
||||
|
||||
// distinct users get distinct keys.
|
||||
other, err := getOrMintSwarmModelToken(7002)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, first, other)
|
||||
|
||||
// invalid user rejected.
|
||||
_, err = getOrMintSwarmModelToken(0)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRevokeSwarmModelKeyReMintsFresh(t *testing.T) {
|
||||
setupSwarmModelKeyTestDB(t)
|
||||
|
||||
first, err := getOrMintSwarmModelToken(7003)
|
||||
require.NoError(t, err)
|
||||
|
||||
// revoke soft-deletes the token (KV not configured -> delete logged, non-fatal).
|
||||
revokeSwarmModelKey(7003)
|
||||
var live []model.Token
|
||||
require.NoError(t, model.DB.Where("user_id = ?", 7003).Find(&live).Error)
|
||||
require.Len(t, live, 0, "revoked swarm token must be soft-deleted")
|
||||
|
||||
// next provision mints a brand-new key (A.5: stop -> revoke -> new key on re-run).
|
||||
second, err := getOrMintSwarmModelToken(7003)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, first, second)
|
||||
}
|
||||
|
||||
func TestHandleSwarmPoolTerminatedRevokesByPayloadUserID(t *testing.T) {
|
||||
setupSwarmModelKeyTestDB(t)
|
||||
_, err := getOrMintSwarmModelToken(7004)
|
||||
require.NoError(t, err)
|
||||
|
||||
payload := agentCallbackEnvelope{
|
||||
EventType: "swarm.pool_terminated",
|
||||
Payload: map[string]any{"user_id": "7004", "secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/swarm-model-key-7004"},
|
||||
}
|
||||
handleSwarmPoolTerminated(payload, agentDeploymentRecord{})
|
||||
|
||||
var live []model.Token
|
||||
require.NoError(t, model.DB.Where("user_id = ?", 7004).Find(&live).Error)
|
||||
require.Len(t, live, 0)
|
||||
}
|
||||
|
||||
func TestHandleSwarmPoolTerminatedFallsBackToDeploymentUser(t *testing.T) {
|
||||
setupSwarmModelKeyTestDB(t)
|
||||
_, err := getOrMintSwarmModelToken(7005)
|
||||
require.NoError(t, err)
|
||||
|
||||
// no user_id in payload -> fall back to deployment user context.
|
||||
payload := agentCallbackEnvelope{EventType: "swarm.pool_terminated", Payload: map[string]any{}}
|
||||
record := agentDeploymentRecord{}
|
||||
record.Plan.UserContext.UserID = "7005"
|
||||
handleSwarmPoolTerminated(payload, record)
|
||||
|
||||
var live []model.Token
|
||||
require.NoError(t, model.DB.Where("user_id = ?", 7005).Find(&live).Error)
|
||||
require.Len(t, live, 0)
|
||||
}
|
||||
|
||||
func TestHandleSwarmPoolTerminatedIgnoresOtherEvents(t *testing.T) {
|
||||
setupSwarmModelKeyTestDB(t)
|
||||
_, err := getOrMintSwarmModelToken(7006)
|
||||
require.NoError(t, err)
|
||||
|
||||
// wrong event type -> no revocation.
|
||||
handleSwarmPoolTerminated(agentCallbackEnvelope{EventType: "swarm.stopped", Payload: map[string]any{"user_id": "7006"}}, agentDeploymentRecord{})
|
||||
var live []model.Token
|
||||
require.NoError(t, model.DB.Where("user_id = ?", 7006).Find(&live).Error)
|
||||
require.Len(t, live, 1)
|
||||
}
|
||||
|
||||
func TestSwarmPoolTerminatedEventRegistered(t *testing.T) {
|
||||
// control-plane lifecycle event must be a known callback type so the schema
|
||||
// validator accepts it and routes it to the swarm_lifecycle category.
|
||||
_, hasFields := agentCallbackEventRequiredFields["swarm.pool_terminated"]
|
||||
require.True(t, hasFields)
|
||||
require.Equal(t, "swarm_lifecycle", agentCallbackEventCategories["swarm.pool_terminated"])
|
||||
}
|
||||
@@ -171,6 +171,12 @@ type SubscriptionPlan struct {
|
||||
// expressed: admins set each plan's cap; the code does not hard-code tiers.
|
||||
MaxAgents int `json:"max_agents" gorm:"type:int;default:0"`
|
||||
|
||||
// Whether users on this plan may use the multi-agent swarm (蜂群).
|
||||
// Default false: swarm access is opt-in per plan; admins toggle it in the
|
||||
// plan editor. Enforced in AgentCreateUserSwarm via GetUserSwarmEnabled.
|
||||
// No hard-coded tiers — admins decide which plans get swarm.
|
||||
SwarmEnabled bool `json:"swarm_enabled" gorm:"default:false"`
|
||||
|
||||
// Upgrade user group after purchase (empty = no change)
|
||||
UpgradeGroup string `json:"upgrade_group" gorm:"type:varchar(64);default:''"`
|
||||
|
||||
@@ -732,6 +738,32 @@ func GetUserMaxAgents(userId int, defaultMax int) int {
|
||||
return defaultMax
|
||||
}
|
||||
|
||||
// GetUserSwarmEnabled reports whether the user has any active subscription plan
|
||||
// with SwarmEnabled=true. Mirrors GetUserMaxAgents (#8, tier-aware): no
|
||||
// hard-coded tiers — swarm access is per-plan, set by admins. No active plan /
|
||||
// DB unavailable => false (swarm is opt-in per plan).
|
||||
func GetUserSwarmEnabled(userId int) bool {
|
||||
if userId <= 0 || DB == nil {
|
||||
return false
|
||||
}
|
||||
now := common.GetTimestamp()
|
||||
var subs []UserSubscription
|
||||
if err := DB.Where("user_id = ? AND status = ? AND end_time > ?", userId, "active", now).
|
||||
Find(&subs).Error; err != nil || len(subs) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, s := range subs {
|
||||
plan, err := GetSubscriptionPlanById(s.PlanId)
|
||||
if err != nil || plan == nil {
|
||||
continue
|
||||
}
|
||||
if plan.SwarmEnabled {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetAllUserSubscriptions returns all subscriptions (active and expired) for a user.
|
||||
func GetAllUserSubscriptions(userId int) ([]SubscriptionSummary, error) {
|
||||
if userId <= 0 {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// GetUserSwarmEnabled gates swarm access per subscription plan (admin-set,
|
||||
// opt-in): true iff the user has any ACTIVE plan with SwarmEnabled=true.
|
||||
func TestGetUserSwarmEnabled(t *testing.T) {
|
||||
now := common.GetTimestamp()
|
||||
future := now + 100000
|
||||
|
||||
onPlan := SubscriptionPlan{Title: "swarm-on", SwarmEnabled: true}
|
||||
require.NoError(t, DB.Create(&onPlan).Error)
|
||||
offPlan := SubscriptionPlan{Title: "swarm-off", SwarmEnabled: false}
|
||||
require.NoError(t, DB.Create(&offPlan).Error)
|
||||
|
||||
mkSub := func(uid, planId int, end int64) {
|
||||
require.NoError(t, DB.Create(&UserSubscription{UserId: uid, PlanId: planId, Status: "active", EndTime: end}).Error)
|
||||
}
|
||||
|
||||
// 1. no subscription -> false (opt-in default)
|
||||
require.False(t, GetUserSwarmEnabled(991001))
|
||||
|
||||
// 2. active plan with swarm on -> true
|
||||
mkSub(991002, onPlan.Id, future)
|
||||
require.True(t, GetUserSwarmEnabled(991002))
|
||||
|
||||
// 3. active plan with swarm off -> false
|
||||
mkSub(991003, offPlan.Id, future)
|
||||
require.False(t, GetUserSwarmEnabled(991003))
|
||||
|
||||
// 4. expired swarm-on plan -> false (not active)
|
||||
mkSub(991004, onPlan.Id, now-100)
|
||||
require.False(t, GetUserSwarmEnabled(991004))
|
||||
|
||||
// 5. multiple active plans, one on -> true
|
||||
mkSub(991005, offPlan.Id, future)
|
||||
mkSub(991005, onPlan.Id, future)
|
||||
require.True(t, GetUserSwarmEnabled(991005))
|
||||
|
||||
// guard: invalid user -> false
|
||||
require.False(t, GetUserSwarmEnabled(0))
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TelemetryEvent stores client error-telemetry (issue #24). It is deliberately
|
||||
// isolated from billing: ingest never writes a consume Log nor touches
|
||||
// user.Quota. Event payload carries NO user content — only hashes, enums,
|
||||
@@ -40,6 +47,121 @@ func InsertTelemetryEvents(events []TelemetryEvent) error {
|
||||
return LOG_DB.Create(&events).Error
|
||||
}
|
||||
|
||||
// TelemetryQueryFilter scopes admin telemetry queries. Empty fields are ignored.
|
||||
// Time bounds are server unix seconds (ReceivedAt). No content fields exist to
|
||||
// filter on by design — only enums/hashes/versions.
|
||||
type TelemetryQueryFilter struct {
|
||||
UserId int
|
||||
ClientId string
|
||||
Platform string
|
||||
AppVersion string
|
||||
ErrorCategory string
|
||||
ErrorCode string
|
||||
StartReceivedAt int64
|
||||
EndReceivedAt int64
|
||||
}
|
||||
|
||||
func (f TelemetryQueryFilter) apply(db *gorm.DB) *gorm.DB {
|
||||
if f.UserId > 0 {
|
||||
db = db.Where("user_id = ?", f.UserId)
|
||||
}
|
||||
if strings.TrimSpace(f.ClientId) != "" {
|
||||
db = db.Where("client_id = ?", strings.TrimSpace(f.ClientId))
|
||||
}
|
||||
if strings.TrimSpace(f.Platform) != "" {
|
||||
db = db.Where("platform = ?", strings.TrimSpace(f.Platform))
|
||||
}
|
||||
if strings.TrimSpace(f.AppVersion) != "" {
|
||||
db = db.Where("app_version = ?", strings.TrimSpace(f.AppVersion))
|
||||
}
|
||||
if strings.TrimSpace(f.ErrorCategory) != "" {
|
||||
db = db.Where("error_category = ?", strings.TrimSpace(f.ErrorCategory))
|
||||
}
|
||||
if strings.TrimSpace(f.ErrorCode) != "" {
|
||||
db = db.Where("error_code = ?", strings.TrimSpace(f.ErrorCode))
|
||||
}
|
||||
if f.StartReceivedAt > 0 {
|
||||
db = db.Where("received_at >= ?", f.StartReceivedAt)
|
||||
}
|
||||
if f.EndReceivedAt > 0 {
|
||||
db = db.Where("received_at <= ?", f.EndReceivedAt)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// ListTelemetryEvents returns a filtered, paged slice of telemetry rows (newest
|
||||
// first) plus the total matching count. Admin-only read path (#24 follow-up).
|
||||
func ListTelemetryEvents(filter TelemetryQueryFilter, startIdx, pageSize int) ([]TelemetryEvent, int64, error) {
|
||||
if LOG_DB == nil {
|
||||
return nil, 0, nil
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
if startIdx < 0 {
|
||||
startIdx = 0
|
||||
}
|
||||
var total int64
|
||||
if err := filter.apply(LOG_DB.Model(&TelemetryEvent{})).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var events []TelemetryEvent
|
||||
if total == 0 {
|
||||
return events, 0, nil
|
||||
}
|
||||
err := filter.apply(LOG_DB.Model(&TelemetryEvent{})).
|
||||
Order("received_at desc").
|
||||
Limit(pageSize).Offset(startIdx).
|
||||
Find(&events).Error
|
||||
return events, total, err
|
||||
}
|
||||
|
||||
// telemetryAggDimensions whitelists the columns admins may group by, mapping the
|
||||
// public dimension name to a real column. The whitelist is the SQL-injection
|
||||
// guard — the dimension is interpolated into GROUP BY/SELECT, so it MUST come
|
||||
// from this map, never from raw client input.
|
||||
var telemetryAggDimensions = map[string]string{
|
||||
"error_category": "error_category",
|
||||
"error_code": "error_code",
|
||||
"platform": "platform",
|
||||
"app_version": "app_version",
|
||||
"os_version": "os_version",
|
||||
"arch": "arch",
|
||||
"stack_hash": "stack_hash",
|
||||
}
|
||||
|
||||
// TelemetryAggBucket is one group in an aggregate: the dimension value, the row
|
||||
// count, and the number of distinct accounts that produced it.
|
||||
type TelemetryAggBucket struct {
|
||||
Key string `json:"key"`
|
||||
Count int64 `json:"count"`
|
||||
Users int64 `json:"users"`
|
||||
}
|
||||
|
||||
// AggregateTelemetryEvents groups matching telemetry by one whitelisted
|
||||
// dimension, returning counts (and distinct-user counts) per bucket, busiest
|
||||
// first. Cross-DB safe: only COUNT/COUNT(DISTINCT)/GROUP BY on a fixed column.
|
||||
func AggregateTelemetryEvents(filter TelemetryQueryFilter, dimension string, limit int) ([]TelemetryAggBucket, error) {
|
||||
col, ok := telemetryAggDimensions[strings.TrimSpace(dimension)]
|
||||
if !ok {
|
||||
return nil, errors.New("unsupported telemetry aggregate dimension")
|
||||
}
|
||||
if LOG_DB == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
var buckets []TelemetryAggBucket
|
||||
err := filter.apply(LOG_DB.Model(&TelemetryEvent{})).
|
||||
Select(col+" as key, COUNT(*) as count, COUNT(DISTINCT user_id) as users").
|
||||
Group(col).
|
||||
Order("count desc").
|
||||
Limit(limit).
|
||||
Scan(&buckets).Error
|
||||
return buckets, err
|
||||
}
|
||||
|
||||
// DeleteTelemetryEventsBefore removes telemetry rows received before cutoffUnix
|
||||
// (server unix seconds), enforcing the retention window (#32). Returns the
|
||||
// number of rows deleted. Account-linkable device telemetry must not be kept
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 管理员遥测查询:列表过滤 + 分页(最新在前)、聚合(按维度计数 + 去重用户数)、维度白名单。
|
||||
// TelemetryEvent 由包级 TestMain 迁移;LOG_DB == DB。
|
||||
func seedTelemetry(t *testing.T) {
|
||||
t.Helper()
|
||||
require.NoError(t, LOG_DB.Where("1 = 1").Delete(&TelemetryEvent{}).Error)
|
||||
rows := []TelemetryEvent{
|
||||
{ReceivedAt: 1000, UserId: 1, ClientId: "dev-a", Platform: "darwin", AppVersion: "1.0.0", ErrorCategory: "network", ErrorCode: "ETIMEDOUT"},
|
||||
{ReceivedAt: 2000, UserId: 1, ClientId: "dev-a", Platform: "darwin", AppVersion: "1.0.0", ErrorCategory: "network", ErrorCode: "ECONNRESET"},
|
||||
{ReceivedAt: 3000, UserId: 2, ClientId: "dev-b", Platform: "windows", AppVersion: "1.1.0", ErrorCategory: "network", ErrorCode: "ETIMEDOUT"},
|
||||
{ReceivedAt: 4000, UserId: 3, ClientId: "dev-c", Platform: "windows", AppVersion: "1.1.0", ErrorCategory: "crash", ErrorCode: "SIGSEGV"},
|
||||
}
|
||||
for i := range rows {
|
||||
rows[i].SchemaVersion = 1
|
||||
require.NoError(t, LOG_DB.Create(&rows[i]).Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTelemetryEvents_FilterAndPaging(t *testing.T) {
|
||||
seedTelemetry(t)
|
||||
|
||||
// no filter -> all 4, newest first.
|
||||
events, total, err := ListTelemetryEvents(TelemetryQueryFilter{}, 0, 10)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 4, total)
|
||||
require.Len(t, events, 4)
|
||||
require.EqualValues(t, 4000, events[0].ReceivedAt, "newest first")
|
||||
require.EqualValues(t, 1000, events[3].ReceivedAt)
|
||||
|
||||
// platform filter.
|
||||
events, total, err = ListTelemetryEvents(TelemetryQueryFilter{Platform: "windows"}, 0, 10)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 2, total)
|
||||
require.Len(t, events, 2)
|
||||
|
||||
// user + category filter.
|
||||
_, total, err = ListTelemetryEvents(TelemetryQueryFilter{UserId: 1, ErrorCategory: "network"}, 0, 10)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 2, total)
|
||||
|
||||
// time range (received_at in [2000,3000]).
|
||||
_, total, err = ListTelemetryEvents(TelemetryQueryFilter{StartReceivedAt: 2000, EndReceivedAt: 3000}, 0, 10)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 2, total)
|
||||
|
||||
// pagination: page size 2 -> total stays 4, page returns 2.
|
||||
events, total, err = ListTelemetryEvents(TelemetryQueryFilter{}, 0, 2)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 4, total)
|
||||
require.Len(t, events, 2)
|
||||
events2, _, err := ListTelemetryEvents(TelemetryQueryFilter{}, 2, 2)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, events2, 2)
|
||||
require.NotEqual(t, events[0].Id, events2[0].Id, "pages are disjoint")
|
||||
}
|
||||
|
||||
func TestAggregateTelemetryEvents_CountsAndDistinctUsers(t *testing.T) {
|
||||
seedTelemetry(t)
|
||||
|
||||
// by error_category: network=3 (users 1,2 -> 2 distinct), crash=1 (user 3).
|
||||
buckets, err := AggregateTelemetryEvents(TelemetryQueryFilter{}, "error_category", 0)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, buckets, 2)
|
||||
require.Equal(t, "network", buckets[0].Key, "busiest first")
|
||||
require.EqualValues(t, 3, buckets[0].Count)
|
||||
require.EqualValues(t, 2, buckets[0].Users, "distinct accounts")
|
||||
require.Equal(t, "crash", buckets[1].Key)
|
||||
require.EqualValues(t, 1, buckets[1].Count)
|
||||
require.EqualValues(t, 1, buckets[1].Users)
|
||||
|
||||
// by platform with a filter applied (only network rows): darwin=2, windows=1.
|
||||
buckets, err = AggregateTelemetryEvents(TelemetryQueryFilter{ErrorCategory: "network"}, "platform", 0)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, buckets, 2)
|
||||
require.Equal(t, "darwin", buckets[0].Key)
|
||||
require.EqualValues(t, 2, buckets[0].Count)
|
||||
|
||||
// unsupported dimension is rejected (SQL-injection guard).
|
||||
_, err = AggregateTelemetryEvents(TelemetryQueryFilter{}, "user_id; drop table", 0)
|
||||
require.Error(t, err)
|
||||
_, err = AggregateTelemetryEvents(TelemetryQueryFilter{}, "", 0)
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -203,6 +203,15 @@ func SetApiRouter(router *gin.Engine) {
|
||||
subscriptionAdminRoute.DELETE("/user_subscriptions/:id", controller.AdminDeleteUserSubscription)
|
||||
}
|
||||
|
||||
// Client telemetry admin read-only views (#24 follow-up): list + aggregate.
|
||||
// Admin-gated — device_id is account-linkable, so only admins may read.
|
||||
telemetryAdminRoute := apiRouter.Group("/telemetry")
|
||||
telemetryAdminRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
telemetryAdminRoute.GET("/events", controller.AdminListTelemetryEvents)
|
||||
telemetryAdminRoute.GET("/aggregate", controller.AdminAggregateTelemetryEvents)
|
||||
}
|
||||
|
||||
// Subscription payment callbacks (no auth)
|
||||
apiRouter.POST("/subscription/epay/notify", controller.SubscriptionEpayNotify)
|
||||
apiRouter.GET("/subscription/epay/notify", controller.SubscriptionEpayNotify)
|
||||
@@ -560,6 +569,10 @@ func SetApiRouter(router *gin.Engine) {
|
||||
heicodeAgentRoute.GET("/swarms/:id/events/stream", controller.HeicodeStreamSwarmEvents)
|
||||
heicodeAgentRoute.GET("/swarms/:id/artifacts", controller.HeicodeListSwarmArtifacts)
|
||||
heicodeAgentRoute.POST("/swarms/:id/stop", controller.HeicodeStopSwarm)
|
||||
// I/O 闭环(#46/#28):追加输入(写,代理到 Swarm POST …/input)+ 结果交付(读,
|
||||
// 代理到 Swarm GET …/result)。契约见 agent_swarm PR#41 / CLIENT_GUIDE §9。
|
||||
heicodeAgentRoute.POST("/swarms/:id/input", controller.HeicodeAppendSwarmInput)
|
||||
heicodeAgentRoute.GET("/swarms/:id/result", controller.HeicodeGetSwarmResult)
|
||||
}
|
||||
|
||||
// Client↔agent access control (HM-provided, AM-optional). Called by the
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type TFunction } from 'i18next'
|
||||
import {
|
||||
Activity,
|
||||
Box,
|
||||
Boxes,
|
||||
Building2,
|
||||
@@ -84,6 +85,11 @@ export function getSystemSettingsNavGroups(t: TFunction): NavGroup[] {
|
||||
url: '/usage-logs/common',
|
||||
icon: ClipboardList,
|
||||
},
|
||||
{
|
||||
title: t('Client Telemetry'),
|
||||
url: '/telemetry',
|
||||
icon: Activity,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ const workspaceRegistry: WorkspaceConfig[] = [
|
||||
id: WORKSPACE_IDS.SYSTEM_SETTINGS,
|
||||
name: 'System Settings',
|
||||
pathPattern:
|
||||
/^\/(system-settings|channels|redemption-codes|users|templates|agents|subscriptions|models|usage-logs)(\/|$)/,
|
||||
/^\/(system-settings|channels|redemption-codes|users|templates|agents|subscriptions|models|usage-logs|telemetry)(\/|$)/,
|
||||
getNavGroups: getSystemSettingsNavGroups,
|
||||
},
|
||||
// Default workspace (must be last)
|
||||
|
||||
+18
@@ -350,6 +350,24 @@ export function SubscriptionsMutateDrawer({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='swarm_enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center gap-2 pt-8'>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className='!mt-0'>
|
||||
{t('Swarm Access')}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export function getPlanFormSchema(t: TFunction) {
|
||||
sort_order: z.coerce.number(),
|
||||
max_purchase_per_user: z.coerce.number().min(0),
|
||||
max_agents: z.coerce.number().min(0),
|
||||
swarm_enabled: z.boolean(),
|
||||
total_amount: z.coerce.number().min(0),
|
||||
upgrade_group: z.string().optional(),
|
||||
stripe_price_id: z.string().optional(),
|
||||
@@ -44,6 +45,7 @@ export const PLAN_FORM_DEFAULTS: PlanFormValues = {
|
||||
sort_order: 0,
|
||||
max_purchase_per_user: 0,
|
||||
max_agents: 0,
|
||||
swarm_enabled: false,
|
||||
total_amount: 0,
|
||||
upgrade_group: '',
|
||||
stripe_price_id: '',
|
||||
@@ -64,6 +66,7 @@ export function planToFormValues(plan: SubscriptionPlan): PlanFormValues {
|
||||
sort_order: Number(plan.sort_order || 0),
|
||||
max_purchase_per_user: Number(plan.max_purchase_per_user || 0),
|
||||
max_agents: Number(plan.max_agents || 0),
|
||||
swarm_enabled: plan.swarm_enabled === true,
|
||||
total_amount: Number(plan.total_amount || 0),
|
||||
upgrade_group: plan.upgrade_group || '',
|
||||
stripe_price_id: plan.stripe_price_id || '',
|
||||
|
||||
@@ -19,6 +19,7 @@ export const subscriptionPlanSchema = z.object({
|
||||
sort_order: z.number(),
|
||||
max_purchase_per_user: z.number(),
|
||||
max_agents: z.number(),
|
||||
swarm_enabled: z.boolean().optional().default(false),
|
||||
total_amount: z.number(),
|
||||
upgrade_group: z.string().optional(),
|
||||
stripe_price_id: z.string().optional(),
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type {
|
||||
ApiResponse,
|
||||
TelemetryAggregateData,
|
||||
TelemetryFilters,
|
||||
TelemetryListData,
|
||||
} from './types'
|
||||
|
||||
function buildParams(params: Record<string, unknown>): string {
|
||||
const sp = new URLSearchParams()
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v === undefined || v === null) continue
|
||||
const s = String(v).trim()
|
||||
if (s !== '') sp.set(k, s)
|
||||
}
|
||||
return sp.toString()
|
||||
}
|
||||
|
||||
export async function getTelemetryEvents(
|
||||
page: number,
|
||||
pageSize: number,
|
||||
filters: TelemetryFilters
|
||||
): Promise<ApiResponse<TelemetryListData>> {
|
||||
const query = buildParams({ p: page, page_size: pageSize, ...filters })
|
||||
const res = await api.get(`/api/telemetry/events?${query}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getTelemetryAggregate(
|
||||
dimension: string,
|
||||
filters: TelemetryFilters,
|
||||
limit = 100
|
||||
): Promise<ApiResponse<TelemetryAggregateData>> {
|
||||
const query = buildParams({ dimension, limit, ...filters })
|
||||
const res = await api.get(`/api/telemetry/aggregate?${query}`)
|
||||
return res.data
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SectionPageLayout } from '@/components/layout'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { getTelemetryAggregate, getTelemetryEvents } from './api'
|
||||
import {
|
||||
TELEMETRY_AGG_DIMENSIONS,
|
||||
type TelemetryFilters,
|
||||
} from './types'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const EMPTY_FILTERS: TelemetryFilters = {
|
||||
user_id: '',
|
||||
client_id: '',
|
||||
platform: '',
|
||||
app_version: '',
|
||||
error_category: '',
|
||||
error_code: '',
|
||||
}
|
||||
|
||||
function fmtTime(unix: number): string {
|
||||
if (!unix) return '-'
|
||||
return new Date(unix * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
export function Telemetry() {
|
||||
const { t } = useTranslation()
|
||||
// Draft filters bound to inputs; `applied` is what queries actually use.
|
||||
const [draft, setDraft] = useState<TelemetryFilters>(EMPTY_FILTERS)
|
||||
const [applied, setApplied] = useState<TelemetryFilters>(EMPTY_FILTERS)
|
||||
const [dimension, setDimension] = useState<string>('error_category')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const { data: listResp, isLoading: listLoading } = useQuery({
|
||||
queryKey: ['admin-telemetry-events', page, applied],
|
||||
queryFn: async () => (await getTelemetryEvents(page, PAGE_SIZE, applied)).data,
|
||||
placeholderData: (prev) => prev,
|
||||
})
|
||||
|
||||
const { data: aggResp, isLoading: aggLoading } = useQuery({
|
||||
queryKey: ['admin-telemetry-aggregate', dimension, applied],
|
||||
queryFn: async () => (await getTelemetryAggregate(dimension, applied)).data,
|
||||
placeholderData: (prev) => prev,
|
||||
})
|
||||
|
||||
const events = useMemo(() => listResp?.items ?? [], [listResp])
|
||||
const total = listResp?.total ?? 0
|
||||
const buckets = useMemo(() => aggResp?.buckets ?? [], [aggResp])
|
||||
const maxCount = buckets.reduce((m, b) => Math.max(m, b.count), 0) || 1
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
const apply = () => {
|
||||
setPage(1)
|
||||
setApplied(draft)
|
||||
}
|
||||
const reset = () => {
|
||||
setDraft(EMPTY_FILTERS)
|
||||
setApplied(EMPTY_FILTERS)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const setField = (k: keyof TelemetryFilters) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setDraft((d) => ({ ...d, [k]: e.target.value }))
|
||||
|
||||
return (
|
||||
<SectionPageLayout>
|
||||
<SectionPageLayout.Title>{t('Client Telemetry')}</SectionPageLayout.Title>
|
||||
<SectionPageLayout.Description>
|
||||
{t(
|
||||
'Diagnostic error telemetry from clients. No user content — only categories, codes, hashes and versions. device_id is account-linkable.'
|
||||
)}
|
||||
</SectionPageLayout.Description>
|
||||
<SectionPageLayout.Content>
|
||||
<div className='space-y-4'>
|
||||
{/* Filters */}
|
||||
<div className='grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6'>
|
||||
<Input placeholder={t('User ID')} value={draft.user_id} onChange={setField('user_id')} />
|
||||
<Input placeholder={t('Device ID')} value={draft.client_id} onChange={setField('client_id')} />
|
||||
<Input placeholder={t('Platform')} value={draft.platform} onChange={setField('platform')} />
|
||||
<Input placeholder={t('App version')} value={draft.app_version} onChange={setField('app_version')} />
|
||||
<Input placeholder={t('Error category')} value={draft.error_category} onChange={setField('error_category')} />
|
||||
<Input placeholder={t('Error code')} value={draft.error_code} onChange={setField('error_code')} />
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Button size='sm' onClick={apply}>{t('Apply')}</Button>
|
||||
<Button size='sm' variant='outline' onClick={reset}>{t('Reset')}</Button>
|
||||
</div>
|
||||
|
||||
{/* Aggregate */}
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between gap-2 space-y-0'>
|
||||
<CardTitle className='text-sm'>{t('Aggregate')}</CardTitle>
|
||||
<Select value={dimension} onValueChange={setDimension}>
|
||||
<SelectTrigger className='w-44'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TELEMETRY_AGG_DIMENSIONS.map((d) => (
|
||||
<SelectItem key={d} value={d}>{d}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{aggLoading && buckets.length === 0 ? (
|
||||
<p className='text-muted-foreground text-sm'>{t('Loading...')}</p>
|
||||
) : buckets.length === 0 ? (
|
||||
<p className='text-muted-foreground text-sm'>{t('No data')}</p>
|
||||
) : (
|
||||
<div className='space-y-1.5'>
|
||||
{buckets.map((b) => (
|
||||
<div key={b.key || '(empty)'} className='flex items-center gap-2 text-sm'>
|
||||
<span className='w-40 truncate font-mono text-xs' title={b.key}>
|
||||
{b.key || '(empty)'}
|
||||
</span>
|
||||
<div className='bg-muted h-4 flex-1 overflow-hidden rounded'>
|
||||
<div
|
||||
className='bg-primary h-full'
|
||||
style={{ width: `${(b.count / maxCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className='w-12 text-right tabular-nums'>{b.count}</span>
|
||||
<Badge variant='secondary' className='tabular-nums' title={t('Distinct users')}>
|
||||
{b.users}u
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Event list */}
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Time')}</TableHead>
|
||||
<TableHead>{t('User ID')}</TableHead>
|
||||
<TableHead>{t('Device ID')}</TableHead>
|
||||
<TableHead>{t('Platform')}</TableHead>
|
||||
<TableHead>{t('App version')}</TableHead>
|
||||
<TableHead>{t('Error category')}</TableHead>
|
||||
<TableHead>{t('Error code')}</TableHead>
|
||||
<TableHead>{t('Stack hash')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{listLoading && events.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className='text-muted-foreground h-20 text-center'>
|
||||
{t('Loading...')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : events.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className='text-muted-foreground h-20 text-center'>
|
||||
{t('No data')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
events.map((ev) => (
|
||||
<TableRow key={ev.id}>
|
||||
<TableCell className='whitespace-nowrap'>{fmtTime(ev.received_at)}</TableCell>
|
||||
<TableCell className='tabular-nums'>{ev.user_id}</TableCell>
|
||||
<TableCell className='max-w-32 truncate font-mono text-xs' title={ev.client_id}>{ev.client_id}</TableCell>
|
||||
<TableCell>{ev.platform}</TableCell>
|
||||
<TableCell>{ev.app_version}</TableCell>
|
||||
<TableCell>{ev.error_category}</TableCell>
|
||||
<TableCell className='max-w-40 truncate' title={ev.error_code}>{ev.error_code}</TableCell>
|
||||
<TableCell className='font-mono text-xs'>{ev.stack_hash}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('Total')}: {total}
|
||||
</span>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button size='sm' variant='outline' disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||
{t('Previous')}
|
||||
</Button>
|
||||
<span className='text-sm tabular-nums'>{page} / {totalPages}</span>
|
||||
<Button size='sm' variant='outline' disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
||||
{t('Next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionPageLayout.Content>
|
||||
</SectionPageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Client error-telemetry (#24) admin read-only views. No user content — only
|
||||
// enums/hashes/versions/counts. device_id (client_id) is account-linkable, so
|
||||
// these views are admin-gated.
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
export interface TelemetryEvent {
|
||||
id: number
|
||||
received_at: number
|
||||
user_id: number
|
||||
client_id: string
|
||||
schema_version: number
|
||||
app_version: string
|
||||
platform: string
|
||||
os_version: string
|
||||
arch: string
|
||||
locale: string
|
||||
error_category: string
|
||||
error_code: string
|
||||
error_message_hash: string
|
||||
stack_hash: string
|
||||
stack_top: string
|
||||
context: string
|
||||
timestamp: string
|
||||
session_seq: number
|
||||
}
|
||||
|
||||
export interface TelemetryListData {
|
||||
items: TelemetryEvent[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface TelemetryAggBucket {
|
||||
key: string
|
||||
count: number
|
||||
users: number
|
||||
}
|
||||
|
||||
export interface TelemetryAggregateData {
|
||||
dimension: string
|
||||
buckets: TelemetryAggBucket[]
|
||||
}
|
||||
|
||||
export interface TelemetryFilters {
|
||||
user_id?: string
|
||||
client_id?: string
|
||||
platform?: string
|
||||
app_version?: string
|
||||
error_category?: string
|
||||
error_code?: string
|
||||
start_timestamp?: string
|
||||
end_timestamp?: string
|
||||
}
|
||||
|
||||
export const TELEMETRY_AGG_DIMENSIONS = [
|
||||
'error_category',
|
||||
'error_code',
|
||||
'platform',
|
||||
'app_version',
|
||||
'os_version',
|
||||
'arch',
|
||||
'stack_hash',
|
||||
] as const
|
||||
|
||||
export type TelemetryAggDimension = (typeof TELEMETRY_AGG_DIMENSIONS)[number]
|
||||
@@ -113,6 +113,7 @@ export function useSidebarData(): SidebarData {
|
||||
'/subscriptions',
|
||||
'/models',
|
||||
'/usage-logs',
|
||||
'/telemetry',
|
||||
],
|
||||
icon: Settings,
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"0 uses the global default cap": "0 表示使用全局默认上限",
|
||||
"1 Day": "1 天",
|
||||
"Agent Deploy Limit": "Agent 部署上限",
|
||||
"Swarm Access": "蜂群使用权",
|
||||
"1 day ago": "1 天前",
|
||||
"1 Hour": "1 小时",
|
||||
"1 hour ago": "1 小时前",
|
||||
|
||||
+22
@@ -35,6 +35,7 @@ import { Route as PricingModelIdIndexRouteImport } from './routes/pricing/$model
|
||||
import { Route as AuthenticatedWalletIndexRouteImport } from './routes/_authenticated/wallet/index'
|
||||
import { Route as AuthenticatedUsersIndexRouteImport } from './routes/_authenticated/users/index'
|
||||
import { Route as AuthenticatedUsageLogsIndexRouteImport } from './routes/_authenticated/usage-logs/index'
|
||||
import { Route as AuthenticatedTelemetryIndexRouteImport } from './routes/_authenticated/telemetry/index'
|
||||
import { Route as AuthenticatedSystemSettingsIndexRouteImport } from './routes/_authenticated/system-settings/index'
|
||||
import { Route as AuthenticatedSubscriptionsIndexRouteImport } from './routes/_authenticated/subscriptions/index'
|
||||
import { Route as AuthenticatedResourcesIndexRouteImport } from './routes/_authenticated/resources/index'
|
||||
@@ -202,6 +203,12 @@ const AuthenticatedUsageLogsIndexRoute =
|
||||
path: '/usage-logs/',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedTelemetryIndexRoute =
|
||||
AuthenticatedTelemetryIndexRouteImport.update({
|
||||
id: '/telemetry/',
|
||||
path: '/telemetry/',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedSystemSettingsIndexRoute =
|
||||
AuthenticatedSystemSettingsIndexRouteImport.update({
|
||||
id: '/',
|
||||
@@ -452,6 +459,7 @@ export interface FileRoutesByFullPath {
|
||||
'/resources/': typeof AuthenticatedResourcesIndexRoute
|
||||
'/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute
|
||||
'/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute
|
||||
'/telemetry/': typeof AuthenticatedTelemetryIndexRoute
|
||||
'/usage-logs/': typeof AuthenticatedUsageLogsIndexRoute
|
||||
'/users/': typeof AuthenticatedUsersIndexRoute
|
||||
'/wallet/': typeof AuthenticatedWalletIndexRoute
|
||||
@@ -512,6 +520,7 @@ export interface FileRoutesByTo {
|
||||
'/resources': typeof AuthenticatedResourcesIndexRoute
|
||||
'/subscriptions': typeof AuthenticatedSubscriptionsIndexRoute
|
||||
'/system-settings': typeof AuthenticatedSystemSettingsIndexRoute
|
||||
'/telemetry': typeof AuthenticatedTelemetryIndexRoute
|
||||
'/usage-logs': typeof AuthenticatedUsageLogsIndexRoute
|
||||
'/users': typeof AuthenticatedUsersIndexRoute
|
||||
'/wallet': typeof AuthenticatedWalletIndexRoute
|
||||
@@ -576,6 +585,7 @@ export interface FileRoutesById {
|
||||
'/_authenticated/resources/': typeof AuthenticatedResourcesIndexRoute
|
||||
'/_authenticated/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute
|
||||
'/_authenticated/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute
|
||||
'/_authenticated/telemetry/': typeof AuthenticatedTelemetryIndexRoute
|
||||
'/_authenticated/usage-logs/': typeof AuthenticatedUsageLogsIndexRoute
|
||||
'/_authenticated/users/': typeof AuthenticatedUsersIndexRoute
|
||||
'/_authenticated/wallet/': typeof AuthenticatedWalletIndexRoute
|
||||
@@ -639,6 +649,7 @@ export interface FileRouteTypes {
|
||||
| '/resources/'
|
||||
| '/subscriptions/'
|
||||
| '/system-settings/'
|
||||
| '/telemetry/'
|
||||
| '/usage-logs/'
|
||||
| '/users/'
|
||||
| '/wallet/'
|
||||
@@ -699,6 +710,7 @@ export interface FileRouteTypes {
|
||||
| '/resources'
|
||||
| '/subscriptions'
|
||||
| '/system-settings'
|
||||
| '/telemetry'
|
||||
| '/usage-logs'
|
||||
| '/users'
|
||||
| '/wallet'
|
||||
@@ -762,6 +774,7 @@ export interface FileRouteTypes {
|
||||
| '/_authenticated/resources/'
|
||||
| '/_authenticated/subscriptions/'
|
||||
| '/_authenticated/system-settings/'
|
||||
| '/_authenticated/telemetry/'
|
||||
| '/_authenticated/usage-logs/'
|
||||
| '/_authenticated/users/'
|
||||
| '/_authenticated/wallet/'
|
||||
@@ -984,6 +997,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedUsageLogsIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
'/_authenticated/telemetry/': {
|
||||
id: '/_authenticated/telemetry/'
|
||||
path: '/telemetry'
|
||||
fullPath: '/telemetry/'
|
||||
preLoaderRoute: typeof AuthenticatedTelemetryIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
'/_authenticated/system-settings/': {
|
||||
id: '/_authenticated/system-settings/'
|
||||
path: '/'
|
||||
@@ -1335,6 +1355,7 @@ interface AuthenticatedRouteRouteChildren {
|
||||
AuthenticatedRedemptionCodesIndexRoute: typeof AuthenticatedRedemptionCodesIndexRoute
|
||||
AuthenticatedResourcesIndexRoute: typeof AuthenticatedResourcesIndexRoute
|
||||
AuthenticatedSubscriptionsIndexRoute: typeof AuthenticatedSubscriptionsIndexRoute
|
||||
AuthenticatedTelemetryIndexRoute: typeof AuthenticatedTelemetryIndexRoute
|
||||
AuthenticatedUsageLogsIndexRoute: typeof AuthenticatedUsageLogsIndexRoute
|
||||
AuthenticatedUsersIndexRoute: typeof AuthenticatedUsersIndexRoute
|
||||
AuthenticatedWalletIndexRoute: typeof AuthenticatedWalletIndexRoute
|
||||
@@ -1365,6 +1386,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
|
||||
AuthenticatedRedemptionCodesIndexRoute,
|
||||
AuthenticatedResourcesIndexRoute: AuthenticatedResourcesIndexRoute,
|
||||
AuthenticatedSubscriptionsIndexRoute: AuthenticatedSubscriptionsIndexRoute,
|
||||
AuthenticatedTelemetryIndexRoute: AuthenticatedTelemetryIndexRoute,
|
||||
AuthenticatedUsageLogsIndexRoute: AuthenticatedUsageLogsIndexRoute,
|
||||
AuthenticatedUsersIndexRoute: AuthenticatedUsersIndexRoute,
|
||||
AuthenticatedWalletIndexRoute: AuthenticatedWalletIndexRoute,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { Telemetry } from '@/features/telemetry'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/telemetry/')({
|
||||
beforeLoad: () => {
|
||||
const { auth } = useAuthStore.getState()
|
||||
if (!auth.user || auth.user.role < ROLE.ADMIN) {
|
||||
throw redirect({ to: '/403' })
|
||||
}
|
||||
},
|
||||
component: Telemetry,
|
||||
})
|
||||
Reference in New Issue
Block a user