mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-06 16:30:22 +08:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93db889a10 | ||
|
|
0df7385c4e | ||
|
|
1a3fa6411c | ||
|
|
64614756d1 | ||
|
|
bb1fd54d4d | ||
|
|
d85288a6c0 | ||
|
|
3402acb606 | ||
|
|
7fdc25df3c | ||
|
|
ea699cbdc2 | ||
|
|
fe6a3f4267 | ||
|
|
fe8198c8cd | ||
|
|
675e61385f | ||
|
|
67acac1082 | ||
|
|
d02e1db018 | ||
|
|
0da515071b | ||
|
|
524d80ae1c | ||
|
|
3b71bc3df1 | ||
|
|
22ef9534e0 | ||
|
|
c206d12d5c | ||
|
|
6ad29a470c | ||
|
|
2d45e61a9b | ||
|
|
b98fb013ae | ||
|
|
345a965fa3 | ||
|
|
c02c120579 |
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -76,7 +77,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
||||
|
||||
// Turnstile 验证(当提供了邮箱验证码时跳过,因为发送验证码时已验证过)
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -105,7 +106,7 @@ func (h *AuthHandler) SendVerifyCode(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
@@ -132,7 +133,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -275,12 +275,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
var failoverErr *service.UpstreamFailoverError
|
||||
if errors.As(err, &failoverErr) {
|
||||
failedAccountIDs[account.ID] = struct{}{}
|
||||
lastFailoverStatus = failoverErr.StatusCode
|
||||
if switchCount >= maxAccountSwitches {
|
||||
lastFailoverStatus = failoverErr.StatusCode
|
||||
h.handleFailoverExhausted(c, lastFailoverStatus, streamStarted)
|
||||
return
|
||||
}
|
||||
lastFailoverStatus = failoverErr.StatusCode
|
||||
switchCount++
|
||||
log.Printf("Account %d: upstream error %d, switching account %d/%d", account.ID, failoverErr.StatusCode, switchCount, maxAccountSwitches)
|
||||
continue
|
||||
@@ -409,12 +408,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
var failoverErr *service.UpstreamFailoverError
|
||||
if errors.As(err, &failoverErr) {
|
||||
failedAccountIDs[account.ID] = struct{}{}
|
||||
lastFailoverStatus = failoverErr.StatusCode
|
||||
if switchCount >= maxAccountSwitches {
|
||||
lastFailoverStatus = failoverErr.StatusCode
|
||||
h.handleFailoverExhausted(c, lastFailoverStatus, streamStarted)
|
||||
return
|
||||
}
|
||||
lastFailoverStatus = failoverErr.StatusCode
|
||||
switchCount++
|
||||
log.Printf("Account %d: upstream error %d, switching account %d/%d", account.ID, failoverErr.StatusCode, switchCount, maxAccountSwitches)
|
||||
continue
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/gemini"
|
||||
"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/service"
|
||||
|
||||
@@ -316,7 +317,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
|
||||
// 捕获请求信息(用于异步记录,避免在 goroutine 中访问 gin.Context)
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
clientIP := c.ClientIP()
|
||||
clientIP := ip.GetClientIP(c)
|
||||
|
||||
// 6) record usage async
|
||||
go func(result *service.ForwardResult, usedAccount *service.Account, ua, ip string) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -265,7 +266,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
|
||||
// 捕获请求信息(用于异步记录,避免在 goroutine 中访问 gin.Context)
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
clientIP := c.ClientIP()
|
||||
clientIP := ip.GetClientIP(c)
|
||||
|
||||
// Async record usage
|
||||
go func(result *service.OpenAIForwardResult, usedAccount *service.Account, ua, ip string) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"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"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -489,6 +490,7 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
|
||||
Severity: classifyOpsSeverity("upstream_error", effectiveUpstreamStatus),
|
||||
StatusCode: status,
|
||||
IsBusinessLimited: false,
|
||||
IsCountTokens: isCountTokensRequest(c),
|
||||
|
||||
ErrorMessage: recoveredMsg,
|
||||
ErrorBody: "",
|
||||
@@ -521,7 +523,7 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
var clientIP string
|
||||
if ip := strings.TrimSpace(c.ClientIP()); ip != "" {
|
||||
if ip := strings.TrimSpace(ip.GetClientIP(c)); ip != "" {
|
||||
clientIP = ip
|
||||
entry.ClientIP = &clientIP
|
||||
}
|
||||
@@ -598,6 +600,7 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
|
||||
Severity: classifyOpsSeverity(parsed.ErrorType, status),
|
||||
StatusCode: status,
|
||||
IsBusinessLimited: isBusinessLimited,
|
||||
IsCountTokens: isCountTokensRequest(c),
|
||||
|
||||
ErrorMessage: parsed.Message,
|
||||
// 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
|
||||
if ip := strings.TrimSpace(c.ClientIP()); ip != "" {
|
||||
if ip := strings.TrimSpace(ip.GetClientIP(c)); ip != "" {
|
||||
clientIP = ip
|
||||
entry.ClientIP = &clientIP
|
||||
}
|
||||
@@ -704,6 +707,14 @@ var opsRetryRequestHeaderAllowlist = []string{
|
||||
"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 {
|
||||
if c == nil || c.Request == nil {
|
||||
return nil
|
||||
|
||||
@@ -46,6 +46,7 @@ INSERT INTO ops_error_logs (
|
||||
severity,
|
||||
status_code,
|
||||
is_business_limited,
|
||||
is_count_tokens,
|
||||
error_message,
|
||||
error_body,
|
||||
error_source,
|
||||
@@ -64,7 +65,7 @@ INSERT INTO ops_error_logs (
|
||||
retry_count,
|
||||
created_at
|
||||
) 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`
|
||||
|
||||
var id int64
|
||||
@@ -88,6 +89,7 @@ INSERT INTO ops_error_logs (
|
||||
opsNullString(input.Severity),
|
||||
opsNullInt(input.StatusCode),
|
||||
input.IsBusinessLimited,
|
||||
input.IsCountTokens,
|
||||
opsNullString(input.ErrorMessage),
|
||||
opsNullString(input.ErrorBody),
|
||||
opsNullString(input.ErrorSource),
|
||||
|
||||
@@ -964,8 +964,8 @@ func buildErrorWhere(filter *service.OpsDashboardFilter, start, end time.Time, s
|
||||
}
|
||||
|
||||
idx := startIndex
|
||||
clauses := make([]string, 0, 4)
|
||||
args = make([]any, 0, 4)
|
||||
clauses := make([]string, 0, 5)
|
||||
args = make([]any, 0, 5)
|
||||
|
||||
args = append(args, start)
|
||||
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))
|
||||
idx++
|
||||
|
||||
clauses = append(clauses, "is_count_tokens = FALSE")
|
||||
|
||||
if groupID != nil && *groupID > 0 {
|
||||
args = append(args, *groupID)
|
||||
clauses = append(clauses, fmt.Sprintf("group_id = $%d", idx))
|
||||
|
||||
@@ -78,7 +78,9 @@ error_base AS (
|
||||
status_code AS client_status_code,
|
||||
COALESCE(upstream_status_code, status_code, 0) AS effective_status_code
|
||||
FROM ops_error_logs
|
||||
-- Exclude count_tokens requests from error metrics as they are informational probes
|
||||
WHERE created_at >= $1 AND created_at < $2
|
||||
AND is_count_tokens = FALSE
|
||||
),
|
||||
error_agg AS (
|
||||
SELECT
|
||||
|
||||
@@ -170,6 +170,7 @@ error_totals AS (
|
||||
FROM ops_error_logs
|
||||
WHERE created_at >= $1 AND created_at < $2
|
||||
AND COALESCE(status_code, 0) >= 400
|
||||
AND is_count_tokens = FALSE -- 排除 count_tokens 请求的错误
|
||||
GROUP BY 1
|
||||
),
|
||||
combined AS (
|
||||
@@ -243,6 +244,7 @@ error_totals AS (
|
||||
AND platform = $3
|
||||
AND group_id IS NOT NULL
|
||||
AND COALESCE(status_code, 0) >= 400
|
||||
AND is_count_tokens = FALSE -- 排除 count_tokens 请求的错误
|
||||
GROUP BY 1
|
||||
),
|
||||
combined AS (
|
||||
|
||||
@@ -523,6 +523,9 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
// Sanitize thinking blocks (clean cache_control and flatten history thinking)
|
||||
sanitizeThinkingBlocks(&claudeReq)
|
||||
|
||||
// 获取转换选项
|
||||
// Antigravity 上游要求必须包含身份提示词,否则会返回 429
|
||||
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)
|
||||
}
|
||||
|
||||
// Safety net: ensure no cache_control leaked into Gemini request
|
||||
geminiBody = cleanCacheControlFromGeminiJSON(geminiBody)
|
||||
|
||||
// Antigravity 上游只支持流式请求,统一使用 streamGenerateContent
|
||||
// 如果客户端请求非流式,在响应处理阶段会收集完整流式响应后转换返回
|
||||
action := "streamGenerateContent"
|
||||
@@ -903,6 +909,143 @@ func extractAntigravityErrorMessage(body []byte) string {
|
||||
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.
|
||||
// This preserves the thinking content while avoiding signature validation errors.
|
||||
// Note: redacted_thinking blocks are removed because they cannot be converted to text.
|
||||
|
||||
@@ -1227,6 +1227,9 @@ func enforceCacheControlLimit(body []byte) []byte {
|
||||
return body
|
||||
}
|
||||
|
||||
// 清理 thinking 块中的非法 cache_control(thinking 块不支持该字段)
|
||||
removeCacheControlFromThinkingBlocks(data)
|
||||
|
||||
// 计算当前 cache_control 块数量
|
||||
count := countCacheControlBlocks(data)
|
||||
if count <= maxCacheControlBlocks {
|
||||
@@ -1254,6 +1257,7 @@ func enforceCacheControlLimit(body []byte) []byte {
|
||||
}
|
||||
|
||||
// countCacheControlBlocks 统计 system 和 messages 中的 cache_control 块数量
|
||||
// 注意:thinking 块不支持 cache_control,统计时跳过
|
||||
func countCacheControlBlocks(data map[string]any) int {
|
||||
count := 0
|
||||
|
||||
@@ -1261,6 +1265,10 @@ func countCacheControlBlocks(data map[string]any) int {
|
||||
if system, ok := data["system"].([]any); ok {
|
||||
for _, item := range system {
|
||||
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 {
|
||||
count++
|
||||
}
|
||||
@@ -1275,6 +1283,10 @@ func countCacheControlBlocks(data map[string]any) int {
|
||||
if content, ok := msgMap["content"].([]any); ok {
|
||||
for _, item := range content {
|
||||
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 {
|
||||
count++
|
||||
}
|
||||
@@ -1290,6 +1302,7 @@ func countCacheControlBlocks(data map[string]any) int {
|
||||
|
||||
// removeCacheControlFromMessages 从 messages 中移除一个 cache_control(从头开始)
|
||||
// 返回 true 表示成功移除,false 表示没有可移除的
|
||||
// 注意:跳过 thinking 块(它不支持 cache_control)
|
||||
func removeCacheControlFromMessages(data map[string]any) bool {
|
||||
messages, ok := data["messages"].([]any)
|
||||
if !ok {
|
||||
@@ -1307,6 +1320,10 @@ func removeCacheControlFromMessages(data map[string]any) bool {
|
||||
}
|
||||
for _, item := range content {
|
||||
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 {
|
||||
delete(m, "cache_control")
|
||||
return true
|
||||
@@ -1319,6 +1336,7 @@ func removeCacheControlFromMessages(data map[string]any) bool {
|
||||
|
||||
// removeCacheControlFromSystem 从 system 中移除一个 cache_control(从尾部开始,保护注入的 prompt)
|
||||
// 返回 true 表示成功移除,false 表示没有可移除的
|
||||
// 注意:跳过 thinking 块(它不支持 cache_control)
|
||||
func removeCacheControlFromSystem(data map[string]any) bool {
|
||||
system, ok := data["system"].([]any)
|
||||
if !ok {
|
||||
@@ -1328,6 +1346,10 @@ func removeCacheControlFromSystem(data map[string]any) bool {
|
||||
// 从尾部开始移除,保护开头注入的 Claude Code prompt
|
||||
for i := len(system) - 1; i >= 0; i-- {
|
||||
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 {
|
||||
delete(m, "cache_control")
|
||||
return true
|
||||
@@ -1337,6 +1359,44 @@ func removeCacheControlFromSystem(data map[string]any) bool {
|
||||
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
|
||||
func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) (*ForwardResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
@@ -545,14 +545,12 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
|
||||
isCodexCLI := openai.IsCodexCLIRequest(c.GetHeader("User-Agent"))
|
||||
|
||||
// Apply model mapping (skip for Codex CLI for transparent forwarding)
|
||||
mappedModel := reqModel
|
||||
if !isCodexCLI {
|
||||
mappedModel = account.GetMappedModel(reqModel)
|
||||
if mappedModel != reqModel {
|
||||
reqBody["model"] = mappedModel
|
||||
bodyModified = true
|
||||
}
|
||||
// Apply model mapping for all requests (including Codex CLI)
|
||||
mappedModel := account.GetMappedModel(reqModel)
|
||||
if mappedModel != reqModel {
|
||||
log.Printf("[OpenAI] Model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", reqModel, mappedModel, account.Name, isCodexCLI)
|
||||
reqBody["model"] = mappedModel
|
||||
bodyModified = true
|
||||
}
|
||||
|
||||
if account.Type == AccountTypeOAuth && !isCodexCLI {
|
||||
@@ -568,6 +566,44 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
}
|
||||
}
|
||||
|
||||
// Handle max_output_tokens based on platform and account type
|
||||
if !isCodexCLI {
|
||||
if maxOutputTokens, hasMaxOutputTokens := reqBody["max_output_tokens"]; hasMaxOutputTokens {
|
||||
switch account.Platform {
|
||||
case PlatformOpenAI:
|
||||
// For OpenAI API Key, remove max_output_tokens (not supported)
|
||||
// For OpenAI OAuth (Responses API), keep it (supported)
|
||||
if account.Type == AccountTypeAPIKey {
|
||||
delete(reqBody, "max_output_tokens")
|
||||
bodyModified = true
|
||||
}
|
||||
case PlatformAnthropic:
|
||||
// For Anthropic (Claude), convert to max_tokens
|
||||
delete(reqBody, "max_output_tokens")
|
||||
if _, hasMaxTokens := reqBody["max_tokens"]; !hasMaxTokens {
|
||||
reqBody["max_tokens"] = maxOutputTokens
|
||||
}
|
||||
bodyModified = true
|
||||
case PlatformGemini:
|
||||
// For Gemini, remove (will be handled by Gemini-specific transform)
|
||||
delete(reqBody, "max_output_tokens")
|
||||
bodyModified = true
|
||||
default:
|
||||
// For unknown platforms, remove to be safe
|
||||
delete(reqBody, "max_output_tokens")
|
||||
bodyModified = true
|
||||
}
|
||||
}
|
||||
|
||||
// Also handle max_completion_tokens (similar logic)
|
||||
if _, hasMaxCompletionTokens := reqBody["max_completion_tokens"]; hasMaxCompletionTokens {
|
||||
if account.Type == AccountTypeAPIKey || account.Platform != PlatformOpenAI {
|
||||
delete(reqBody, "max_completion_tokens")
|
||||
bodyModified = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-serialize body only if modified
|
||||
if bodyModified {
|
||||
var err error
|
||||
|
||||
@@ -73,6 +73,7 @@ type OpsInsertErrorLogInput struct {
|
||||
Severity string
|
||||
StatusCode int
|
||||
IsBusinessLimited bool
|
||||
IsCountTokens bool // 是否为 count_tokens 请求
|
||||
|
||||
ErrorMessage string
|
||||
ErrorBody string
|
||||
|
||||
@@ -368,6 +368,9 @@ func defaultOpsAdvancedSettings() *OpsAdvancedSettings {
|
||||
Aggregation: OpsAggregationSettings{
|
||||
AggregationEnabled: false,
|
||||
},
|
||||
IgnoreCountTokensErrors: false,
|
||||
AutoRefreshEnabled: false,
|
||||
AutoRefreshIntervalSec: 30,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,6 +391,10 @@ func normalizeOpsAdvancedSettings(cfg *OpsAdvancedSettings) {
|
||||
if cfg.DataRetention.HourlyMetricsRetentionDays <= 0 {
|
||||
cfg.DataRetention.HourlyMetricsRetentionDays = 30
|
||||
}
|
||||
// Normalize auto refresh interval (default 30 seconds)
|
||||
if cfg.AutoRefreshIntervalSec <= 0 {
|
||||
cfg.AutoRefreshIntervalSec = 30
|
||||
}
|
||||
}
|
||||
|
||||
func validateOpsAdvancedSettings(cfg *OpsAdvancedSettings) error {
|
||||
@@ -403,6 +410,9 @@ func validateOpsAdvancedSettings(cfg *OpsAdvancedSettings) error {
|
||||
if cfg.DataRetention.HourlyMetricsRetentionDays < 1 || cfg.DataRetention.HourlyMetricsRetentionDays > 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
|
||||
}
|
||||
|
||||
|
||||
@@ -79,8 +79,11 @@ type OpsAlertRuntimeSettings struct {
|
||||
|
||||
// OpsAdvancedSettings stores advanced ops configuration (data retention, aggregation).
|
||||
type OpsAdvancedSettings struct {
|
||||
DataRetention OpsDataRetentionSettings `json:"data_retention"`
|
||||
Aggregation OpsAggregationSettings `json:"aggregation"`
|
||||
DataRetention OpsDataRetentionSettings `json:"data_retention"`
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -734,6 +734,9 @@ export interface OpsAlertRuntimeSettings {
|
||||
export interface OpsAdvancedSettings {
|
||||
data_retention: OpsDataRetentionSettings
|
||||
aggregation: OpsAggregationSettings
|
||||
ignore_count_tokens_errors: boolean
|
||||
auto_refresh_enabled: boolean
|
||||
auto_refresh_interval_seconds: number
|
||||
}
|
||||
|
||||
export interface OpsDataRetentionSettings {
|
||||
|
||||
@@ -376,6 +376,10 @@ const currentFiles = computed((): FileConfig[] => {
|
||||
const trimmed = `${baseRoot}/antigravity`.replace(/\/+$/, '')
|
||||
return trimmed.endsWith('/v1beta') ? trimmed : `${trimmed}/v1beta`
|
||||
})()
|
||||
const geminiBase = (() => {
|
||||
const trimmed = baseRoot.replace(/\/+$/, '')
|
||||
return trimmed.endsWith('/v1beta') ? trimmed : `${trimmed}/v1beta`
|
||||
})()
|
||||
|
||||
if (activeClientTab.value === 'opencode') {
|
||||
switch (props.platform) {
|
||||
@@ -384,7 +388,7 @@ const currentFiles = computed((): FileConfig[] => {
|
||||
case 'openai':
|
||||
return [generateOpenCodeConfig('openai', apiBase, apiKey)]
|
||||
case 'gemini':
|
||||
return [generateOpenCodeConfig('gemini', apiBase, apiKey)]
|
||||
return [generateOpenCodeConfig('gemini', geminiBase, apiKey)]
|
||||
case 'antigravity':
|
||||
return [
|
||||
generateOpenCodeConfig('antigravity-claude', antigravityBase, apiKey, 'opencode.json (Claude)'),
|
||||
@@ -525,14 +529,16 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
|
||||
[platform]: {
|
||||
options: {
|
||||
baseURL: baseUrl,
|
||||
apiKey,
|
||||
...(platform === 'openai' ? { store: false } : {})
|
||||
apiKey
|
||||
}
|
||||
}
|
||||
}
|
||||
const openaiModels = {
|
||||
'gpt-5.2-codex': {
|
||||
name: 'GPT-5.2 Codex',
|
||||
options: {
|
||||
store: false
|
||||
},
|
||||
variants: {
|
||||
low: {},
|
||||
medium: {},
|
||||
@@ -574,9 +580,26 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
|
||||
provider[platform].models = openaiModels
|
||||
}
|
||||
|
||||
const agent =
|
||||
platform === 'openai'
|
||||
? {
|
||||
build: {
|
||||
options: {
|
||||
store: false
|
||||
}
|
||||
},
|
||||
plan: {
|
||||
options: {
|
||||
store: false
|
||||
}
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
|
||||
const content = JSON.stringify(
|
||||
{
|
||||
provider,
|
||||
...(agent ? { agent } : {}),
|
||||
$schema: 'https://opencode.ai/config.json'
|
||||
},
|
||||
null,
|
||||
|
||||
@@ -13,7 +13,17 @@ const openaiModels = [
|
||||
'o1', 'o1-preview', 'o1-mini', 'o1-pro',
|
||||
'o3', 'o3-mini', 'o3-pro',
|
||||
'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',
|
||||
'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: '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: '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 = [
|
||||
|
||||
@@ -390,7 +390,7 @@ export default {
|
||||
opencode: {
|
||||
title: 'OpenCode Example',
|
||||
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',
|
||||
@@ -1943,6 +1943,9 @@ export default {
|
||||
'6h': 'Last 6 hours',
|
||||
'24h': 'Last 24 hours'
|
||||
},
|
||||
fullscreen: {
|
||||
enter: 'Enter Fullscreen'
|
||||
},
|
||||
diagnosis: {
|
||||
title: 'Smart Diagnosis',
|
||||
footer: 'Automated diagnostic suggestions based on current metrics',
|
||||
|
||||
@@ -387,7 +387,7 @@ export default {
|
||||
opencode: {
|
||||
title: 'OpenCode 配置示例',
|
||||
subtitle: 'opencode.json',
|
||||
hint: '示例仅用于演示分组配置,模型与选项可按需调整。',
|
||||
hint: '配置文件路径:~/.config/opencode/opencode.json(或 opencode.jsonc),不存在需手动创建。可使用默认 provider(openai/anthropic/google)或自定义 provider_id。API Key 支持直接配置或通过客户端 /connect 命令配置。示例仅供参考,模型与选项可按需调整。',
|
||||
},
|
||||
},
|
||||
customKeyLabel: '自定义密钥',
|
||||
@@ -2088,6 +2088,9 @@ export default {
|
||||
'6h': '近6小时',
|
||||
'24h': '近24小时'
|
||||
},
|
||||
fullscreen: {
|
||||
enter: '进入全屏'
|
||||
},
|
||||
diagnosis: {
|
||||
title: '智能诊断',
|
||||
footer: '基于当前指标的自动诊断建议',
|
||||
|
||||
@@ -19,7 +19,22 @@
|
||||
@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 {
|
||||
@apply h-2 w-2;
|
||||
}
|
||||
@@ -29,10 +44,15 @@
|
||||
}
|
||||
|
||||
::-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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6 pb-12">
|
||||
<component :is="isFullscreen ? 'div' : AppLayout" :class="isFullscreen ? 'flex min-h-screen flex-col justify-center bg-gray-50 dark:bg-dark-950' : ''">
|
||||
<div :class="[isFullscreen ? 'p-4 md:p-6' : '', 'space-y-6 pb-12']">
|
||||
<div
|
||||
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"
|
||||
@@ -20,6 +20,9 @@
|
||||
:loading="loading"
|
||||
:last-updated="lastUpdated"
|
||||
:thresholds="metricThresholds"
|
||||
:auto-refresh-enabled="autoRefreshEnabled"
|
||||
:auto-refresh-countdown="autoRefreshCountdown"
|
||||
:fullscreen="isFullscreen"
|
||||
@update:time-range="onTimeRangeChange"
|
||||
@update:platform="onPlatformChange"
|
||||
@update:group="onGroupChange"
|
||||
@@ -29,6 +32,8 @@
|
||||
@open-error-details="openErrorDetails"
|
||||
@open-settings="showSettingsDialog = true"
|
||||
@open-alert-rules="showAlertRulesCard = true"
|
||||
@enter-fullscreen="enterFullscreen"
|
||||
@exit-fullscreen="exitFullscreen"
|
||||
/>
|
||||
|
||||
<!-- Row: Concurrency + Throughput -->
|
||||
@@ -43,6 +48,7 @@
|
||||
:top-groups="throughputTrend?.top_groups ?? []"
|
||||
:loading="loadingTrend"
|
||||
:time-range="timeRange"
|
||||
:fullscreen="isFullscreen"
|
||||
@select-platform="handleThroughputSelectPlatform"
|
||||
@select-group="handleThroughputSelectGroup"
|
||||
@open-details="handleOpenRequestDetails"
|
||||
@@ -70,41 +76,42 @@
|
||||
<!-- Alert Events -->
|
||||
<OpsAlertEventsCard v-if="opsEnabled && !(loading && !hasLoadedOnce)" />
|
||||
|
||||
<!-- Settings Dialog -->
|
||||
<OpsSettingsDialog :show="showSettingsDialog" @close="showSettingsDialog = false" @saved="onSettingsSaved" />
|
||||
<!-- Settings Dialog (hidden in fullscreen mode) -->
|
||||
<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">
|
||||
<OpsAlertRulesCard />
|
||||
</BaseDialog>
|
||||
<BaseDialog :show="showAlertRulesCard" :title="t('admin.ops.alertRules.title')" width="extra-wide" @close="showAlertRulesCard = false">
|
||||
<OpsAlertRulesCard />
|
||||
</BaseDialog>
|
||||
|
||||
<OpsErrorDetailsModal
|
||||
:show="showErrorDetails"
|
||||
:time-range="timeRange"
|
||||
:platform="platform"
|
||||
:group-id="groupId"
|
||||
:error-type="errorDetailsType"
|
||||
@update:show="showErrorDetails = $event"
|
||||
@openErrorDetail="openError"
|
||||
/>
|
||||
<OpsErrorDetailsModal
|
||||
:show="showErrorDetails"
|
||||
:time-range="timeRange"
|
||||
:platform="platform"
|
||||
:group-id="groupId"
|
||||
:error-type="errorDetailsType"
|
||||
@update:show="showErrorDetails = $event"
|
||||
@openErrorDetail="openError"
|
||||
/>
|
||||
|
||||
<OpsErrorDetailModal v-model:show="showErrorModal" :error-id="selectedErrorId" />
|
||||
<OpsErrorDetailModal v-model:show="showErrorModal" :error-id="selectedErrorId" />
|
||||
|
||||
<OpsRequestDetailsModal
|
||||
v-model="showRequestDetails"
|
||||
:time-range="timeRange"
|
||||
:preset="requestDetailsPreset"
|
||||
:platform="platform"
|
||||
:group-id="groupId"
|
||||
@openErrorDetail="openError"
|
||||
/>
|
||||
<OpsRequestDetailsModal
|
||||
v-model="showRequestDetails"
|
||||
:time-range="timeRange"
|
||||
:preset="requestDetailsPreset"
|
||||
:platform="platform"
|
||||
:group-id="groupId"
|
||||
@openErrorDetail="openError"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 { useRoute, useRouter } from 'vue-router'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
@@ -161,12 +168,36 @@ const QUERY_KEYS = {
|
||||
timeRange: 'tr',
|
||||
platform: 'platform',
|
||||
groupId: 'group_id',
|
||||
queryMode: 'mode'
|
||||
queryMode: 'mode',
|
||||
fullscreen: 'fullscreen'
|
||||
} as const
|
||||
|
||||
const isApplyingRouteQuery = ref(false)
|
||||
const isSyncingRouteQuery = ref(false)
|
||||
|
||||
// Fullscreen mode
|
||||
const isFullscreen = computed(() => {
|
||||
const val = route.query[QUERY_KEYS.fullscreen]
|
||||
return val === '1' || val === 'true'
|
||||
})
|
||||
|
||||
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 dashboardFetchSeq = 0
|
||||
|
||||
@@ -287,6 +318,45 @@ const requestDetailsPreset = ref<OpsRequestDetailsPreset>({
|
||||
const showSettingsDialog = 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) {
|
||||
platform.value = nextPlatform || ''
|
||||
groupId.value = null
|
||||
@@ -510,6 +580,10 @@ async function fetchData() {
|
||||
])
|
||||
if (fetchSeq !== dashboardFetchSeq) return
|
||||
lastUpdated.value = new Date()
|
||||
// Reset auto refresh countdown after successful fetch
|
||||
if (autoRefreshEnabled.value) {
|
||||
autoRefreshCountdown.value = Math.floor(autoRefreshIntervalMs.value / 1000)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isOpsDisabledError(err)) {
|
||||
console.error('[ops] failed to fetch dashboard data', err)
|
||||
@@ -558,6 +632,9 @@ watch(
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
// Fullscreen mode: listen for ESC key
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
|
||||
await adminSettingsStore.fetch()
|
||||
if (!adminSettingsStore.opsMonitoringEnabled) {
|
||||
await router.replace('/admin/settings')
|
||||
@@ -567,9 +644,18 @@ onMounted(async () => {
|
||||
// Load thresholds configuration
|
||||
loadThresholds()
|
||||
|
||||
// Load auto refresh settings
|
||||
await loadAutoRefreshSettings()
|
||||
|
||||
if (opsEnabled.value) {
|
||||
await fetchData()
|
||||
}
|
||||
|
||||
// Start auto refresh if enabled
|
||||
if (autoRefreshEnabled.value) {
|
||||
resumeAutoRefresh()
|
||||
resumeCountdown()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadThresholds() {
|
||||
@@ -583,6 +669,29 @@ async function loadThresholds() {
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
abortDashboardFetch()
|
||||
pauseAutoRefresh()
|
||||
pauseCountdown()
|
||||
})
|
||||
|
||||
// Watch auto refresh settings changes
|
||||
watch(autoRefreshEnabled, (enabled) => {
|
||||
if (enabled) {
|
||||
autoRefreshCountdown.value = Math.floor(autoRefreshIntervalMs.value / 1000)
|
||||
resumeAutoRefresh()
|
||||
resumeCountdown()
|
||||
} else {
|
||||
pauseAutoRefresh()
|
||||
pauseCountdown()
|
||||
autoRefreshCountdown.value = 0
|
||||
}
|
||||
})
|
||||
|
||||
// Reload auto refresh settings after settings dialog is closed
|
||||
watch(showSettingsDialog, async (show) => {
|
||||
if (!show) {
|
||||
await loadAutoRefreshSettings()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -23,6 +23,9 @@ interface Props {
|
||||
loading: boolean
|
||||
lastUpdated: Date | null
|
||||
thresholds?: OpsMetricThresholds | null // 阈值配置
|
||||
autoRefreshEnabled?: boolean
|
||||
autoRefreshCountdown?: number
|
||||
fullscreen?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -35,6 +38,8 @@ interface Emits {
|
||||
(e: 'openErrorDetails', kind: 'request' | 'upstream'): void
|
||||
(e: 'openSettings'): void
|
||||
(e: 'openAlertRules'): void
|
||||
(e: 'enterFullscreen'): void
|
||||
(e: 'exitFullscreen'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -389,15 +394,15 @@ const healthScoreClass = computed(() => {
|
||||
return 'text-red-500'
|
||||
})
|
||||
|
||||
const circleSize = 100
|
||||
const strokeWidth = 8
|
||||
const radius = (circleSize - strokeWidth) / 2
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const circleSize = computed(() => props.fullscreen ? 140 : 100)
|
||||
const strokeWidth = computed(() => props.fullscreen ? 10 : 8)
|
||||
const radius = computed(() => (circleSize.value - strokeWidth.value) / 2)
|
||||
const circumference = computed(() => 2 * Math.PI * radius.value)
|
||||
const dashOffset = computed(() => {
|
||||
if (isSystemIdle.value) return 0
|
||||
if (healthScoreValue.value == null) return 0
|
||||
const score = Math.max(0, Math.min(100, healthScoreValue.value))
|
||||
return circumference - (score / 100) * circumference
|
||||
return circumference.value - (score / 100) * circumference.value
|
||||
})
|
||||
|
||||
interface DiagnosisItem {
|
||||
@@ -812,7 +817,7 @@ function handleToolbarRefresh() {
|
||||
</script>
|
||||
|
||||
<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 -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-gray-100 pb-4 dark:border-dark-700">
|
||||
<div>
|
||||
@@ -828,7 +833,7 @@ function handleToolbarRefresh() {
|
||||
{{ t('admin.ops.title') }}
|
||||
</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="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>
|
||||
@@ -839,6 +844,17 @@ function handleToolbarRefresh() {
|
||||
<span>·</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">
|
||||
<span>·</span>
|
||||
<span>
|
||||
@@ -850,28 +866,30 @@ function handleToolbarRefresh() {
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Select
|
||||
:model-value="platform"
|
||||
:options="platformOptions"
|
||||
class="w-full sm:w-[140px]"
|
||||
@update:model-value="handlePlatformChange"
|
||||
/>
|
||||
<template v-if="!props.fullscreen">
|
||||
<Select
|
||||
:model-value="platform"
|
||||
:options="platformOptions"
|
||||
class="w-full sm:w-[140px]"
|
||||
@update:model-value="handlePlatformChange"
|
||||
/>
|
||||
|
||||
<Select
|
||||
:model-value="groupId"
|
||||
:options="groupOptions"
|
||||
class="w-full sm:w-[160px]"
|
||||
@update:model-value="handleGroupChange"
|
||||
/>
|
||||
<Select
|
||||
:model-value="groupId"
|
||||
:options="groupOptions"
|
||||
class="w-full sm:w-[160px]"
|
||||
@update:model-value="handleGroupChange"
|
||||
/>
|
||||
|
||||
<div class="mx-1 hidden h-4 w-[1px] bg-gray-200 dark:bg-dark-700 sm:block"></div>
|
||||
<div class="mx-1 hidden h-4 w-[1px] bg-gray-200 dark:bg-dark-700 sm:block"></div>
|
||||
|
||||
<Select
|
||||
:model-value="timeRange"
|
||||
:options="timeRangeOptions"
|
||||
class="relative w-full sm:w-[150px]"
|
||||
@update:model-value="handleTimeRangeChange"
|
||||
/>
|
||||
<Select
|
||||
:model-value="timeRange"
|
||||
:options="timeRangeOptions"
|
||||
class="relative w-full sm:w-[150px]"
|
||||
@update:model-value="handleTimeRangeChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<Select
|
||||
v-if="false"
|
||||
@@ -882,6 +900,7 @@ function handleToolbarRefresh() {
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="!props.fullscreen"
|
||||
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"
|
||||
:disabled="loading"
|
||||
@@ -898,9 +917,11 @@ function handleToolbarRefresh() {
|
||||
</svg>
|
||||
</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
|
||||
v-if="!props.fullscreen"
|
||||
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"
|
||||
:title="t('admin.ops.alertRules.title')"
|
||||
@@ -912,7 +933,9 @@ function handleToolbarRefresh() {
|
||||
<span class="hidden sm:inline">{{ t('admin.ops.alertRules.manage') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Settings Button (hidden in fullscreen) -->
|
||||
<button
|
||||
v-if="!props.fullscreen"
|
||||
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"
|
||||
:title="t('admin.ops.settings.title')"
|
||||
@@ -924,13 +947,26 @@ function handleToolbarRefresh() {
|
||||
</svg>
|
||||
<span class="hidden sm:inline">{{ t('common.settings') }}</span>
|
||||
</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 v-if="overview" class="grid grid-cols-1 gap-6 lg:grid-cols-12">
|
||||
<!-- Left: Health + Realtime -->
|
||||
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900 lg:col-span-5">
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-[200px_1fr] md:items-center">
|
||||
<div :class="['rounded-2xl bg-gray-50 dark:bg-dark-900 lg:col-span-5', props.fullscreen ? 'p-6' : 'p-4']">
|
||||
<div class="grid h-full grid-cols-1 gap-6 md:grid-cols-[200px_1fr] md:items-center">
|
||||
<!-- 1) Health Score -->
|
||||
<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"
|
||||
@@ -1013,14 +1049,14 @@ function handleToolbarRefresh() {
|
||||
</svg>
|
||||
|
||||
<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 ?? '--') }}
|
||||
</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 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">
|
||||
{{ t('admin.ops.healthCondition') }}
|
||||
<HelpTooltip :content="t('admin.ops.healthHelp')" />
|
||||
@@ -1038,7 +1074,7 @@ function handleToolbarRefresh() {
|
||||
</div>
|
||||
|
||||
<!-- 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="flex items-center gap-2">
|
||||
<div class="relative flex h-3 w-3 shrink-0">
|
||||
@@ -1046,7 +1082,7 @@ function handleToolbarRefresh() {
|
||||
<span class="relative inline-flex h-3 w-3 rounded-full bg-blue-500"></span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- Time Window Selector -->
|
||||
@@ -1066,18 +1102,18 @@ function handleToolbarRefresh() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div :class="props.fullscreen ? 'space-y-4' : 'space-y-3'">
|
||||
<!-- Row 1: Current -->
|
||||
<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="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="text-xs font-bold text-gray-500">QPS</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="[props.fullscreen ? 'text-sm' : 'text-xs', 'font-bold text-gray-500']">QPS</span>
|
||||
</div>
|
||||
<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="text-xs font-bold text-gray-500">TPS</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="[props.fullscreen ? 'text-sm' : 'text-xs', 'font-bold text-gray-500']">TPS</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1086,8 +1122,8 @@ function handleToolbarRefresh() {
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<!-- Peak -->
|
||||
<div>
|
||||
<div class="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-xs' : 'text-[10px]', 'font-bold uppercase text-gray-400']">{{ t('admin.ops.peak') }}</div>
|
||||
<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">
|
||||
<span class="font-black text-gray-900 dark:text-white">{{ realtimeQpsPeakLabel }}</span>
|
||||
<span class="text-xs">QPS</span>
|
||||
@@ -1101,8 +1137,8 @@ function handleToolbarRefresh() {
|
||||
|
||||
<!-- Average -->
|
||||
<div>
|
||||
<div class="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-xs' : 'text-[10px]', 'font-bold uppercase text-gray-400']">{{ t('admin.ops.average') }}</div>
|
||||
<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">
|
||||
<span class="font-black text-gray-900 dark:text-white">{{ realtimeQpsAvgLabel }}</span>
|
||||
<span class="text-xs">QPS</span>
|
||||
@@ -1143,15 +1179,16 @@ function handleToolbarRefresh() {
|
||||
</div>
|
||||
|
||||
<!-- 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 -->
|
||||
<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 gap-1">
|
||||
<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>
|
||||
<button
|
||||
v-if="!props.fullscreen"
|
||||
class="text-[10px] font-bold text-blue-500 hover:underline"
|
||||
type="button"
|
||||
@click="openDetails({ title: t('admin.ops.requestDetails.title') })"
|
||||
@@ -1184,10 +1221,11 @@ function handleToolbarRefresh() {
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<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="isSLABelowThreshold(slaPercent) ? 'bg-red-500' : (slaPercent ?? 0) >= 99.5 ? 'bg-green-500' : 'bg-yellow-500'"></span>
|
||||
</div>
|
||||
<button
|
||||
v-if="!props.fullscreen"
|
||||
class="text-[10px] font-bold text-blue-500 hover:underline"
|
||||
type="button"
|
||||
@click="openDetails({ title: t('admin.ops.requestDetails.title'), kind: 'error' })"
|
||||
@@ -1214,9 +1252,10 @@ function handleToolbarRefresh() {
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-1">
|
||||
<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>
|
||||
<button
|
||||
v-if="!props.fullscreen"
|
||||
class="text-[10px] font-bold text-blue-500 hover:underline"
|
||||
type="button"
|
||||
@click="openDetails({ title: t('admin.ops.latencyDuration'), sort: 'duration_desc', min_duration_ms: Math.max(Number(durationP99Ms ?? 0), 0) })"
|
||||
@@ -1264,9 +1303,10 @@ function handleToolbarRefresh() {
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-1">
|
||||
<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>
|
||||
<button
|
||||
v-if="!props.fullscreen"
|
||||
class="text-[10px] font-bold text-blue-500 hover:underline"
|
||||
type="button"
|
||||
@click="openDetails({ title: 'TTFT', sort: 'duration_desc' })"
|
||||
@@ -1314,9 +1354,9 @@ function handleToolbarRefresh() {
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-1">
|
||||
<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>
|
||||
<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') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1340,9 +1380,9 @@ function handleToolbarRefresh() {
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-1">
|
||||
<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>
|
||||
<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') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1370,12 +1410,12 @@ function handleToolbarRefresh() {
|
||||
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
|
||||
<div class="flex items-center gap-1">
|
||||
<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 class="mt-1 text-lg font-black" :class="cpuPercentClass">
|
||||
{{ cpuPercentValue == null ? '-' : `${cpuPercentValue.toFixed(1)}%` }}
|
||||
</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%
|
||||
</div>
|
||||
</div>
|
||||
@@ -1384,12 +1424,12 @@ function handleToolbarRefresh() {
|
||||
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
|
||||
<div class="flex items-center gap-1">
|
||||
<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 class="mt-1 text-lg font-black" :class="memPercentClass">
|
||||
{{ memPercentValue == null ? '-' : `${memPercentValue.toFixed(1)}%` }}
|
||||
</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
|
||||
? '-'
|
||||
@@ -1402,12 +1442,12 @@ function handleToolbarRefresh() {
|
||||
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
|
||||
<div class="flex items-center gap-1">
|
||||
<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 class="mt-1 text-lg font-black" :class="dbMiddleClass">
|
||||
{{ dbMiddleLabel }}
|
||||
</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.active') }} {{ dbConnActiveValue ?? '-' }}
|
||||
· {{ t('admin.ops.idle') }} {{ dbConnIdleValue ?? '-' }}
|
||||
@@ -1419,12 +1459,12 @@ function handleToolbarRefresh() {
|
||||
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
|
||||
<div class="flex items-center gap-1">
|
||||
<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 class="mt-1 text-lg font-black" :class="redisMiddleClass">
|
||||
{{ redisMiddleLabel }}
|
||||
</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 ?? '-' }}
|
||||
<span v-if="redisConnActiveValue != null"> · {{ t('admin.ops.active') }} {{ redisConnActiveValue }} </span>
|
||||
<span v-if="redisConnIdleValue != null"> · {{ t('admin.ops.idle') }} {{ redisConnIdleValue }} </span>
|
||||
@@ -1435,12 +1475,12 @@ function handleToolbarRefresh() {
|
||||
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
|
||||
<div class="flex items-center gap-1">
|
||||
<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 class="mt-1 text-lg font-black" :class="goroutineStatusClass">
|
||||
{{ goroutineStatusLabel }}
|
||||
</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('common.warning') }} <span class="font-mono">{{ goroutinesWarnThreshold }}</span>
|
||||
· {{ t('common.critical') }} <span class="font-mono">{{ goroutinesCriticalThreshold }}</span>
|
||||
@@ -1455,9 +1495,9 @@ function handleToolbarRefresh() {
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<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>
|
||||
<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') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1466,7 +1506,7 @@ function handleToolbarRefresh() {
|
||||
{{ jobsStatusLabel }}
|
||||
</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.warning') }} <span class="font-mono">{{ jobsWarnCount }}</span>
|
||||
</div>
|
||||
|
||||
@@ -487,6 +487,48 @@ async function saveAllSettings() {
|
||||
<Toggle v-model="advancedSettings.aggregation.aggregation_enabled" />
|
||||
</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>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ interface Props {
|
||||
timeRange: string
|
||||
byPlatform?: OpsThroughputPlatformBreakdownItem[]
|
||||
topGroups?: OpsThroughputGroupBreakdownItem[]
|
||||
fullscreen?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -179,38 +180,40 @@ function downloadChart() {
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
{{ t('admin.ops.throughputTrend') }}
|
||||
<HelpTooltip :content="t('admin.ops.tooltips.throughputTrend')" />
|
||||
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.throughputTrend')" />
|
||||
</h3>
|
||||
<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-green-500"></span>{{ t('admin.ops.tpsK') }}</span>
|
||||
<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"
|
||||
:disabled="state !== 'ready'"
|
||||
:title="t('admin.ops.requestDetails.title')"
|
||||
@click="emit('openDetails')"
|
||||
>
|
||||
{{ t('admin.ops.requestDetails.details') }}
|
||||
</button>
|
||||
<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"
|
||||
:disabled="state !== 'ready'"
|
||||
:title="t('admin.ops.charts.resetZoomHint')"
|
||||
@click="resetZoom"
|
||||
>
|
||||
{{ t('admin.ops.charts.resetZoom') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
|
||||
:disabled="state !== 'ready'"
|
||||
:title="t('admin.ops.charts.downloadChartHint')"
|
||||
@click="downloadChart"
|
||||
>
|
||||
{{ t('admin.ops.charts.downloadChart') }}
|
||||
</button>
|
||||
<template v-if="!props.fullscreen">
|
||||
<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"
|
||||
:disabled="state !== 'ready'"
|
||||
:title="t('admin.ops.requestDetails.title')"
|
||||
@click="emit('openDetails')"
|
||||
>
|
||||
{{ t('admin.ops.requestDetails.details') }}
|
||||
</button>
|
||||
<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"
|
||||
:disabled="state !== 'ready'"
|
||||
:title="t('admin.ops.charts.resetZoomHint')"
|
||||
@click="resetZoom"
|
||||
>
|
||||
{{ t('admin.ops.charts.resetZoom') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
|
||||
:disabled="state !== 'ready'"
|
||||
:title="t('admin.ops.charts.downloadChartHint')"
|
||||
@click="downloadChart"
|
||||
>
|
||||
{{ t('admin.ops.charts.downloadChart') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user