feat(admin): 补全平台任务观测 + 空间管理 + 基建加 MinIO/实时探针
对照已实现系统功能补 admin 缺失模块(feat/site): - 全平台任务/运行观测:GET /admin/tasks(跨租户查 sundynix_task,join 租户名/提交人邮箱/评测, 按状态/租户筛 + 状态计数;含 HITL 待审批=筛 waiting)+ TasksPage(状态卡+筛+表)。 - 空间(Space)管理:GET /admin/spaces(跨租户列 Space + 成员数子查询 + kind/归档态)+ SpacesPage。 - 基建观测:infra 加 MinIO(126 对象存储);postgres/redis/minio 从启动标志升级为实时 ping (blob.Ping/Postgres.Ping/Redis.Ping),能反映中途掉线。StatusPage 加 minio 标签、6 个依赖。 - 模型健康/熔断:确认 DashboardPage 已有「运行时链路态」渲染 m.health,无需重做。 验证:admin tsc + vitest 41 过、gateway build/vet/test 过;两新页浏览器实测渲染+优雅错误处理; 两新端点起临时 gateway 打真 PG 实测——tasks(counts+3路join)、spaces(成员数子查询)均返真数据。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -305,6 +306,31 @@ func (h *Handler) AdminReconcile(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"diffs": rows, "ok": len(rows) == 0})
|
||||
}
|
||||
|
||||
// AdminTasks: GET /api/v1/admin/tasks?status=&tenant=&limit= —— 全平台任务/运行观测。
|
||||
// 跨租户看所有任务(状态/租户/提交人/评测),含 HITL 待审批(status=waiting)。返回列表 + 状态计数。
|
||||
func (h *Handler) AdminTasks(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
limit := 50
|
||||
if v := c.Query("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
rows := h.db.AllTasks(ctx, c.Query("status"), c.Query("tenant"), limit)
|
||||
c.JSON(http.StatusOK, gin.H{"tasks": rows, "counts": h.db.TaskStatusCounts(ctx)})
|
||||
}
|
||||
|
||||
// AdminSpaces: GET /api/v1/admin/spaces?limit= —— 全平台空间观测(跨租户)。
|
||||
func (h *Handler) AdminSpaces(c *gin.Context) {
|
||||
limit := 200
|
||||
if v := c.Query("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"spaces": h.db.AllSpaces(c.Request.Context(), limit)})
|
||||
}
|
||||
|
||||
// AdminRefundOrder: POST /api/v1/admin/orders/:id/refund —— 人工退款(PAYMENT_DESIGN §5)。
|
||||
// 只退 paid 单:订单置 refunded + 记 adjust 负分录 + 回退余额(幂等,可能扣成负余额)。
|
||||
// 真渠道(微信)退款仅冲销本地积分与订单态,钱的原路退回由 admin 在微信商户后台线下操作
|
||||
|
||||
@@ -65,9 +65,11 @@ func (h *Handler) AdminStatus(c *gin.Context) {
|
||||
dispUp bool // dispatcher 在线
|
||||
dispDetail string // dispatcher 详情(模型/运行时长)
|
||||
dispLatency int // dispatcher 探针耗时
|
||||
|
||||
pgUp, redisUp, minioUp bool // 基建活性探针(实时 ping,非仅启动标志)
|
||||
)
|
||||
|
||||
wg.Add(4)
|
||||
wg.Add(5)
|
||||
|
||||
// 1) mcp-go health → milvus / neo4j 基建灯
|
||||
go func() {
|
||||
@@ -115,16 +117,27 @@ func (h *Handler) AdminStatus(c *gin.Context) {
|
||||
}
|
||||
}()
|
||||
|
||||
// 5) 基建活性探针:PG / Redis / MinIO 实时 ping(非仅启动标志,能反映中途掉线)。
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ctx, cancel := context.WithTimeout(parent, probeTimeout)
|
||||
defer cancel()
|
||||
pgUp = h.db.Ping(ctx)
|
||||
redisUp = h.cache.Ping(ctx)
|
||||
minioUp = h.blob != nil && h.blob.Ping(ctx)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
c.JSON(http.StatusOK, systemStatus{
|
||||
CheckedAt: time.Now().Format(time.RFC3339),
|
||||
Infra: []statusItem{
|
||||
{Name: "postgres", Up: h.db.Enabled()},
|
||||
{Name: "redis", Up: h.cache.Enabled()},
|
||||
{Name: "postgres", Up: pgUp},
|
||||
{Name: "redis", Up: redisUp},
|
||||
{Name: "nats", Up: true}, // 网关连不上 NATS 即 fatal,能应答即在线
|
||||
{Name: "milvus", Up: milvus},
|
||||
{Name: "neo4j", Up: neo4j},
|
||||
{Name: "minio", Up: minioUp}, // 对象存储(报告/KB 正文/blob,126)
|
||||
},
|
||||
Services: []statusItem{
|
||||
{Name: "gateway", Up: true, Detail: "在线"},
|
||||
|
||||
@@ -158,6 +158,8 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
||||
admin.DELETE("/tenants/:id/members/:uid", h.AdminRemoveMember) // 移除成员
|
||||
admin.GET("/status", h.AdminStatus) // 服务状态:基建/服务探活 + MCP 工具注册
|
||||
admin.GET("/overview", h.AdminOverview) // 系统级聚合:全平台用户/任务/评测/模型态/提示词态/健康
|
||||
admin.GET("/tasks", h.AdminTasks) // 全平台任务/运行观测(状态/租户筛 + HITL 待审批)
|
||||
admin.GET("/spaces", h.AdminSpaces) // 全平台空间观测(跨租户 Space + 成员数)
|
||||
admin.GET("/usage", h.AdminUsage) // 用量/积分/成本:全平台按天趋势 + 租户排行 / 单租户余额
|
||||
admin.GET("/evals", h.AdminEvals) // 自动评测观测:质量趋势 + 计数 + 错题本(真数据)
|
||||
admin.GET("/datasources", h.AdminDatasources) // 数据源清单:全平台知识库 + 文档数(真数据)
|
||||
|
||||
@@ -126,6 +126,18 @@ func migrateDocLinkToID(db *gorm.DB) {
|
||||
// Enabled 报告是否处于真实持久化模式。
|
||||
func (p *Postgres) Enabled() bool { return p.db != nil }
|
||||
|
||||
// Ping 活性探测:底层连接池发一次 PingContext,验证 PG 此刻真的可达(非仅启动时连过)。
|
||||
func (p *Postgres) Ping(ctx context.Context) bool {
|
||||
if p.db == nil {
|
||||
return false
|
||||
}
|
||||
sqlDB, err := p.db.DB()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return sqlDB.PingContext(ctx) == nil
|
||||
}
|
||||
|
||||
// SaveTask 持久化一次任务提交(best-effort:降级模式下静默跳过)。
|
||||
func (p *Postgres) SaveTask(ctx context.Context, owner, id, graph string) error {
|
||||
if p.db == nil {
|
||||
|
||||
@@ -31,6 +31,14 @@ func OpenRedis(addr string) *Redis {
|
||||
// Enabled 报告是否处于真实限流模式。
|
||||
func (r *Redis) Enabled() bool { return r.rdb != nil }
|
||||
|
||||
// Ping 活性探测:发一次 PING,验证 Redis 此刻真的可达(非仅启动时连过)。
|
||||
func (r *Redis) Ping(ctx context.Context) bool {
|
||||
if r.rdb == nil {
|
||||
return false
|
||||
}
|
||||
return r.rdb.Ping(ctx).Err() == nil
|
||||
}
|
||||
|
||||
// Allow 滑动窗口计数限流:在 window 内对 key 累加,超过 limit 即拒绝。
|
||||
// 降级模式(rdb==nil)始终放行。
|
||||
func (r *Redis) Allow(ctx context.Context, key string, limit int64, window time.Duration) (bool, error) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 全平台空间观测(管理端「空间管理」页)。系统级口径:跨租户看所有 Space,
|
||||
// 走 WithoutTenant 旁路租户插件。
|
||||
|
||||
// AdminSpaceRow 是管理端空间一行:空间 + 租户名 + 建者邮箱 + 成员数。
|
||||
type AdminSpaceRow struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
TenantName string `json:"tenant_name"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"` // personal / project / tenant
|
||||
Creator string `json:"creator"`
|
||||
CreatorEmail string `json:"creator_email"`
|
||||
Archived bool `json:"archived"`
|
||||
Members int64 `json:"members"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// AllSpaces 全平台空间流(管理端观测;跨租户)。倒序,翻页。
|
||||
func (p *Postgres) AllSpaces(ctx context.Context, limit int) []AdminSpaceRow {
|
||||
if p.db == nil {
|
||||
return nil
|
||||
}
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 200
|
||||
}
|
||||
ctx = WithoutTenant(ctx) // 系统级:看所有租户的空间
|
||||
var out []AdminSpaceRow
|
||||
p.db.WithContext(ctx).Table("sundynix_space as s").
|
||||
Select("s.id, s.tenant_id, s.name, s.kind, s.creator, s.archived, s.created_at, " +
|
||||
"coalesce(tn.name,'') as tenant_name, coalesce(u.email,'') as creator_email, " +
|
||||
"(select count(*) from sundynix_space_member m where m.space_id = s.id and m.deleted_at is null) as members").
|
||||
Joins("left join sundynix_tenant tn on tn.id = s.tenant_id").
|
||||
Joins("left join sundynix_user u on u.id = s.creator").
|
||||
Where("s.deleted_at is null").
|
||||
Order("s.created_at desc").Limit(limit).Scan(&out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 全平台任务观测(管理端「运行观测」页)。系统级口径:跨租户看所有任务,
|
||||
// 故走 WithoutTenant 旁路租户插件 + raw Table 查询(同 RecentRuns 手动控制过滤)。
|
||||
|
||||
// AdminTaskRow 是管理端任务流一行:任务 + 租户名 + 提交人邮箱 + 评测分级(LEFT JOIN)。
|
||||
type AdminTaskRow struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
TenantName string `json:"tenant_name"`
|
||||
Owner string `json:"owner"`
|
||||
OwnerEmail string `json:"owner_email"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
Topic string `json:"topic"`
|
||||
At time.Time `json:"at"`
|
||||
EvalLevel string `json:"eval_level"`
|
||||
EvalOverall float64 `json:"eval_overall"`
|
||||
}
|
||||
|
||||
// AllTasks 全平台任务流(管理端观测;可按状态/租户过滤)。倒序,翻页。
|
||||
// status 空=全部;tenantID 空=全租户。
|
||||
func (p *Postgres) AllTasks(ctx context.Context, status, tenantID string, limit int) []AdminTaskRow {
|
||||
if p.db == nil {
|
||||
return nil
|
||||
}
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
ctx = WithoutTenant(ctx) // 系统级:看所有租户的任务
|
||||
q := p.db.WithContext(ctx).Table("sundynix_task as t").
|
||||
Select("t.task_id, t.tenant_id, t.owner, t.status, t.detail, t.created_at as at, " +
|
||||
"coalesce(tn.name,'') as tenant_name, coalesce(u.email,'') as owner_email, " +
|
||||
"coalesce(e.level,'') as eval_level, coalesce(e.overall,0) as eval_overall, " +
|
||||
"coalesce(t.graph->>'topic','') as topic").
|
||||
Joins("left join sundynix_tenant tn on tn.id = t.tenant_id").
|
||||
Joins("left join sundynix_user u on u.id = t.owner").
|
||||
Joins("left join sundynix_eval e on e.task_id = t.task_id").
|
||||
Where("t.deleted_at is null")
|
||||
if status != "" {
|
||||
q = q.Where("t.status = ?", status)
|
||||
}
|
||||
if tenantID != "" {
|
||||
q = q.Where("t.tenant_id = ?", tenantID)
|
||||
}
|
||||
var out []AdminTaskRow
|
||||
q.Order("t.created_at desc").Limit(limit).Scan(&out)
|
||||
return out
|
||||
}
|
||||
|
||||
// TaskStatusCounts 全平台任务按状态计数(观测页的筛选标签 + 分布)。
|
||||
func (p *Postgres) TaskStatusCounts(ctx context.Context) map[string]int64 {
|
||||
out := map[string]int64{}
|
||||
if p.db == nil {
|
||||
return out
|
||||
}
|
||||
ctx = WithoutTenant(ctx)
|
||||
var rows []struct {
|
||||
Status string
|
||||
N int64
|
||||
}
|
||||
p.db.WithContext(ctx).Table("sundynix_task").
|
||||
Select("status, count(*) as n").
|
||||
Where("deleted_at is null").
|
||||
Group("status").Scan(&rows)
|
||||
for _, r := range rows {
|
||||
out[r.Status] = r.N
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user