Merge branch 'main' into dev
This commit is contained in:
@@ -118,3 +118,96 @@ func (h *OpsHandler) GetAccountAvailability(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
response.Success(c, payload)
|
response.Success(c, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseOpsRealtimeWindow(v string) (time.Duration, string, bool) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||||
|
case "", "1min", "1m":
|
||||||
|
return 1 * time.Minute, "1min", true
|
||||||
|
case "5min", "5m":
|
||||||
|
return 5 * time.Minute, "5min", true
|
||||||
|
case "30min", "30m":
|
||||||
|
return 30 * time.Minute, "30min", true
|
||||||
|
case "1h", "60m", "60min":
|
||||||
|
return 1 * time.Hour, "1h", true
|
||||||
|
default:
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRealtimeTrafficSummary returns QPS/TPS current/peak/avg for the selected window.
|
||||||
|
// GET /api/v1/admin/ops/realtime-traffic
|
||||||
|
//
|
||||||
|
// Query params:
|
||||||
|
// - window: 1min|5min|30min|1h (default: 1min)
|
||||||
|
// - platform: optional
|
||||||
|
// - group_id: optional
|
||||||
|
func (h *OpsHandler) GetRealtimeTrafficSummary(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
|
||||||
|
}
|
||||||
|
|
||||||
|
windowDur, windowLabel, ok := parseOpsRealtimeWindow(c.Query("window"))
|
||||||
|
if !ok {
|
||||||
|
response.BadRequest(c, "Invalid window")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
platform := strings.TrimSpace(c.Query("platform"))
|
||||||
|
var groupID *int64
|
||||||
|
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
|
||||||
|
}
|
||||||
|
groupID = &id
|
||||||
|
}
|
||||||
|
|
||||||
|
endTime := time.Now().UTC()
|
||||||
|
startTime := endTime.Add(-windowDur)
|
||||||
|
|
||||||
|
if !h.opsService.IsRealtimeMonitoringEnabled(c.Request.Context()) {
|
||||||
|
disabledSummary := &service.OpsRealtimeTrafficSummary{
|
||||||
|
Window: windowLabel,
|
||||||
|
StartTime: startTime,
|
||||||
|
EndTime: endTime,
|
||||||
|
Platform: platform,
|
||||||
|
GroupID: groupID,
|
||||||
|
QPS: service.OpsRateSummary{},
|
||||||
|
TPS: service.OpsRateSummary{},
|
||||||
|
}
|
||||||
|
response.Success(c, gin.H{
|
||||||
|
"enabled": false,
|
||||||
|
"summary": disabledSummary,
|
||||||
|
"timestamp": endTime,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := &service.OpsDashboardFilter{
|
||||||
|
StartTime: startTime,
|
||||||
|
EndTime: endTime,
|
||||||
|
Platform: platform,
|
||||||
|
GroupID: groupID,
|
||||||
|
QueryMode: service.OpsQueryModeRaw,
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := h.opsService.GetRealtimeTrafficSummary(c.Request.Context(), filter)
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if summary != nil {
|
||||||
|
summary.Window = windowLabel
|
||||||
|
}
|
||||||
|
response.Success(c, gin.H{
|
||||||
|
"enabled": true,
|
||||||
|
"summary": summary,
|
||||||
|
"timestamp": endTime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -146,3 +146,49 @@ func (h *OpsHandler) UpdateAdvancedSettings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
response.Success(c, updated)
|
response.Success(c, updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetMetricThresholds returns Ops metric thresholds (DB-backed).
|
||||||
|
// GET /api/v1/admin/ops/settings/metric-thresholds
|
||||||
|
func (h *OpsHandler) GetMetricThresholds(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
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := h.opsService.GetMetricThresholds(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "Failed to get metric thresholds")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMetricThresholds updates Ops metric thresholds (DB-backed).
|
||||||
|
// PUT /api/v1/admin/ops/settings/metric-thresholds
|
||||||
|
func (h *OpsHandler) UpdateMetricThresholds(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
|
||||||
|
}
|
||||||
|
|
||||||
|
var req service.OpsMetricThresholds
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "Invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := h.opsService.UpdateMetricThresholds(c.Request.Context(), &req)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, updated)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
@@ -76,7 +77,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
|||||||
|
|
||||||
// Turnstile 验证(当提供了邮箱验证码时跳过,因为发送验证码时已验证过)
|
// Turnstile 验证(当提供了邮箱验证码时跳过,因为发送验证码时已验证过)
|
||||||
if req.VerifyCode == "" {
|
if req.VerifyCode == "" {
|
||||||
if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, c.ClientIP()); err != nil {
|
if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil {
|
||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -105,7 +106,7 @@ func (h *AuthHandler) SendVerifyCode(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Turnstile 验证
|
// Turnstile 验证
|
||||||
if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, c.ClientIP()); err != nil {
|
if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil {
|
||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -132,7 +133,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Turnstile 验证
|
// Turnstile 验证
|
||||||
if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, c.ClientIP()); err != nil {
|
if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil {
|
||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||||
pkgerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
pkgerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
@@ -88,6 +89,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否为 Claude Code 客户端,设置到 context 中
|
||||||
|
SetClaudeCodeClientContext(c, body)
|
||||||
|
|
||||||
setOpsRequestContext(c, "", false, body)
|
setOpsRequestContext(c, "", false, body)
|
||||||
|
|
||||||
parsedReq, err := service.ParseGatewayRequest(body)
|
parsedReq, err := service.ParseGatewayRequest(body)
|
||||||
@@ -271,12 +275,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
|||||||
var failoverErr *service.UpstreamFailoverError
|
var failoverErr *service.UpstreamFailoverError
|
||||||
if errors.As(err, &failoverErr) {
|
if errors.As(err, &failoverErr) {
|
||||||
failedAccountIDs[account.ID] = struct{}{}
|
failedAccountIDs[account.ID] = struct{}{}
|
||||||
|
lastFailoverStatus = failoverErr.StatusCode
|
||||||
if switchCount >= maxAccountSwitches {
|
if switchCount >= maxAccountSwitches {
|
||||||
lastFailoverStatus = failoverErr.StatusCode
|
|
||||||
h.handleFailoverExhausted(c, lastFailoverStatus, streamStarted)
|
h.handleFailoverExhausted(c, lastFailoverStatus, streamStarted)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastFailoverStatus = failoverErr.StatusCode
|
|
||||||
switchCount++
|
switchCount++
|
||||||
log.Printf("Account %d: upstream error %d, switching account %d/%d", account.ID, failoverErr.StatusCode, switchCount, maxAccountSwitches)
|
log.Printf("Account %d: upstream error %d, switching account %d/%d", account.ID, failoverErr.StatusCode, switchCount, maxAccountSwitches)
|
||||||
continue
|
continue
|
||||||
@@ -286,8 +289,12 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 捕获请求信息(用于异步记录,避免在 goroutine 中访问 gin.Context)
|
||||||
|
userAgent := c.GetHeader("User-Agent")
|
||||||
|
clientIP := ip.GetClientIP(c)
|
||||||
|
|
||||||
// 异步记录使用量(subscription已在函数开头获取)
|
// 异步记录使用量(subscription已在函数开头获取)
|
||||||
go func(result *service.ForwardResult, usedAccount *service.Account) {
|
go func(result *service.ForwardResult, usedAccount *service.Account, ua, clientIP string) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
|
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
|
||||||
@@ -296,10 +303,12 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
|||||||
User: apiKey.User,
|
User: apiKey.User,
|
||||||
Account: usedAccount,
|
Account: usedAccount,
|
||||||
Subscription: subscription,
|
Subscription: subscription,
|
||||||
|
UserAgent: ua,
|
||||||
|
IPAddress: clientIP,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("Record usage failed: %v", err)
|
log.Printf("Record usage failed: %v", err)
|
||||||
}
|
}
|
||||||
}(result, account)
|
}(result, account, userAgent, clientIP)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -399,12 +408,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
|||||||
var failoverErr *service.UpstreamFailoverError
|
var failoverErr *service.UpstreamFailoverError
|
||||||
if errors.As(err, &failoverErr) {
|
if errors.As(err, &failoverErr) {
|
||||||
failedAccountIDs[account.ID] = struct{}{}
|
failedAccountIDs[account.ID] = struct{}{}
|
||||||
|
lastFailoverStatus = failoverErr.StatusCode
|
||||||
if switchCount >= maxAccountSwitches {
|
if switchCount >= maxAccountSwitches {
|
||||||
lastFailoverStatus = failoverErr.StatusCode
|
|
||||||
h.handleFailoverExhausted(c, lastFailoverStatus, streamStarted)
|
h.handleFailoverExhausted(c, lastFailoverStatus, streamStarted)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastFailoverStatus = failoverErr.StatusCode
|
|
||||||
switchCount++
|
switchCount++
|
||||||
log.Printf("Account %d: upstream error %d, switching account %d/%d", account.ID, failoverErr.StatusCode, switchCount, maxAccountSwitches)
|
log.Printf("Account %d: upstream error %d, switching account %d/%d", account.ID, failoverErr.StatusCode, switchCount, maxAccountSwitches)
|
||||||
continue
|
continue
|
||||||
@@ -414,8 +422,12 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 捕获请求信息(用于异步记录,避免在 goroutine 中访问 gin.Context)
|
||||||
|
userAgent := c.GetHeader("User-Agent")
|
||||||
|
clientIP := ip.GetClientIP(c)
|
||||||
|
|
||||||
// 异步记录使用量(subscription已在函数开头获取)
|
// 异步记录使用量(subscription已在函数开头获取)
|
||||||
go func(result *service.ForwardResult, usedAccount *service.Account) {
|
go func(result *service.ForwardResult, usedAccount *service.Account, ua, clientIP string) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
|
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
|
||||||
@@ -424,10 +436,12 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
|||||||
User: apiKey.User,
|
User: apiKey.User,
|
||||||
Account: usedAccount,
|
Account: usedAccount,
|
||||||
Subscription: subscription,
|
Subscription: subscription,
|
||||||
|
UserAgent: ua,
|
||||||
|
IPAddress: clientIP,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("Record usage failed: %v", err)
|
log.Printf("Record usage failed: %v", err)
|
||||||
}
|
}
|
||||||
}(result, account)
|
}(result, account, userAgent, clientIP)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/gemini"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/gemini"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/googleapi"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/googleapi"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
@@ -314,8 +315,12 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 捕获请求信息(用于异步记录,避免在 goroutine 中访问 gin.Context)
|
||||||
|
userAgent := c.GetHeader("User-Agent")
|
||||||
|
clientIP := ip.GetClientIP(c)
|
||||||
|
|
||||||
// 6) record usage async
|
// 6) record usage async
|
||||||
go func(result *service.ForwardResult, usedAccount *service.Account) {
|
go func(result *service.ForwardResult, usedAccount *service.Account, ua, ip string) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
|
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
|
||||||
@@ -324,10 +329,12 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
|||||||
User: apiKey.User,
|
User: apiKey.User,
|
||||||
Account: usedAccount,
|
Account: usedAccount,
|
||||||
Subscription: subscription,
|
Subscription: subscription,
|
||||||
|
UserAgent: ua,
|
||||||
|
IPAddress: ip,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("Record usage failed: %v", err)
|
log.Printf("Record usage failed: %v", err)
|
||||||
}
|
}
|
||||||
}(result, account)
|
}(result, account, userAgent, clientIP)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
@@ -263,8 +264,12 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 捕获请求信息(用于异步记录,避免在 goroutine 中访问 gin.Context)
|
||||||
|
userAgent := c.GetHeader("User-Agent")
|
||||||
|
clientIP := ip.GetClientIP(c)
|
||||||
|
|
||||||
// Async record usage
|
// Async record usage
|
||||||
go func(result *service.OpenAIForwardResult, usedAccount *service.Account) {
|
go func(result *service.OpenAIForwardResult, usedAccount *service.Account, ua, ip string) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
|
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
|
||||||
@@ -273,10 +278,12 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
|||||||
User: apiKey.User,
|
User: apiKey.User,
|
||||||
Account: usedAccount,
|
Account: usedAccount,
|
||||||
Subscription: subscription,
|
Subscription: subscription,
|
||||||
|
UserAgent: ua,
|
||||||
|
IPAddress: ip,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("Record usage failed: %v", err)
|
log.Printf("Record usage failed: %v", err)
|
||||||
}
|
}
|
||||||
}(result, account)
|
}(result, account, userAgent, clientIP)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -489,6 +490,7 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
|
|||||||
Severity: classifyOpsSeverity("upstream_error", effectiveUpstreamStatus),
|
Severity: classifyOpsSeverity("upstream_error", effectiveUpstreamStatus),
|
||||||
StatusCode: status,
|
StatusCode: status,
|
||||||
IsBusinessLimited: false,
|
IsBusinessLimited: false,
|
||||||
|
IsCountTokens: isCountTokensRequest(c),
|
||||||
|
|
||||||
ErrorMessage: recoveredMsg,
|
ErrorMessage: recoveredMsg,
|
||||||
ErrorBody: "",
|
ErrorBody: "",
|
||||||
@@ -521,7 +523,7 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var clientIP string
|
var clientIP string
|
||||||
if ip := strings.TrimSpace(c.ClientIP()); ip != "" {
|
if ip := strings.TrimSpace(ip.GetClientIP(c)); ip != "" {
|
||||||
clientIP = ip
|
clientIP = ip
|
||||||
entry.ClientIP = &clientIP
|
entry.ClientIP = &clientIP
|
||||||
}
|
}
|
||||||
@@ -598,6 +600,7 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
|
|||||||
Severity: classifyOpsSeverity(parsed.ErrorType, status),
|
Severity: classifyOpsSeverity(parsed.ErrorType, status),
|
||||||
StatusCode: status,
|
StatusCode: status,
|
||||||
IsBusinessLimited: isBusinessLimited,
|
IsBusinessLimited: isBusinessLimited,
|
||||||
|
IsCountTokens: isCountTokensRequest(c),
|
||||||
|
|
||||||
ErrorMessage: parsed.Message,
|
ErrorMessage: parsed.Message,
|
||||||
// Keep the full captured error body (capture is already capped at 64KB) so the
|
// Keep the full captured error body (capture is already capped at 64KB) so the
|
||||||
@@ -680,7 +683,7 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var clientIP string
|
var clientIP string
|
||||||
if ip := strings.TrimSpace(c.ClientIP()); ip != "" {
|
if ip := strings.TrimSpace(ip.GetClientIP(c)); ip != "" {
|
||||||
clientIP = ip
|
clientIP = ip
|
||||||
entry.ClientIP = &clientIP
|
entry.ClientIP = &clientIP
|
||||||
}
|
}
|
||||||
@@ -704,6 +707,14 @@ var opsRetryRequestHeaderAllowlist = []string{
|
|||||||
"anthropic-version",
|
"anthropic-version",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isCountTokensRequest checks if the request is a count_tokens request
|
||||||
|
func isCountTokensRequest(c *gin.Context) bool {
|
||||||
|
if c == nil || c.Request == nil || c.Request.URL == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(c.Request.URL.Path, "/count_tokens")
|
||||||
|
}
|
||||||
|
|
||||||
func extractOpsRetryRequestHeaders(c *gin.Context) *string {
|
func extractOpsRetryRequestHeaders(c *gin.Context) *string {
|
||||||
if c == nil || c.Request == nil {
|
if c == nil || c.Request == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ INSERT INTO ops_error_logs (
|
|||||||
severity,
|
severity,
|
||||||
status_code,
|
status_code,
|
||||||
is_business_limited,
|
is_business_limited,
|
||||||
|
is_count_tokens,
|
||||||
error_message,
|
error_message,
|
||||||
error_body,
|
error_body,
|
||||||
error_source,
|
error_source,
|
||||||
@@ -64,7 +65,7 @@ INSERT INTO ops_error_logs (
|
|||||||
retry_count,
|
retry_count,
|
||||||
created_at
|
created_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34
|
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35
|
||||||
) RETURNING id`
|
) RETURNING id`
|
||||||
|
|
||||||
var id int64
|
var id int64
|
||||||
@@ -88,6 +89,7 @@ INSERT INTO ops_error_logs (
|
|||||||
opsNullString(input.Severity),
|
opsNullString(input.Severity),
|
||||||
opsNullInt(input.StatusCode),
|
opsNullInt(input.StatusCode),
|
||||||
input.IsBusinessLimited,
|
input.IsBusinessLimited,
|
||||||
|
input.IsCountTokens,
|
||||||
opsNullString(input.ErrorMessage),
|
opsNullString(input.ErrorMessage),
|
||||||
opsNullString(input.ErrorBody),
|
opsNullString(input.ErrorBody),
|
||||||
opsNullString(input.ErrorSource),
|
opsNullString(input.ErrorSource),
|
||||||
|
|||||||
@@ -964,8 +964,8 @@ func buildErrorWhere(filter *service.OpsDashboardFilter, start, end time.Time, s
|
|||||||
}
|
}
|
||||||
|
|
||||||
idx := startIndex
|
idx := startIndex
|
||||||
clauses := make([]string, 0, 4)
|
clauses := make([]string, 0, 5)
|
||||||
args = make([]any, 0, 4)
|
args = make([]any, 0, 5)
|
||||||
|
|
||||||
args = append(args, start)
|
args = append(args, start)
|
||||||
clauses = append(clauses, fmt.Sprintf("created_at >= $%d", idx))
|
clauses = append(clauses, fmt.Sprintf("created_at >= $%d", idx))
|
||||||
@@ -974,6 +974,8 @@ func buildErrorWhere(filter *service.OpsDashboardFilter, start, end time.Time, s
|
|||||||
clauses = append(clauses, fmt.Sprintf("created_at < $%d", idx))
|
clauses = append(clauses, fmt.Sprintf("created_at < $%d", idx))
|
||||||
idx++
|
idx++
|
||||||
|
|
||||||
|
clauses = append(clauses, "is_count_tokens = FALSE")
|
||||||
|
|
||||||
if groupID != nil && *groupID > 0 {
|
if groupID != nil && *groupID > 0 {
|
||||||
args = append(args, *groupID)
|
args = append(args, *groupID)
|
||||||
clauses = append(clauses, fmt.Sprintf("group_id = $%d", idx))
|
clauses = append(clauses, fmt.Sprintf("group_id = $%d", idx))
|
||||||
|
|||||||
@@ -78,7 +78,9 @@ error_base AS (
|
|||||||
status_code AS client_status_code,
|
status_code AS client_status_code,
|
||||||
COALESCE(upstream_status_code, status_code, 0) AS effective_status_code
|
COALESCE(upstream_status_code, status_code, 0) AS effective_status_code
|
||||||
FROM ops_error_logs
|
FROM ops_error_logs
|
||||||
|
-- Exclude count_tokens requests from error metrics as they are informational probes
|
||||||
WHERE created_at >= $1 AND created_at < $2
|
WHERE created_at >= $1 AND created_at < $2
|
||||||
|
AND is_count_tokens = FALSE
|
||||||
),
|
),
|
||||||
error_agg AS (
|
error_agg AS (
|
||||||
SELECT
|
SELECT
|
||||||
|
|||||||
129
backend/internal/repository/ops_repo_realtime_traffic.go
Normal file
129
backend/internal/repository/ops_repo_realtime_traffic.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *opsRepository) GetRealtimeTrafficSummary(ctx context.Context, filter *service.OpsDashboardFilter) (*service.OpsRealtimeTrafficSummary, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, fmt.Errorf("nil ops repository")
|
||||||
|
}
|
||||||
|
if filter == nil {
|
||||||
|
return nil, fmt.Errorf("nil filter")
|
||||||
|
}
|
||||||
|
if filter.StartTime.IsZero() || filter.EndTime.IsZero() {
|
||||||
|
return nil, fmt.Errorf("start_time/end_time required")
|
||||||
|
}
|
||||||
|
|
||||||
|
start := filter.StartTime.UTC()
|
||||||
|
end := filter.EndTime.UTC()
|
||||||
|
if start.After(end) {
|
||||||
|
return nil, fmt.Errorf("start_time must be <= end_time")
|
||||||
|
}
|
||||||
|
|
||||||
|
window := end.Sub(start)
|
||||||
|
if window <= 0 {
|
||||||
|
return nil, fmt.Errorf("invalid time window")
|
||||||
|
}
|
||||||
|
if window > time.Hour {
|
||||||
|
return nil, fmt.Errorf("window too large")
|
||||||
|
}
|
||||||
|
|
||||||
|
usageJoin, usageWhere, usageArgs, next := buildUsageWhere(filter, start, end, 1)
|
||||||
|
errorWhere, errorArgs, _ := buildErrorWhere(filter, start, end, next)
|
||||||
|
|
||||||
|
q := `
|
||||||
|
WITH usage_buckets AS (
|
||||||
|
SELECT
|
||||||
|
date_trunc('minute', ul.created_at) AS bucket,
|
||||||
|
COALESCE(COUNT(*), 0) AS success_count,
|
||||||
|
COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) AS token_sum
|
||||||
|
FROM usage_logs ul
|
||||||
|
` + usageJoin + `
|
||||||
|
` + usageWhere + `
|
||||||
|
GROUP BY 1
|
||||||
|
),
|
||||||
|
error_buckets AS (
|
||||||
|
SELECT
|
||||||
|
date_trunc('minute', created_at) AS bucket,
|
||||||
|
COALESCE(COUNT(*), 0) AS error_count
|
||||||
|
FROM ops_error_logs
|
||||||
|
` + errorWhere + `
|
||||||
|
AND COALESCE(status_code, 0) >= 400
|
||||||
|
GROUP BY 1
|
||||||
|
),
|
||||||
|
combined AS (
|
||||||
|
SELECT
|
||||||
|
COALESCE(u.bucket, e.bucket) AS bucket,
|
||||||
|
COALESCE(u.success_count, 0) AS success_count,
|
||||||
|
COALESCE(u.token_sum, 0) AS token_sum,
|
||||||
|
COALESCE(e.error_count, 0) AS error_count,
|
||||||
|
COALESCE(u.success_count, 0) + COALESCE(e.error_count, 0) AS request_total
|
||||||
|
FROM usage_buckets u
|
||||||
|
FULL OUTER JOIN error_buckets e ON u.bucket = e.bucket
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(success_count), 0) AS success_total,
|
||||||
|
COALESCE(SUM(error_count), 0) AS error_total,
|
||||||
|
COALESCE(SUM(token_sum), 0) AS token_total,
|
||||||
|
COALESCE(MAX(request_total), 0) AS peak_requests_per_min,
|
||||||
|
COALESCE(MAX(token_sum), 0) AS peak_tokens_per_min
|
||||||
|
FROM combined`
|
||||||
|
|
||||||
|
args := append(usageArgs, errorArgs...)
|
||||||
|
var successCount int64
|
||||||
|
var errorTotal int64
|
||||||
|
var tokenConsumed int64
|
||||||
|
var peakRequestsPerMin int64
|
||||||
|
var peakTokensPerMin int64
|
||||||
|
if err := r.db.QueryRowContext(ctx, q, args...).Scan(
|
||||||
|
&successCount,
|
||||||
|
&errorTotal,
|
||||||
|
&tokenConsumed,
|
||||||
|
&peakRequestsPerMin,
|
||||||
|
&peakTokensPerMin,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
windowSeconds := window.Seconds()
|
||||||
|
if windowSeconds <= 0 {
|
||||||
|
windowSeconds = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
requestCountTotal := successCount + errorTotal
|
||||||
|
qpsAvg := roundTo1DP(float64(requestCountTotal) / windowSeconds)
|
||||||
|
tpsAvg := roundTo1DP(float64(tokenConsumed) / windowSeconds)
|
||||||
|
|
||||||
|
// Keep "current" consistent with the dashboard overview semantics: last 1 minute.
|
||||||
|
// This remains "within the selected window" since end=start+window.
|
||||||
|
qpsCurrent, tpsCurrent, err := r.queryCurrentRates(ctx, filter, end)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
qpsPeak := roundTo1DP(float64(peakRequestsPerMin) / 60.0)
|
||||||
|
tpsPeak := roundTo1DP(float64(peakTokensPerMin) / 60.0)
|
||||||
|
|
||||||
|
return &service.OpsRealtimeTrafficSummary{
|
||||||
|
StartTime: start,
|
||||||
|
EndTime: end,
|
||||||
|
Platform: strings.TrimSpace(filter.Platform),
|
||||||
|
GroupID: filter.GroupID,
|
||||||
|
QPS: service.OpsRateSummary{
|
||||||
|
Current: qpsCurrent,
|
||||||
|
Peak: qpsPeak,
|
||||||
|
Avg: qpsAvg,
|
||||||
|
},
|
||||||
|
TPS: service.OpsRateSummary{
|
||||||
|
Current: tpsCurrent,
|
||||||
|
Peak: tpsPeak,
|
||||||
|
Avg: tpsAvg,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -170,6 +170,7 @@ error_totals AS (
|
|||||||
FROM ops_error_logs
|
FROM ops_error_logs
|
||||||
WHERE created_at >= $1 AND created_at < $2
|
WHERE created_at >= $1 AND created_at < $2
|
||||||
AND COALESCE(status_code, 0) >= 400
|
AND COALESCE(status_code, 0) >= 400
|
||||||
|
AND is_count_tokens = FALSE -- 排除 count_tokens 请求的错误
|
||||||
GROUP BY 1
|
GROUP BY 1
|
||||||
),
|
),
|
||||||
combined AS (
|
combined AS (
|
||||||
@@ -243,6 +244,7 @@ error_totals AS (
|
|||||||
AND platform = $3
|
AND platform = $3
|
||||||
AND group_id IS NOT NULL
|
AND group_id IS NOT NULL
|
||||||
AND COALESCE(status_code, 0) >= 400
|
AND COALESCE(status_code, 0) >= 400
|
||||||
|
AND is_count_tokens = FALSE -- 排除 count_tokens 请求的错误
|
||||||
GROUP BY 1
|
GROUP BY 1
|
||||||
),
|
),
|
||||||
combined AS (
|
combined AS (
|
||||||
|
|||||||
@@ -80,17 +80,17 @@ func enqueueSchedulerOutbox(ctx context.Context, exec sqlExecutor, eventType str
|
|||||||
if exec == nil {
|
if exec == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var payloadJSON []byte
|
var payloadArg any
|
||||||
if payload != nil {
|
if payload != nil {
|
||||||
encoded, err := json.Marshal(payload)
|
encoded, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
payloadJSON = encoded
|
payloadArg = encoded
|
||||||
}
|
}
|
||||||
_, err := exec.ExecContext(ctx, `
|
_, err := exec.ExecContext(ctx, `
|
||||||
INSERT INTO scheduler_outbox (event_type, account_id, group_id, payload)
|
INSERT INTO scheduler_outbox (event_type, account_id, group_id, payload)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
`, eventType, accountID, groupID, payloadJSON)
|
`, eventType, accountID, groupID, payloadArg)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,25 +46,12 @@ func TestSchedulerSnapshotOutboxReplay(t *testing.T) {
|
|||||||
Extra: map[string]any{},
|
Extra: map[string]any{},
|
||||||
}
|
}
|
||||||
require.NoError(t, accountRepo.Create(ctx, account))
|
require.NoError(t, accountRepo.Create(ctx, account))
|
||||||
|
require.NoError(t, cache.SetAccount(ctx, account))
|
||||||
|
|
||||||
svc := service.NewSchedulerSnapshotService(cache, outboxRepo, accountRepo, nil, cfg)
|
svc := service.NewSchedulerSnapshotService(cache, outboxRepo, accountRepo, nil, cfg)
|
||||||
svc.Start()
|
svc.Start()
|
||||||
t.Cleanup(svc.Stop)
|
t.Cleanup(svc.Stop)
|
||||||
|
|
||||||
bucket := service.SchedulerBucket{GroupID: 0, Platform: service.PlatformOpenAI, Mode: service.SchedulerModeSingle}
|
|
||||||
require.Eventually(t, func() bool {
|
|
||||||
accounts, hit, err := cache.GetSnapshot(ctx, bucket)
|
|
||||||
if err != nil || !hit {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, acc := range accounts {
|
|
||||||
if acc.ID == account.ID {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}, 5*time.Second, 100*time.Millisecond)
|
|
||||||
|
|
||||||
require.NoError(t, accountRepo.UpdateLastUsed(ctx, account.ID))
|
require.NoError(t, accountRepo.UpdateLastUsed(ctx, account.ID))
|
||||||
updated, err := accountRepo.GetByID(ctx, account.ID)
|
updated, err := accountRepo.GetByID(ctx, account.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ func registerOpsRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
|||||||
// Realtime ops signals
|
// Realtime ops signals
|
||||||
ops.GET("/concurrency", h.Admin.Ops.GetConcurrencyStats)
|
ops.GET("/concurrency", h.Admin.Ops.GetConcurrencyStats)
|
||||||
ops.GET("/account-availability", h.Admin.Ops.GetAccountAvailability)
|
ops.GET("/account-availability", h.Admin.Ops.GetAccountAvailability)
|
||||||
|
ops.GET("/realtime-traffic", h.Admin.Ops.GetRealtimeTrafficSummary)
|
||||||
|
|
||||||
// Alerts (rules + events)
|
// Alerts (rules + events)
|
||||||
ops.GET("/alert-rules", h.Admin.Ops.ListAlertRules)
|
ops.GET("/alert-rules", h.Admin.Ops.ListAlertRules)
|
||||||
@@ -96,6 +97,13 @@ func registerOpsRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
|||||||
ops.GET("/advanced-settings", h.Admin.Ops.GetAdvancedSettings)
|
ops.GET("/advanced-settings", h.Admin.Ops.GetAdvancedSettings)
|
||||||
ops.PUT("/advanced-settings", h.Admin.Ops.UpdateAdvancedSettings)
|
ops.PUT("/advanced-settings", h.Admin.Ops.UpdateAdvancedSettings)
|
||||||
|
|
||||||
|
// Settings group (DB-backed)
|
||||||
|
settings := ops.Group("/settings")
|
||||||
|
{
|
||||||
|
settings.GET("/metric-thresholds", h.Admin.Ops.GetMetricThresholds)
|
||||||
|
settings.PUT("/metric-thresholds", h.Admin.Ops.UpdateMetricThresholds)
|
||||||
|
}
|
||||||
|
|
||||||
// WebSocket realtime (QPS/TPS)
|
// WebSocket realtime (QPS/TPS)
|
||||||
ws := ops.Group("/ws")
|
ws := ops.Group("/ws")
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -523,6 +523,9 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
|||||||
proxyURL = account.Proxy.URL()
|
proxyURL = account.Proxy.URL()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sanitize thinking blocks (clean cache_control and flatten history thinking)
|
||||||
|
sanitizeThinkingBlocks(&claudeReq)
|
||||||
|
|
||||||
// 获取转换选项
|
// 获取转换选项
|
||||||
// Antigravity 上游要求必须包含身份提示词,否则会返回 429
|
// Antigravity 上游要求必须包含身份提示词,否则会返回 429
|
||||||
transformOpts := s.getClaudeTransformOptions(ctx)
|
transformOpts := s.getClaudeTransformOptions(ctx)
|
||||||
@@ -534,6 +537,9 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
|||||||
return nil, fmt.Errorf("transform request: %w", err)
|
return nil, fmt.Errorf("transform request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Safety net: ensure no cache_control leaked into Gemini request
|
||||||
|
geminiBody = cleanCacheControlFromGeminiJSON(geminiBody)
|
||||||
|
|
||||||
// Antigravity 上游只支持流式请求,统一使用 streamGenerateContent
|
// Antigravity 上游只支持流式请求,统一使用 streamGenerateContent
|
||||||
// 如果客户端请求非流式,在响应处理阶段会收集完整流式响应后转换返回
|
// 如果客户端请求非流式,在响应处理阶段会收集完整流式响应后转换返回
|
||||||
action := "streamGenerateContent"
|
action := "streamGenerateContent"
|
||||||
@@ -903,6 +909,143 @@ func extractAntigravityErrorMessage(body []byte) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cleanCacheControlFromGeminiJSON removes cache_control from Gemini JSON (emergency fix)
|
||||||
|
// This should not be needed if transformation is correct, but serves as a safety net
|
||||||
|
func cleanCacheControlFromGeminiJSON(body []byte) []byte {
|
||||||
|
// Try a more robust approach: parse and clean
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal(body, &data); err != nil {
|
||||||
|
log.Printf("[Antigravity] Failed to parse Gemini JSON for cache_control cleaning: %v", err)
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaned := removeCacheControlFromAny(data)
|
||||||
|
if !cleaned {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
if result, err := json.Marshal(data); err == nil {
|
||||||
|
log.Printf("[Antigravity] Successfully cleaned cache_control from Gemini JSON")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeCacheControlFromAny recursively removes cache_control fields
|
||||||
|
func removeCacheControlFromAny(v any) bool {
|
||||||
|
cleaned := false
|
||||||
|
|
||||||
|
switch val := v.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
for k, child := range val {
|
||||||
|
if k == "cache_control" {
|
||||||
|
delete(val, k)
|
||||||
|
cleaned = true
|
||||||
|
} else if removeCacheControlFromAny(child) {
|
||||||
|
cleaned = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, item := range val {
|
||||||
|
if removeCacheControlFromAny(item) {
|
||||||
|
cleaned = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeThinkingBlocks cleans cache_control and flattens history thinking blocks
|
||||||
|
// Thinking blocks do NOT support cache_control field (Anthropic API/Vertex AI requirement)
|
||||||
|
// Additionally, history thinking blocks are flattened to text to avoid upstream validation errors
|
||||||
|
func sanitizeThinkingBlocks(req *antigravity.ClaudeRequest) {
|
||||||
|
if req == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[Antigravity] sanitizeThinkingBlocks: processing request with %d messages", len(req.Messages))
|
||||||
|
|
||||||
|
// Clean system blocks
|
||||||
|
if len(req.System) > 0 {
|
||||||
|
var systemBlocks []map[string]any
|
||||||
|
if err := json.Unmarshal(req.System, &systemBlocks); err == nil {
|
||||||
|
for i := range systemBlocks {
|
||||||
|
if blockType, _ := systemBlocks[i]["type"].(string); blockType == "thinking" || systemBlocks[i]["thinking"] != nil {
|
||||||
|
if removeCacheControlFromAny(systemBlocks[i]) {
|
||||||
|
log.Printf("[Antigravity] Deep cleaned cache_control from thinking block in system[%d]", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Marshal back
|
||||||
|
if cleaned, err := json.Marshal(systemBlocks); err == nil {
|
||||||
|
req.System = cleaned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean message content blocks and flatten history
|
||||||
|
lastMsgIdx := len(req.Messages) - 1
|
||||||
|
for msgIdx := range req.Messages {
|
||||||
|
raw := req.Messages[msgIdx].Content
|
||||||
|
if len(raw) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse as blocks array
|
||||||
|
var blocks []map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &blocks); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaned := false
|
||||||
|
for blockIdx := range blocks {
|
||||||
|
blockType, _ := blocks[blockIdx]["type"].(string)
|
||||||
|
|
||||||
|
// Check for thinking blocks (typed or untyped)
|
||||||
|
if blockType == "thinking" || blocks[blockIdx]["thinking"] != nil {
|
||||||
|
// 1. Clean cache_control
|
||||||
|
if removeCacheControlFromAny(blocks[blockIdx]) {
|
||||||
|
log.Printf("[Antigravity] Deep cleaned cache_control from thinking block in messages[%d].content[%d]", msgIdx, blockIdx)
|
||||||
|
cleaned = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Flatten to text if it's a history message (not the last one)
|
||||||
|
if msgIdx < lastMsgIdx {
|
||||||
|
log.Printf("[Antigravity] Flattening history thinking block to text at messages[%d].content[%d]", msgIdx, blockIdx)
|
||||||
|
|
||||||
|
// Extract thinking content
|
||||||
|
var textContent string
|
||||||
|
if t, ok := blocks[blockIdx]["thinking"].(string); ok {
|
||||||
|
textContent = t
|
||||||
|
} else {
|
||||||
|
// Fallback for non-string content (marshal it)
|
||||||
|
if b, err := json.Marshal(blocks[blockIdx]["thinking"]); err == nil {
|
||||||
|
textContent = string(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to text block
|
||||||
|
blocks[blockIdx]["type"] = "text"
|
||||||
|
blocks[blockIdx]["text"] = textContent
|
||||||
|
delete(blocks[blockIdx], "thinking")
|
||||||
|
delete(blocks[blockIdx], "signature")
|
||||||
|
delete(blocks[blockIdx], "cache_control") // Ensure it's gone
|
||||||
|
cleaned = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal back if modified
|
||||||
|
if cleaned {
|
||||||
|
if marshaled, err := json.Marshal(blocks); err == nil {
|
||||||
|
req.Messages[msgIdx].Content = marshaled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// stripThinkingFromClaudeRequest converts thinking blocks to text blocks in a Claude Messages request.
|
// stripThinkingFromClaudeRequest converts thinking blocks to text blocks in a Claude Messages request.
|
||||||
// This preserves the thinking content while avoiding signature validation errors.
|
// This preserves the thinking content while avoiding signature validation errors.
|
||||||
// Note: redacted_thinking blocks are removed because they cannot be converted to text.
|
// Note: redacted_thinking blocks are removed because they cannot be converted to text.
|
||||||
|
|||||||
@@ -1227,6 +1227,9 @@ func enforceCacheControlLimit(body []byte) []byte {
|
|||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清理 thinking 块中的非法 cache_control(thinking 块不支持该字段)
|
||||||
|
removeCacheControlFromThinkingBlocks(data)
|
||||||
|
|
||||||
// 计算当前 cache_control 块数量
|
// 计算当前 cache_control 块数量
|
||||||
count := countCacheControlBlocks(data)
|
count := countCacheControlBlocks(data)
|
||||||
if count <= maxCacheControlBlocks {
|
if count <= maxCacheControlBlocks {
|
||||||
@@ -1254,6 +1257,7 @@ func enforceCacheControlLimit(body []byte) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// countCacheControlBlocks 统计 system 和 messages 中的 cache_control 块数量
|
// countCacheControlBlocks 统计 system 和 messages 中的 cache_control 块数量
|
||||||
|
// 注意:thinking 块不支持 cache_control,统计时跳过
|
||||||
func countCacheControlBlocks(data map[string]any) int {
|
func countCacheControlBlocks(data map[string]any) int {
|
||||||
count := 0
|
count := 0
|
||||||
|
|
||||||
@@ -1261,6 +1265,10 @@ func countCacheControlBlocks(data map[string]any) int {
|
|||||||
if system, ok := data["system"].([]any); ok {
|
if system, ok := data["system"].([]any); ok {
|
||||||
for _, item := range system {
|
for _, item := range system {
|
||||||
if m, ok := item.(map[string]any); ok {
|
if m, ok := item.(map[string]any); ok {
|
||||||
|
// thinking 块不支持 cache_control,跳过
|
||||||
|
if blockType, _ := m["type"].(string); blockType == "thinking" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if _, has := m["cache_control"]; has {
|
if _, has := m["cache_control"]; has {
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
@@ -1275,6 +1283,10 @@ func countCacheControlBlocks(data map[string]any) int {
|
|||||||
if content, ok := msgMap["content"].([]any); ok {
|
if content, ok := msgMap["content"].([]any); ok {
|
||||||
for _, item := range content {
|
for _, item := range content {
|
||||||
if m, ok := item.(map[string]any); ok {
|
if m, ok := item.(map[string]any); ok {
|
||||||
|
// thinking 块不支持 cache_control,跳过
|
||||||
|
if blockType, _ := m["type"].(string); blockType == "thinking" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if _, has := m["cache_control"]; has {
|
if _, has := m["cache_control"]; has {
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
@@ -1290,6 +1302,7 @@ func countCacheControlBlocks(data map[string]any) int {
|
|||||||
|
|
||||||
// removeCacheControlFromMessages 从 messages 中移除一个 cache_control(从头开始)
|
// removeCacheControlFromMessages 从 messages 中移除一个 cache_control(从头开始)
|
||||||
// 返回 true 表示成功移除,false 表示没有可移除的
|
// 返回 true 表示成功移除,false 表示没有可移除的
|
||||||
|
// 注意:跳过 thinking 块(它不支持 cache_control)
|
||||||
func removeCacheControlFromMessages(data map[string]any) bool {
|
func removeCacheControlFromMessages(data map[string]any) bool {
|
||||||
messages, ok := data["messages"].([]any)
|
messages, ok := data["messages"].([]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -1307,6 +1320,10 @@ func removeCacheControlFromMessages(data map[string]any) bool {
|
|||||||
}
|
}
|
||||||
for _, item := range content {
|
for _, item := range content {
|
||||||
if m, ok := item.(map[string]any); ok {
|
if m, ok := item.(map[string]any); ok {
|
||||||
|
// thinking 块不支持 cache_control,跳过
|
||||||
|
if blockType, _ := m["type"].(string); blockType == "thinking" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if _, has := m["cache_control"]; has {
|
if _, has := m["cache_control"]; has {
|
||||||
delete(m, "cache_control")
|
delete(m, "cache_control")
|
||||||
return true
|
return true
|
||||||
@@ -1319,6 +1336,7 @@ func removeCacheControlFromMessages(data map[string]any) bool {
|
|||||||
|
|
||||||
// removeCacheControlFromSystem 从 system 中移除一个 cache_control(从尾部开始,保护注入的 prompt)
|
// removeCacheControlFromSystem 从 system 中移除一个 cache_control(从尾部开始,保护注入的 prompt)
|
||||||
// 返回 true 表示成功移除,false 表示没有可移除的
|
// 返回 true 表示成功移除,false 表示没有可移除的
|
||||||
|
// 注意:跳过 thinking 块(它不支持 cache_control)
|
||||||
func removeCacheControlFromSystem(data map[string]any) bool {
|
func removeCacheControlFromSystem(data map[string]any) bool {
|
||||||
system, ok := data["system"].([]any)
|
system, ok := data["system"].([]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -1328,6 +1346,10 @@ func removeCacheControlFromSystem(data map[string]any) bool {
|
|||||||
// 从尾部开始移除,保护开头注入的 Claude Code prompt
|
// 从尾部开始移除,保护开头注入的 Claude Code prompt
|
||||||
for i := len(system) - 1; i >= 0; i-- {
|
for i := len(system) - 1; i >= 0; i-- {
|
||||||
if m, ok := system[i].(map[string]any); ok {
|
if m, ok := system[i].(map[string]any); ok {
|
||||||
|
// thinking 块不支持 cache_control,跳过
|
||||||
|
if blockType, _ := m["type"].(string); blockType == "thinking" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if _, has := m["cache_control"]; has {
|
if _, has := m["cache_control"]; has {
|
||||||
delete(m, "cache_control")
|
delete(m, "cache_control")
|
||||||
return true
|
return true
|
||||||
@@ -1337,6 +1359,44 @@ func removeCacheControlFromSystem(data map[string]any) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// removeCacheControlFromThinkingBlocks 强制清理所有 thinking 块中的非法 cache_control
|
||||||
|
// thinking 块不支持 cache_control 字段,这个函数确保所有 thinking 块都不含该字段
|
||||||
|
func removeCacheControlFromThinkingBlocks(data map[string]any) {
|
||||||
|
// 清理 system 中的 thinking 块
|
||||||
|
if system, ok := data["system"].([]any); ok {
|
||||||
|
for _, item := range system {
|
||||||
|
if m, ok := item.(map[string]any); ok {
|
||||||
|
if blockType, _ := m["type"].(string); blockType == "thinking" {
|
||||||
|
if _, has := m["cache_control"]; has {
|
||||||
|
delete(m, "cache_control")
|
||||||
|
log.Printf("[Warning] Removed illegal cache_control from thinking block in system")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理 messages 中的 thinking 块
|
||||||
|
if messages, ok := data["messages"].([]any); ok {
|
||||||
|
for msgIdx, msg := range messages {
|
||||||
|
if msgMap, ok := msg.(map[string]any); ok {
|
||||||
|
if content, ok := msgMap["content"].([]any); ok {
|
||||||
|
for contentIdx, item := range content {
|
||||||
|
if m, ok := item.(map[string]any); ok {
|
||||||
|
if blockType, _ := m["type"].(string); blockType == "thinking" {
|
||||||
|
if _, has := m["cache_control"]; has {
|
||||||
|
delete(m, "cache_control")
|
||||||
|
log.Printf("[Warning] Removed illegal cache_control from thinking block in messages[%d].content[%d]", msgIdx, contentIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Forward 转发请求到Claude API
|
// Forward 转发请求到Claude API
|
||||||
func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) (*ForwardResult, error) {
|
func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) (*ForwardResult, error) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
|||||||
@@ -545,14 +545,12 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
|||||||
|
|
||||||
isCodexCLI := openai.IsCodexCLIRequest(c.GetHeader("User-Agent"))
|
isCodexCLI := openai.IsCodexCLIRequest(c.GetHeader("User-Agent"))
|
||||||
|
|
||||||
// Apply model mapping (skip for Codex CLI for transparent forwarding)
|
// Apply model mapping for all requests (including Codex CLI)
|
||||||
mappedModel := reqModel
|
mappedModel := account.GetMappedModel(reqModel)
|
||||||
if !isCodexCLI {
|
if mappedModel != reqModel {
|
||||||
mappedModel = account.GetMappedModel(reqModel)
|
log.Printf("[OpenAI] Model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", reqModel, mappedModel, account.Name, isCodexCLI)
|
||||||
if mappedModel != reqModel {
|
reqBody["model"] = mappedModel
|
||||||
reqBody["model"] = mappedModel
|
bodyModified = true
|
||||||
bodyModified = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if account.Type == AccountTypeOAuth && !isCodexCLI {
|
if account.Type == AccountTypeOAuth && !isCodexCLI {
|
||||||
@@ -568,6 +566,44 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle max_output_tokens based on platform and account type
|
||||||
|
if !isCodexCLI {
|
||||||
|
if maxOutputTokens, hasMaxOutputTokens := reqBody["max_output_tokens"]; hasMaxOutputTokens {
|
||||||
|
switch account.Platform {
|
||||||
|
case PlatformOpenAI:
|
||||||
|
// For OpenAI API Key, remove max_output_tokens (not supported)
|
||||||
|
// For OpenAI OAuth (Responses API), keep it (supported)
|
||||||
|
if account.Type == AccountTypeAPIKey {
|
||||||
|
delete(reqBody, "max_output_tokens")
|
||||||
|
bodyModified = true
|
||||||
|
}
|
||||||
|
case PlatformAnthropic:
|
||||||
|
// For Anthropic (Claude), convert to max_tokens
|
||||||
|
delete(reqBody, "max_output_tokens")
|
||||||
|
if _, hasMaxTokens := reqBody["max_tokens"]; !hasMaxTokens {
|
||||||
|
reqBody["max_tokens"] = maxOutputTokens
|
||||||
|
}
|
||||||
|
bodyModified = true
|
||||||
|
case PlatformGemini:
|
||||||
|
// For Gemini, remove (will be handled by Gemini-specific transform)
|
||||||
|
delete(reqBody, "max_output_tokens")
|
||||||
|
bodyModified = true
|
||||||
|
default:
|
||||||
|
// For unknown platforms, remove to be safe
|
||||||
|
delete(reqBody, "max_output_tokens")
|
||||||
|
bodyModified = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also handle max_completion_tokens (similar logic)
|
||||||
|
if _, hasMaxCompletionTokens := reqBody["max_completion_tokens"]; hasMaxCompletionTokens {
|
||||||
|
if account.Type == AccountTypeAPIKey || account.Platform != PlatformOpenAI {
|
||||||
|
delete(reqBody, "max_completion_tokens")
|
||||||
|
bodyModified = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Re-serialize body only if modified
|
// Re-serialize body only if modified
|
||||||
if bodyModified {
|
if bodyModified {
|
||||||
var err error
|
var err error
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ type OpsRepository interface {
|
|||||||
|
|
||||||
// Lightweight window stats (for realtime WS / quick sampling).
|
// Lightweight window stats (for realtime WS / quick sampling).
|
||||||
GetWindowStats(ctx context.Context, filter *OpsDashboardFilter) (*OpsWindowStats, error)
|
GetWindowStats(ctx context.Context, filter *OpsDashboardFilter) (*OpsWindowStats, error)
|
||||||
|
// Lightweight realtime traffic summary (for the Ops dashboard header card).
|
||||||
|
GetRealtimeTrafficSummary(ctx context.Context, filter *OpsDashboardFilter) (*OpsRealtimeTrafficSummary, error)
|
||||||
|
|
||||||
GetDashboardOverview(ctx context.Context, filter *OpsDashboardFilter) (*OpsDashboardOverview, error)
|
GetDashboardOverview(ctx context.Context, filter *OpsDashboardFilter) (*OpsDashboardOverview, error)
|
||||||
GetThroughputTrend(ctx context.Context, filter *OpsDashboardFilter, bucketSeconds int) (*OpsThroughputTrendResponse, error)
|
GetThroughputTrend(ctx context.Context, filter *OpsDashboardFilter, bucketSeconds int) (*OpsThroughputTrendResponse, error)
|
||||||
@@ -71,6 +73,7 @@ type OpsInsertErrorLogInput struct {
|
|||||||
Severity string
|
Severity string
|
||||||
StatusCode int
|
StatusCode int
|
||||||
IsBusinessLimited bool
|
IsBusinessLimited bool
|
||||||
|
IsCountTokens bool // 是否为 count_tokens 请求
|
||||||
|
|
||||||
ErrorMessage string
|
ErrorMessage string
|
||||||
ErrorBody string
|
ErrorBody string
|
||||||
|
|||||||
36
backend/internal/service/ops_realtime_traffic.go
Normal file
36
backend/internal/service/ops_realtime_traffic.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetRealtimeTrafficSummary returns QPS/TPS current/peak/avg for the provided window.
|
||||||
|
// This is used by the Ops dashboard "Realtime Traffic" card and is intentionally lightweight.
|
||||||
|
func (s *OpsService) GetRealtimeTrafficSummary(ctx context.Context, filter *OpsDashboardFilter) (*OpsRealtimeTrafficSummary, error) {
|
||||||
|
if err := s.RequireMonitoringEnabled(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if s.opsRepo == nil {
|
||||||
|
return nil, infraerrors.ServiceUnavailable("OPS_REPO_UNAVAILABLE", "Ops repository not available")
|
||||||
|
}
|
||||||
|
if filter == nil {
|
||||||
|
return nil, infraerrors.BadRequest("OPS_FILTER_REQUIRED", "filter is required")
|
||||||
|
}
|
||||||
|
if filter.StartTime.IsZero() || filter.EndTime.IsZero() {
|
||||||
|
return nil, infraerrors.BadRequest("OPS_TIME_RANGE_REQUIRED", "start_time/end_time are required")
|
||||||
|
}
|
||||||
|
if filter.StartTime.After(filter.EndTime) {
|
||||||
|
return nil, infraerrors.BadRequest("OPS_TIME_RANGE_INVALID", "start_time must be <= end_time")
|
||||||
|
}
|
||||||
|
if filter.EndTime.Sub(filter.StartTime) > time.Hour {
|
||||||
|
return nil, infraerrors.BadRequest("OPS_TIME_RANGE_TOO_LARGE", "invalid time range: max window is 1 hour")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Realtime traffic summary always uses raw logs (minute granularity peaks).
|
||||||
|
filter.QueryMode = OpsQueryModeRaw
|
||||||
|
|
||||||
|
return s.opsRepo.GetRealtimeTrafficSummary(ctx, filter)
|
||||||
|
}
|
||||||
19
backend/internal/service/ops_realtime_traffic_models.go
Normal file
19
backend/internal/service/ops_realtime_traffic_models.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// OpsRealtimeTrafficSummary is a lightweight summary used by the Ops dashboard "Realtime Traffic" card.
|
||||||
|
// It reports QPS/TPS current/peak/avg for the requested time window.
|
||||||
|
type OpsRealtimeTrafficSummary struct {
|
||||||
|
// Window is a normalized label (e.g. "1min", "5min", "30min", "1h").
|
||||||
|
Window string `json:"window"`
|
||||||
|
|
||||||
|
StartTime time.Time `json:"start_time"`
|
||||||
|
EndTime time.Time `json:"end_time"`
|
||||||
|
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
GroupID *int64 `json:"group_id"`
|
||||||
|
|
||||||
|
QPS OpsRateSummary `json:"qps"`
|
||||||
|
TPS OpsRateSummary `json:"tps"`
|
||||||
|
}
|
||||||
@@ -368,6 +368,9 @@ func defaultOpsAdvancedSettings() *OpsAdvancedSettings {
|
|||||||
Aggregation: OpsAggregationSettings{
|
Aggregation: OpsAggregationSettings{
|
||||||
AggregationEnabled: false,
|
AggregationEnabled: false,
|
||||||
},
|
},
|
||||||
|
IgnoreCountTokensErrors: false,
|
||||||
|
AutoRefreshEnabled: false,
|
||||||
|
AutoRefreshIntervalSec: 30,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,6 +391,10 @@ func normalizeOpsAdvancedSettings(cfg *OpsAdvancedSettings) {
|
|||||||
if cfg.DataRetention.HourlyMetricsRetentionDays <= 0 {
|
if cfg.DataRetention.HourlyMetricsRetentionDays <= 0 {
|
||||||
cfg.DataRetention.HourlyMetricsRetentionDays = 30
|
cfg.DataRetention.HourlyMetricsRetentionDays = 30
|
||||||
}
|
}
|
||||||
|
// Normalize auto refresh interval (default 30 seconds)
|
||||||
|
if cfg.AutoRefreshIntervalSec <= 0 {
|
||||||
|
cfg.AutoRefreshIntervalSec = 30
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateOpsAdvancedSettings(cfg *OpsAdvancedSettings) error {
|
func validateOpsAdvancedSettings(cfg *OpsAdvancedSettings) error {
|
||||||
@@ -403,6 +410,9 @@ func validateOpsAdvancedSettings(cfg *OpsAdvancedSettings) error {
|
|||||||
if cfg.DataRetention.HourlyMetricsRetentionDays < 1 || cfg.DataRetention.HourlyMetricsRetentionDays > 365 {
|
if cfg.DataRetention.HourlyMetricsRetentionDays < 1 || cfg.DataRetention.HourlyMetricsRetentionDays > 365 {
|
||||||
return errors.New("hourly_metrics_retention_days must be between 1 and 365")
|
return errors.New("hourly_metrics_retention_days must be between 1 and 365")
|
||||||
}
|
}
|
||||||
|
if cfg.AutoRefreshIntervalSec < 15 || cfg.AutoRefreshIntervalSec > 300 {
|
||||||
|
return errors.New("auto_refresh_interval_seconds must be between 15 and 300")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,3 +473,93 @@ func (s *OpsService) UpdateOpsAdvancedSettings(ctx context.Context, cfg *OpsAdva
|
|||||||
_ = json.Unmarshal(raw, updated)
|
_ = json.Unmarshal(raw, updated)
|
||||||
return updated, nil
|
return updated, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// Metric thresholds
|
||||||
|
// =========================
|
||||||
|
|
||||||
|
const SettingKeyOpsMetricThresholds = "ops_metric_thresholds"
|
||||||
|
|
||||||
|
func defaultOpsMetricThresholds() *OpsMetricThresholds {
|
||||||
|
slaMin := 99.5
|
||||||
|
latencyMax := 2000.0
|
||||||
|
ttftMax := 500.0
|
||||||
|
reqErrMax := 5.0
|
||||||
|
upstreamErrMax := 5.0
|
||||||
|
return &OpsMetricThresholds{
|
||||||
|
SLAPercentMin: &slaMin,
|
||||||
|
LatencyP99MsMax: &latencyMax,
|
||||||
|
TTFTp99MsMax: &ttftMax,
|
||||||
|
RequestErrorRatePercentMax: &reqErrMax,
|
||||||
|
UpstreamErrorRatePercentMax: &upstreamErrMax,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OpsService) GetMetricThresholds(ctx context.Context) (*OpsMetricThresholds, error) {
|
||||||
|
defaultCfg := defaultOpsMetricThresholds()
|
||||||
|
if s == nil || s.settingRepo == nil {
|
||||||
|
return defaultCfg, nil
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := s.settingRepo.GetValue(ctx, SettingKeyOpsMetricThresholds)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrSettingNotFound) {
|
||||||
|
if b, mErr := json.Marshal(defaultCfg); mErr == nil {
|
||||||
|
_ = s.settingRepo.Set(ctx, SettingKeyOpsMetricThresholds, string(b))
|
||||||
|
}
|
||||||
|
return defaultCfg, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &OpsMetricThresholds{}
|
||||||
|
if err := json.Unmarshal([]byte(raw), cfg); err != nil {
|
||||||
|
return defaultCfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OpsService) UpdateMetricThresholds(ctx context.Context, cfg *OpsMetricThresholds) (*OpsMetricThresholds, error) {
|
||||||
|
if s == nil || s.settingRepo == nil {
|
||||||
|
return nil, errors.New("setting repository not initialized")
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
return nil, errors.New("invalid config")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate thresholds
|
||||||
|
if cfg.SLAPercentMin != nil && (*cfg.SLAPercentMin < 0 || *cfg.SLAPercentMin > 100) {
|
||||||
|
return nil, errors.New("sla_percent_min must be between 0 and 100")
|
||||||
|
}
|
||||||
|
if cfg.LatencyP99MsMax != nil && *cfg.LatencyP99MsMax < 0 {
|
||||||
|
return nil, errors.New("latency_p99_ms_max must be >= 0")
|
||||||
|
}
|
||||||
|
if cfg.TTFTp99MsMax != nil && *cfg.TTFTp99MsMax < 0 {
|
||||||
|
return nil, errors.New("ttft_p99_ms_max must be >= 0")
|
||||||
|
}
|
||||||
|
if cfg.RequestErrorRatePercentMax != nil && (*cfg.RequestErrorRatePercentMax < 0 || *cfg.RequestErrorRatePercentMax > 100) {
|
||||||
|
return nil, errors.New("request_error_rate_percent_max must be between 0 and 100")
|
||||||
|
}
|
||||||
|
if cfg.UpstreamErrorRatePercentMax != nil && (*cfg.UpstreamErrorRatePercentMax < 0 || *cfg.UpstreamErrorRatePercentMax > 100) {
|
||||||
|
return nil, errors.New("upstream_error_rate_percent_max must be between 0 and 100")
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.settingRepo.Set(ctx, SettingKeyOpsMetricThresholds, string(raw)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := &OpsMetricThresholds{}
|
||||||
|
_ = json.Unmarshal(raw, updated)
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,17 +61,29 @@ type OpsAlertSilencingSettings struct {
|
|||||||
Entries []OpsAlertSilenceEntry `json:"entries,omitempty"`
|
Entries []OpsAlertSilenceEntry `json:"entries,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OpsMetricThresholds struct {
|
||||||
|
SLAPercentMin *float64 `json:"sla_percent_min,omitempty"` // SLA低于此值变红
|
||||||
|
LatencyP99MsMax *float64 `json:"latency_p99_ms_max,omitempty"` // 延迟P99高于此值变红
|
||||||
|
TTFTp99MsMax *float64 `json:"ttft_p99_ms_max,omitempty"` // TTFT P99高于此值变红
|
||||||
|
RequestErrorRatePercentMax *float64 `json:"request_error_rate_percent_max,omitempty"` // 请求错误率高于此值变红
|
||||||
|
UpstreamErrorRatePercentMax *float64 `json:"upstream_error_rate_percent_max,omitempty"` // 上游错误率高于此值变红
|
||||||
|
}
|
||||||
|
|
||||||
type OpsAlertRuntimeSettings struct {
|
type OpsAlertRuntimeSettings struct {
|
||||||
EvaluationIntervalSeconds int `json:"evaluation_interval_seconds"`
|
EvaluationIntervalSeconds int `json:"evaluation_interval_seconds"`
|
||||||
|
|
||||||
DistributedLock OpsDistributedLockSettings `json:"distributed_lock"`
|
DistributedLock OpsDistributedLockSettings `json:"distributed_lock"`
|
||||||
Silencing OpsAlertSilencingSettings `json:"silencing"`
|
Silencing OpsAlertSilencingSettings `json:"silencing"`
|
||||||
|
Thresholds OpsMetricThresholds `json:"thresholds"` // 指标阈值配置
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpsAdvancedSettings stores advanced ops configuration (data retention, aggregation).
|
// OpsAdvancedSettings stores advanced ops configuration (data retention, aggregation).
|
||||||
type OpsAdvancedSettings struct {
|
type OpsAdvancedSettings struct {
|
||||||
DataRetention OpsDataRetentionSettings `json:"data_retention"`
|
DataRetention OpsDataRetentionSettings `json:"data_retention"`
|
||||||
Aggregation OpsAggregationSettings `json:"aggregation"`
|
Aggregation OpsAggregationSettings `json:"aggregation"`
|
||||||
|
IgnoreCountTokensErrors bool `json:"ignore_count_tokens_errors"`
|
||||||
|
AutoRefreshEnabled bool `json:"auto_refresh_enabled"`
|
||||||
|
AutoRefreshIntervalSec int `json:"auto_refresh_interval_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OpsDataRetentionSettings struct {
|
type OpsDataRetentionSettings struct {
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ func (s *RateLimitService) SetSettingService(settingService *SettingService) {
|
|||||||
func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Account, statusCode int, headers http.Header, responseBody []byte) (shouldDisable bool) {
|
func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Account, statusCode int, headers http.Header, responseBody []byte) (shouldDisable bool) {
|
||||||
// apikey 类型账号:检查自定义错误码配置
|
// apikey 类型账号:检查自定义错误码配置
|
||||||
// 如果启用且错误码不在列表中,则不处理(不停止调度、不标记限流/过载)
|
// 如果启用且错误码不在列表中,则不处理(不停止调度、不标记限流/过载)
|
||||||
|
customErrorCodesEnabled := account.IsCustomErrorCodesEnabled()
|
||||||
if !account.ShouldHandleErrorCode(statusCode) {
|
if !account.ShouldHandleErrorCode(statusCode) {
|
||||||
log.Printf("Account %d: error %d skipped (not in custom error codes)", account.ID, statusCode)
|
log.Printf("Account %d: error %d skipped (not in custom error codes)", account.ID, statusCode)
|
||||||
return false
|
return false
|
||||||
@@ -105,11 +106,19 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
|
|||||||
s.handle529(ctx, account)
|
s.handle529(ctx, account)
|
||||||
shouldDisable = false
|
shouldDisable = false
|
||||||
default:
|
default:
|
||||||
// 其他5xx错误:记录但不停止调度
|
// 自定义错误码启用时:在列表中的错误码都应该停止调度
|
||||||
if statusCode >= 500 {
|
if customErrorCodesEnabled {
|
||||||
|
msg := "Custom error code triggered"
|
||||||
|
if upstreamMsg != "" {
|
||||||
|
msg = upstreamMsg
|
||||||
|
}
|
||||||
|
s.handleCustomErrorCode(ctx, account, statusCode, msg)
|
||||||
|
shouldDisable = true
|
||||||
|
} else if statusCode >= 500 {
|
||||||
|
// 未启用自定义错误码时:仅记录5xx错误
|
||||||
log.Printf("Account %d received upstream error %d", account.ID, statusCode)
|
log.Printf("Account %d received upstream error %d", account.ID, statusCode)
|
||||||
|
shouldDisable = false
|
||||||
}
|
}
|
||||||
shouldDisable = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if tempMatched {
|
if tempMatched {
|
||||||
@@ -285,6 +294,16 @@ func (s *RateLimitService) handleAuthError(ctx context.Context, account *Account
|
|||||||
log.Printf("Account %d disabled due to auth error: %s", account.ID, errorMsg)
|
log.Printf("Account %d disabled due to auth error: %s", account.ID, errorMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleCustomErrorCode 处理自定义错误码,停止账号调度
|
||||||
|
func (s *RateLimitService) handleCustomErrorCode(ctx context.Context, account *Account, statusCode int, errorMsg string) {
|
||||||
|
msg := "Custom error code " + strconv.Itoa(statusCode) + ": " + errorMsg
|
||||||
|
if err := s.accountRepo.SetError(ctx, account.ID, msg); err != nil {
|
||||||
|
log.Printf("SetError failed for account %d: %v", account.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("Account %d disabled due to custom error code %d: %s", account.ID, statusCode, errorMsg)
|
||||||
|
}
|
||||||
|
|
||||||
// handle429 处理429限流错误
|
// handle429 处理429限流错误
|
||||||
// 解析响应头获取重置时间,标记账号为限流状态
|
// 解析响应头获取重置时间,标记账号为限流状态
|
||||||
func (s *RateLimitService) handle429(ctx context.Context, account *Account, headers http.Header) {
|
func (s *RateLimitService) handle429(ctx context.Context, account *Account, headers http.Header) {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Migration: 添加 is_count_tokens 字段到 ops_error_logs 表
|
||||||
|
-- Purpose: 标记 count_tokens 请求的错误,以便在统计和告警中根据配置动态过滤
|
||||||
|
-- Author: System
|
||||||
|
-- Date: 2026-01-12
|
||||||
|
|
||||||
|
-- Add is_count_tokens column to ops_error_logs table
|
||||||
|
ALTER TABLE ops_error_logs
|
||||||
|
ADD COLUMN is_count_tokens BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- Add comment
|
||||||
|
COMMENT ON COLUMN ops_error_logs.is_count_tokens IS '是否为 count_tokens 请求的错误(用于统计过滤)';
|
||||||
|
|
||||||
|
-- Create index for filtering (optional, improves query performance)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ops_error_logs_is_count_tokens
|
||||||
|
ON ops_error_logs(is_count_tokens)
|
||||||
|
WHERE is_count_tokens = TRUE;
|
||||||
14
frontend/.eslintignore
Normal file
14
frontend/.eslintignore
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# 忽略编译后的文件
|
||||||
|
vite.config.js
|
||||||
|
vite.config.d.ts
|
||||||
|
|
||||||
|
# 忽略依赖
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# 忽略构建输出
|
||||||
|
dist/
|
||||||
|
../backend/internal/web/dist/
|
||||||
|
|
||||||
|
# 忽略缓存
|
||||||
|
.cache/
|
||||||
|
.vite/
|
||||||
@@ -362,6 +362,45 @@ export async function getAccountAvailabilityStats(platform?: string, groupId?: n
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OpsRateSummary {
|
||||||
|
current: number
|
||||||
|
peak: number
|
||||||
|
avg: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpsRealtimeTrafficSummary {
|
||||||
|
window: string
|
||||||
|
start_time: string
|
||||||
|
end_time: string
|
||||||
|
platform: string
|
||||||
|
group_id?: number | null
|
||||||
|
qps: OpsRateSummary
|
||||||
|
tps: OpsRateSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpsRealtimeTrafficSummaryResponse {
|
||||||
|
enabled: boolean
|
||||||
|
summary: OpsRealtimeTrafficSummary | null
|
||||||
|
timestamp?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRealtimeTrafficSummary(
|
||||||
|
window: string,
|
||||||
|
platform?: string,
|
||||||
|
groupId?: number | null
|
||||||
|
): Promise<OpsRealtimeTrafficSummaryResponse> {
|
||||||
|
const params: Record<string, any> = { window }
|
||||||
|
if (platform) {
|
||||||
|
params.platform = platform
|
||||||
|
}
|
||||||
|
if (typeof groupId === 'number' && groupId > 0) {
|
||||||
|
params.group_id = groupId
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await apiClient.get<OpsRealtimeTrafficSummaryResponse>('/admin/ops/realtime-traffic', { params })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe to realtime QPS updates via WebSocket.
|
* Subscribe to realtime QPS updates via WebSocket.
|
||||||
*
|
*
|
||||||
@@ -661,6 +700,14 @@ export interface EmailNotificationConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OpsMetricThresholds {
|
||||||
|
sla_percent_min?: number | null // SLA低于此值变红
|
||||||
|
latency_p99_ms_max?: number | null // 延迟P99高于此值变红
|
||||||
|
ttft_p99_ms_max?: number | null // TTFT P99高于此值变红
|
||||||
|
request_error_rate_percent_max?: number | null // 请求错误率高于此值变红
|
||||||
|
upstream_error_rate_percent_max?: number | null // 上游错误率高于此值变红
|
||||||
|
}
|
||||||
|
|
||||||
export interface OpsDistributedLockSettings {
|
export interface OpsDistributedLockSettings {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
key: string
|
key: string
|
||||||
@@ -681,11 +728,15 @@ export interface OpsAlertRuntimeSettings {
|
|||||||
reason: string
|
reason: string
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
thresholds: OpsMetricThresholds // 指标阈值配置
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpsAdvancedSettings {
|
export interface OpsAdvancedSettings {
|
||||||
data_retention: OpsDataRetentionSettings
|
data_retention: OpsDataRetentionSettings
|
||||||
aggregation: OpsAggregationSettings
|
aggregation: OpsAggregationSettings
|
||||||
|
ignore_count_tokens_errors: boolean
|
||||||
|
auto_refresh_enabled: boolean
|
||||||
|
auto_refresh_interval_seconds: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpsDataRetentionSettings {
|
export interface OpsDataRetentionSettings {
|
||||||
@@ -929,6 +980,17 @@ export async function updateAdvancedSettings(config: OpsAdvancedSettings): Promi
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Metric Thresholds ====================
|
||||||
|
|
||||||
|
async function getMetricThresholds(): Promise<OpsMetricThresholds> {
|
||||||
|
const { data } = await apiClient.get<OpsMetricThresholds>('/admin/ops/settings/metric-thresholds')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateMetricThresholds(thresholds: OpsMetricThresholds): Promise<void> {
|
||||||
|
await apiClient.put('/admin/ops/settings/metric-thresholds', thresholds)
|
||||||
|
}
|
||||||
|
|
||||||
export const opsAPI = {
|
export const opsAPI = {
|
||||||
getDashboardOverview,
|
getDashboardOverview,
|
||||||
getThroughputTrend,
|
getThroughputTrend,
|
||||||
@@ -937,6 +999,7 @@ export const opsAPI = {
|
|||||||
getErrorDistribution,
|
getErrorDistribution,
|
||||||
getConcurrencyStats,
|
getConcurrencyStats,
|
||||||
getAccountAvailabilityStats,
|
getAccountAvailabilityStats,
|
||||||
|
getRealtimeTrafficSummary,
|
||||||
subscribeQPS,
|
subscribeQPS,
|
||||||
listErrorLogs,
|
listErrorLogs,
|
||||||
getErrorLogDetail,
|
getErrorLogDetail,
|
||||||
@@ -952,7 +1015,9 @@ export const opsAPI = {
|
|||||||
getAlertRuntimeSettings,
|
getAlertRuntimeSettings,
|
||||||
updateAlertRuntimeSettings,
|
updateAlertRuntimeSettings,
|
||||||
getAdvancedSettings,
|
getAdvancedSettings,
|
||||||
updateAdvancedSettings
|
updateAdvancedSettings,
|
||||||
|
getMetricThresholds,
|
||||||
|
updateMetricThresholds
|
||||||
}
|
}
|
||||||
|
|
||||||
export default opsAPI
|
export default opsAPI
|
||||||
|
|||||||
158
frontend/src/components/account/AccountGroupsCell.vue
Normal file
158
frontend/src/components/account/AccountGroupsCell.vue
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="groups && groups.length > 0" class="relative max-w-56">
|
||||||
|
<!-- 分组容器:固定最大宽度,最多显示2行 -->
|
||||||
|
<div class="flex flex-wrap gap-1 max-h-14 overflow-hidden">
|
||||||
|
<GroupBadge
|
||||||
|
v-for="group in displayGroups"
|
||||||
|
:key="group.id"
|
||||||
|
:name="group.name"
|
||||||
|
:platform="group.platform"
|
||||||
|
:subscription-type="group.subscription_type"
|
||||||
|
:rate-multiplier="group.rate_multiplier"
|
||||||
|
:show-rate="false"
|
||||||
|
class="max-w-24"
|
||||||
|
/>
|
||||||
|
<!-- 更多数量徽章 -->
|
||||||
|
<button
|
||||||
|
v-if="hiddenCount > 0"
|
||||||
|
ref="moreButtonRef"
|
||||||
|
@click.stop="showPopover = !showPopover"
|
||||||
|
class="inline-flex items-center gap-0.5 rounded-md px-1.5 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500 transition-colors cursor-pointer whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<span>+{{ hiddenCount }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Popover 显示完整列表 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition duration-150 ease-out"
|
||||||
|
enter-from-class="opacity-0 scale-95"
|
||||||
|
enter-to-class="opacity-100 scale-100"
|
||||||
|
leave-active-class="transition duration-100 ease-in"
|
||||||
|
leave-from-class="opacity-100 scale-100"
|
||||||
|
leave-to-class="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="showPopover"
|
||||||
|
ref="popoverRef"
|
||||||
|
class="fixed z-50 min-w-48 max-w-96 rounded-lg border border-gray-200 bg-white p-3 shadow-lg dark:border-dark-600 dark:bg-dark-800"
|
||||||
|
:style="popoverStyle"
|
||||||
|
>
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.accounts.allGroups', { count: groups.length }) }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
@click="showPopover = false"
|
||||||
|
class="rounded p-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dark-700 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-1.5 max-h-64 overflow-y-auto">
|
||||||
|
<GroupBadge
|
||||||
|
v-for="group in groups"
|
||||||
|
:key="group.id"
|
||||||
|
:name="group.name"
|
||||||
|
:platform="group.platform"
|
||||||
|
:subscription-type="group.subscription_type"
|
||||||
|
:rate-multiplier="group.rate_multiplier"
|
||||||
|
:show-rate="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- 点击外部关闭 popover -->
|
||||||
|
<div
|
||||||
|
v-if="showPopover"
|
||||||
|
class="fixed inset-0 z-40"
|
||||||
|
@click="showPopover = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||||
|
import type { Group } from '@/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
groups: Group[] | null | undefined
|
||||||
|
maxDisplay?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
maxDisplay: 4
|
||||||
|
})
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const moreButtonRef = ref<HTMLElement | null>(null)
|
||||||
|
const popoverRef = ref<HTMLElement | null>(null)
|
||||||
|
const showPopover = ref(false)
|
||||||
|
|
||||||
|
// 显示的分组(最多显示 maxDisplay 个)
|
||||||
|
const displayGroups = computed(() => {
|
||||||
|
if (!props.groups) return []
|
||||||
|
if (props.groups.length <= props.maxDisplay) {
|
||||||
|
return props.groups
|
||||||
|
}
|
||||||
|
// 留一个位置给 +N 按钮
|
||||||
|
return props.groups.slice(0, props.maxDisplay - 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 隐藏的数量
|
||||||
|
const hiddenCount = computed(() => {
|
||||||
|
if (!props.groups) return 0
|
||||||
|
if (props.groups.length <= props.maxDisplay) return 0
|
||||||
|
return props.groups.length - (props.maxDisplay - 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Popover 位置样式
|
||||||
|
const popoverStyle = computed(() => {
|
||||||
|
if (!moreButtonRef.value) return {}
|
||||||
|
const rect = moreButtonRef.value.getBoundingClientRect()
|
||||||
|
const viewportHeight = window.innerHeight
|
||||||
|
const viewportWidth = window.innerWidth
|
||||||
|
|
||||||
|
let top = rect.bottom + 8
|
||||||
|
let left = rect.left
|
||||||
|
|
||||||
|
// 如果下方空间不足,显示在上方
|
||||||
|
if (top + 280 > viewportHeight) {
|
||||||
|
top = Math.max(8, rect.top - 280)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果右侧空间不足,向左偏移
|
||||||
|
if (left + 384 > viewportWidth) {
|
||||||
|
left = Math.max(8, viewportWidth - 392)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: `${top}px`,
|
||||||
|
left: `${left}px`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 关闭 popover 的键盘事件
|
||||||
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
showPopover.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', handleKeydown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', handleKeydown)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -778,6 +778,16 @@ const addPresetMapping = (from: string, to: string) => {
|
|||||||
const toggleErrorCode = (code: number) => {
|
const toggleErrorCode = (code: number) => {
|
||||||
const index = selectedErrorCodes.value.indexOf(code)
|
const index = selectedErrorCodes.value.indexOf(code)
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
|
// Adding code - check for 429/529 warning
|
||||||
|
if (code === 429) {
|
||||||
|
if (!confirm(t('admin.accounts.customErrorCodes429Warning'))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if (code === 529) {
|
||||||
|
if (!confirm(t('admin.accounts.customErrorCodes529Warning'))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
selectedErrorCodes.value.push(code)
|
selectedErrorCodes.value.push(code)
|
||||||
} else {
|
} else {
|
||||||
selectedErrorCodes.value.splice(index, 1)
|
selectedErrorCodes.value.splice(index, 1)
|
||||||
@@ -794,6 +804,16 @@ const addCustomErrorCode = () => {
|
|||||||
appStore.showInfo(t('admin.accounts.errorCodeExists'))
|
appStore.showInfo(t('admin.accounts.errorCodeExists'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Check for 429/529 warning
|
||||||
|
if (code === 429) {
|
||||||
|
if (!confirm(t('admin.accounts.customErrorCodes429Warning'))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if (code === 529) {
|
||||||
|
if (!confirm(t('admin.accounts.customErrorCodes529Warning'))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
selectedErrorCodes.value.push(code)
|
selectedErrorCodes.value.push(code)
|
||||||
customErrorCodeInput.value = null
|
customErrorCodeInput.value = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1976,6 +1976,16 @@ const addPresetMapping = (from: string, to: string) => {
|
|||||||
const toggleErrorCode = (code: number) => {
|
const toggleErrorCode = (code: number) => {
|
||||||
const index = selectedErrorCodes.value.indexOf(code)
|
const index = selectedErrorCodes.value.indexOf(code)
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
|
// Adding code - check for 429/529 warning
|
||||||
|
if (code === 429) {
|
||||||
|
if (!confirm(t('admin.accounts.customErrorCodes429Warning'))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if (code === 529) {
|
||||||
|
if (!confirm(t('admin.accounts.customErrorCodes529Warning'))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
selectedErrorCodes.value.push(code)
|
selectedErrorCodes.value.push(code)
|
||||||
} else {
|
} else {
|
||||||
selectedErrorCodes.value.splice(index, 1)
|
selectedErrorCodes.value.splice(index, 1)
|
||||||
@@ -1993,6 +2003,16 @@ const addCustomErrorCode = () => {
|
|||||||
appStore.showInfo(t('admin.accounts.errorCodeExists'))
|
appStore.showInfo(t('admin.accounts.errorCodeExists'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Check for 429/529 warning
|
||||||
|
if (code === 429) {
|
||||||
|
if (!confirm(t('admin.accounts.customErrorCodes429Warning'))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if (code === 529) {
|
||||||
|
if (!confirm(t('admin.accounts.customErrorCodes529Warning'))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
selectedErrorCodes.value.push(code)
|
selectedErrorCodes.value.push(code)
|
||||||
customErrorCodeInput.value = null
|
customErrorCodeInput.value = null
|
||||||
}
|
}
|
||||||
@@ -2462,6 +2482,7 @@ const handleCookieAuth = async (sessionKey: string) => {
|
|||||||
|
|
||||||
await adminAPI.accounts.create({
|
await adminAPI.accounts.create({
|
||||||
name: accountName,
|
name: accountName,
|
||||||
|
notes: form.notes,
|
||||||
platform: form.platform,
|
platform: form.platform,
|
||||||
type: addMethod.value, // Use addMethod as type: 'oauth' or 'setup-token'
|
type: addMethod.value, // Use addMethod as type: 'oauth' or 'setup-token'
|
||||||
credentials,
|
credentials,
|
||||||
@@ -2469,6 +2490,8 @@ const handleCookieAuth = async (sessionKey: string) => {
|
|||||||
proxy_id: form.proxy_id,
|
proxy_id: form.proxy_id,
|
||||||
concurrency: form.concurrency,
|
concurrency: form.concurrency,
|
||||||
priority: form.priority,
|
priority: form.priority,
|
||||||
|
group_ids: form.group_ids,
|
||||||
|
expires_at: form.expires_at,
|
||||||
auto_pause_on_expired: autoPauseOnExpired.value
|
auto_pause_on_expired: autoPauseOnExpired.value
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -936,6 +936,16 @@ const addPresetMapping = (from: string, to: string) => {
|
|||||||
const toggleErrorCode = (code: number) => {
|
const toggleErrorCode = (code: number) => {
|
||||||
const index = selectedErrorCodes.value.indexOf(code)
|
const index = selectedErrorCodes.value.indexOf(code)
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
|
// Adding code - check for 429/529 warning
|
||||||
|
if (code === 429) {
|
||||||
|
if (!confirm(t('admin.accounts.customErrorCodes429Warning'))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if (code === 529) {
|
||||||
|
if (!confirm(t('admin.accounts.customErrorCodes529Warning'))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
selectedErrorCodes.value.push(code)
|
selectedErrorCodes.value.push(code)
|
||||||
} else {
|
} else {
|
||||||
selectedErrorCodes.value.splice(index, 1)
|
selectedErrorCodes.value.splice(index, 1)
|
||||||
@@ -953,6 +963,16 @@ const addCustomErrorCode = () => {
|
|||||||
appStore.showInfo(t('admin.accounts.errorCodeExists'))
|
appStore.showInfo(t('admin.accounts.errorCodeExists'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Check for 429/529 warning
|
||||||
|
if (code === 429) {
|
||||||
|
if (!confirm(t('admin.accounts.customErrorCodes429Warning'))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if (code === 529) {
|
||||||
|
if (!confirm(t('admin.accounts.customErrorCodes529Warning'))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
selectedErrorCodes.value.push(code)
|
selectedErrorCodes.value.push(code)
|
||||||
customErrorCodeInput.value = null
|
customErrorCodeInput.value = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,8 @@ const icons = {
|
|||||||
chatBubble: 'M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z',
|
chatBubble: 'M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z',
|
||||||
calculator: 'M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z',
|
calculator: 'M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z',
|
||||||
fire: 'M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z',
|
fire: 'M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z',
|
||||||
badge: 'M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 01-1.043 3.296 3.745 3.745 0 01-3.296 1.043A3.745 3.745 0 0112 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 01-3.296-1.043 3.745 3.745 0 01-1.043-3.296A3.745 3.745 0 013 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 011.043-3.296 3.746 3.746 0 013.296-1.043A3.746 3.746 0 0112 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 013.296 1.043 3.746 3.746 0 011.043 3.296A3.745 3.745 0 0121 12z'
|
badge: 'M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 01-1.043 3.296 3.745 3.745 0 01-3.296 1.043A3.745 3.745 0 0112 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 01-3.296-1.043 3.745 3.745 0 01-1.043-3.296A3.745 3.745 0 013 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 011.043-3.296 3.746 3.746 0 013.296-1.043A3.746 3.746 0 0112 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 013.296 1.043 3.746 3.746 0 011.043 3.296A3.745 3.745 0 0121 12z',
|
||||||
|
brain: 'M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m0 0l-2.69 2.689c-1.232 1.232-.65 3.318 1.067 3.611A48.309 48.309 0 0012 21c2.773 0 5.491-.235 8.135-.687 1.718-.293 2.3-2.379 1.067-3.61L19.8 15.3M12 8.25a1.5 1.5 0 100-3 1.5 1.5 0 000 3zm0 0v3m-3-1.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3zm0 0h6m-3 4.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3z'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
const iconPath = computed(() => icons[props.name])
|
const iconPath = computed(() => icons[props.name])
|
||||||
|
|||||||
@@ -376,6 +376,10 @@ const currentFiles = computed((): FileConfig[] => {
|
|||||||
const trimmed = `${baseRoot}/antigravity`.replace(/\/+$/, '')
|
const trimmed = `${baseRoot}/antigravity`.replace(/\/+$/, '')
|
||||||
return trimmed.endsWith('/v1beta') ? trimmed : `${trimmed}/v1beta`
|
return trimmed.endsWith('/v1beta') ? trimmed : `${trimmed}/v1beta`
|
||||||
})()
|
})()
|
||||||
|
const geminiBase = (() => {
|
||||||
|
const trimmed = baseRoot.replace(/\/+$/, '')
|
||||||
|
return trimmed.endsWith('/v1beta') ? trimmed : `${trimmed}/v1beta`
|
||||||
|
})()
|
||||||
|
|
||||||
if (activeClientTab.value === 'opencode') {
|
if (activeClientTab.value === 'opencode') {
|
||||||
switch (props.platform) {
|
switch (props.platform) {
|
||||||
@@ -384,7 +388,7 @@ const currentFiles = computed((): FileConfig[] => {
|
|||||||
case 'openai':
|
case 'openai':
|
||||||
return [generateOpenCodeConfig('openai', apiBase, apiKey)]
|
return [generateOpenCodeConfig('openai', apiBase, apiKey)]
|
||||||
case 'gemini':
|
case 'gemini':
|
||||||
return [generateOpenCodeConfig('gemini', apiBase, apiKey)]
|
return [generateOpenCodeConfig('gemini', geminiBase, apiKey)]
|
||||||
case 'antigravity':
|
case 'antigravity':
|
||||||
return [
|
return [
|
||||||
generateOpenCodeConfig('antigravity-claude', antigravityBase, apiKey, 'opencode.json (Claude)'),
|
generateOpenCodeConfig('antigravity-claude', antigravityBase, apiKey, 'opencode.json (Claude)'),
|
||||||
@@ -525,14 +529,16 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
|
|||||||
[platform]: {
|
[platform]: {
|
||||||
options: {
|
options: {
|
||||||
baseURL: baseUrl,
|
baseURL: baseUrl,
|
||||||
apiKey,
|
apiKey
|
||||||
...(platform === 'openai' ? { store: false } : {})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const openaiModels = {
|
const openaiModels = {
|
||||||
'gpt-5.2-codex': {
|
'gpt-5.2-codex': {
|
||||||
name: 'GPT-5.2 Codex',
|
name: 'GPT-5.2 Codex',
|
||||||
|
options: {
|
||||||
|
store: false
|
||||||
|
},
|
||||||
variants: {
|
variants: {
|
||||||
low: {},
|
low: {},
|
||||||
medium: {},
|
medium: {},
|
||||||
@@ -574,9 +580,26 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
|
|||||||
provider[platform].models = openaiModels
|
provider[platform].models = openaiModels
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const agent =
|
||||||
|
platform === 'openai'
|
||||||
|
? {
|
||||||
|
build: {
|
||||||
|
options: {
|
||||||
|
store: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plan: {
|
||||||
|
options: {
|
||||||
|
store: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
const content = JSON.stringify(
|
const content = JSON.stringify(
|
||||||
{
|
{
|
||||||
provider,
|
provider,
|
||||||
|
...(agent ? { agent } : {}),
|
||||||
$schema: 'https://opencode.ai/config.json'
|
$schema: 'https://opencode.ai/config.json'
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
|
|||||||
@@ -13,7 +13,17 @@ const openaiModels = [
|
|||||||
'o1', 'o1-preview', 'o1-mini', 'o1-pro',
|
'o1', 'o1-preview', 'o1-mini', 'o1-pro',
|
||||||
'o3', 'o3-mini', 'o3-pro',
|
'o3', 'o3-mini', 'o3-pro',
|
||||||
'o4-mini',
|
'o4-mini',
|
||||||
'gpt-5', 'gpt-5-mini', 'gpt-5-nano',
|
// GPT-5 系列(同步后端定价文件)
|
||||||
|
'gpt-5', 'gpt-5-2025-08-07', 'gpt-5-chat', 'gpt-5-chat-latest',
|
||||||
|
'gpt-5-codex', 'gpt-5-pro', 'gpt-5-pro-2025-10-06',
|
||||||
|
'gpt-5-mini', 'gpt-5-mini-2025-08-07',
|
||||||
|
'gpt-5-nano', 'gpt-5-nano-2025-08-07',
|
||||||
|
// GPT-5.1 系列
|
||||||
|
'gpt-5.1', 'gpt-5.1-2025-11-13', 'gpt-5.1-chat-latest',
|
||||||
|
'gpt-5.1-codex', 'gpt-5.1-codex-max', 'gpt-5.1-codex-mini',
|
||||||
|
// GPT-5.2 系列
|
||||||
|
'gpt-5.2', 'gpt-5.2-2025-12-11', 'gpt-5.2-chat-latest',
|
||||||
|
'gpt-5.2-codex', 'gpt-5.2-pro', 'gpt-5.2-pro-2025-12-11',
|
||||||
'chatgpt-4o-latest',
|
'chatgpt-4o-latest',
|
||||||
'gpt-4o-audio-preview', 'gpt-4o-realtime-preview'
|
'gpt-4o-audio-preview', 'gpt-4o-realtime-preview'
|
||||||
]
|
]
|
||||||
@@ -211,7 +221,10 @@ const openaiPresetMappings = [
|
|||||||
{ label: 'GPT-4.1', from: 'gpt-4.1', to: 'gpt-4.1', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
{ label: 'GPT-4.1', from: 'gpt-4.1', to: 'gpt-4.1', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||||
{ label: 'o1', from: 'o1', to: 'o1', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
{ label: 'o1', from: 'o1', to: 'o1', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||||
{ label: 'o3', from: 'o3', to: 'o3', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
|
{ label: 'o3', from: 'o3', to: 'o3', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
|
||||||
{ label: 'GPT-5', from: 'gpt-5', to: 'gpt-5', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }
|
{ label: 'GPT-5', from: 'gpt-5', to: 'gpt-5', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' },
|
||||||
|
{ label: 'GPT-5.1', from: 'gpt-5.1', to: 'gpt-5.1', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
|
||||||
|
{ label: 'GPT-5.2', from: 'gpt-5.2', to: 'gpt-5.2', color: 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400' },
|
||||||
|
{ label: 'GPT-5.1 Codex', from: 'gpt-5.1-codex', to: 'gpt-5.1-codex', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const geminiPresetMappings = [
|
const geminiPresetMappings = [
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ export default {
|
|||||||
unknownError: 'Unknown error occurred',
|
unknownError: 'Unknown error occurred',
|
||||||
saving: 'Saving...',
|
saving: 'Saving...',
|
||||||
selectedCount: '({count} selected)', refresh: 'Refresh',
|
selectedCount: '({count} selected)', refresh: 'Refresh',
|
||||||
|
settings: 'Settings',
|
||||||
notAvailable: 'N/A',
|
notAvailable: 'N/A',
|
||||||
now: 'Now',
|
now: 'Now',
|
||||||
unknown: 'Unknown',
|
unknown: 'Unknown',
|
||||||
@@ -389,7 +390,7 @@ export default {
|
|||||||
opencode: {
|
opencode: {
|
||||||
title: 'OpenCode Example',
|
title: 'OpenCode Example',
|
||||||
subtitle: 'opencode.json',
|
subtitle: 'opencode.json',
|
||||||
hint: 'This is a group configuration example. Adjust model and options as needed.',
|
hint: 'Config path: ~/.config/opencode/opencode.json (or opencode.jsonc), create if not exists. Use default providers (openai/anthropic/google) or custom provider_id. API Key can be configured directly or via /connect command. This is an example, adjust models and options as needed.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
customKeyLabel: 'Custom Key',
|
customKeyLabel: 'Custom Key',
|
||||||
@@ -1021,6 +1022,7 @@ export default {
|
|||||||
schedulableEnabled: 'Scheduling enabled',
|
schedulableEnabled: 'Scheduling enabled',
|
||||||
schedulableDisabled: 'Scheduling disabled',
|
schedulableDisabled: 'Scheduling disabled',
|
||||||
failedToToggleSchedulable: 'Failed to toggle scheduling status',
|
failedToToggleSchedulable: 'Failed to toggle scheduling status',
|
||||||
|
allGroups: '{count} groups total',
|
||||||
platforms: {
|
platforms: {
|
||||||
anthropic: 'Anthropic',
|
anthropic: 'Anthropic',
|
||||||
claude: 'Claude',
|
claude: 'Claude',
|
||||||
@@ -1203,6 +1205,10 @@ export default {
|
|||||||
customErrorCodesHint: 'Only stop scheduling for selected error codes',
|
customErrorCodesHint: 'Only stop scheduling for selected error codes',
|
||||||
customErrorCodesWarning:
|
customErrorCodesWarning:
|
||||||
'Only selected error codes will stop scheduling. Other errors will return 500.',
|
'Only selected error codes will stop scheduling. Other errors will return 500.',
|
||||||
|
customErrorCodes429Warning:
|
||||||
|
'429 already has built-in rate limit handling. Adding it to custom error codes will disable the account instead of temporary rate limiting. Are you sure?',
|
||||||
|
customErrorCodes529Warning:
|
||||||
|
'529 already has built-in overload handling. Adding it to custom error codes will disable the account instead of temporary overload marking. Are you sure?',
|
||||||
selectedErrorCodes: 'Selected',
|
selectedErrorCodes: 'Selected',
|
||||||
noneSelectedUsesDefault: 'None selected (uses default policy)',
|
noneSelectedUsesDefault: 'None selected (uses default policy)',
|
||||||
enterErrorCode: 'Enter error code (100-599)',
|
enterErrorCode: 'Enter error code (100-599)',
|
||||||
@@ -1902,6 +1908,7 @@ export default {
|
|||||||
max: 'max:',
|
max: 'max:',
|
||||||
qps: 'QPS',
|
qps: 'QPS',
|
||||||
requests: 'Requests',
|
requests: 'Requests',
|
||||||
|
requestsTitle: 'Requests',
|
||||||
upstream: 'Upstream',
|
upstream: 'Upstream',
|
||||||
client: 'Client',
|
client: 'Client',
|
||||||
system: 'System',
|
system: 'System',
|
||||||
@@ -1936,6 +1943,9 @@ export default {
|
|||||||
'6h': 'Last 6 hours',
|
'6h': 'Last 6 hours',
|
||||||
'24h': 'Last 24 hours'
|
'24h': 'Last 24 hours'
|
||||||
},
|
},
|
||||||
|
fullscreen: {
|
||||||
|
enter: 'Enter Fullscreen'
|
||||||
|
},
|
||||||
diagnosis: {
|
diagnosis: {
|
||||||
title: 'Smart Diagnosis',
|
title: 'Smart Diagnosis',
|
||||||
footer: 'Automated diagnostic suggestions based on current metrics',
|
footer: 'Automated diagnostic suggestions based on current metrics',
|
||||||
@@ -2114,7 +2124,10 @@ export default {
|
|||||||
empty: 'No alert rules',
|
empty: 'No alert rules',
|
||||||
loadFailed: 'Failed to load alert rules',
|
loadFailed: 'Failed to load alert rules',
|
||||||
saveFailed: 'Failed to save alert rule',
|
saveFailed: 'Failed to save alert rule',
|
||||||
|
saveSuccess: 'Alert rule saved successfully',
|
||||||
deleteFailed: 'Failed to delete alert rule',
|
deleteFailed: 'Failed to delete alert rule',
|
||||||
|
deleteSuccess: 'Alert rule deleted successfully',
|
||||||
|
manage: 'Manage Alert Rules',
|
||||||
create: 'Create Rule',
|
create: 'Create Rule',
|
||||||
createTitle: 'Create Alert Rule',
|
createTitle: 'Create Alert Rule',
|
||||||
editTitle: 'Edit Alert Rule',
|
editTitle: 'Edit Alert Rule',
|
||||||
@@ -2297,6 +2310,54 @@ export default {
|
|||||||
accountHealthThresholdRange: 'Account health threshold must be between 0 and 100'
|
accountHealthThresholdRange: 'Account health threshold must be between 0 and 100'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
settings: {
|
||||||
|
title: 'Ops Monitoring Settings',
|
||||||
|
loadFailed: 'Failed to load settings',
|
||||||
|
saveSuccess: 'Ops monitoring settings saved successfully',
|
||||||
|
saveFailed: 'Failed to save settings',
|
||||||
|
dataCollection: 'Data Collection',
|
||||||
|
evaluationInterval: 'Evaluation Interval (seconds)',
|
||||||
|
evaluationIntervalHint: 'Frequency of detection tasks, recommended to keep default',
|
||||||
|
alertConfig: 'Alert Configuration',
|
||||||
|
enableAlert: 'Enable Alerts',
|
||||||
|
alertRecipients: 'Alert Recipient Emails',
|
||||||
|
emailPlaceholder: 'Enter email address',
|
||||||
|
recipientsHint: 'If empty, the system will use the first admin email as default recipient',
|
||||||
|
minSeverity: 'Minimum Severity',
|
||||||
|
reportConfig: 'Report Configuration',
|
||||||
|
enableReport: 'Enable Reports',
|
||||||
|
reportRecipients: 'Report Recipient Emails',
|
||||||
|
dailySummary: 'Daily Summary',
|
||||||
|
weeklySummary: 'Weekly Summary',
|
||||||
|
metricThresholds: 'Metric Thresholds',
|
||||||
|
metricThresholdsHint: 'Configure alert thresholds for metrics, values exceeding thresholds will be displayed in red',
|
||||||
|
slaMinPercent: 'SLA Minimum Percentage',
|
||||||
|
slaMinPercentHint: 'SLA below this value will be displayed in red (default: 99.5%)',
|
||||||
|
latencyP99MaxMs: 'Latency P99 Maximum (ms)',
|
||||||
|
latencyP99MaxMsHint: 'Latency P99 above this value will be displayed in red (default: 2000ms)',
|
||||||
|
ttftP99MaxMs: 'TTFT P99 Maximum (ms)',
|
||||||
|
ttftP99MaxMsHint: 'TTFT P99 above this value will be displayed in red (default: 500ms)',
|
||||||
|
requestErrorRateMaxPercent: 'Request Error Rate Maximum (%)',
|
||||||
|
requestErrorRateMaxPercentHint: 'Request error rate above this value will be displayed in red (default: 5%)',
|
||||||
|
upstreamErrorRateMaxPercent: 'Upstream Error Rate Maximum (%)',
|
||||||
|
upstreamErrorRateMaxPercentHint: 'Upstream error rate above this value will be displayed in red (default: 5%)',
|
||||||
|
advancedSettings: 'Advanced Settings',
|
||||||
|
dataRetention: 'Data Retention Policy',
|
||||||
|
enableCleanup: 'Enable Data Cleanup',
|
||||||
|
cleanupSchedule: 'Cleanup Schedule (Cron)',
|
||||||
|
cleanupScheduleHint: 'Example: 0 2 * * * means 2 AM daily',
|
||||||
|
errorLogRetentionDays: 'Error Log Retention Days',
|
||||||
|
minuteMetricsRetentionDays: 'Minute Metrics Retention Days',
|
||||||
|
hourlyMetricsRetentionDays: 'Hourly Metrics Retention Days',
|
||||||
|
retentionDaysHint: 'Recommended 7-90 days, longer periods will consume more storage',
|
||||||
|
aggregation: 'Pre-aggregation Tasks',
|
||||||
|
enableAggregation: 'Enable Pre-aggregation',
|
||||||
|
aggregationHint: 'Pre-aggregation improves query performance for long time windows',
|
||||||
|
validation: {
|
||||||
|
title: 'Please fix the following issues',
|
||||||
|
retentionDaysRange: 'Retention days must be between 1-365 days'
|
||||||
|
}
|
||||||
|
},
|
||||||
concurrency: {
|
concurrency: {
|
||||||
title: 'Concurrency / Queue',
|
title: 'Concurrency / Queue',
|
||||||
byPlatform: 'By Platform',
|
byPlatform: 'By Platform',
|
||||||
@@ -2330,12 +2391,13 @@ export default {
|
|||||||
accountError: 'Error'
|
accountError: 'Error'
|
||||||
},
|
},
|
||||||
tooltips: {
|
tooltips: {
|
||||||
|
totalRequests: 'Total number of requests (including both successful and failed requests) in the selected time window.',
|
||||||
throughputTrend: 'Requests/QPS + Tokens/TPS in the selected window.',
|
throughputTrend: 'Requests/QPS + Tokens/TPS in the selected window.',
|
||||||
latencyHistogram: 'Latency distribution (duration_ms) for successful requests.',
|
latencyHistogram: 'Latency distribution (duration_ms) for successful requests.',
|
||||||
errorTrend: 'Error counts over time (SLA scope excludes business limits; upstream excludes 429/529).',
|
errorTrend: 'Error counts over time (SLA scope excludes business limits; upstream excludes 429/529).',
|
||||||
errorDistribution: 'Error distribution by status code.',
|
errorDistribution: 'Error distribution by status code.',
|
||||||
goroutines:
|
goroutines:
|
||||||
'Number of Go runtime goroutines (lightweight threads). There is no absolute “safe” number—use your historical baseline. Heuristic: <2k is common; 2k–8k watch; >8k plus rising queue/latency often suggests blocking/leaks.',
|
'Number of Go runtime goroutines (lightweight threads). There is no absolute "safe" number—use your historical baseline. Heuristic: <2k is common; 2k–8k watch; >8k plus rising queue/latency often suggests blocking/leaks.',
|
||||||
cpu: 'CPU usage percentage, showing system processor load.',
|
cpu: 'CPU usage percentage, showing system processor load.',
|
||||||
memory: 'Memory usage, including used and total available memory.',
|
memory: 'Memory usage, including used and total available memory.',
|
||||||
db: 'Database connection pool status, including active, idle, and waiting connections.',
|
db: 'Database connection pool status, including active, idle, and waiting connections.',
|
||||||
@@ -2345,6 +2407,7 @@ export default {
|
|||||||
tokens: 'Total number of tokens processed in the current time window.',
|
tokens: 'Total number of tokens processed in the current time window.',
|
||||||
sla: 'Service Level Agreement success rate, excluding business limits (e.g., insufficient balance, quota exceeded).',
|
sla: 'Service Level Agreement success rate, excluding business limits (e.g., insufficient balance, quota exceeded).',
|
||||||
errors: 'Error statistics, including total errors, error rate, and upstream error rate.',
|
errors: 'Error statistics, including total errors, error rate, and upstream error rate.',
|
||||||
|
upstreamErrors: 'Upstream error statistics, excluding rate limit errors (429/529).',
|
||||||
latency: 'Request latency statistics, including p50, p90, p95, p99 percentiles.',
|
latency: 'Request latency statistics, including p50, p90, p95, p99 percentiles.',
|
||||||
ttft: 'Time To First Token, measuring the speed of first byte return in streaming responses.',
|
ttft: 'Time To First Token, measuring the speed of first byte return in streaming responses.',
|
||||||
health: 'System health score (0-100), considering SLA, error rate, and resource usage.'
|
health: 'System health score (0-100), considering SLA, error rate, and resource usage.'
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ export default {
|
|||||||
opencode: {
|
opencode: {
|
||||||
title: 'OpenCode 配置示例',
|
title: 'OpenCode 配置示例',
|
||||||
subtitle: 'opencode.json',
|
subtitle: 'opencode.json',
|
||||||
hint: '示例仅用于演示分组配置,模型与选项可按需调整。',
|
hint: '配置文件路径:~/.config/opencode/opencode.json(或 opencode.jsonc),不存在需手动创建。可使用默认 provider(openai/anthropic/google)或自定义 provider_id。API Key 支持直接配置或通过客户端 /connect 命令配置。示例仅供参考,模型与选项可按需调整。',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
customKeyLabel: '自定义密钥',
|
customKeyLabel: '自定义密钥',
|
||||||
@@ -1099,6 +1099,7 @@ export default {
|
|||||||
schedulableEnabled: '调度已开启',
|
schedulableEnabled: '调度已开启',
|
||||||
schedulableDisabled: '调度已关闭',
|
schedulableDisabled: '调度已关闭',
|
||||||
failedToToggleSchedulable: '切换调度状态失败',
|
failedToToggleSchedulable: '切换调度状态失败',
|
||||||
|
allGroups: '共 {count} 个分组',
|
||||||
columns: {
|
columns: {
|
||||||
name: '名称',
|
name: '名称',
|
||||||
platformType: '平台/类型',
|
platformType: '平台/类型',
|
||||||
@@ -1339,6 +1340,10 @@ export default {
|
|||||||
customErrorCodes: '自定义错误码',
|
customErrorCodes: '自定义错误码',
|
||||||
customErrorCodesHint: '仅对选中的错误码停止调度',
|
customErrorCodesHint: '仅对选中的错误码停止调度',
|
||||||
customErrorCodesWarning: '仅选中的错误码会停止调度,其他错误将返回 500。',
|
customErrorCodesWarning: '仅选中的错误码会停止调度,其他错误将返回 500。',
|
||||||
|
customErrorCodes429Warning:
|
||||||
|
'429 已有内置的限流处理机制。添加到自定义错误码后,将直接停止调度而非临时限流。确定要添加吗?',
|
||||||
|
customErrorCodes529Warning:
|
||||||
|
'529 已有内置的过载处理机制。添加到自定义错误码后,将直接停止调度而非临时标记过载。确定要添加吗?',
|
||||||
selectedErrorCodes: '已选择',
|
selectedErrorCodes: '已选择',
|
||||||
noneSelectedUsesDefault: '未选择(使用默认策略)',
|
noneSelectedUsesDefault: '未选择(使用默认策略)',
|
||||||
enterErrorCode: '输入错误码 (100-599)',
|
enterErrorCode: '输入错误码 (100-599)',
|
||||||
@@ -2018,7 +2023,7 @@ export default {
|
|||||||
ready: '就绪',
|
ready: '就绪',
|
||||||
requestsTotal: '请求(总计)',
|
requestsTotal: '请求(总计)',
|
||||||
slaScope: 'SLA 范围:',
|
slaScope: 'SLA 范围:',
|
||||||
tokens: 'Token',
|
tokens: 'Token数',
|
||||||
tps: 'TPS',
|
tps: 'TPS',
|
||||||
current: '当前',
|
current: '当前',
|
||||||
peak: '峰值',
|
peak: '峰值',
|
||||||
@@ -2047,7 +2052,8 @@ export default {
|
|||||||
avg: 'avg',
|
avg: 'avg',
|
||||||
max: 'max',
|
max: 'max',
|
||||||
qps: 'QPS',
|
qps: 'QPS',
|
||||||
requests: '请求',
|
requests: '请求数',
|
||||||
|
requestsTitle: '请求',
|
||||||
upstream: '上游',
|
upstream: '上游',
|
||||||
client: '客户端',
|
client: '客户端',
|
||||||
system: '系统',
|
system: '系统',
|
||||||
@@ -2082,6 +2088,9 @@ export default {
|
|||||||
'6h': '近6小时',
|
'6h': '近6小时',
|
||||||
'24h': '近24小时'
|
'24h': '近24小时'
|
||||||
},
|
},
|
||||||
|
fullscreen: {
|
||||||
|
enter: '进入全屏'
|
||||||
|
},
|
||||||
diagnosis: {
|
diagnosis: {
|
||||||
title: '智能诊断',
|
title: '智能诊断',
|
||||||
footer: '基于当前指标的自动诊断建议',
|
footer: '基于当前指标的自动诊断建议',
|
||||||
@@ -2465,6 +2474,18 @@ export default {
|
|||||||
reportRecipients: '评估报告接收邮箱',
|
reportRecipients: '评估报告接收邮箱',
|
||||||
dailySummary: '每日摘要',
|
dailySummary: '每日摘要',
|
||||||
weeklySummary: '每周摘要',
|
weeklySummary: '每周摘要',
|
||||||
|
metricThresholds: '指标阈值配置',
|
||||||
|
metricThresholdsHint: '配置各项指标的告警阈值,超出阈值时将以红色显示',
|
||||||
|
slaMinPercent: 'SLA最低百分比',
|
||||||
|
slaMinPercentHint: 'SLA低于此值时显示为红色(默认:99.5%)',
|
||||||
|
latencyP99MaxMs: '延迟P99最大值(毫秒)',
|
||||||
|
latencyP99MaxMsHint: '延迟P99高于此值时显示为红色(默认:2000ms)',
|
||||||
|
ttftP99MaxMs: 'TTFT P99最大值(毫秒)',
|
||||||
|
ttftP99MaxMsHint: 'TTFT P99高于此值时显示为红色(默认:500ms)',
|
||||||
|
requestErrorRateMaxPercent: '请求错误率最大值(%)',
|
||||||
|
requestErrorRateMaxPercentHint: '请求错误率高于此值时显示为红色(默认:5%)',
|
||||||
|
upstreamErrorRateMaxPercent: '上游错误率最大值(%)',
|
||||||
|
upstreamErrorRateMaxPercentHint: '上游错误率高于此值时显示为红色(默认:5%)',
|
||||||
advancedSettings: '高级设置',
|
advancedSettings: '高级设置',
|
||||||
dataRetention: '数据保留策略',
|
dataRetention: '数据保留策略',
|
||||||
enableCleanup: '启用数据清理',
|
enableCleanup: '启用数据清理',
|
||||||
|
|||||||
@@ -19,7 +19,22 @@
|
|||||||
@apply min-h-screen;
|
@apply min-h-screen;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 自定义滚动条 */
|
/* 自定义滚动条 - 默认隐藏,悬停或滚动时显示 */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: transparent transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
*:hover,
|
||||||
|
*:focus-within {
|
||||||
|
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark *:hover,
|
||||||
|
.dark *:focus-within {
|
||||||
|
scrollbar-color: rgba(75, 85, 99, 0.5) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
@apply h-2 w-2;
|
@apply h-2 w-2;
|
||||||
}
|
}
|
||||||
@@ -29,10 +44,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
@apply rounded-full bg-gray-300 dark:bg-dark-600;
|
@apply rounded-full bg-transparent;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
*:hover::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-gray-300/50 dark:bg-dark-600/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
*:hover::-webkit-scrollbar-thumb:hover {
|
||||||
@apply bg-gray-400 dark:bg-dark-500;
|
@apply bg-gray-400 dark:bg-dark-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,10 +56,7 @@
|
|||||||
<AccountTodayStatsCell :account="row" />
|
<AccountTodayStatsCell :account="row" />
|
||||||
</template>
|
</template>
|
||||||
<template #cell-groups="{ row }">
|
<template #cell-groups="{ row }">
|
||||||
<div v-if="row.groups && row.groups.length > 0" class="flex flex-wrap gap-1.5">
|
<AccountGroupsCell :groups="row.groups" :max-display="4" />
|
||||||
<GroupBadge v-for="group in row.groups" :key="group.id" :name="group.name" :platform="group.platform" :subscription-type="group.subscription_type" :rate-multiplier="group.rate_multiplier" :show-rate="false" />
|
|
||||||
</div>
|
|
||||||
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
|
|
||||||
</template>
|
</template>
|
||||||
<template #cell-usage="{ row }">
|
<template #cell-usage="{ row }">
|
||||||
<AccountUsageCell :account="row" />
|
<AccountUsageCell :account="row" />
|
||||||
@@ -145,7 +142,7 @@ import AccountStatsModal from '@/components/admin/account/AccountStatsModal.vue'
|
|||||||
import AccountStatusIndicator from '@/components/account/AccountStatusIndicator.vue'
|
import AccountStatusIndicator from '@/components/account/AccountStatusIndicator.vue'
|
||||||
import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
|
import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
|
||||||
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
|
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
|
||||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
import AccountGroupsCell from '@/components/account/AccountGroupsCell.vue'
|
||||||
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
|
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
|
||||||
import { formatDateTime, formatRelativeTime } from '@/utils/format'
|
import { formatDateTime, formatRelativeTime } from '@/utils/format'
|
||||||
import type { Account, Proxy, Group } from '@/types'
|
import type { Account, Proxy, Group } from '@/types'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppLayout>
|
<component :is="isFullscreen ? 'div' : AppLayout" :class="isFullscreen ? 'flex min-h-screen flex-col justify-center bg-gray-50 dark:bg-dark-950' : ''">
|
||||||
<div class="space-y-6 pb-12">
|
<div :class="[isFullscreen ? 'p-4 md:p-6' : '', 'space-y-6 pb-12']">
|
||||||
<div
|
<div
|
||||||
v-if="errorMessage"
|
v-if="errorMessage"
|
||||||
class="rounded-2xl bg-red-50 p-4 text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400"
|
class="rounded-2xl bg-red-50 p-4 text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400"
|
||||||
@@ -13,17 +13,16 @@
|
|||||||
<OpsDashboardHeader
|
<OpsDashboardHeader
|
||||||
v-else-if="opsEnabled"
|
v-else-if="opsEnabled"
|
||||||
:overview="overview"
|
:overview="overview"
|
||||||
:ws-status="wsStatus"
|
|
||||||
:ws-reconnect-in-ms="wsReconnectInMs"
|
|
||||||
:ws-has-data="wsHasData"
|
|
||||||
:real-time-qps="realTimeQPS"
|
|
||||||
:real-time-tps="realTimeTPS"
|
|
||||||
:platform="platform"
|
:platform="platform"
|
||||||
:group-id="groupId"
|
:group-id="groupId"
|
||||||
:time-range="timeRange"
|
:time-range="timeRange"
|
||||||
:query-mode="queryMode"
|
:query-mode="queryMode"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:last-updated="lastUpdated"
|
:last-updated="lastUpdated"
|
||||||
|
:thresholds="metricThresholds"
|
||||||
|
:auto-refresh-enabled="autoRefreshEnabled"
|
||||||
|
:auto-refresh-countdown="autoRefreshCountdown"
|
||||||
|
:fullscreen="isFullscreen"
|
||||||
@update:time-range="onTimeRangeChange"
|
@update:time-range="onTimeRangeChange"
|
||||||
@update:platform="onPlatformChange"
|
@update:platform="onPlatformChange"
|
||||||
@update:group="onGroupChange"
|
@update:group="onGroupChange"
|
||||||
@@ -33,6 +32,8 @@
|
|||||||
@open-error-details="openErrorDetails"
|
@open-error-details="openErrorDetails"
|
||||||
@open-settings="showSettingsDialog = true"
|
@open-settings="showSettingsDialog = true"
|
||||||
@open-alert-rules="showAlertRulesCard = true"
|
@open-alert-rules="showAlertRulesCard = true"
|
||||||
|
@enter-fullscreen="enterFullscreen"
|
||||||
|
@exit-fullscreen="exitFullscreen"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Row: Concurrency + Throughput -->
|
<!-- Row: Concurrency + Throughput -->
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
:top-groups="throughputTrend?.top_groups ?? []"
|
:top-groups="throughputTrend?.top_groups ?? []"
|
||||||
:loading="loadingTrend"
|
:loading="loadingTrend"
|
||||||
:time-range="timeRange"
|
:time-range="timeRange"
|
||||||
|
:fullscreen="isFullscreen"
|
||||||
@select-platform="handleThroughputSelectPlatform"
|
@select-platform="handleThroughputSelectPlatform"
|
||||||
@select-group="handleThroughputSelectGroup"
|
@select-group="handleThroughputSelectGroup"
|
||||||
@open-details="handleOpenRequestDetails"
|
@open-details="handleOpenRequestDetails"
|
||||||
@@ -74,54 +76,54 @@
|
|||||||
<!-- Alert Events -->
|
<!-- Alert Events -->
|
||||||
<OpsAlertEventsCard v-if="opsEnabled && !(loading && !hasLoadedOnce)" />
|
<OpsAlertEventsCard v-if="opsEnabled && !(loading && !hasLoadedOnce)" />
|
||||||
|
|
||||||
<!-- Settings Dialog -->
|
<!-- Settings Dialog (hidden in fullscreen mode) -->
|
||||||
<OpsSettingsDialog :show="showSettingsDialog" @close="showSettingsDialog = false" @saved="fetchData" />
|
<template v-if="!isFullscreen">
|
||||||
|
<OpsSettingsDialog :show="showSettingsDialog" @close="showSettingsDialog = false" @saved="onSettingsSaved" />
|
||||||
|
|
||||||
<!-- Alert Rules Dialog -->
|
<BaseDialog :show="showAlertRulesCard" :title="t('admin.ops.alertRules.title')" width="extra-wide" @close="showAlertRulesCard = false">
|
||||||
<BaseDialog :show="showAlertRulesCard" :title="t('admin.ops.alertRules.title')" width="extra-wide" @close="showAlertRulesCard = false">
|
<OpsAlertRulesCard />
|
||||||
<OpsAlertRulesCard />
|
</BaseDialog>
|
||||||
</BaseDialog>
|
|
||||||
|
|
||||||
<OpsErrorDetailsModal
|
<OpsErrorDetailsModal
|
||||||
:show="showErrorDetails"
|
:show="showErrorDetails"
|
||||||
:time-range="timeRange"
|
:time-range="timeRange"
|
||||||
:platform="platform"
|
:platform="platform"
|
||||||
:group-id="groupId"
|
:group-id="groupId"
|
||||||
:error-type="errorDetailsType"
|
:error-type="errorDetailsType"
|
||||||
@update:show="showErrorDetails = $event"
|
@update:show="showErrorDetails = $event"
|
||||||
@openErrorDetail="openError"
|
@openErrorDetail="openError"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<OpsErrorDetailModal v-model:show="showErrorModal" :error-id="selectedErrorId" />
|
<OpsErrorDetailModal v-model:show="showErrorModal" :error-id="selectedErrorId" />
|
||||||
|
|
||||||
<OpsRequestDetailsModal
|
<OpsRequestDetailsModal
|
||||||
v-model="showRequestDetails"
|
v-model="showRequestDetails"
|
||||||
:time-range="timeRange"
|
:time-range="timeRange"
|
||||||
:preset="requestDetailsPreset"
|
:preset="requestDetailsPreset"
|
||||||
:platform="platform"
|
:platform="platform"
|
||||||
:group-id="groupId"
|
:group-id="groupId"
|
||||||
@openErrorDetail="openError"
|
@openErrorDetail="openError"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useDebounceFn } from '@vueuse/core'
|
import { useDebounceFn, useIntervalFn } from '@vueuse/core'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||||
import {
|
import {
|
||||||
opsAPI,
|
opsAPI,
|
||||||
OPS_WS_CLOSE_CODES,
|
|
||||||
type OpsWSStatus,
|
|
||||||
type OpsDashboardOverview,
|
type OpsDashboardOverview,
|
||||||
type OpsErrorDistributionResponse,
|
type OpsErrorDistributionResponse,
|
||||||
type OpsErrorTrendResponse,
|
type OpsErrorTrendResponse,
|
||||||
type OpsLatencyHistogramResponse,
|
type OpsLatencyHistogramResponse,
|
||||||
type OpsThroughputTrendResponse
|
type OpsThroughputTrendResponse,
|
||||||
|
type OpsMetricThresholds
|
||||||
} from '@/api/admin/ops'
|
} from '@/api/admin/ops'
|
||||||
import { useAdminSettingsStore, useAppStore } from '@/stores'
|
import { useAdminSettingsStore, useAppStore } from '@/stores'
|
||||||
import OpsDashboardHeader from './components/OpsDashboardHeader.vue'
|
import OpsDashboardHeader from './components/OpsDashboardHeader.vue'
|
||||||
@@ -166,19 +168,35 @@ const QUERY_KEYS = {
|
|||||||
timeRange: 'tr',
|
timeRange: 'tr',
|
||||||
platform: 'platform',
|
platform: 'platform',
|
||||||
groupId: 'group_id',
|
groupId: 'group_id',
|
||||||
queryMode: 'mode'
|
queryMode: 'mode',
|
||||||
|
fullscreen: 'fullscreen'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
const isApplyingRouteQuery = ref(false)
|
const isApplyingRouteQuery = ref(false)
|
||||||
const isSyncingRouteQuery = ref(false)
|
const isSyncingRouteQuery = ref(false)
|
||||||
|
|
||||||
// WebSocket for realtime QPS/TPS
|
// Fullscreen mode
|
||||||
const realTimeQPS = ref(0)
|
const isFullscreen = computed(() => {
|
||||||
const realTimeTPS = ref(0)
|
const val = route.query[QUERY_KEYS.fullscreen]
|
||||||
const wsStatus = ref<OpsWSStatus>('closed')
|
return val === '1' || val === 'true'
|
||||||
const wsReconnectInMs = ref<number | null>(null)
|
})
|
||||||
const wsHasData = ref(false)
|
|
||||||
let unsubscribeQPS: (() => void) | null = null
|
function exitFullscreen() {
|
||||||
|
const nextQuery = { ...route.query }
|
||||||
|
delete nextQuery[QUERY_KEYS.fullscreen]
|
||||||
|
router.replace({ query: nextQuery })
|
||||||
|
}
|
||||||
|
|
||||||
|
function enterFullscreen() {
|
||||||
|
const nextQuery = { ...route.query, [QUERY_KEYS.fullscreen]: '1' }
|
||||||
|
router.replace({ query: nextQuery })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape' && isFullscreen.value) {
|
||||||
|
exitFullscreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let dashboardFetchController: AbortController | null = null
|
let dashboardFetchController: AbortController | null = null
|
||||||
let dashboardFetchSeq = 0
|
let dashboardFetchSeq = 0
|
||||||
@@ -199,50 +217,6 @@ function abortDashboardFetch() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopQPSSubscription(options?: { resetMetrics?: boolean }) {
|
|
||||||
wsStatus.value = 'closed'
|
|
||||||
wsReconnectInMs.value = null
|
|
||||||
if (unsubscribeQPS) unsubscribeQPS()
|
|
||||||
unsubscribeQPS = null
|
|
||||||
|
|
||||||
if (options?.resetMetrics) {
|
|
||||||
realTimeQPS.value = 0
|
|
||||||
realTimeTPS.value = 0
|
|
||||||
wsHasData.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startQPSSubscription() {
|
|
||||||
stopQPSSubscription()
|
|
||||||
unsubscribeQPS = opsAPI.subscribeQPS(
|
|
||||||
(payload) => {
|
|
||||||
if (payload && typeof payload === 'object' && payload.type === 'qps_update' && payload.data) {
|
|
||||||
realTimeQPS.value = payload.data.qps || 0
|
|
||||||
realTimeTPS.value = payload.data.tps || 0
|
|
||||||
wsHasData.value = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onStatusChange: (status) => {
|
|
||||||
wsStatus.value = status
|
|
||||||
if (status === 'connected') wsReconnectInMs.value = null
|
|
||||||
},
|
|
||||||
onReconnectScheduled: ({ delayMs }) => {
|
|
||||||
wsReconnectInMs.value = delayMs
|
|
||||||
},
|
|
||||||
onFatalClose: (event) => {
|
|
||||||
// Server-side feature flag says realtime is disabled; keep UI consistent and avoid reconnect loops.
|
|
||||||
if (event && event.code === OPS_WS_CLOSE_CODES.REALTIME_DISABLED) {
|
|
||||||
adminSettingsStore.setOpsRealtimeMonitoringEnabledLocal(false)
|
|
||||||
stopQPSSubscription({ resetMetrics: true })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// QPS updates may be sparse in idle periods; keep the timeout conservative.
|
|
||||||
staleTimeoutMs: 180_000
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const readQueryString = (key: string): string => {
|
const readQueryString = (key: string): string => {
|
||||||
const value = route.query[key]
|
const value = route.query[key]
|
||||||
if (typeof value === 'string') return value
|
if (typeof value === 'string') return value
|
||||||
@@ -314,6 +288,7 @@ const syncQueryToRoute = useDebounceFn(async () => {
|
|||||||
}, 250)
|
}, 250)
|
||||||
|
|
||||||
const overview = ref<OpsDashboardOverview | null>(null)
|
const overview = ref<OpsDashboardOverview | null>(null)
|
||||||
|
const metricThresholds = ref<OpsMetricThresholds | null>(null)
|
||||||
|
|
||||||
const throughputTrend = ref<OpsThroughputTrendResponse | null>(null)
|
const throughputTrend = ref<OpsThroughputTrendResponse | null>(null)
|
||||||
const loadingTrend = ref(false)
|
const loadingTrend = ref(false)
|
||||||
@@ -343,6 +318,45 @@ const requestDetailsPreset = ref<OpsRequestDetailsPreset>({
|
|||||||
const showSettingsDialog = ref(false)
|
const showSettingsDialog = ref(false)
|
||||||
const showAlertRulesCard = ref(false)
|
const showAlertRulesCard = ref(false)
|
||||||
|
|
||||||
|
// Auto refresh settings
|
||||||
|
const autoRefreshEnabled = ref(false)
|
||||||
|
const autoRefreshIntervalMs = ref(30000) // default 30 seconds
|
||||||
|
const autoRefreshCountdown = ref(0)
|
||||||
|
|
||||||
|
// Auto refresh timer
|
||||||
|
const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn(
|
||||||
|
() => {
|
||||||
|
if (autoRefreshEnabled.value && opsEnabled.value && !loading.value) {
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
autoRefreshIntervalMs,
|
||||||
|
{ immediate: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Countdown timer (updates every second)
|
||||||
|
const { pause: pauseCountdown, resume: resumeCountdown } = useIntervalFn(
|
||||||
|
() => {
|
||||||
|
if (autoRefreshEnabled.value && autoRefreshCountdown.value > 0) {
|
||||||
|
autoRefreshCountdown.value--
|
||||||
|
}
|
||||||
|
},
|
||||||
|
1000,
|
||||||
|
{ immediate: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Load auto refresh settings from backend
|
||||||
|
async function loadAutoRefreshSettings() {
|
||||||
|
try {
|
||||||
|
const settings = await opsAPI.getAdvancedSettings()
|
||||||
|
autoRefreshEnabled.value = settings.auto_refresh_enabled
|
||||||
|
autoRefreshIntervalMs.value = settings.auto_refresh_interval_seconds * 1000
|
||||||
|
autoRefreshCountdown.value = settings.auto_refresh_interval_seconds
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[OpsDashboard] Failed to load auto refresh settings', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleThroughputSelectPlatform(nextPlatform: string) {
|
function handleThroughputSelectPlatform(nextPlatform: string) {
|
||||||
platform.value = nextPlatform || ''
|
platform.value = nextPlatform || ''
|
||||||
groupId.value = null
|
groupId.value = null
|
||||||
@@ -376,6 +390,11 @@ function onTimeRangeChange(v: string | number | boolean | null) {
|
|||||||
timeRange.value = v as TimeRange
|
timeRange.value = v as TimeRange
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onSettingsSaved() {
|
||||||
|
loadThresholds()
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
function onPlatformChange(v: string | number | boolean | null) {
|
function onPlatformChange(v: string | number | boolean | null) {
|
||||||
platform.value = typeof v === 'string' ? v : ''
|
platform.value = typeof v === 'string' ? v : ''
|
||||||
}
|
}
|
||||||
@@ -561,6 +580,10 @@ async function fetchData() {
|
|||||||
])
|
])
|
||||||
if (fetchSeq !== dashboardFetchSeq) return
|
if (fetchSeq !== dashboardFetchSeq) return
|
||||||
lastUpdated.value = new Date()
|
lastUpdated.value = new Date()
|
||||||
|
// Reset auto refresh countdown after successful fetch
|
||||||
|
if (autoRefreshEnabled.value) {
|
||||||
|
autoRefreshCountdown.value = Math.floor(autoRefreshIntervalMs.value / 1000)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isOpsDisabledError(err)) {
|
if (!isOpsDisabledError(err)) {
|
||||||
console.error('[ops] failed to fetch dashboard data', err)
|
console.error('[ops] failed to fetch dashboard data', err)
|
||||||
@@ -609,37 +632,66 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
// Fullscreen mode: listen for ESC key
|
||||||
|
window.addEventListener('keydown', handleKeydown)
|
||||||
|
|
||||||
await adminSettingsStore.fetch()
|
await adminSettingsStore.fetch()
|
||||||
if (!adminSettingsStore.opsMonitoringEnabled) {
|
if (!adminSettingsStore.opsMonitoringEnabled) {
|
||||||
await router.replace('/admin/settings')
|
await router.replace('/admin/settings')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (adminSettingsStore.opsRealtimeMonitoringEnabled) {
|
// Load thresholds configuration
|
||||||
startQPSSubscription()
|
loadThresholds()
|
||||||
} else {
|
|
||||||
stopQPSSubscription({ resetMetrics: true })
|
// Load auto refresh settings
|
||||||
}
|
await loadAutoRefreshSettings()
|
||||||
|
|
||||||
if (opsEnabled.value) {
|
if (opsEnabled.value) {
|
||||||
await fetchData()
|
await fetchData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start auto refresh if enabled
|
||||||
|
if (autoRefreshEnabled.value) {
|
||||||
|
resumeAutoRefresh()
|
||||||
|
resumeCountdown()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function loadThresholds() {
|
||||||
|
try {
|
||||||
|
const settings = await opsAPI.getAlertRuntimeSettings()
|
||||||
|
metricThresholds.value = settings.thresholds || null
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[OpsDashboard] Failed to load thresholds', err)
|
||||||
|
metricThresholds.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopQPSSubscription()
|
window.removeEventListener('keydown', handleKeydown)
|
||||||
abortDashboardFetch()
|
abortDashboardFetch()
|
||||||
|
pauseAutoRefresh()
|
||||||
|
pauseCountdown()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
// Watch auto refresh settings changes
|
||||||
() => adminSettingsStore.opsRealtimeMonitoringEnabled,
|
watch(autoRefreshEnabled, (enabled) => {
|
||||||
(enabled) => {
|
if (enabled) {
|
||||||
if (!opsEnabled.value) return
|
autoRefreshCountdown.value = Math.floor(autoRefreshIntervalMs.value / 1000)
|
||||||
if (enabled) {
|
resumeAutoRefresh()
|
||||||
startQPSSubscription()
|
resumeCountdown()
|
||||||
} else {
|
} else {
|
||||||
stopQPSSubscription({ resetMetrics: true })
|
pauseAutoRefresh()
|
||||||
}
|
pauseCountdown()
|
||||||
|
autoRefreshCountdown.value = 0
|
||||||
}
|
}
|
||||||
)
|
})
|
||||||
|
|
||||||
|
// Reload auto refresh settings after settings dialog is closed
|
||||||
|
watch(showSettingsDialog, async (show) => {
|
||||||
|
if (!show) {
|
||||||
|
await loadAutoRefreshSettings()
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,29 +1,31 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { useIntervalFn } from '@vueuse/core'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import Select from '@/components/common/Select.vue'
|
import Select from '@/components/common/Select.vue'
|
||||||
import HelpTooltip from '@/components/common/HelpTooltip.vue'
|
import HelpTooltip from '@/components/common/HelpTooltip.vue'
|
||||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||||
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import { adminAPI } from '@/api'
|
import { adminAPI } from '@/api'
|
||||||
import type { OpsDashboardOverview, OpsWSStatus } from '@/api/admin/ops'
|
import { opsAPI, type OpsDashboardOverview, type OpsMetricThresholds, type OpsRealtimeTrafficSummary } from '@/api/admin/ops'
|
||||||
import type { OpsRequestDetailsPreset } from './OpsRequestDetailsModal.vue'
|
import type { OpsRequestDetailsPreset } from './OpsRequestDetailsModal.vue'
|
||||||
|
import { useAdminSettingsStore } from '@/stores'
|
||||||
import { formatNumber } from '@/utils/format'
|
import { formatNumber } from '@/utils/format'
|
||||||
|
|
||||||
type RealtimeWindow = '1min' | '5min' | '30min' | '1h'
|
type RealtimeWindow = '1min' | '5min' | '30min' | '1h'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
overview?: OpsDashboardOverview | null
|
overview?: OpsDashboardOverview | null
|
||||||
wsStatus: OpsWSStatus
|
|
||||||
wsReconnectInMs?: number | null
|
|
||||||
wsHasData?: boolean
|
|
||||||
realTimeQps: number
|
|
||||||
realTimeTps: number
|
|
||||||
platform: string
|
platform: string
|
||||||
groupId: number | null
|
groupId: number | null
|
||||||
timeRange: string
|
timeRange: string
|
||||||
queryMode: string
|
queryMode: string
|
||||||
loading: boolean
|
loading: boolean
|
||||||
lastUpdated: Date | null
|
lastUpdated: Date | null
|
||||||
|
thresholds?: OpsMetricThresholds | null // 阈值配置
|
||||||
|
autoRefreshEnabled?: boolean
|
||||||
|
autoRefreshCountdown?: number
|
||||||
|
fullscreen?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
@@ -36,18 +38,51 @@ interface Emits {
|
|||||||
(e: 'openErrorDetails', kind: 'request' | 'upstream'): void
|
(e: 'openErrorDetails', kind: 'request' | 'upstream'): void
|
||||||
(e: 'openSettings'): void
|
(e: 'openSettings'): void
|
||||||
(e: 'openAlertRules'): void
|
(e: 'openAlertRules'): void
|
||||||
|
(e: 'enterFullscreen'): void
|
||||||
|
(e: 'exitFullscreen'): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const adminSettingsStore = useAdminSettingsStore()
|
||||||
|
|
||||||
const realtimeWindow = ref<RealtimeWindow>('1min')
|
const realtimeWindow = ref<RealtimeWindow>('1min')
|
||||||
|
|
||||||
const overview = computed(() => props.overview ?? null)
|
const overview = computed(() => props.overview ?? null)
|
||||||
const systemMetrics = computed(() => overview.value?.system_metrics ?? null)
|
const systemMetrics = computed(() => overview.value?.system_metrics ?? null)
|
||||||
|
|
||||||
|
const REALTIME_WINDOW_MINUTES: Record<RealtimeWindow, number> = {
|
||||||
|
'1min': 1,
|
||||||
|
'5min': 5,
|
||||||
|
'30min': 30,
|
||||||
|
'1h': 60
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOLBAR_RANGE_MINUTES: Record<string, number> = {
|
||||||
|
'5m': 5,
|
||||||
|
'30m': 30,
|
||||||
|
'1h': 60,
|
||||||
|
'6h': 6 * 60,
|
||||||
|
'24h': 24 * 60
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableRealtimeWindows = computed(() => {
|
||||||
|
const toolbarMinutes = TOOLBAR_RANGE_MINUTES[props.timeRange] ?? 60
|
||||||
|
return (['1min', '5min', '30min', '1h'] as const).filter((w) => REALTIME_WINDOW_MINUTES[w] <= toolbarMinutes)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.timeRange,
|
||||||
|
() => {
|
||||||
|
// The realtime window must be inside the toolbar window; reset to keep UX predictable.
|
||||||
|
realtimeWindow.value = '1min'
|
||||||
|
// Keep realtime traffic consistent with toolbar changes even when the window is already 1min.
|
||||||
|
loadRealtimeTrafficSummary()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// --- Filters ---
|
// --- Filters ---
|
||||||
|
|
||||||
const groups = ref<Array<{ id: number; name: string; platform: string }>>([])
|
const groups = ref<Array<{ id: number; name: string; platform: string }>>([])
|
||||||
@@ -143,56 +178,143 @@ function getLatencyColor(ms: number | null | undefined): string {
|
|||||||
return 'text-red-600 dark:text-red-400'
|
return 'text-red-600 dark:text-red-400'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Threshold checking helpers ---
|
||||||
|
function isSLABelowThreshold(slaPercent: number | null): boolean {
|
||||||
|
if (slaPercent == null) return false
|
||||||
|
const threshold = props.thresholds?.sla_percent_min
|
||||||
|
if (threshold == null) return false
|
||||||
|
return slaPercent < threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLatencyAboveThreshold(latencyP99Ms: number | null): boolean {
|
||||||
|
if (latencyP99Ms == null) return false
|
||||||
|
const threshold = props.thresholds?.latency_p99_ms_max
|
||||||
|
if (threshold == null) return false
|
||||||
|
return latencyP99Ms > threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTTFTAboveThreshold(ttftP99Ms: number | null): boolean {
|
||||||
|
if (ttftP99Ms == null) return false
|
||||||
|
const threshold = props.thresholds?.ttft_p99_ms_max
|
||||||
|
if (threshold == null) return false
|
||||||
|
return ttftP99Ms > threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRequestErrorRateAboveThreshold(errorRatePercent: number | null): boolean {
|
||||||
|
if (errorRatePercent == null) return false
|
||||||
|
const threshold = props.thresholds?.request_error_rate_percent_max
|
||||||
|
if (threshold == null) return false
|
||||||
|
return errorRatePercent > threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUpstreamErrorRateAboveThreshold(upstreamErrorRatePercent: number | null): boolean {
|
||||||
|
if (upstreamErrorRatePercent == null) return false
|
||||||
|
const threshold = props.thresholds?.upstream_error_rate_percent_max
|
||||||
|
if (threshold == null) return false
|
||||||
|
return upstreamErrorRatePercent > threshold
|
||||||
|
}
|
||||||
|
|
||||||
// --- Realtime / Overview labels ---
|
// --- Realtime / Overview labels ---
|
||||||
|
|
||||||
const totalRequestsLabel = computed(() => formatNumber(overview.value?.request_count_total ?? 0))
|
const totalRequestsLabel = computed(() => formatNumber(overview.value?.request_count_total ?? 0))
|
||||||
const totalTokensLabel = computed(() => formatNumber(overview.value?.token_consumed ?? 0))
|
const totalTokensLabel = computed(() => formatNumber(overview.value?.token_consumed ?? 0))
|
||||||
|
|
||||||
|
const realtimeTrafficSummary = ref<OpsRealtimeTrafficSummary | null>(null)
|
||||||
|
const realtimeTrafficLoading = ref(false)
|
||||||
|
|
||||||
|
function makeZeroRealtimeTrafficSummary(): OpsRealtimeTrafficSummary {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
return {
|
||||||
|
window: realtimeWindow.value,
|
||||||
|
start_time: now,
|
||||||
|
end_time: now,
|
||||||
|
platform: props.platform,
|
||||||
|
group_id: props.groupId,
|
||||||
|
qps: { current: 0, peak: 0, avg: 0 },
|
||||||
|
tps: { current: 0, peak: 0, avg: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRealtimeTrafficSummary() {
|
||||||
|
if (realtimeTrafficLoading.value) return
|
||||||
|
if (!adminSettingsStore.opsRealtimeMonitoringEnabled) {
|
||||||
|
realtimeTrafficSummary.value = makeZeroRealtimeTrafficSummary()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
realtimeTrafficLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await opsAPI.getRealtimeTrafficSummary(realtimeWindow.value, props.platform, props.groupId)
|
||||||
|
if (res && res.enabled === false) {
|
||||||
|
adminSettingsStore.setOpsRealtimeMonitoringEnabledLocal(false)
|
||||||
|
}
|
||||||
|
realtimeTrafficSummary.value = res?.summary ?? null
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[OpsDashboardHeader] Failed to load realtime traffic summary', err)
|
||||||
|
realtimeTrafficSummary.value = null
|
||||||
|
} finally {
|
||||||
|
realtimeTrafficLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [realtimeWindow.value, props.platform, props.groupId] as const,
|
||||||
|
() => {
|
||||||
|
loadRealtimeTrafficSummary()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const { pause: pauseRealtimeTrafficRefresh, resume: resumeRealtimeTrafficRefresh } = useIntervalFn(
|
||||||
|
() => {
|
||||||
|
loadRealtimeTrafficSummary()
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
{ immediate: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => adminSettingsStore.opsRealtimeMonitoringEnabled,
|
||||||
|
(enabled) => {
|
||||||
|
if (enabled) {
|
||||||
|
resumeRealtimeTrafficRefresh()
|
||||||
|
} else {
|
||||||
|
pauseRealtimeTrafficRefresh()
|
||||||
|
// Keep UI stable when realtime monitoring is turned off.
|
||||||
|
realtimeTrafficSummary.value = makeZeroRealtimeTrafficSummary()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
pauseRealtimeTrafficRefresh()
|
||||||
|
})
|
||||||
|
|
||||||
const displayRealTimeQps = computed(() => {
|
const displayRealTimeQps = computed(() => {
|
||||||
const ov = overview.value
|
const v = realtimeTrafficSummary.value?.qps?.current
|
||||||
if (!ov) return 0
|
|
||||||
const useRealtime = props.wsStatus === 'connected' && !!props.wsHasData
|
|
||||||
const v = useRealtime ? props.realTimeQps : ov.qps?.current
|
|
||||||
return typeof v === 'number' && Number.isFinite(v) ? v : 0
|
return typeof v === 'number' && Number.isFinite(v) ? v : 0
|
||||||
})
|
})
|
||||||
|
|
||||||
const displayRealTimeTps = computed(() => {
|
const displayRealTimeTps = computed(() => {
|
||||||
const ov = overview.value
|
const v = realtimeTrafficSummary.value?.tps?.current
|
||||||
if (!ov) return 0
|
|
||||||
const useRealtime = props.wsStatus === 'connected' && !!props.wsHasData
|
|
||||||
const v = useRealtime ? props.realTimeTps : ov.tps?.current
|
|
||||||
return typeof v === 'number' && Number.isFinite(v) ? v : 0
|
return typeof v === 'number' && Number.isFinite(v) ? v : 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// Sparkline history (keep last 60 data points)
|
const realtimeQpsPeakLabel = computed(() => {
|
||||||
const qpsHistory = ref<number[]>([])
|
const v = realtimeTrafficSummary.value?.qps?.peak
|
||||||
const tpsHistory = ref<number[]>([])
|
return typeof v === 'number' && Number.isFinite(v) ? v.toFixed(1) : '-'
|
||||||
const MAX_HISTORY_POINTS = 60
|
|
||||||
|
|
||||||
watch([displayRealTimeQps, displayRealTimeTps], ([newQps, newTps]) => {
|
|
||||||
// Add new data points
|
|
||||||
qpsHistory.value.push(newQps)
|
|
||||||
tpsHistory.value.push(newTps)
|
|
||||||
|
|
||||||
// Keep only last N points
|
|
||||||
if (qpsHistory.value.length > MAX_HISTORY_POINTS) {
|
|
||||||
qpsHistory.value.shift()
|
|
||||||
}
|
|
||||||
if (tpsHistory.value.length > MAX_HISTORY_POINTS) {
|
|
||||||
tpsHistory.value.shift()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
const realtimeTpsPeakLabel = computed(() => {
|
||||||
const qpsPeakLabel = computed(() => {
|
const v = realtimeTrafficSummary.value?.tps?.peak
|
||||||
const v = overview.value?.qps?.peak
|
return typeof v === 'number' && Number.isFinite(v) ? v.toFixed(1) : '-'
|
||||||
if (typeof v !== 'number') return '-'
|
|
||||||
return v.toFixed(1)
|
|
||||||
})
|
})
|
||||||
|
const realtimeQpsAvgLabel = computed(() => {
|
||||||
const tpsPeakLabel = computed(() => {
|
const v = realtimeTrafficSummary.value?.qps?.avg
|
||||||
const v = overview.value?.tps?.peak
|
return typeof v === 'number' && Number.isFinite(v) ? v.toFixed(1) : '-'
|
||||||
if (typeof v !== 'number') return '-'
|
})
|
||||||
return v.toFixed(1)
|
const realtimeTpsAvgLabel = computed(() => {
|
||||||
|
const v = realtimeTrafficSummary.value?.tps?.avg
|
||||||
|
return typeof v === 'number' && Number.isFinite(v) ? v.toFixed(1) : '-'
|
||||||
})
|
})
|
||||||
|
|
||||||
const qpsAvgLabel = computed(() => {
|
const qpsAvgLabel = computed(() => {
|
||||||
@@ -244,7 +366,7 @@ const ttftMaxMs = computed(() => overview.value?.ttft?.max_ms ?? null)
|
|||||||
const isSystemIdle = computed(() => {
|
const isSystemIdle = computed(() => {
|
||||||
const ov = overview.value
|
const ov = overview.value
|
||||||
if (!ov) return true
|
if (!ov) return true
|
||||||
const qps = props.wsStatus === 'connected' && props.wsHasData ? props.realTimeQps : ov.qps?.current
|
const qps = ov.qps?.current
|
||||||
const errorRate = ov.error_rate ?? 0
|
const errorRate = ov.error_rate ?? 0
|
||||||
return (qps ?? 0) === 0 && errorRate === 0
|
return (qps ?? 0) === 0 && errorRate === 0
|
||||||
})
|
})
|
||||||
@@ -272,15 +394,15 @@ const healthScoreClass = computed(() => {
|
|||||||
return 'text-red-500'
|
return 'text-red-500'
|
||||||
})
|
})
|
||||||
|
|
||||||
const circleSize = 100
|
const circleSize = computed(() => props.fullscreen ? 140 : 100)
|
||||||
const strokeWidth = 8
|
const strokeWidth = computed(() => props.fullscreen ? 10 : 8)
|
||||||
const radius = (circleSize - strokeWidth) / 2
|
const radius = computed(() => (circleSize.value - strokeWidth.value) / 2)
|
||||||
const circumference = 2 * Math.PI * radius
|
const circumference = computed(() => 2 * Math.PI * radius.value)
|
||||||
const dashOffset = computed(() => {
|
const dashOffset = computed(() => {
|
||||||
if (isSystemIdle.value) return 0
|
if (isSystemIdle.value) return 0
|
||||||
if (healthScoreValue.value == null) return 0
|
if (healthScoreValue.value == null) return 0
|
||||||
const score = Math.max(0, Math.min(100, healthScoreValue.value))
|
const score = Math.max(0, Math.min(100, healthScoreValue.value))
|
||||||
return circumference - (score / 100) * circumference
|
return circumference.value - (score / 100) * circumference.value
|
||||||
})
|
})
|
||||||
|
|
||||||
interface DiagnosisItem {
|
interface DiagnosisItem {
|
||||||
@@ -687,10 +809,15 @@ const showJobsDetails = ref(false)
|
|||||||
function openJobsDetails() {
|
function openJobsDetails() {
|
||||||
showJobsDetails.value = true
|
showJobsDetails.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleToolbarRefresh() {
|
||||||
|
loadRealtimeTrafficSummary()
|
||||||
|
emit('refresh')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-4 rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
|
<div :class="['flex flex-col gap-4 rounded-3xl bg-white shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700', props.fullscreen ? 'p-8' : 'p-6']">
|
||||||
<!-- Top Toolbar -->
|
<!-- Top Toolbar -->
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-gray-100 pb-4 dark:border-dark-700">
|
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-gray-100 pb-4 dark:border-dark-700">
|
||||||
<div>
|
<div>
|
||||||
@@ -706,7 +833,7 @@ function openJobsDetails() {
|
|||||||
{{ t('admin.ops.title') }}
|
{{ t('admin.ops.title') }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div class="mt-1 flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
|
<div v-if="!props.fullscreen" class="mt-1 flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
|
||||||
<span class="flex items-center gap-1.5" :title="props.loading ? t('admin.ops.loadingText') : t('admin.ops.ready')">
|
<span class="flex items-center gap-1.5" :title="props.loading ? t('admin.ops.loadingText') : t('admin.ops.ready')">
|
||||||
<span class="relative flex h-2 w-2">
|
<span class="relative flex h-2 w-2">
|
||||||
<span class="relative inline-flex h-2 w-2 rounded-full" :class="props.loading ? 'bg-gray-400' : 'bg-green-500'"></span>
|
<span class="relative inline-flex h-2 w-2 rounded-full" :class="props.loading ? 'bg-gray-400' : 'bg-green-500'"></span>
|
||||||
@@ -717,6 +844,17 @@ function openJobsDetails() {
|
|||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>{{ t('common.refresh') }}: {{ updatedAtLabel }}</span>
|
<span>{{ t('common.refresh') }}: {{ updatedAtLabel }}</span>
|
||||||
|
|
||||||
|
<template v-if="props.autoRefreshEnabled && props.autoRefreshCountdown !== undefined">
|
||||||
|
<span>·</span>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<svg class="h-3 w-3 animate-spin text-blue-500" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>自动刷新: {{ props.autoRefreshCountdown }}s</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-if="systemMetrics">
|
<template v-if="systemMetrics">
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>
|
<span>
|
||||||
@@ -728,28 +866,30 @@ function openJobsDetails() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<Select
|
<template v-if="!props.fullscreen">
|
||||||
:model-value="platform"
|
<Select
|
||||||
:options="platformOptions"
|
:model-value="platform"
|
||||||
class="w-full sm:w-[140px]"
|
:options="platformOptions"
|
||||||
@update:model-value="handlePlatformChange"
|
class="w-full sm:w-[140px]"
|
||||||
/>
|
@update:model-value="handlePlatformChange"
|
||||||
|
/>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
:model-value="groupId"
|
:model-value="groupId"
|
||||||
:options="groupOptions"
|
:options="groupOptions"
|
||||||
class="w-full sm:w-[160px]"
|
class="w-full sm:w-[160px]"
|
||||||
@update:model-value="handleGroupChange"
|
@update:model-value="handleGroupChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="mx-1 hidden h-4 w-[1px] bg-gray-200 dark:bg-dark-700 sm:block"></div>
|
<div class="mx-1 hidden h-4 w-[1px] bg-gray-200 dark:bg-dark-700 sm:block"></div>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
:model-value="timeRange"
|
:model-value="timeRange"
|
||||||
:options="timeRangeOptions"
|
:options="timeRangeOptions"
|
||||||
class="relative w-full sm:w-[150px]"
|
class="relative w-full sm:w-[150px]"
|
||||||
@update:model-value="handleTimeRangeChange"
|
@update:model-value="handleTimeRangeChange"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
v-if="false"
|
v-if="false"
|
||||||
@@ -760,11 +900,12 @@ function openJobsDetails() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
v-if="!props.fullscreen"
|
||||||
type="button"
|
type="button"
|
||||||
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gray-100 text-gray-500 transition-colors hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600"
|
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gray-100 text-gray-500 transition-colors hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
:title="t('common.refresh')"
|
:title="t('common.refresh')"
|
||||||
@click="emit('refresh')"
|
@click="handleToolbarRefresh"
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4" :class="{ 'animate-spin': loading }" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-4 w-4" :class="{ 'animate-spin': loading }" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path
|
<path
|
||||||
@@ -776,9 +917,11 @@ function openJobsDetails() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="mx-1 hidden h-4 w-[1px] bg-gray-200 dark:bg-dark-700 sm:block"></div>
|
<div v-if="!props.fullscreen" class="mx-1 hidden h-4 w-[1px] bg-gray-200 dark:bg-dark-700 sm:block"></div>
|
||||||
|
|
||||||
|
<!-- Alert Rules Button (hidden in fullscreen) -->
|
||||||
<button
|
<button
|
||||||
|
v-if="!props.fullscreen"
|
||||||
type="button"
|
type="button"
|
||||||
class="flex h-8 items-center gap-1.5 rounded-lg bg-blue-100 px-3 text-xs font-bold text-blue-700 transition-colors hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-900/50"
|
class="flex h-8 items-center gap-1.5 rounded-lg bg-blue-100 px-3 text-xs font-bold text-blue-700 transition-colors hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-900/50"
|
||||||
:title="t('admin.ops.alertRules.title')"
|
:title="t('admin.ops.alertRules.title')"
|
||||||
@@ -790,7 +933,9 @@ function openJobsDetails() {
|
|||||||
<span class="hidden sm:inline">{{ t('admin.ops.alertRules.manage') }}</span>
|
<span class="hidden sm:inline">{{ t('admin.ops.alertRules.manage') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Settings Button (hidden in fullscreen) -->
|
||||||
<button
|
<button
|
||||||
|
v-if="!props.fullscreen"
|
||||||
type="button"
|
type="button"
|
||||||
class="flex h-8 items-center gap-1.5 rounded-lg bg-gray-100 px-3 text-xs font-bold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
|
class="flex h-8 items-center gap-1.5 rounded-lg bg-gray-100 px-3 text-xs font-bold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
|
||||||
:title="t('admin.ops.settings.title')"
|
:title="t('admin.ops.settings.title')"
|
||||||
@@ -802,13 +947,26 @@ function openJobsDetails() {
|
|||||||
</svg>
|
</svg>
|
||||||
<span class="hidden sm:inline">{{ t('common.settings') }}</span>
|
<span class="hidden sm:inline">{{ t('common.settings') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Enter Fullscreen Button (hidden in fullscreen mode) -->
|
||||||
|
<button
|
||||||
|
v-if="!props.fullscreen"
|
||||||
|
type="button"
|
||||||
|
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gray-100 text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
|
||||||
|
:title="t('admin.ops.fullscreen.enter')"
|
||||||
|
@click="emit('enterFullscreen')"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="overview" class="grid grid-cols-1 gap-6 lg:grid-cols-12">
|
<div v-if="overview" class="grid grid-cols-1 gap-6 lg:grid-cols-12">
|
||||||
<!-- Left: Health + Realtime -->
|
<!-- Left: Health + Realtime -->
|
||||||
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900 lg:col-span-5">
|
<div :class="['rounded-2xl bg-gray-50 dark:bg-dark-900 lg:col-span-5', props.fullscreen ? 'p-6' : 'p-4']">
|
||||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-[200px_1fr] md:items-center">
|
<div class="grid h-full grid-cols-1 gap-6 md:grid-cols-[200px_1fr] md:items-center">
|
||||||
<!-- 1) Health Score -->
|
<!-- 1) Health Score -->
|
||||||
<div
|
<div
|
||||||
class="group relative flex cursor-pointer flex-col items-center justify-center rounded-xl py-2 transition-all hover:bg-white/60 dark:hover:bg-dark-800/60 md:border-r md:border-gray-200 md:pr-6 dark:md:border-dark-700"
|
class="group relative flex cursor-pointer flex-col items-center justify-center rounded-xl py-2 transition-all hover:bg-white/60 dark:hover:bg-dark-800/60 md:border-r md:border-gray-200 md:pr-6 dark:md:border-dark-700"
|
||||||
@@ -818,8 +976,9 @@ function openJobsDetails() {
|
|||||||
class="pointer-events-none absolute left-1/2 top-full z-50 mt-2 w-72 -translate-x-1/2 opacity-0 transition-opacity duration-200 group-hover:pointer-events-auto group-hover:opacity-100 md:left-full md:top-0 md:ml-2 md:mt-0 md:translate-x-0"
|
class="pointer-events-none absolute left-1/2 top-full z-50 mt-2 w-72 -translate-x-1/2 opacity-0 transition-opacity duration-200 group-hover:pointer-events-auto group-hover:opacity-100 md:left-full md:top-0 md:ml-2 md:mt-0 md:translate-x-0"
|
||||||
>
|
>
|
||||||
<div class="rounded-xl bg-white p-4 shadow-xl ring-1 ring-black/5 dark:bg-gray-800 dark:ring-white/10">
|
<div class="rounded-xl bg-white p-4 shadow-xl ring-1 ring-black/5 dark:bg-gray-800 dark:ring-white/10">
|
||||||
<h4 class="mb-3 border-b border-gray-100 pb-2 text-sm font-bold text-gray-900 dark:border-gray-700 dark:text-white">
|
<h4 class="mb-3 border-b border-gray-100 pb-2 text-sm font-bold text-gray-900 dark:border-gray-700 dark:text-white flex items-center gap-2">
|
||||||
🧠 {{ t('admin.ops.diagnosis.title') }}
|
<Icon name="brain" size="sm" class="text-blue-500" />
|
||||||
|
{{ t('admin.ops.diagnosis.title') }}
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@@ -850,8 +1009,9 @@ function openJobsDetails() {
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="text-xs font-semibold text-gray-900 dark:text-white">{{ item.message }}</div>
|
<div class="text-xs font-semibold text-gray-900 dark:text-white">{{ item.message }}</div>
|
||||||
<div class="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">{{ item.impact }}</div>
|
<div class="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">{{ item.impact }}</div>
|
||||||
<div v-if="item.action" class="mt-1 text-[11px] text-blue-600 dark:text-blue-400">
|
<div v-if="item.action" class="mt-1 text-[11px] text-blue-600 dark:text-blue-400 flex items-center gap-1">
|
||||||
💡 {{ item.action }}
|
<Icon name="lightbulb" size="xs" />
|
||||||
|
{{ item.action }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -889,14 +1049,14 @@ function openJobsDetails() {
|
|||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<div class="absolute flex flex-col items-center">
|
<div class="absolute flex flex-col items-center">
|
||||||
<span class="text-3xl font-black" :class="healthScoreClass">
|
<span :class="[props.fullscreen ? 'text-5xl' : 'text-3xl', 'font-black', healthScoreClass]">
|
||||||
{{ isSystemIdle ? t('admin.ops.idleStatus') : (overview.health_score ?? '--') }}
|
{{ isSystemIdle ? t('admin.ops.idleStatus') : (overview.health_score ?? '--') }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.health') }}</span>
|
<span :class="[props.fullscreen ? 'text-xs' : 'text-[10px]', 'font-bold uppercase tracking-wider text-gray-400']">{{ t('admin.ops.health') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 text-center">
|
<div class="mt-4 text-center" v-if="!props.fullscreen">
|
||||||
<div class="flex items-center justify-center gap-1 text-xs font-medium text-gray-500">
|
<div class="flex items-center justify-center gap-1 text-xs font-medium text-gray-500">
|
||||||
{{ t('admin.ops.healthCondition') }}
|
{{ t('admin.ops.healthCondition') }}
|
||||||
<HelpTooltip :content="t('admin.ops.healthHelp')" />
|
<HelpTooltip :content="t('admin.ops.healthHelp')" />
|
||||||
@@ -914,7 +1074,7 @@ function openJobsDetails() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 2) Realtime Traffic -->
|
<!-- 2) Realtime Traffic -->
|
||||||
<div class="flex flex-col justify-center py-2">
|
<div class="flex h-full flex-col justify-center py-2">
|
||||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="relative flex h-3 w-3 shrink-0">
|
<div class="relative flex h-3 w-3 shrink-0">
|
||||||
@@ -922,13 +1082,13 @@ function openJobsDetails() {
|
|||||||
<span class="relative inline-flex h-3 w-3 rounded-full bg-blue-500"></span>
|
<span class="relative inline-flex h-3 w-3 rounded-full bg-blue-500"></span>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.realtime.title') }}</h3>
|
<h3 class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.realtime.title') }}</h3>
|
||||||
<HelpTooltip :content="t('admin.ops.tooltips.qps')" />
|
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.qps')" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Time Window Selector -->
|
<!-- Time Window Selector -->
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
<button
|
<button
|
||||||
v-for="window in (['1min', '5min', '30min', '1h'] as RealtimeWindow[])"
|
v-for="window in availableRealtimeWindows"
|
||||||
:key="window"
|
:key="window"
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded px-1.5 py-0.5 text-[9px] font-bold transition-colors sm:px-2 sm:text-[10px]"
|
class="rounded px-1.5 py-0.5 text-[9px] font-bold transition-colors sm:px-2 sm:text-[10px]"
|
||||||
@@ -942,18 +1102,18 @@ function openJobsDetails() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div :class="props.fullscreen ? 'space-y-4' : 'space-y-3'">
|
||||||
<!-- Row 1: Current -->
|
<!-- Row 1: Current -->
|
||||||
<div>
|
<div>
|
||||||
<div class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.current') }}</div>
|
<div :class="[props.fullscreen ? 'text-xs' : 'text-[10px]', 'font-bold uppercase text-gray-400']">{{ t('admin.ops.current') }}</div>
|
||||||
<div class="mt-1 flex flex-wrap items-baseline gap-x-4 gap-y-2">
|
<div class="mt-1 flex flex-wrap items-baseline gap-x-4 gap-y-2">
|
||||||
<div class="flex items-baseline gap-1.5">
|
<div class="flex items-baseline gap-1.5">
|
||||||
<span class="text-xl font-black text-gray-900 dark:text-white sm:text-2xl">{{ displayRealTimeQps.toFixed(1) }}</span>
|
<span :class="[props.fullscreen ? 'text-4xl' : 'text-xl sm:text-2xl', 'font-black text-gray-900 dark:text-white']">{{ displayRealTimeQps.toFixed(1) }}</span>
|
||||||
<span class="text-xs font-bold text-gray-500">QPS</span>
|
<span :class="[props.fullscreen ? 'text-sm' : 'text-xs', 'font-bold text-gray-500']">QPS</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-baseline gap-1.5">
|
<div class="flex items-baseline gap-1.5">
|
||||||
<span class="text-xl font-black text-gray-900 dark:text-white sm:text-2xl">{{ displayRealTimeTps.toFixed(1) }}</span>
|
<span :class="[props.fullscreen ? 'text-4xl' : 'text-xl sm:text-2xl', 'font-black text-gray-900 dark:text-white']">{{ displayRealTimeTps.toFixed(1) }}</span>
|
||||||
<span class="text-xs font-bold text-gray-500">TPS</span>
|
<span :class="[props.fullscreen ? 'text-sm' : 'text-xs', 'font-bold text-gray-500']">TPS</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -962,14 +1122,14 @@ function openJobsDetails() {
|
|||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<!-- Peak -->
|
<!-- Peak -->
|
||||||
<div>
|
<div>
|
||||||
<div class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.peak') }}</div>
|
<div :class="[props.fullscreen ? 'text-xs' : 'text-[10px]', 'font-bold uppercase text-gray-400']">{{ t('admin.ops.peak') }}</div>
|
||||||
<div class="mt-1 space-y-0.5 text-sm font-medium text-gray-600 dark:text-gray-400">
|
<div :class="[props.fullscreen ? 'text-base' : 'text-sm', 'mt-1 space-y-0.5 font-medium text-gray-600 dark:text-gray-400']">
|
||||||
<div class="flex items-baseline gap-1.5">
|
<div class="flex items-baseline gap-1.5">
|
||||||
<span class="font-black text-gray-900 dark:text-white">{{ qpsPeakLabel }}</span>
|
<span class="font-black text-gray-900 dark:text-white">{{ realtimeQpsPeakLabel }}</span>
|
||||||
<span class="text-xs">QPS</span>
|
<span class="text-xs">QPS</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-baseline gap-1.5">
|
<div class="flex items-baseline gap-1.5">
|
||||||
<span class="font-black text-gray-900 dark:text-white">{{ tpsPeakLabel }}</span>
|
<span class="font-black text-gray-900 dark:text-white">{{ realtimeTpsPeakLabel }}</span>
|
||||||
<span class="text-xs">TPS</span>
|
<span class="text-xs">TPS</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -977,14 +1137,14 @@ function openJobsDetails() {
|
|||||||
|
|
||||||
<!-- Average -->
|
<!-- Average -->
|
||||||
<div>
|
<div>
|
||||||
<div class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.average') }}</div>
|
<div :class="[props.fullscreen ? 'text-xs' : 'text-[10px]', 'font-bold uppercase text-gray-400']">{{ t('admin.ops.average') }}</div>
|
||||||
<div class="mt-1 space-y-0.5 text-sm font-medium text-gray-600 dark:text-gray-400">
|
<div :class="[props.fullscreen ? 'text-base' : 'text-sm', 'mt-1 space-y-0.5 font-medium text-gray-600 dark:text-gray-400']">
|
||||||
<div class="flex items-baseline gap-1.5">
|
<div class="flex items-baseline gap-1.5">
|
||||||
<span class="font-black text-gray-900 dark:text-white">{{ qpsAvgLabel }}</span>
|
<span class="font-black text-gray-900 dark:text-white">{{ realtimeQpsAvgLabel }}</span>
|
||||||
<span class="text-xs">QPS</span>
|
<span class="text-xs">QPS</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-baseline gap-1.5">
|
<div class="flex items-baseline gap-1.5">
|
||||||
<span class="font-black text-gray-900 dark:text-white">{{ tpsAvgLabel }}</span>
|
<span class="font-black text-gray-900 dark:text-white">{{ realtimeTpsAvgLabel }}</span>
|
||||||
<span class="text-xs">TPS</span>
|
<span class="text-xs">TPS</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1019,15 +1179,16 @@ function openJobsDetails() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: 6 cards (3 cols x 2 rows) -->
|
<!-- Right: 6 cards (3 cols x 2 rows) -->
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:col-span-7 lg:grid-cols-3">
|
<div class="grid h-full grid-cols-1 content-center gap-4 sm:grid-cols-2 lg:col-span-7 lg:grid-cols-3">
|
||||||
<!-- Card 1: Requests -->
|
<!-- Card 1: Requests -->
|
||||||
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900">
|
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.requests') }}</span>
|
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.requestsTitle') }}</span>
|
||||||
<HelpTooltip :content="t('admin.ops.tooltips.totalRequests')" />
|
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.totalRequests')" />
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
v-if="!props.fullscreen"
|
||||||
class="text-[10px] font-bold text-blue-500 hover:underline"
|
class="text-[10px] font-bold text-blue-500 hover:underline"
|
||||||
type="button"
|
type="button"
|
||||||
@click="openDetails({ title: t('admin.ops.requestDetails.title') })"
|
@click="openDetails({ title: t('admin.ops.requestDetails.title') })"
|
||||||
@@ -1060,22 +1221,23 @@ function openJobsDetails() {
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-[10px] font-bold uppercase text-gray-400">SLA</span>
|
<span class="text-[10px] font-bold uppercase text-gray-400">SLA</span>
|
||||||
<HelpTooltip :content="t('admin.ops.tooltips.sla')" />
|
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.sla')" />
|
||||||
<span class="h-1.5 w-1.5 rounded-full" :class="(slaPercent ?? 0) >= 99.5 ? 'bg-green-500' : 'bg-yellow-500'"></span>
|
<span class="h-1.5 w-1.5 rounded-full" :class="isSLABelowThreshold(slaPercent) ? 'bg-red-500' : (slaPercent ?? 0) >= 99.5 ? 'bg-green-500' : 'bg-yellow-500'"></span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
v-if="!props.fullscreen"
|
||||||
class="text-[10px] font-bold text-blue-500 hover:underline"
|
class="text-[10px] font-bold text-blue-500 hover:underline"
|
||||||
type="button"
|
type="button"
|
||||||
@click="openDetails({ title: t('admin.ops.requestDetails.title') })"
|
@click="openDetails({ title: t('admin.ops.requestDetails.title'), kind: 'error' })"
|
||||||
>
|
>
|
||||||
{{ t('admin.ops.requestDetails.details') }}
|
{{ t('admin.ops.requestDetails.details') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 text-3xl font-black text-gray-900 dark:text-white">
|
<div class="mt-2 text-3xl font-black" :class="isSLABelowThreshold(slaPercent) ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-white'">
|
||||||
{{ slaPercent == null ? '-' : `${slaPercent.toFixed(3)}%` }}
|
{{ slaPercent == null ? '-' : `${slaPercent.toFixed(3)}%` }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-700">
|
<div class="mt-3 h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-700">
|
||||||
<div class="h-full bg-green-500 transition-all" :style="{ width: `${Math.max((slaPercent ?? 0) - 90, 0) * 10}%` }"></div>
|
<div class="h-full transition-all" :class="isSLABelowThreshold(slaPercent) ? 'bg-red-500' : 'bg-green-500'" :style="{ width: `${Math.max((slaPercent ?? 0) - 90, 0) * 10}%` }"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 text-xs">
|
<div class="mt-3 text-xs">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
@@ -1090,9 +1252,10 @@ function openJobsDetails() {
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.latencyDuration') }}</span>
|
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.latencyDuration') }}</span>
|
||||||
<HelpTooltip :content="t('admin.ops.tooltips.latency')" />
|
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.latency')" />
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
v-if="!props.fullscreen"
|
||||||
class="text-[10px] font-bold text-blue-500 hover:underline"
|
class="text-[10px] font-bold text-blue-500 hover:underline"
|
||||||
type="button"
|
type="button"
|
||||||
@click="openDetails({ title: t('admin.ops.latencyDuration'), sort: 'duration_desc', min_duration_ms: Math.max(Number(durationP99Ms ?? 0), 0) })"
|
@click="openDetails({ title: t('admin.ops.latencyDuration'), sort: 'duration_desc', min_duration_ms: Math.max(Number(durationP99Ms ?? 0), 0) })"
|
||||||
@@ -1101,7 +1264,7 @@ function openJobsDetails() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex items-baseline gap-2">
|
<div class="mt-2 flex items-baseline gap-2">
|
||||||
<div class="text-3xl font-black" :class="getLatencyColor(durationP99Ms)">
|
<div class="text-3xl font-black" :class="isLatencyAboveThreshold(durationP99Ms) ? 'text-red-600 dark:text-red-400' : getLatencyColor(durationP99Ms)">
|
||||||
{{ durationP99Ms ?? '-' }}
|
{{ durationP99Ms ?? '-' }}
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs font-bold text-gray-400">ms (P99)</span>
|
<span class="text-xs font-bold text-gray-400">ms (P99)</span>
|
||||||
@@ -1140,18 +1303,19 @@ function openJobsDetails() {
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<span class="text-[10px] font-bold uppercase text-gray-400">TTFT</span>
|
<span class="text-[10px] font-bold uppercase text-gray-400">TTFT</span>
|
||||||
<HelpTooltip :content="t('admin.ops.tooltips.ttft')" />
|
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.ttft')" />
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
v-if="!props.fullscreen"
|
||||||
class="text-[10px] font-bold text-blue-500 hover:underline"
|
class="text-[10px] font-bold text-blue-500 hover:underline"
|
||||||
type="button"
|
type="button"
|
||||||
@click="openDetails({ title: 'TTFT' })"
|
@click="openDetails({ title: 'TTFT', sort: 'duration_desc' })"
|
||||||
>
|
>
|
||||||
{{ t('admin.ops.requestDetails.details') }}
|
{{ t('admin.ops.requestDetails.details') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex items-baseline gap-2">
|
<div class="mt-2 flex items-baseline gap-2">
|
||||||
<div class="text-3xl font-black" :class="getLatencyColor(ttftP99Ms)">
|
<div class="text-3xl font-black" :class="isTTFTAboveThreshold(ttftP99Ms) ? 'text-red-600 dark:text-red-400' : getLatencyColor(ttftP99Ms)">
|
||||||
{{ ttftP99Ms ?? '-' }}
|
{{ ttftP99Ms ?? '-' }}
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs font-bold text-gray-400">ms (P99)</span>
|
<span class="text-xs font-bold text-gray-400">ms (P99)</span>
|
||||||
@@ -1190,13 +1354,13 @@ function openJobsDetails() {
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.requestErrors') }}</span>
|
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.requestErrors') }}</span>
|
||||||
<HelpTooltip :content="t('admin.ops.tooltips.errors')" />
|
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.errors')" />
|
||||||
</div>
|
</div>
|
||||||
<button class="text-[10px] font-bold text-blue-500 hover:underline" type="button" @click="openErrorDetails('request')">
|
<button v-if="!props.fullscreen" class="text-[10px] font-bold text-blue-500 hover:underline" type="button" @click="openErrorDetails('request')">
|
||||||
{{ t('admin.ops.requestDetails.details') }}
|
{{ t('admin.ops.requestDetails.details') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 text-3xl font-black" :class="(errorRatePercent ?? 0) > 5 ? 'text-red-500' : 'text-gray-900 dark:text-white'">
|
<div class="mt-2 text-3xl font-black" :class="isRequestErrorRateAboveThreshold(errorRatePercent) ? 'text-red-600 dark:text-red-400' : (errorRatePercent ?? 0) > 5 ? 'text-red-500' : 'text-gray-900 dark:text-white'">
|
||||||
{{ errorRatePercent == null ? '-' : `${errorRatePercent.toFixed(2)}%` }}
|
{{ errorRatePercent == null ? '-' : `${errorRatePercent.toFixed(2)}%` }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 space-y-1 text-xs">
|
<div class="mt-3 space-y-1 text-xs">
|
||||||
@@ -1216,13 +1380,13 @@ function openJobsDetails() {
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.upstreamErrors') }}</span>
|
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.upstreamErrors') }}</span>
|
||||||
<HelpTooltip :content="t('admin.ops.tooltips.upstreamErrors')" />
|
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.upstreamErrors')" />
|
||||||
</div>
|
</div>
|
||||||
<button class="text-[10px] font-bold text-blue-500 hover:underline" type="button" @click="openErrorDetails('upstream')">
|
<button v-if="!props.fullscreen" class="text-[10px] font-bold text-blue-500 hover:underline" type="button" @click="openErrorDetails('upstream')">
|
||||||
{{ t('admin.ops.requestDetails.details') }}
|
{{ t('admin.ops.requestDetails.details') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 text-3xl font-black" :class="(upstreamErrorRatePercent ?? 0) > 5 ? 'text-red-500' : 'text-gray-900 dark:text-white'">
|
<div class="mt-2 text-3xl font-black" :class="isUpstreamErrorRateAboveThreshold(upstreamErrorRatePercent) ? 'text-red-600 dark:text-red-400' : (upstreamErrorRatePercent ?? 0) > 5 ? 'text-red-500' : 'text-gray-900 dark:text-white'">
|
||||||
{{ upstreamErrorRatePercent == null ? '-' : `${upstreamErrorRatePercent.toFixed(2)}%` }}
|
{{ upstreamErrorRatePercent == null ? '-' : `${upstreamErrorRatePercent.toFixed(2)}%` }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 space-y-1 text-xs">
|
<div class="mt-3 space-y-1 text-xs">
|
||||||
@@ -1246,12 +1410,12 @@ function openJobsDetails() {
|
|||||||
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
|
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">CPU</div>
|
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">CPU</div>
|
||||||
<HelpTooltip :content="t('admin.ops.tooltips.cpu')" />
|
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.cpu')" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-lg font-black" :class="cpuPercentClass">
|
<div class="mt-1 text-lg font-black" :class="cpuPercentClass">
|
||||||
{{ cpuPercentValue == null ? '-' : `${cpuPercentValue.toFixed(1)}%` }}
|
{{ cpuPercentValue == null ? '-' : `${cpuPercentValue.toFixed(1)}%` }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
|
<div v-if="!props.fullscreen" class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
|
||||||
{{ t('common.warning') }} 80% · {{ t('common.critical') }} 95%
|
{{ t('common.warning') }} 80% · {{ t('common.critical') }} 95%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1260,12 +1424,12 @@ function openJobsDetails() {
|
|||||||
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
|
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">MEM</div>
|
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">MEM</div>
|
||||||
<HelpTooltip :content="t('admin.ops.tooltips.memory')" />
|
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.memory')" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-lg font-black" :class="memPercentClass">
|
<div class="mt-1 text-lg font-black" :class="memPercentClass">
|
||||||
{{ memPercentValue == null ? '-' : `${memPercentValue.toFixed(1)}%` }}
|
{{ memPercentValue == null ? '-' : `${memPercentValue.toFixed(1)}%` }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
|
<div v-if="!props.fullscreen" class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
|
||||||
{{
|
{{
|
||||||
systemMetrics?.memory_used_mb == null || systemMetrics?.memory_total_mb == null
|
systemMetrics?.memory_used_mb == null || systemMetrics?.memory_total_mb == null
|
||||||
? '-'
|
? '-'
|
||||||
@@ -1278,12 +1442,12 @@ function openJobsDetails() {
|
|||||||
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
|
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">DB</div>
|
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">DB</div>
|
||||||
<HelpTooltip :content="t('admin.ops.tooltips.db')" />
|
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.db')" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-lg font-black" :class="dbMiddleClass">
|
<div class="mt-1 text-lg font-black" :class="dbMiddleClass">
|
||||||
{{ dbMiddleLabel }}
|
{{ dbMiddleLabel }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
|
<div v-if="!props.fullscreen" class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
|
||||||
{{ t('admin.ops.conns') }} {{ dbConnOpenValue ?? '-' }} / {{ dbMaxOpenConnsValue ?? '-' }}
|
{{ t('admin.ops.conns') }} {{ dbConnOpenValue ?? '-' }} / {{ dbMaxOpenConnsValue ?? '-' }}
|
||||||
· {{ t('admin.ops.active') }} {{ dbConnActiveValue ?? '-' }}
|
· {{ t('admin.ops.active') }} {{ dbConnActiveValue ?? '-' }}
|
||||||
· {{ t('admin.ops.idle') }} {{ dbConnIdleValue ?? '-' }}
|
· {{ t('admin.ops.idle') }} {{ dbConnIdleValue ?? '-' }}
|
||||||
@@ -1295,12 +1459,12 @@ function openJobsDetails() {
|
|||||||
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
|
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">Redis</div>
|
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">Redis</div>
|
||||||
<HelpTooltip :content="t('admin.ops.tooltips.redis')" />
|
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.redis')" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-lg font-black" :class="redisMiddleClass">
|
<div class="mt-1 text-lg font-black" :class="redisMiddleClass">
|
||||||
{{ redisMiddleLabel }}
|
{{ redisMiddleLabel }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
|
<div v-if="!props.fullscreen" class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
|
||||||
{{ t('admin.ops.conns') }} {{ redisConnTotalValue ?? '-' }} / {{ redisPoolSizeValue ?? '-' }}
|
{{ t('admin.ops.conns') }} {{ redisConnTotalValue ?? '-' }} / {{ redisPoolSizeValue ?? '-' }}
|
||||||
<span v-if="redisConnActiveValue != null"> · {{ t('admin.ops.active') }} {{ redisConnActiveValue }} </span>
|
<span v-if="redisConnActiveValue != null"> · {{ t('admin.ops.active') }} {{ redisConnActiveValue }} </span>
|
||||||
<span v-if="redisConnIdleValue != null"> · {{ t('admin.ops.idle') }} {{ redisConnIdleValue }} </span>
|
<span v-if="redisConnIdleValue != null"> · {{ t('admin.ops.idle') }} {{ redisConnIdleValue }} </span>
|
||||||
@@ -1311,12 +1475,12 @@ function openJobsDetails() {
|
|||||||
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
|
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.goroutines') }}</div>
|
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.goroutines') }}</div>
|
||||||
<HelpTooltip :content="t('admin.ops.tooltips.goroutines')" />
|
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.goroutines')" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-lg font-black" :class="goroutineStatusClass">
|
<div class="mt-1 text-lg font-black" :class="goroutineStatusClass">
|
||||||
{{ goroutineStatusLabel }}
|
{{ goroutineStatusLabel }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
|
<div v-if="!props.fullscreen" class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
|
||||||
{{ t('admin.ops.current') }} <span class="font-mono">{{ goroutineCountValue ?? '-' }}</span>
|
{{ t('admin.ops.current') }} <span class="font-mono">{{ goroutineCountValue ?? '-' }}</span>
|
||||||
· {{ t('common.warning') }} <span class="font-mono">{{ goroutinesWarnThreshold }}</span>
|
· {{ t('common.warning') }} <span class="font-mono">{{ goroutinesWarnThreshold }}</span>
|
||||||
· {{ t('common.critical') }} <span class="font-mono">{{ goroutinesCriticalThreshold }}</span>
|
· {{ t('common.critical') }} <span class="font-mono">{{ goroutinesCriticalThreshold }}</span>
|
||||||
@@ -1331,9 +1495,9 @@ function openJobsDetails() {
|
|||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.jobs') }}</div>
|
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.jobs') }}</div>
|
||||||
<HelpTooltip :content="t('admin.ops.tooltips.jobs')" />
|
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.jobs')" />
|
||||||
</div>
|
</div>
|
||||||
<button class="text-[10px] font-bold text-blue-500 hover:underline" type="button" @click="openJobsDetails">
|
<button v-if="!props.fullscreen" class="text-[10px] font-bold text-blue-500 hover:underline" type="button" @click="openJobsDetails">
|
||||||
{{ t('admin.ops.requestDetails.details') }}
|
{{ t('admin.ops.requestDetails.details') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1342,7 +1506,7 @@ function openJobsDetails() {
|
|||||||
{{ jobsStatusLabel }}
|
{{ jobsStatusLabel }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
|
<div v-if="!props.fullscreen" class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
|
||||||
{{ t('common.total') }} <span class="font-mono">{{ jobHeartbeats.length }}</span>
|
{{ t('common.total') }} <span class="font-mono">{{ jobHeartbeats.length }}</span>
|
||||||
· {{ t('common.warning') }} <span class="font-mono">{{ jobsWarnCount }}</span>
|
· {{ t('common.warning') }} <span class="font-mono">{{ jobsWarnCount }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -174,69 +174,75 @@ watch(
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BaseDialog :show="show" :title="modalTitle" width="full" @close="close">
|
<BaseDialog :show="show" :title="modalTitle" width="full" @close="close">
|
||||||
<!-- Filters -->
|
<div class="flex h-full min-h-0 flex-col">
|
||||||
<div class="border-b border-gray-200 pb-4 mb-4 dark:border-dark-700">
|
<!-- Filters -->
|
||||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-12">
|
<div class="mb-4 flex-shrink-0 border-b border-gray-200 pb-4 dark:border-dark-700">
|
||||||
<div class="lg:col-span-5">
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-12">
|
||||||
<div class="relative group">
|
<div class="lg:col-span-5">
|
||||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
|
<div class="relative group">
|
||||||
<svg
|
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
|
||||||
class="h-4 w-4 text-gray-400 transition-colors group-focus-within:text-blue-500"
|
<svg
|
||||||
fill="none"
|
class="h-4 w-4 text-gray-400 transition-colors group-focus-within:text-blue-500"
|
||||||
viewBox="0 0 24 24"
|
fill="none"
|
||||||
stroke="currentColor"
|
viewBox="0 0 24 24"
|
||||||
>
|
stroke="currentColor"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
>
|
||||||
</svg>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="q"
|
||||||
|
type="text"
|
||||||
|
class="w-full rounded-2xl border-gray-200 bg-gray-50/50 py-2 pl-10 pr-4 text-sm font-medium text-gray-700 transition-all focus:border-blue-500 focus:bg-white focus:ring-4 focus:ring-blue-500/10 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:focus:bg-dark-800"
|
||||||
|
:placeholder="t('admin.ops.errorDetails.searchPlaceholder')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<Select :model-value="statusCode" :options="statusCodeSelectOptions" class="w-full" @update:model-value="statusCode = $event as any" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<Select :model-value="phase" :options="phaseSelectOptions" class="w-full" @update:model-value="phase = String($event ?? '')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lg:col-span-2">
|
||||||
<input
|
<input
|
||||||
v-model="q"
|
v-model="accountIdInput"
|
||||||
type="text"
|
type="text"
|
||||||
class="w-full rounded-2xl border-gray-200 bg-gray-50/50 py-2 pl-10 pr-4 text-sm font-medium text-gray-700 transition-all focus:border-blue-500 focus:bg-white focus:ring-4 focus:ring-blue-500/10 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:focus:bg-dark-800"
|
inputmode="numeric"
|
||||||
:placeholder="t('admin.ops.errorDetails.searchPlaceholder')"
|
class="input w-full text-sm"
|
||||||
|
:placeholder="t('admin.ops.errorDetails.accountIdPlaceholder')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="lg:col-span-2">
|
<div class="lg:col-span-1 flex items-center justify-end">
|
||||||
<Select :model-value="statusCode" :options="statusCodeSelectOptions" class="w-full" @update:model-value="statusCode = $event as any" />
|
<button type="button" class="btn btn-secondary btn-sm" @click="resetFilters">
|
||||||
</div>
|
{{ t('common.reset') }}
|
||||||
|
</button>
|
||||||
<div class="lg:col-span-2">
|
</div>
|
||||||
<Select :model-value="phase" :options="phaseSelectOptions" class="w-full" @update:model-value="phase = String($event ?? '')" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="lg:col-span-2">
|
|
||||||
<input
|
|
||||||
v-model="accountIdInput"
|
|
||||||
type="text"
|
|
||||||
inputmode="numeric"
|
|
||||||
class="input w-full text-sm"
|
|
||||||
:placeholder="t('admin.ops.errorDetails.accountIdPlaceholder')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="lg:col-span-1 flex items-center justify-end">
|
|
||||||
<button type="button" class="btn btn-secondary btn-sm" @click="resetFilters">
|
|
||||||
{{ t('common.reset') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400 mb-2">
|
<div class="flex min-h-0 flex-1 flex-col">
|
||||||
{{ t('admin.ops.errorDetails.total') }} {{ total }}
|
<div class="mb-2 flex-shrink-0 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.ops.errorDetails.total') }} {{ total }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OpsErrorLogTable
|
||||||
|
class="min-h-0 flex-1"
|
||||||
|
:rows="rows"
|
||||||
|
:total="total"
|
||||||
|
:loading="loading"
|
||||||
|
:page="page"
|
||||||
|
:page-size="pageSize"
|
||||||
|
@openErrorDetail="emit('openErrorDetail', $event)"
|
||||||
|
@update:page="page = $event"
|
||||||
|
@update:pageSize="pageSize = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<OpsErrorLogTable
|
|
||||||
:rows="rows"
|
|
||||||
:total="total"
|
|
||||||
:loading="loading"
|
|
||||||
:page="page"
|
|
||||||
:page-size="pageSize"
|
|
||||||
@openErrorDetail="emit('openErrorDetail', $event)"
|
|
||||||
@update:page="page = $event"
|
|
||||||
@update:pageSize="pageSize = $event"
|
|
||||||
/>
|
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,176 +1,178 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="flex h-full min-h-0 flex-col">
|
||||||
<div v-if="loading" class="flex items-center justify-center py-10">
|
<div v-if="loading" class="flex flex-1 items-center justify-center py-10">
|
||||||
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-600"></div>
|
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-600"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="overflow-x-auto">
|
<div v-else class="flex min-h-0 flex-1 flex-col">
|
||||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
<div class="min-h-0 flex-1 overflow-auto">
|
||||||
<thead class="sticky top-0 z-10 bg-gray-50/50 dark:bg-dark-800/50">
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||||||
<tr>
|
<thead class="sticky top-0 z-10 bg-gray-50/50 dark:bg-dark-800/50">
|
||||||
<th
|
<tr>
|
||||||
scope="col"
|
<th
|
||||||
class="whitespace-nowrap px-6 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
|
scope="col"
|
||||||
>
|
class="whitespace-nowrap px-6 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
|
||||||
{{ t('admin.ops.errorLog.timeId') }}
|
>
|
||||||
</th>
|
{{ t('admin.ops.errorLog.timeId') }}
|
||||||
<th
|
</th>
|
||||||
scope="col"
|
<th
|
||||||
class="whitespace-nowrap px-6 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
|
scope="col"
|
||||||
>
|
class="whitespace-nowrap px-6 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
|
||||||
{{ t('admin.ops.errorLog.context') }}
|
>
|
||||||
</th>
|
{{ t('admin.ops.errorLog.context') }}
|
||||||
<th
|
</th>
|
||||||
scope="col"
|
<th
|
||||||
class="whitespace-nowrap px-6 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
|
scope="col"
|
||||||
>
|
class="whitespace-nowrap px-6 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
|
||||||
{{ t('admin.ops.errorLog.status') }}
|
>
|
||||||
</th>
|
{{ t('admin.ops.errorLog.status') }}
|
||||||
<th
|
</th>
|
||||||
scope="col"
|
<th
|
||||||
class="px-6 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
|
scope="col"
|
||||||
>
|
class="px-6 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
|
||||||
{{ t('admin.ops.errorLog.message') }}
|
>
|
||||||
</th>
|
{{ t('admin.ops.errorLog.message') }}
|
||||||
<th
|
</th>
|
||||||
scope="col"
|
<th
|
||||||
class="whitespace-nowrap px-6 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
|
scope="col"
|
||||||
>
|
class="whitespace-nowrap px-6 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
|
||||||
{{ t('admin.ops.errorLog.latency') }}
|
>
|
||||||
</th>
|
{{ t('admin.ops.errorLog.latency') }}
|
||||||
<th
|
</th>
|
||||||
scope="col"
|
<th
|
||||||
class="whitespace-nowrap px-6 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
|
scope="col"
|
||||||
>
|
class="whitespace-nowrap px-6 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
|
||||||
{{ t('admin.ops.errorLog.action') }}
|
>
|
||||||
</th>
|
{{ t('admin.ops.errorLog.action') }}
|
||||||
</tr>
|
</th>
|
||||||
</thead>
|
</tr>
|
||||||
<tbody class="divide-y divide-gray-100 dark:divide-dark-700">
|
</thead>
|
||||||
<tr v-if="rows.length === 0" class="bg-white dark:bg-dark-900">
|
<tbody class="divide-y divide-gray-100 dark:divide-dark-700">
|
||||||
<td colspan="6" class="py-16 text-center text-sm text-gray-400 dark:text-dark-500">
|
<tr v-if="rows.length === 0" class="bg-white dark:bg-dark-900">
|
||||||
{{ t('admin.ops.errorLog.noErrors') }}
|
<td colspan="6" class="py-16 text-center text-sm text-gray-400 dark:text-dark-500">
|
||||||
</td>
|
{{ t('admin.ops.errorLog.noErrors') }}
|
||||||
</tr>
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
<tr
|
<tr
|
||||||
v-for="log in rows"
|
v-for="log in rows"
|
||||||
:key="log.id"
|
:key="log.id"
|
||||||
class="group cursor-pointer transition-all duration-200 hover:bg-gray-50/80 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:hover:bg-dark-800/50 dark:focus:ring-offset-dark-900"
|
class="group cursor-pointer transition-all duration-200 hover:bg-gray-50/80 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:hover:bg-dark-800/50 dark:focus:ring-offset-dark-900"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="button"
|
role="button"
|
||||||
@click="emit('openErrorDetail', log.id)"
|
@click="emit('openErrorDetail', log.id)"
|
||||||
@keydown.enter.prevent="emit('openErrorDetail', log.id)"
|
@keydown.enter.prevent="emit('openErrorDetail', log.id)"
|
||||||
@keydown.space.prevent="emit('openErrorDetail', log.id)"
|
@keydown.space.prevent="emit('openErrorDetail', log.id)"
|
||||||
>
|
>
|
||||||
<!-- Time & ID -->
|
<!-- Time & ID -->
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="flex flex-col gap-0.5">
|
||||||
<span class="font-mono text-xs font-bold text-gray-900 dark:text-gray-200">
|
<span class="font-mono text-xs font-bold text-gray-900 dark:text-gray-200">
|
||||||
{{ formatDateTime(log.created_at).split(' ')[1] }}
|
{{ formatDateTime(log.created_at).split(' ')[1] }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
class="font-mono text-[10px] text-gray-400 transition-colors group-hover:text-primary-600 dark:group-hover:text-primary-400"
|
class="font-mono text-[10px] text-gray-400 transition-colors group-hover:text-primary-600 dark:group-hover:text-primary-400"
|
||||||
:title="log.request_id || log.client_request_id"
|
:title="log.request_id || log.client_request_id"
|
||||||
>
|
>
|
||||||
{{ (log.request_id || log.client_request_id || '').substring(0, 12) }}
|
{{ (log.request_id || log.client_request_id || '').substring(0, 12) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Context (Platform/Model) -->
|
<!-- Context (Platform/Model) -->
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<div class="flex flex-col items-start gap-1.5">
|
<div class="flex flex-col items-start gap-1.5">
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center rounded-md bg-gray-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-tight text-gray-600 dark:bg-dark-700 dark:text-gray-300"
|
class="inline-flex items-center rounded-md bg-gray-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-tight text-gray-600 dark:bg-dark-700 dark:text-gray-300"
|
||||||
>
|
>
|
||||||
{{ log.platform || '-' }}
|
{{ log.platform || '-' }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="log.model"
|
v-if="log.model"
|
||||||
class="max-w-[160px] truncate font-mono text-[10px] text-gray-500 dark:text-dark-400"
|
class="max-w-[160px] truncate font-mono text-[10px] text-gray-500 dark:text-dark-400"
|
||||||
:title="log.model"
|
:title="log.model"
|
||||||
>
|
>
|
||||||
{{ log.model }}
|
{{ log.model }}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
v-if="log.group_id || log.account_id"
|
v-if="log.group_id || log.account_id"
|
||||||
class="flex flex-wrap items-center gap-2 font-mono text-[10px] font-semibold text-gray-400 dark:text-dark-500"
|
class="flex flex-wrap items-center gap-2 font-mono text-[10px] font-semibold text-gray-400 dark:text-dark-500"
|
||||||
>
|
>
|
||||||
<span v-if="log.group_id">{{ t('admin.ops.errorLog.grp') }} {{ log.group_id }}</span>
|
<span v-if="log.group_id">{{ t('admin.ops.errorLog.grp') }} {{ log.group_id }}</span>
|
||||||
<span v-if="log.account_id">{{ t('admin.ops.errorLog.acc') }} {{ log.account_id }}</span>
|
<span v-if="log.account_id">{{ t('admin.ops.errorLog.acc') }} {{ log.account_id }}</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<!-- Status & Severity -->
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<span
|
|
||||||
:class="[
|
|
||||||
'inline-flex items-center rounded-lg px-2 py-1 text-xs font-black ring-1 ring-inset shadow-sm',
|
|
||||||
getStatusClass(log.status_code)
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
{{ log.status_code }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="log.severity"
|
|
||||||
:class="['rounded-md px-2 py-0.5 text-[10px] font-black shadow-sm', getSeverityClass(log.severity)]"
|
|
||||||
>
|
|
||||||
{{ log.severity }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<!-- Message -->
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
<div class="max-w-md lg:max-w-2xl">
|
|
||||||
<p class="truncate text-xs font-semibold text-gray-700 dark:text-gray-300" :title="log.message">
|
|
||||||
{{ formatSmartMessage(log.message) || '-' }}
|
|
||||||
</p>
|
|
||||||
<div class="mt-1.5 flex flex-wrap gap-x-3 gap-y-1">
|
|
||||||
<div v-if="log.phase" class="flex items-center gap-1">
|
|
||||||
<span class="h-1 w-1 rounded-full bg-gray-300"></span>
|
|
||||||
<span class="text-[9px] font-black uppercase tracking-tighter text-gray-400">{{ log.phase }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="log.client_ip" class="flex items-center gap-1">
|
|
||||||
<span class="h-1 w-1 rounded-full bg-gray-300"></span>
|
|
||||||
<span class="text-[9px] font-mono font-bold text-gray-400">{{ log.client_ip }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
|
||||||
|
|
||||||
<!-- Latency -->
|
<!-- Status & Severity -->
|
||||||
<td class="px-6 py-4 text-right">
|
<td class="px-6 py-4">
|
||||||
<div class="flex flex-col items-end">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="font-mono text-xs font-black" :class="getLatencyClass(log.latency_ms ?? null)">
|
<span
|
||||||
{{ log.latency_ms != null ? Math.round(log.latency_ms) + 'ms' : '--' }}
|
:class="[
|
||||||
</span>
|
'inline-flex items-center rounded-lg px-2 py-1 text-xs font-black ring-1 ring-inset shadow-sm',
|
||||||
</div>
|
getStatusClass(log.status_code)
|
||||||
</td>
|
]"
|
||||||
|
>
|
||||||
|
{{ log.status_code }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="log.severity"
|
||||||
|
:class="['rounded-md px-2 py-0.5 text-[10px] font-black shadow-sm', getSeverityClass(log.severity)]"
|
||||||
|
>
|
||||||
|
{{ log.severity }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Message -->
|
||||||
<td class="px-6 py-4 text-right" @click.stop>
|
<td class="px-6 py-4">
|
||||||
<button type="button" class="btn btn-secondary btn-sm" @click="emit('openErrorDetail', log.id)">
|
<div class="max-w-md lg:max-w-2xl">
|
||||||
{{ t('admin.ops.errorLog.details') }}
|
<p class="truncate text-xs font-semibold text-gray-700 dark:text-gray-300" :title="log.message">
|
||||||
</button>
|
{{ formatSmartMessage(log.message) || '-' }}
|
||||||
</td>
|
</p>
|
||||||
</tr>
|
<div class="mt-1.5 flex flex-wrap gap-x-3 gap-y-1">
|
||||||
</tbody>
|
<div v-if="log.phase" class="flex items-center gap-1">
|
||||||
</table>
|
<span class="h-1 w-1 rounded-full bg-gray-300"></span>
|
||||||
|
<span class="text-[9px] font-black uppercase tracking-tighter text-gray-400">{{ log.phase }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="log.client_ip" class="flex items-center gap-1">
|
||||||
|
<span class="h-1 w-1 rounded-full bg-gray-300"></span>
|
||||||
|
<span class="text-[9px] font-mono font-bold text-gray-400">{{ log.client_ip }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Latency -->
|
||||||
|
<td class="px-6 py-4 text-right">
|
||||||
|
<div class="flex flex-col items-end">
|
||||||
|
<span class="font-mono text-xs font-black" :class="getLatencyClass(log.latency_ms ?? null)">
|
||||||
|
{{ log.latency_ms != null ? Math.round(log.latency_ms) + 'ms' : '--' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<td class="px-6 py-4 text-right" @click.stop>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" @click="emit('openErrorDetail', log.id)">
|
||||||
|
{{ t('admin.ops.errorLog.details') }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
v-if="total > 0"
|
||||||
|
:total="total"
|
||||||
|
:page="page"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:page-size-options="[10, 20, 50, 100, 200, 500]"
|
||||||
|
@update:page="emit('update:page', $event)"
|
||||||
|
@update:pageSize="emit('update:pageSize', $event)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Pagination
|
|
||||||
v-if="total > 0"
|
|
||||||
:total="total"
|
|
||||||
:page="page"
|
|
||||||
:page-size="pageSize"
|
|
||||||
:page-size-options="[10, 20, 50, 100, 200, 500]"
|
|
||||||
@update:page="emit('update:page', $event)"
|
|
||||||
@update:pageSize="emit('update:pageSize', $event)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ watch(
|
|||||||
(open) => {
|
(open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
page.value = 1
|
page.value = 1
|
||||||
|
pageSize.value = 20
|
||||||
fetchData()
|
fetchData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,45 +151,46 @@ const kindBadgeClass = (kind: string) => {
|
|||||||
<template>
|
<template>
|
||||||
<BaseDialog :show="modelValue" :title="props.preset.title || t('admin.ops.requestDetails.title')" width="full" @close="close">
|
<BaseDialog :show="modelValue" :title="props.preset.title || t('admin.ops.requestDetails.title')" width="full" @close="close">
|
||||||
<template #default>
|
<template #default>
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex h-full min-h-0 flex-col">
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
<div class="mb-4 flex flex-shrink-0 items-center justify-between">
|
||||||
{{ t('admin.ops.requestDetails.rangeLabel', { range: rangeLabel }) }}
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
</div>
|
{{ t('admin.ops.requestDetails.rangeLabel', { range: rangeLabel }) }}
|
||||||
<button
|
</div>
|
||||||
type="button"
|
<button
|
||||||
class="btn btn-secondary btn-sm"
|
type="button"
|
||||||
@click="fetchData"
|
class="btn btn-secondary btn-sm"
|
||||||
>
|
@click="fetchData"
|
||||||
{{ t('common.refresh') }}
|
>
|
||||||
</button>
|
{{ t('common.refresh') }}
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<!-- Loading -->
|
|
||||||
<div v-if="loading" class="flex items-center justify-center py-16">
|
|
||||||
<div class="flex flex-col items-center gap-3">
|
|
||||||
<svg class="h-8 w-8 animate-spin text-blue-500" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
||||||
<path
|
|
||||||
class="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
<span class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t('common.loading') }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Table -->
|
|
||||||
<div v-else>
|
|
||||||
<div v-if="items.length === 0" class="rounded-xl border border-dashed border-gray-200 p-10 text-center dark:border-dark-700">
|
|
||||||
<div class="text-sm font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.requestDetails.empty') }}</div>
|
|
||||||
<div class="mt-1 text-xs text-gray-400">{{ t('admin.ops.requestDetails.emptyHint') }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
|
<!-- Loading -->
|
||||||
<div class="overflow-x-auto">
|
<div v-if="loading" class="flex flex-1 items-center justify-center py-16">
|
||||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
<div class="flex flex-col items-center gap-3">
|
||||||
<thead class="bg-gray-50 dark:bg-dark-900">
|
<svg class="h-8 w-8 animate-spin text-blue-500" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t('common.loading') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div v-else class="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div v-if="items.length === 0" class="rounded-xl border border-dashed border-gray-200 p-10 text-center dark:border-dark-700">
|
||||||
|
<div class="text-sm font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.requestDetails.empty') }}</div>
|
||||||
|
<div class="mt-1 text-xs text-gray-400">{{ t('admin.ops.requestDetails.emptyHint') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
|
||||||
|
<div class="min-h-0 flex-1 overflow-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||||||
|
<thead class="sticky top-0 z-10 bg-gray-50 dark:bg-dark-900">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||||
{{ t('admin.ops.requestDetails.table.time') }}
|
{{ t('admin.ops.requestDetails.table.time') }}
|
||||||
@@ -265,15 +267,16 @@ const kindBadgeClass = (kind: string) => {
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Pagination
|
<Pagination
|
||||||
:total="total"
|
:total="total"
|
||||||
:page="page"
|
:page="page"
|
||||||
:page-size="pageSize"
|
:page-size="pageSize"
|
||||||
@update:page="handlePageChange"
|
@update:page="handlePageChange"
|
||||||
@update:pageSize="handlePageSizeChange"
|
@update:pageSize="handlePageSizeChange"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -45,6 +45,36 @@ function validateRuntimeSettings(settings: OpsAlertRuntimeSettings): ValidationR
|
|||||||
errors.push(t('admin.ops.runtime.validation.evalIntervalRange'))
|
errors.push(t('admin.ops.runtime.validation.evalIntervalRange'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Thresholds validation
|
||||||
|
const thresholds = settings.thresholds
|
||||||
|
if (thresholds) {
|
||||||
|
if (thresholds.sla_percent_min != null) {
|
||||||
|
if (!Number.isFinite(thresholds.sla_percent_min) || thresholds.sla_percent_min < 0 || thresholds.sla_percent_min > 100) {
|
||||||
|
errors.push('SLA 最低值必须在 0-100 之间')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (thresholds.latency_p99_ms_max != null) {
|
||||||
|
if (!Number.isFinite(thresholds.latency_p99_ms_max) || thresholds.latency_p99_ms_max < 0) {
|
||||||
|
errors.push('延迟 P99 最大值必须大于或等于 0')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (thresholds.ttft_p99_ms_max != null) {
|
||||||
|
if (!Number.isFinite(thresholds.ttft_p99_ms_max) || thresholds.ttft_p99_ms_max < 0) {
|
||||||
|
errors.push('TTFT P99 最大值必须大于或等于 0')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (thresholds.request_error_rate_percent_max != null) {
|
||||||
|
if (!Number.isFinite(thresholds.request_error_rate_percent_max) || thresholds.request_error_rate_percent_max < 0 || thresholds.request_error_rate_percent_max > 100) {
|
||||||
|
errors.push('请求错误率最大值必须在 0-100 之间')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (thresholds.upstream_error_rate_percent_max != null) {
|
||||||
|
if (!Number.isFinite(thresholds.upstream_error_rate_percent_max) || thresholds.upstream_error_rate_percent_max < 0 || thresholds.upstream_error_rate_percent_max > 100) {
|
||||||
|
errors.push('上游错误率最大值必须在 0-100 之间')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const lock = settings.distributed_lock
|
const lock = settings.distributed_lock
|
||||||
if (lock?.enabled) {
|
if (lock?.enabled) {
|
||||||
if (!lock.key || lock.key.trim().length < 3) {
|
if (!lock.key || lock.key.trim().length < 3) {
|
||||||
@@ -130,6 +160,15 @@ function openAlertEditor() {
|
|||||||
if (!Array.isArray(draftAlert.value.silencing.entries)) {
|
if (!Array.isArray(draftAlert.value.silencing.entries)) {
|
||||||
draftAlert.value.silencing.entries = []
|
draftAlert.value.silencing.entries = []
|
||||||
}
|
}
|
||||||
|
if (!draftAlert.value.thresholds) {
|
||||||
|
draftAlert.value.thresholds = {
|
||||||
|
sla_percent_min: 99.5,
|
||||||
|
latency_p99_ms_max: 2000,
|
||||||
|
ttft_p99_ms_max: 500,
|
||||||
|
request_error_rate_percent_max: 5,
|
||||||
|
upstream_error_rate_percent_max: 5
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showAlertEditor.value = true
|
showAlertEditor.value = true
|
||||||
@@ -295,6 +334,81 @@ onMounted(() => {
|
|||||||
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.runtime.evalIntervalHint') }}</p>
|
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.runtime.evalIntervalHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
|
||||||
|
<div class="mb-2 text-sm font-semibold text-gray-900 dark:text-white">指标阈值配置</div>
|
||||||
|
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">配置各项指标的告警阈值。超出阈值的指标将在看板上以红色显示。</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">SLA 最低值 (%)</div>
|
||||||
|
<input
|
||||||
|
v-model.number="draftAlert.thresholds.sla_percent_min"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="0.1"
|
||||||
|
class="input"
|
||||||
|
placeholder="99.5"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">SLA 低于此值时将显示为红色</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">延迟 P99 最大值 (ms)</div>
|
||||||
|
<input
|
||||||
|
v-model.number="draftAlert.thresholds.latency_p99_ms_max"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="100"
|
||||||
|
class="input"
|
||||||
|
placeholder="2000"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">延迟 P99 高于此值时将显示为红色</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">TTFT P99 最大值 (ms)</div>
|
||||||
|
<input
|
||||||
|
v-model.number="draftAlert.thresholds.ttft_p99_ms_max"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="100"
|
||||||
|
class="input"
|
||||||
|
placeholder="500"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">TTFT P99 高于此值时将显示为红色</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">请求错误率最大值 (%)</div>
|
||||||
|
<input
|
||||||
|
v-model.number="draftAlert.thresholds.request_error_rate_percent_max"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="0.1"
|
||||||
|
class="input"
|
||||||
|
placeholder="5"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">请求错误率高于此值时将显示为红色</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">上游错误率最大值 (%)</div>
|
||||||
|
<input
|
||||||
|
v-model.number="draftAlert.thresholds.upstream_error_rate_percent_max"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="0.1"
|
||||||
|
class="input"
|
||||||
|
placeholder="5"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">上游错误率高于此值时将显示为红色</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
|
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
|
||||||
<div class="mb-2 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.runtime.silencing.title') }}</div>
|
<div class="mb-2 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.runtime.silencing.title') }}</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { opsAPI } from '@/api/admin/ops'
|
|||||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||||
import Select from '@/components/common/Select.vue'
|
import Select from '@/components/common/Select.vue'
|
||||||
import Toggle from '@/components/common/Toggle.vue'
|
import Toggle from '@/components/common/Toggle.vue'
|
||||||
import type { OpsAlertRuntimeSettings, EmailNotificationConfig, AlertSeverity, OpsAdvancedSettings } from '../types'
|
import type { OpsAlertRuntimeSettings, EmailNotificationConfig, AlertSeverity, OpsAdvancedSettings, OpsMetricThresholds } from '../types'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
@@ -29,19 +29,38 @@ const runtimeSettings = ref<OpsAlertRuntimeSettings | null>(null)
|
|||||||
const emailConfig = ref<EmailNotificationConfig | null>(null)
|
const emailConfig = ref<EmailNotificationConfig | null>(null)
|
||||||
// 高级设置
|
// 高级设置
|
||||||
const advancedSettings = ref<OpsAdvancedSettings | null>(null)
|
const advancedSettings = ref<OpsAdvancedSettings | null>(null)
|
||||||
|
// 指标阈值配置
|
||||||
|
const metricThresholds = ref<OpsMetricThresholds>({
|
||||||
|
sla_percent_min: 99.5,
|
||||||
|
latency_p99_ms_max: 2000,
|
||||||
|
ttft_p99_ms_max: 500,
|
||||||
|
request_error_rate_percent_max: 5,
|
||||||
|
upstream_error_rate_percent_max: 5
|
||||||
|
})
|
||||||
|
|
||||||
// 加载所有配置
|
// 加载所有配置
|
||||||
async function loadAllSettings() {
|
async function loadAllSettings() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const [runtime, email, advanced] = await Promise.all([
|
const [runtime, email, advanced, thresholds] = await Promise.all([
|
||||||
opsAPI.getAlertRuntimeSettings(),
|
opsAPI.getAlertRuntimeSettings(),
|
||||||
opsAPI.getEmailNotificationConfig(),
|
opsAPI.getEmailNotificationConfig(),
|
||||||
opsAPI.getAdvancedSettings()
|
opsAPI.getAdvancedSettings(),
|
||||||
|
opsAPI.getMetricThresholds()
|
||||||
])
|
])
|
||||||
runtimeSettings.value = runtime
|
runtimeSettings.value = runtime
|
||||||
emailConfig.value = email
|
emailConfig.value = email
|
||||||
advancedSettings.value = advanced
|
advancedSettings.value = advanced
|
||||||
|
// 如果后端返回了阈值,使用后端的值;否则保持默认值
|
||||||
|
if (thresholds && Object.keys(thresholds).length > 0) {
|
||||||
|
metricThresholds.value = {
|
||||||
|
sla_percent_min: thresholds.sla_percent_min ?? 99.5,
|
||||||
|
latency_p99_ms_max: thresholds.latency_p99_ms_max ?? 2000,
|
||||||
|
ttft_p99_ms_max: thresholds.ttft_p99_ms_max ?? 500,
|
||||||
|
request_error_rate_percent_max: thresholds.request_error_rate_percent_max ?? 5,
|
||||||
|
upstream_error_rate_percent_max: thresholds.upstream_error_rate_percent_max ?? 5
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[OpsSettingsDialog] Failed to load settings', err)
|
console.error('[OpsSettingsDialog] Failed to load settings', err)
|
||||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.settings.loadFailed'))
|
appStore.showError(err?.response?.data?.detail || t('admin.ops.settings.loadFailed'))
|
||||||
@@ -138,6 +157,23 @@ const validation = computed(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证指标阈值
|
||||||
|
if (metricThresholds.value.sla_percent_min != null && (metricThresholds.value.sla_percent_min < 0 || metricThresholds.value.sla_percent_min > 100)) {
|
||||||
|
errors.push('SLA最低百分比必须在0-100之间')
|
||||||
|
}
|
||||||
|
if (metricThresholds.value.latency_p99_ms_max != null && metricThresholds.value.latency_p99_ms_max < 0) {
|
||||||
|
errors.push('延迟P99最大值必须大于等于0')
|
||||||
|
}
|
||||||
|
if (metricThresholds.value.ttft_p99_ms_max != null && metricThresholds.value.ttft_p99_ms_max < 0) {
|
||||||
|
errors.push('TTFT P99最大值必须大于等于0')
|
||||||
|
}
|
||||||
|
if (metricThresholds.value.request_error_rate_percent_max != null && (metricThresholds.value.request_error_rate_percent_max < 0 || metricThresholds.value.request_error_rate_percent_max > 100)) {
|
||||||
|
errors.push('请求错误率最大值必须在0-100之间')
|
||||||
|
}
|
||||||
|
if (metricThresholds.value.upstream_error_rate_percent_max != null && (metricThresholds.value.upstream_error_rate_percent_max < 0 || metricThresholds.value.upstream_error_rate_percent_max > 100)) {
|
||||||
|
errors.push('上游错误率最大值必须在0-100之间')
|
||||||
|
}
|
||||||
|
|
||||||
return { valid: errors.length === 0, errors }
|
return { valid: errors.length === 0, errors }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -153,14 +189,15 @@ async function saveAllSettings() {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
runtimeSettings.value ? opsAPI.updateAlertRuntimeSettings(runtimeSettings.value) : Promise.resolve(),
|
runtimeSettings.value ? opsAPI.updateAlertRuntimeSettings(runtimeSettings.value) : Promise.resolve(),
|
||||||
emailConfig.value ? opsAPI.updateEmailNotificationConfig(emailConfig.value) : Promise.resolve(),
|
emailConfig.value ? opsAPI.updateEmailNotificationConfig(emailConfig.value) : Promise.resolve(),
|
||||||
advancedSettings.value ? opsAPI.updateAdvancedSettings(advancedSettings.value) : Promise.resolve()
|
advancedSettings.value ? opsAPI.updateAdvancedSettings(advancedSettings.value) : Promise.resolve(),
|
||||||
|
opsAPI.updateMetricThresholds(metricThresholds.value)
|
||||||
])
|
])
|
||||||
appStore.showSuccess(t('admin.ops.settings.saveSuccess'))
|
appStore.showSuccess(t('admin.ops.settings.saveSuccess'))
|
||||||
emit('saved')
|
emit('saved')
|
||||||
emit('close')
|
emit('close')
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[OpsSettingsDialog] Failed to save settings', err)
|
console.error('[OpsSettingsDialog] Failed to save settings', err)
|
||||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.settings.saveFailed'))
|
appStore.showError(err?.response?.data?.message || err?.response?.data?.detail || t('admin.ops.settings.saveFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
@@ -306,6 +343,77 @@ async function saveAllSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 指标阈值配置 -->
|
||||||
|
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
|
||||||
|
<h4 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.settings.metricThresholds') }}</h4>
|
||||||
|
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.settings.metricThresholdsHint') }}</p>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.ops.settings.slaMinPercent') }}</label>
|
||||||
|
<input
|
||||||
|
v-model.number="metricThresholds.sla_percent_min"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="0.1"
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.settings.slaMinPercentHint') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.ops.settings.latencyP99MaxMs') }}</label>
|
||||||
|
<input
|
||||||
|
v-model.number="metricThresholds.latency_p99_ms_max"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="100"
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.settings.latencyP99MaxMsHint') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.ops.settings.ttftP99MaxMs') }}</label>
|
||||||
|
<input
|
||||||
|
v-model.number="metricThresholds.ttft_p99_ms_max"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="50"
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.settings.ttftP99MaxMsHint') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.ops.settings.requestErrorRateMaxPercent') }}</label>
|
||||||
|
<input
|
||||||
|
v-model.number="metricThresholds.request_error_rate_percent_max"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="0.1"
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.settings.requestErrorRateMaxPercentHint') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.ops.settings.upstreamErrorRateMaxPercent') }}</label>
|
||||||
|
<input
|
||||||
|
v-model.number="metricThresholds.upstream_error_rate_percent_max"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="0.1"
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.settings.upstreamErrorRateMaxPercentHint') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 高级设置 -->
|
<!-- 高级设置 -->
|
||||||
<details class="rounded-2xl bg-gray-50 dark:bg-dark-700/50">
|
<details class="rounded-2xl bg-gray-50 dark:bg-dark-700/50">
|
||||||
<summary class="cursor-pointer p-4 text-sm font-semibold text-gray-900 dark:text-white">
|
<summary class="cursor-pointer p-4 text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
@@ -379,6 +487,48 @@ async function saveAllSettings() {
|
|||||||
<Toggle v-model="advancedSettings.aggregation.aggregation_enabled" />
|
<Toggle v-model="advancedSettings.aggregation.aggregation_enabled" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 错误过滤 -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<h5 class="text-xs font-semibold text-gray-700 dark:text-gray-300">错误过滤</h5>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">忽略 count_tokens 错误</label>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
启用后,count_tokens 请求的错误将不计入运维监控的统计和告警中(但仍会存储在数据库中)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Toggle v-model="advancedSettings.ignore_count_tokens_errors" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 自动刷新 -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<h5 class="text-xs font-semibold text-gray-700 dark:text-gray-300">自动刷新</h5>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">启用自动刷新</label>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
自动刷新仪表板数据,启用后会定期拉取最新数据
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Toggle v-model="advancedSettings.auto_refresh_enabled" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="advancedSettings.auto_refresh_enabled">
|
||||||
|
<label class="input-label">刷新间隔</label>
|
||||||
|
<Select
|
||||||
|
v-model="advancedSettings.auto_refresh_interval_seconds"
|
||||||
|
:options="[
|
||||||
|
{ value: 15, label: '15 秒' },
|
||||||
|
{ value: 30, label: '30 秒' },
|
||||||
|
{ value: 60, label: '60 秒' }
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface Props {
|
|||||||
timeRange: string
|
timeRange: string
|
||||||
byPlatform?: OpsThroughputPlatformBreakdownItem[]
|
byPlatform?: OpsThroughputPlatformBreakdownItem[]
|
||||||
topGroups?: OpsThroughputGroupBreakdownItem[]
|
topGroups?: OpsThroughputGroupBreakdownItem[]
|
||||||
|
fullscreen?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
@@ -179,38 +180,40 @@ function downloadChart() {
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||||
</svg>
|
</svg>
|
||||||
{{ t('admin.ops.throughputTrend') }}
|
{{ t('admin.ops.throughputTrend') }}
|
||||||
<HelpTooltip :content="t('admin.ops.tooltips.throughputTrend')" />
|
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.throughputTrend')" />
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
<span class="flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-blue-500"></span>{{ t('admin.ops.qps') }}</span>
|
<span class="flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-blue-500"></span>{{ t('admin.ops.qps') }}</span>
|
||||||
<span class="flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-green-500"></span>{{ t('admin.ops.tpsK') }}</span>
|
<span class="flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-green-500"></span>{{ t('admin.ops.tpsK') }}</span>
|
||||||
<button
|
<template v-if="!props.fullscreen">
|
||||||
type="button"
|
<button
|
||||||
class="ml-2 inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
|
type="button"
|
||||||
:disabled="state !== 'ready'"
|
class="ml-2 inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
|
||||||
:title="t('admin.ops.requestDetails.title')"
|
:disabled="state !== 'ready'"
|
||||||
@click="emit('openDetails')"
|
:title="t('admin.ops.requestDetails.title')"
|
||||||
>
|
@click="emit('openDetails')"
|
||||||
{{ t('admin.ops.requestDetails.details') }}
|
>
|
||||||
</button>
|
{{ t('admin.ops.requestDetails.details') }}
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<button
|
||||||
class="ml-2 inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
|
type="button"
|
||||||
:disabled="state !== 'ready'"
|
class="ml-2 inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
|
||||||
:title="t('admin.ops.charts.resetZoomHint')"
|
:disabled="state !== 'ready'"
|
||||||
@click="resetZoom"
|
:title="t('admin.ops.charts.resetZoomHint')"
|
||||||
>
|
@click="resetZoom"
|
||||||
{{ t('admin.ops.charts.resetZoom') }}
|
>
|
||||||
</button>
|
{{ t('admin.ops.charts.resetZoom') }}
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<button
|
||||||
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
|
type="button"
|
||||||
:disabled="state !== 'ready'"
|
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
|
||||||
:title="t('admin.ops.charts.downloadChartHint')"
|
:disabled="state !== 'ready'"
|
||||||
@click="downloadChart"
|
:title="t('admin.ops.charts.downloadChartHint')"
|
||||||
>
|
@click="downloadChart"
|
||||||
{{ t('admin.ops.charts.downloadChart') }}
|
>
|
||||||
</button>
|
{{ t('admin.ops.charts.downloadChart') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type {
|
|||||||
EmailNotificationConfig,
|
EmailNotificationConfig,
|
||||||
OpsDistributedLockSettings,
|
OpsDistributedLockSettings,
|
||||||
OpsAlertRuntimeSettings,
|
OpsAlertRuntimeSettings,
|
||||||
|
OpsMetricThresholds,
|
||||||
OpsAdvancedSettings,
|
OpsAdvancedSettings,
|
||||||
OpsDataRetentionSettings,
|
OpsDataRetentionSettings,
|
||||||
OpsAggregationSettings
|
OpsAggregationSettings
|
||||||
|
|||||||
Reference in New Issue
Block a user