Compare commits

...
10 Commits
Author SHA1 Message Date
chenchen 992c322858 Merge remote-tracking branch 'origin/feat/admin-telemetry-views' into deploy/test-pr69-73 2026-06-11 19:19:36 +08:00
chenchen b36dc3fe28 Merge remote-tracking branch 'origin/feat/swarm-io-proxy-46' into deploy/test-pr69-73 2026-06-11 19:19:35 +08:00
chenchen 66947cd543 Merge remote-tracking branch 'origin/feat/swarm-model-key-injection-60' into deploy/test-pr69-73 2026-06-11 19:19:34 +08:00
chenchen f091089365 Merge remote-tracking branch 'origin/feat/swarm-subscription-gate' into deploy/test-pr69-73 2026-06-11 19:19:32 +08:00
chenchenandClaude Opus 4.8 e7239c24b6 feat(telemetry): 管理员遥测后台只读视图(列表 + 聚合)
客户端错误遥测(#24)此前只有 ingest + 留存清理,无后台查看。新增管理员只读视图
(列表 + 聚合两者),admin-gated —— device_id 可关联账号,仅管理员可读。

后端:
- model.ListTelemetryEvents(filter, startIdx, pageSize):分页列表(received_at desc),
  按 user_id/client_id/platform/app_version/error_category/error_code + 时间范围过滤。
- model.AggregateTelemetryEvents(filter, dimension, limit):按白名单维度 group by 计数 +
  COUNT(DISTINCT user_id),busiest first。维度白名单是 SQL 注入防线(列名只能来自 map)。
  跨库安全(仅 COUNT/COUNT(DISTINCT)/GROUP BY 固定列,无 DB 特有日期函数)。
- 查询走 LOG_DB(与 ingest 同库);遥测仍隔离于计费、载荷无用户内容。
- controller AdminListTelemetryEvents / AdminAggregateTelemetryEvents;路由
  GET /api/telemetry/{events,aggregate},middleware.AdminAuth() 守门。

前端(web/default):
- features/telemetry:api + types + 页面(过滤条 + 聚合面板含维度切换/条形/去重用户数 +
  分页列表表)。
- 路由 /_authenticated/telemetry(ROLE.ADMIN 守门),系统设置工作区侧栏加「Client Telemetry」入口。
- routeTree.gen.ts 由 router-generator 重新生成(仅 +22 行 telemetry 路由,无重排)。

测试:model/telemetry_query_test.go 覆盖列表过滤/分页/最新在前、聚合计数 + 去重用户、
维度白名单拒绝(注入防线)。go build ./... + model/controller/router 全绿;前端 tsc 仅
resources-page 一处既有报错(与本改动无关)。

影响面:Manager ✅(新增 admin 只读端点 + 前端页面)/ 计费 ❌(遥测隔离)/ 密钥 ❌ /
审计 ❌ / Client ❌ / agent_swarm ❌ / 发布链路 ❌。隐私:沿用 #24 口径,载荷无用户内容,
device_id 账号可关联故管理员才可读。

Refs #24。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 19:13:38 +08:00
chenchenandClaude Opus 4.8 2c60af0216 feat(swarm): #46/#28 暴露 input/result 代理端点(I/O 闭环)
agent_swarm PR#41 已合并 main(契约冻结),HM 补齐客户端+swarm 双方点名需要的
两条透传端点(均设备签名,挂 /api/heicode/swarms/:id 下):

- POST /swarms/:id/input  → 代理 Swarm POST …/{deployment_id}/input(追加用户输入,
  注入 source=user_append;终态 run 自动 reopen,stopped 拒绝)。写路,需
  SWARM_RUNTIME_ENABLED+base+token,缺一即拒不伪造受理。**指令文本仅转发,绝不落
  HM 日志/审计/事件**(#40 原文不进事件流 + #46 勿落日志);审计只记发生过一次 append。
- GET  /swarms/:id/result → 代理 Swarm GET …/{deployment_id}/result,返回
  {summary, deliverable, artifacts[], termination_reason, status};产物按 uri 取非内联。
  读路,需 base+token(与派发开关解耦);透出前 stripSensitiveKeys 递归剔除 secret_ref 等兜底。

新增:callSwarmRuntimeJSON(通用 JSON 调用 + envelope 归一,错误信息不含请求体)、
agentRuntimeSwarm{Input,Result}Path(默认 create-path 基路径,SWARM_RUNTIME_{INPUT,RESULT}_PATH 可覆盖)。

测试:swarm_io_proxy_test.go 覆盖路径构造(默认/空回退/env 覆盖)、input 转发(方法/路径/鉴权/body)、
result 解析 + deliverable/artifacts 脱敏、运行时非 2xx 报错。go build ./... + controller/router 全绿。

影响面:Manager ✅(透传端点)/ agent_swarm ✅(消费 PR#41 契约)/ Client ✅(驾驶舱 I/O 闭环)/
Agent ❌ / CodeGW ❌ / 计费 ❌ / 密钥 ❌(不经手 secret)/ 审计 ✅(input_appended,不含原文)/ 发布链路 ❌

Refs #46 #28。Depends-on-contract: agent_swarm PR#41 (已合并 main)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 18:41:39 +08:00
chenchenandClaude Opus 4.8 c494e9576e feat(swarm): #60 模型 key 注入(方案A)+ pool_terminated 吊销握手
对接 agent_swarm PR#43 定死的参数,实现 HM 侧的 per-user 模型 key 注入:

- A.1 粒度/命名:为认证用户 mint 一把 per-user sk-(getOrMintSwarmModelToken),
  跨该用户所有 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 不经 billing_context 下发。
- A.5 吊销:事件驱动。注册 swarm.pool_terminated(swarm_lifecycle 类),
  回调 handleSwarmPoolTerminated 删 KV 密文 + 软删 token;user_id 优先取
  payload,缺失回退 deployment 上下文。

createAgentDeploymentFromPlan 在 swarm 模式且 billing_context 未自带 secret_ref
时调 provisionSwarmModelKey,把 azkv:// 引用写进 billing_context.secret_ref 下发。
Key Vault 未配置/不可达时降级:记日志、secret_ref 留空,不阻断 create(联调前可用)。
明文 sk- 仅经 secret_ref 服务端解析,绝不入代码/日志/事件/argv。

测试:swarm_model_key_test.go 覆盖 per-user 复用、吊销重 mint、pool_terminated
回调(payload/上下文两路 user_id)、非目标事件不误吊销、事件已注册。

影响面:agent_swarm(契约消费侧)、密钥(per-user sk- mint+吊销)、计费
(走 User.Quota)、审计(回调入库+审计事件)。不影响 Client/CodeGW/发布链路。
未决依赖:A.3(Swarm 读 heicode-kv 的 RBAC,运维授权 pending)是端到端联调前置,
不阻塞本 PR。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 18:30:32 +08:00
chenchenandClaude Opus 4.8 c55e60b3b3 test(swarm): adapter 用例走 admin 旁路通过蜂群订阅 gate
PR#70 给 AgentCreateUserSwarm 加了订阅 gate 后,TestAgentUserSwarmsAdapter
CreatesScopedDeployment(普通用户、无订阅)被 POLICY_REJECTED。该用例聚焦
adapter 的用户作用域而非 gate 本身(gate 由 TestGetUserSwarmEnabled 覆盖),
故走 admin 旁路。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 18:28:37 +08:00
chenchenandClaude Opus 4.8 a0616eaebf feat(swarm): 按订阅套餐控制蜂群使用权(管理员可配)
需求:管理员在 HM 控制台按订阅套餐控制谁能用蜂群。照搬现有 MaxAgents(#8)模式
(per-plan、管理员设、不硬编码 tier)。

后端:
- model SubscriptionPlan 加 SwarmEnabled bool(default false;AutoMigrate 加列,跨三库)。
- model GetUserSwarmEnabled(userId):用户任一活跃套餐 SwarmEnabled=true 即放行(镜像
  GetUserMaxAgents);无套餐/DB 不可用 => false(opt-in)。
- controller AgentCreateUserSwarm gate:普通用户须有开通套餐,管理员(role>=admin)绕过(测试/运维)。
- controller 套餐 admin upsert 字段映射加 swarm_enabled(create 整结构已自动带)。

前端(web/default 套餐编辑):
- plan-form schema/defaults/映射 + types 加 swarm_enabled。
- 套餐编辑抽屉加「蜂群使用权」Switch(镜像 enabled)。
- zh.json 加 "Swarm Access" → 蜂群使用权(en 用 key 兜底)。

默认关闭(opt-in)、管理员逐套餐勾选;gate 在 swarm 创建入口,与 swarm 运行时是否启用无关。
测试 TestGetUserSwarmEnabled(无套餐/开通/未开通/过期/多套餐/非法 user)。build+vet+model 测试全绿。

影响面:HM 订阅模型(加字段)+ swarm 创建鉴权 + admin UI;不涉计费扣费/密钥/swarm 契约。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 17:55:55 +08:00
chenchenandClaude Opus 4.8 a5419f9a15 feat(swarm): GET /swarms/:id 详情补 resource_grants/budget/audit_logs (#66 里程碑07/08)
HM 自有数据,不依赖 swarm 运行时,仅 detail 带、list 不带(免膨胀):
- resource_grants:plan_json 提取,脱敏(grant_id/resource_id/resource_type/binding_scope/
  target_role/permission_scope/status + has_secret);绝不下发 secret_ref 值。
- budget:max_tokens/max_cost_usd/max_duration_sec(plan.Budget 上限)。
- audit_logs:HM 控制面 deployment 级审计(脱敏 + 截断 100);run 级 trace 待 swarm /audit 代理(PR#41)。

usage/used_*/cost_by_phase 待 #60 + swarm metrics,本批不臆造。
测试:脱敏(secret_ref 值不外泄)/has_secret/budget 上限/空坏JSON。build+vet+controller 测试全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 17:24:09 +08:00
28 changed files with 1476 additions and 3 deletions
+64
View File
@@ -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,
})
}
+26
View File
@@ -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,
+20
View File
@@ -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.
+245 -1
View File
@@ -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"])
}
+2 -1
View File
@@ -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,
+114
View File
@@ -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)
}
+112
View File
@@ -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())
}
}
}
+139
View File
@@ -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"])
}
+32
View File
@@ -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))
}
+122
View File
@@ -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
+90
View File
@@ -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)
}
+13
View File
@@ -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)
@@ -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(),
+37
View File
@@ -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
}
+218
View File
@@ -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>
)
}
+71
View File
@@ -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]
+1
View File
@@ -113,6 +113,7 @@ export function useSidebarData(): SidebarData {
'/subscriptions',
'/models',
'/usage-logs',
'/telemetry',
],
icon: Settings,
},
+1
View File
@@ -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
View File
@@ -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,
})