Merge branch 'main' into dev

This commit is contained in:
yangjianbo
2026-01-13 10:29:12 +08:00
47 changed files with 2285 additions and 597 deletions

View File

@@ -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,
})
}

View File

@@ -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)
}

View File

@@ -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
} }

View File

@@ -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{}{}
if switchCount >= maxAccountSwitches {
lastFailoverStatus = failoverErr.StatusCode lastFailoverStatus = failoverErr.StatusCode
if switchCount >= maxAccountSwitches {
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{}{}
if switchCount >= maxAccountSwitches {
lastFailoverStatus = failoverErr.StatusCode lastFailoverStatus = failoverErr.StatusCode
if switchCount >= maxAccountSwitches {
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
} }
} }

View File

@@ -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
} }
} }

View File

@@ -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
} }
} }

View File

@@ -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

View File

@@ -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),

View File

@@ -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))

View File

@@ -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

View 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
}

View File

@@ -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 (

View File

@@ -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
} }

View File

@@ -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)

View File

@@ -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")
{ {

View File

@@ -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.

View File

@@ -1227,6 +1227,9 @@ func enforceCacheControlLimit(body []byte) []byte {
return body return body
} }
// 清理 thinking 块中的非法 cache_controlthinking 块不支持该字段)
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()

View File

@@ -545,15 +545,13 @@ 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 {
mappedModel = account.GetMappedModel(reqModel)
if mappedModel != reqModel { if mappedModel != reqModel {
log.Printf("[OpenAI] Model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", reqModel, mappedModel, account.Name, isCodexCLI)
reqBody["model"] = mappedModel reqBody["model"] = mappedModel
bodyModified = true bodyModified = true
} }
}
if account.Type == AccountTypeOAuth && !isCodexCLI { if account.Type == AccountTypeOAuth && !isCodexCLI {
codexResult := applyCodexOAuthTransform(reqBody) codexResult := applyCodexOAuthTransform(reqBody)
@@ -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

View File

@@ -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

View 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)
}

View 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"`
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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,12 +106,20 @@ 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 {
log.Printf("Account %d received upstream error %d", account.ID, statusCode) 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)
shouldDisable = false shouldDisable = false
} }
}
if tempMatched { if tempMatched {
return true return true
@@ -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) {

View File

@@ -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
View File

@@ -0,0 +1,14 @@
# 忽略编译后的文件
vite.config.js
vite.config.d.ts
# 忽略依赖
node_modules/
# 忽略构建输出
dist/
../backend/internal/web/dist/
# 忽略缓存
.cache/
.vite/

View File

@@ -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

View 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>

View File

@@ -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
} }

View File

@@ -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
}) })

View File

@@ -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
} }

View File

@@ -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])

View File

@@ -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,

View File

@@ -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 = [

View File

@@ -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; 2k8k 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; 2k8k 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.'

View File

@@ -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不存在需手动创建。可使用默认 provideropenai/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: '启用数据清理',

View File

@@ -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;
} }

View File

@@ -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'

View File

@@ -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,10 +76,10 @@
<!-- 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>
@@ -102,26 +104,26 @@
: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 (!opsEnabled.value) return
if (enabled) { if (enabled) {
startQPSSubscription() autoRefreshCountdown.value = Math.floor(autoRefreshIntervalMs.value / 1000)
resumeAutoRefresh()
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>

View File

@@ -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,6 +866,7 @@ function openJobsDetails() {
</div> </div>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<template v-if="!props.fullscreen">
<Select <Select
:model-value="platform" :model-value="platform"
:options="platformOptions" :options="platformOptions"
@@ -750,6 +889,7 @@ function openJobsDetails() {
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>

View File

@@ -174,8 +174,9 @@ watch(
<template> <template>
<BaseDialog :show="show" :title="modalTitle" width="full" @close="close"> <BaseDialog :show="show" :title="modalTitle" width="full" @close="close">
<div class="flex h-full min-h-0 flex-col">
<!-- Filters --> <!-- Filters -->
<div class="border-b border-gray-200 pb-4 mb-4 dark:border-dark-700"> <div class="mb-4 flex-shrink-0 border-b border-gray-200 pb-4 dark:border-dark-700">
<div class="grid grid-cols-1 gap-4 lg:grid-cols-12"> <div class="grid grid-cols-1 gap-4 lg:grid-cols-12">
<div class="lg:col-span-5"> <div class="lg:col-span-5">
<div class="relative group"> <div class="relative group">
@@ -225,10 +226,13 @@ watch(
</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">
<div class="mb-2 flex-shrink-0 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.ops.errorDetails.total') }} {{ total }} {{ t('admin.ops.errorDetails.total') }} {{ total }}
</div> </div>
<OpsErrorLogTable <OpsErrorLogTable
class="min-h-0 flex-1"
:rows="rows" :rows="rows"
:total="total" :total="total"
:loading="loading" :loading="loading"
@@ -238,5 +242,7 @@ watch(
@update:page="page = $event" @update:page="page = $event"
@update:pageSize="pageSize = $event" @update:pageSize="pageSize = $event"
/> />
</div>
</div>
</BaseDialog> </BaseDialog>
</template> </template>

View File

@@ -1,10 +1,11 @@
<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">
<div class="min-h-0 flex-1 overflow-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700"> <table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
<thead class="sticky top-0 z-10 bg-gray-50/50 dark:bg-dark-800/50"> <thead class="sticky top-0 z-10 bg-gray-50/50 dark:bg-dark-800/50">
<tr> <tr>
@@ -172,6 +173,7 @@
@update:pageSize="emit('update:pageSize', $event)" @update:pageSize="emit('update:pageSize', $event)"
/> />
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -95,6 +95,7 @@ watch(
(open) => { (open) => {
if (open) { if (open) {
page.value = 1 page.value = 1
pageSize.value = 20
fetchData() fetchData()
} }
} }
@@ -150,7 +151,8 @@ 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="mb-4 flex flex-shrink-0 items-center justify-between">
<div class="text-xs text-gray-500 dark:text-gray-400"> <div class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.ops.requestDetails.rangeLabel', { range: rangeLabel }) }} {{ t('admin.ops.requestDetails.rangeLabel', { range: rangeLabel }) }}
</div> </div>
@@ -164,7 +166,7 @@ const kindBadgeClass = (kind: string) => {
</div> </div>
<!-- Loading --> <!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-16"> <div v-if="loading" class="flex flex-1 items-center justify-center py-16">
<div class="flex flex-col items-center gap-3"> <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"> <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> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
@@ -179,16 +181,16 @@ const kindBadgeClass = (kind: string) => {
</div> </div>
<!-- Table --> <!-- Table -->
<div v-else> <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 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="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 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"> <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="overflow-x-auto"> <div class="min-h-0 flex-1 overflow-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700"> <table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
<thead class="bg-gray-50 dark:bg-dark-900"> <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') }}
@@ -276,6 +278,7 @@ const kindBadgeClass = (kind: string) => {
/> />
</div> </div>
</div> </div>
</div>
</template> </template>
</BaseDialog> </BaseDialog>
</template> </template>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,11 +180,12 @@ 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>
<template v-if="!props.fullscreen">
<button <button
type="button" type="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" 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"
@@ -211,6 +213,7 @@ function downloadChart() {
> >
{{ t('admin.ops.charts.downloadChart') }} {{ t('admin.ops.charts.downloadChart') }}
</button> </button>
</template>
</div> </div>
</div> </div>

View File

@@ -14,6 +14,7 @@ export type {
EmailNotificationConfig, EmailNotificationConfig,
OpsDistributedLockSettings, OpsDistributedLockSettings,
OpsAlertRuntimeSettings, OpsAlertRuntimeSettings,
OpsMetricThresholds,
OpsAdvancedSettings, OpsAdvancedSettings,
OpsDataRetentionSettings, OpsDataRetentionSettings,
OpsAggregationSettings OpsAggregationSettings