- 新增管理端接口 /api/v1/admin/ops/dashboard/openai-token-stats,按模型聚合统计 gpt% 请求 - 支持 time_range=30m|1h|1d|15d|30d(默认 30d),支持 platform/group_id 过滤 - 支持分页(page/page_size)或 TopN(top_n)互斥查询 - 前端运维监控页新增统计表卡片,包含空态/错误态与分页/TopN 交互 - 补齐后端与前端测试
354 lines
9.1 KiB
Go
354 lines
9.1 KiB
Go
package admin
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// GetDashboardOverview returns vNext ops dashboard overview (raw path).
|
|
// GET /api/v1/admin/ops/dashboard/overview
|
|
func (h *OpsHandler) GetDashboardOverview(c *gin.Context) {
|
|
if h.opsService == nil {
|
|
response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
|
|
return
|
|
}
|
|
if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
startTime, endTime, err := parseOpsTimeRange(c, "1h")
|
|
if err != nil {
|
|
response.BadRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
filter := &service.OpsDashboardFilter{
|
|
StartTime: startTime,
|
|
EndTime: endTime,
|
|
Platform: strings.TrimSpace(c.Query("platform")),
|
|
QueryMode: parseOpsQueryMode(c),
|
|
}
|
|
if v := strings.TrimSpace(c.Query("group_id")); v != "" {
|
|
id, err := strconv.ParseInt(v, 10, 64)
|
|
if err != nil || id <= 0 {
|
|
response.BadRequest(c, "Invalid group_id")
|
|
return
|
|
}
|
|
filter.GroupID = &id
|
|
}
|
|
|
|
data, err := h.opsService.GetDashboardOverview(c.Request.Context(), filter)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
response.Success(c, data)
|
|
}
|
|
|
|
// GetDashboardThroughputTrend returns throughput time series (raw path).
|
|
// GET /api/v1/admin/ops/dashboard/throughput-trend
|
|
func (h *OpsHandler) GetDashboardThroughputTrend(c *gin.Context) {
|
|
if h.opsService == nil {
|
|
response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
|
|
return
|
|
}
|
|
if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
startTime, endTime, err := parseOpsTimeRange(c, "1h")
|
|
if err != nil {
|
|
response.BadRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
filter := &service.OpsDashboardFilter{
|
|
StartTime: startTime,
|
|
EndTime: endTime,
|
|
Platform: strings.TrimSpace(c.Query("platform")),
|
|
QueryMode: parseOpsQueryMode(c),
|
|
}
|
|
if v := strings.TrimSpace(c.Query("group_id")); v != "" {
|
|
id, err := strconv.ParseInt(v, 10, 64)
|
|
if err != nil || id <= 0 {
|
|
response.BadRequest(c, "Invalid group_id")
|
|
return
|
|
}
|
|
filter.GroupID = &id
|
|
}
|
|
|
|
bucketSeconds := pickThroughputBucketSeconds(endTime.Sub(startTime))
|
|
data, err := h.opsService.GetThroughputTrend(c.Request.Context(), filter, bucketSeconds)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
response.Success(c, data)
|
|
}
|
|
|
|
// GetDashboardLatencyHistogram returns the latency distribution histogram (success requests).
|
|
// GET /api/v1/admin/ops/dashboard/latency-histogram
|
|
func (h *OpsHandler) GetDashboardLatencyHistogram(c *gin.Context) {
|
|
if h.opsService == nil {
|
|
response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
|
|
return
|
|
}
|
|
if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
startTime, endTime, err := parseOpsTimeRange(c, "1h")
|
|
if err != nil {
|
|
response.BadRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
filter := &service.OpsDashboardFilter{
|
|
StartTime: startTime,
|
|
EndTime: endTime,
|
|
Platform: strings.TrimSpace(c.Query("platform")),
|
|
QueryMode: parseOpsQueryMode(c),
|
|
}
|
|
if v := strings.TrimSpace(c.Query("group_id")); v != "" {
|
|
id, err := strconv.ParseInt(v, 10, 64)
|
|
if err != nil || id <= 0 {
|
|
response.BadRequest(c, "Invalid group_id")
|
|
return
|
|
}
|
|
filter.GroupID = &id
|
|
}
|
|
|
|
data, err := h.opsService.GetLatencyHistogram(c.Request.Context(), filter)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
response.Success(c, data)
|
|
}
|
|
|
|
// GetDashboardErrorTrend returns error counts time series (raw path).
|
|
// GET /api/v1/admin/ops/dashboard/error-trend
|
|
func (h *OpsHandler) GetDashboardErrorTrend(c *gin.Context) {
|
|
if h.opsService == nil {
|
|
response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
|
|
return
|
|
}
|
|
if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
startTime, endTime, err := parseOpsTimeRange(c, "1h")
|
|
if err != nil {
|
|
response.BadRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
filter := &service.OpsDashboardFilter{
|
|
StartTime: startTime,
|
|
EndTime: endTime,
|
|
Platform: strings.TrimSpace(c.Query("platform")),
|
|
QueryMode: parseOpsQueryMode(c),
|
|
}
|
|
if v := strings.TrimSpace(c.Query("group_id")); v != "" {
|
|
id, err := strconv.ParseInt(v, 10, 64)
|
|
if err != nil || id <= 0 {
|
|
response.BadRequest(c, "Invalid group_id")
|
|
return
|
|
}
|
|
filter.GroupID = &id
|
|
}
|
|
|
|
bucketSeconds := pickThroughputBucketSeconds(endTime.Sub(startTime))
|
|
data, err := h.opsService.GetErrorTrend(c.Request.Context(), filter, bucketSeconds)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
response.Success(c, data)
|
|
}
|
|
|
|
// GetDashboardErrorDistribution returns error distribution by status code (raw path).
|
|
// GET /api/v1/admin/ops/dashboard/error-distribution
|
|
func (h *OpsHandler) GetDashboardErrorDistribution(c *gin.Context) {
|
|
if h.opsService == nil {
|
|
response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
|
|
return
|
|
}
|
|
if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
startTime, endTime, err := parseOpsTimeRange(c, "1h")
|
|
if err != nil {
|
|
response.BadRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
filter := &service.OpsDashboardFilter{
|
|
StartTime: startTime,
|
|
EndTime: endTime,
|
|
Platform: strings.TrimSpace(c.Query("platform")),
|
|
QueryMode: parseOpsQueryMode(c),
|
|
}
|
|
if v := strings.TrimSpace(c.Query("group_id")); v != "" {
|
|
id, err := strconv.ParseInt(v, 10, 64)
|
|
if err != nil || id <= 0 {
|
|
response.BadRequest(c, "Invalid group_id")
|
|
return
|
|
}
|
|
filter.GroupID = &id
|
|
}
|
|
|
|
data, err := h.opsService.GetErrorDistribution(c.Request.Context(), filter)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
response.Success(c, data)
|
|
}
|
|
|
|
// GetDashboardOpenAITokenStats returns OpenAI token efficiency stats grouped by model.
|
|
// GET /api/v1/admin/ops/dashboard/openai-token-stats
|
|
func (h *OpsHandler) GetDashboardOpenAITokenStats(c *gin.Context) {
|
|
if h.opsService == nil {
|
|
response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
|
|
return
|
|
}
|
|
if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
filter, err := parseOpsOpenAITokenStatsFilter(c)
|
|
if err != nil {
|
|
response.BadRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
data, err := h.opsService.GetOpenAITokenStats(c.Request.Context(), filter)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
response.Success(c, data)
|
|
}
|
|
|
|
func parseOpsOpenAITokenStatsFilter(c *gin.Context) (*service.OpsOpenAITokenStatsFilter, error) {
|
|
if c == nil {
|
|
return nil, fmt.Errorf("invalid request")
|
|
}
|
|
|
|
timeRange := strings.TrimSpace(c.Query("time_range"))
|
|
if timeRange == "" {
|
|
timeRange = "30d"
|
|
}
|
|
dur, ok := parseOpsOpenAITokenStatsDuration(timeRange)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid time_range")
|
|
}
|
|
end := time.Now().UTC()
|
|
start := end.Add(-dur)
|
|
|
|
filter := &service.OpsOpenAITokenStatsFilter{
|
|
TimeRange: timeRange,
|
|
StartTime: start,
|
|
EndTime: end,
|
|
Platform: strings.TrimSpace(c.Query("platform")),
|
|
}
|
|
|
|
if v := strings.TrimSpace(c.Query("group_id")); v != "" {
|
|
id, err := strconv.ParseInt(v, 10, 64)
|
|
if err != nil || id <= 0 {
|
|
return nil, fmt.Errorf("invalid group_id")
|
|
}
|
|
filter.GroupID = &id
|
|
}
|
|
|
|
topNRaw := strings.TrimSpace(c.Query("top_n"))
|
|
pageRaw := strings.TrimSpace(c.Query("page"))
|
|
pageSizeRaw := strings.TrimSpace(c.Query("page_size"))
|
|
if topNRaw != "" && (pageRaw != "" || pageSizeRaw != "") {
|
|
return nil, fmt.Errorf("invalid query: top_n cannot be used with page/page_size")
|
|
}
|
|
|
|
if topNRaw != "" {
|
|
topN, err := strconv.Atoi(topNRaw)
|
|
if err != nil || topN < 1 || topN > 100 {
|
|
return nil, fmt.Errorf("invalid top_n")
|
|
}
|
|
filter.TopN = topN
|
|
return filter, nil
|
|
}
|
|
|
|
filter.Page = 1
|
|
filter.PageSize = 20
|
|
if pageRaw != "" {
|
|
page, err := strconv.Atoi(pageRaw)
|
|
if err != nil || page < 1 {
|
|
return nil, fmt.Errorf("invalid page")
|
|
}
|
|
filter.Page = page
|
|
}
|
|
if pageSizeRaw != "" {
|
|
pageSize, err := strconv.Atoi(pageSizeRaw)
|
|
if err != nil || pageSize < 1 || pageSize > 100 {
|
|
return nil, fmt.Errorf("invalid page_size")
|
|
}
|
|
filter.PageSize = pageSize
|
|
}
|
|
return filter, nil
|
|
}
|
|
|
|
func parseOpsOpenAITokenStatsDuration(v string) (time.Duration, bool) {
|
|
switch strings.TrimSpace(v) {
|
|
case "30m":
|
|
return 30 * time.Minute, true
|
|
case "1h":
|
|
return time.Hour, true
|
|
case "1d":
|
|
return 24 * time.Hour, true
|
|
case "15d":
|
|
return 15 * 24 * time.Hour, true
|
|
case "30d":
|
|
return 30 * 24 * time.Hour, true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
func pickThroughputBucketSeconds(window time.Duration) int {
|
|
// Keep buckets predictable and avoid huge responses.
|
|
switch {
|
|
case window <= 2*time.Hour:
|
|
return 60
|
|
case window <= 24*time.Hour:
|
|
return 300
|
|
default:
|
|
return 3600
|
|
}
|
|
}
|
|
|
|
func parseOpsQueryMode(c *gin.Context) service.OpsQueryMode {
|
|
if c == nil {
|
|
return ""
|
|
}
|
|
raw := strings.TrimSpace(c.Query("mode"))
|
|
if raw == "" {
|
|
// Empty means "use server default" (DB setting ops_query_mode_default).
|
|
return ""
|
|
}
|
|
return service.ParseOpsQueryMode(raw)
|
|
}
|