Compare commits

..

16 Commits

Author SHA1 Message Date
shaw
93db889a10 fix: Gemini OpenCode 教程 baseURL 改为 v1beta 2026-01-13 09:52:37 +08:00
Wesley Liddick
0df7385c4e Merge pull request #226 from xilu0/main
feat(gateway): 优化 Antigravity/Gemini 思考块处理 此提交解决了思考块 (thinking blocks) 在转发过程中的兼容性问题
2026-01-13 09:39:43 +08:00
Wesley Liddick
1a3fa6411c Merge pull request #260 from IanShaw027/fix/sync-openai-gpt5-models
fix: 同步 OpenAI GPT-5 模型列表并完善参数处理
2026-01-13 09:31:00 +08:00
Wesley Liddick
64614756d1 Merge pull request #259 from cyhhao/main
fix: adjust OpenCode OpenAI example store placement
2026-01-13 09:30:26 +08:00
Wesley Liddick
bb1fd54d4d Merge pull request #257 from Edric-Li/feat/ops-fullscreen-scrollbar
feat(ops): 添加运维监控全屏模式 & 优化滚动条
2026-01-13 09:29:25 +08:00
ianshaw
d85288a6c0 Revert "fix(gateway): 修复 base_url 包含 /chat/completions 时路径拼接错误"
This reverts commit 7fdc25df3c.
2026-01-12 13:29:04 -08:00
ianshaw
3402acb606 feat(gateway): 对所有请求(包括 Codex CLI)应用模型映射
- 移除 Codex CLI 的模型映射跳过逻辑
- 添加详细的模型映射日志,包含账号名称和请求类型
- 确保所有 OpenAI 请求都能正确应用账号配置的模型映射
2026-01-12 13:23:05 -08:00
ianshaw
7fdc25df3c fix(gateway): 修复 base_url 包含 /chat/completions 时路径拼接错误
问题:
- 当账号的 base_url 配置为 https://example.com/v1/chat/completions 时
- 代码直接追加 /responses,导致路径变成 /v1/chat/completions/responses
- 上游返回 404 错误

修复:
- 在追加 /responses 前,先移除 base_url 中的 /chat/completions 后缀
- 确保最终路径为 https://example.com/v1/responses

影响范围:
- OpenAI API Key 账号的测试接口
- OpenAI API Key 账号的实际网关请求

Related-to: #231
2026-01-12 11:39:45 -08:00
ianshaw
ea699cbdc2 docs(frontend): 完善 OpenCode 配置说明
更新 API 密钥页面 OpenCode 配置提示信息:
- 补充支持 opencode.jsonc 后缀名
- 说明可使用默认 provider(openai/anthropic/google)或自定义 provider_id
- 说明 API Key 支持直接配置或通过 /connect 命令配置
- 保留"示例仅供参考,模型与选项可按需调整"的提示

配置文件路径:~/.config/opencode/opencode.json(或 opencode.jsonc)
2026-01-12 11:17:47 -08:00
ianshaw
fe6a3f4267 fix(gateway): 完善 max_output_tokens 参数处理逻辑
根据不同平台和账号类型处理 max_output_tokens 参数:
- OpenAI OAuth (Responses API): 保留 max_output_tokens(支持)
- OpenAI API Key: 删除 max_output_tokens(不支持)
- Anthropic (Claude): 转换 max_output_tokens 为 max_tokens
- Gemini: 删除 max_output_tokens(由 Gemini 专用转换处理)
- 其他平台: 删除(安全起见)

同时处理 max_completion_tokens 参数,仅在 OpenAI OAuth 时保留。

修复客户端(如 OpenCode)发送不支持参数导致上游返回 400 错误的问题。

Related-to: #231
2026-01-12 11:08:28 -08:00
ianshaw
fe8198c8cd fix(frontend): 同步 OpenAI GPT-5 系列模型列表
修复编辑账号页面 GPT-5 模型只显示 3 个的问题:
- 原来只有: gpt-5, gpt-5-mini, gpt-5-nano
- 现在添加完整的 22 个模型,包括:
  * GPT-5 系列: gpt-5, gpt-5-codex, gpt-5-chat, gpt-5-pro, gpt-5-mini, gpt-5-nano 及各时间戳版本
  * GPT-5.1 系列: gpt-5.1, gpt-5.1-codex, gpt-5.1-codex-max, gpt-5.1-codex-mini 及各版本
  * GPT-5.2 系列: gpt-5.2, gpt-5.2-codex, gpt-5.2-pro 及各版本
- 更新快捷预设按钮,新增 GPT-5.1, GPT-5.2, GPT-5.1 Codex 选项

与后端定价文件 (model_prices_and_context_window.json) 保持一致。

Fixes issue introduced in fb86002 (feat: 添加模型白名单选择器组件)
Related-to: fb86002ef9
2026-01-12 10:14:50 -08:00
cyhhao
675e61385f Merge branch 'main' of github.com:Wei-Shaw/sub2api 2026-01-12 22:36:14 +08:00
cyhhao
67acac1082 fix: adjust OpenCode OpenAI example store placement 2026-01-12 22:31:43 +08:00
Edric Li
d02e1db018 style: 优化滚动条自动隐藏效果
- 默认隐藏滚动条,悬停时显示
- 支持 Webkit (Chrome/Safari/Edge) 和 Firefox
- 滚动条样式与暗色主题适配
2026-01-12 22:10:59 +08:00
Edric Li
0da515071b feat(ops): 添加运维监控全屏模式
- 支持通过 URL 参数 ?fullscreen=1 进入全屏模式
- 全屏模式下隐藏非必要 UI 元素(选择器、按钮、提示等)
- 增大健康评分圆环和字体以提升可读性
- 支持 ESC 键退出全屏
- 添加全屏按钮的 i18n 翻译
2026-01-12 22:10:59 +08:00
xiluo
524d80ae1c feat(gateway): 优化 Antigravity/Gemini 思考块处理
此提交解决了思考块 (thinking blocks) 在转发过程中的兼容性问题。

主要变更:

1. **思考块优化 (Thinking Blocks)**:
   - 在 AntigravityGatewayService 中增加了 sanitizeThinkingBlocks 处理,强制移除思考块中不支持的 cache_control 字段(避免 Anthropic/Vertex AI 报错)
   - 实现历史思考块展平 (Flattening):将非最后一条消息中的思考块转换为普通文本块,以绕过上游对历史思考块签名的严格校验
   - 增加 cleanCacheControlFromGeminiJSON 作为最后一道防线,确保转换后的 Gemini 请求中不残留非法的 cache_control

2. **GatewayService 缓存控制优化**:
   - 更新缓存控制逻辑,跳过 thinking 块(thinking 块不支持 cache_control 字段)
   - 增加 removeCacheControlFromThinkingBlocks 函数强制清理

关联 Issue: #225
2026-01-12 13:36:59 +00:00
12 changed files with 506 additions and 144 deletions

View File

@@ -275,12 +275,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
var failoverErr *service.UpstreamFailoverError var failoverErr *service.UpstreamFailoverError
if errors.As(err, &failoverErr) { if errors.As(err, &failoverErr) {
failedAccountIDs[account.ID] = struct{}{} failedAccountIDs[account.ID] = struct{}{}
lastFailoverStatus = failoverErr.StatusCode
if switchCount >= maxAccountSwitches { if switchCount >= maxAccountSwitches {
lastFailoverStatus = failoverErr.StatusCode
h.handleFailoverExhausted(c, lastFailoverStatus, streamStarted) h.handleFailoverExhausted(c, lastFailoverStatus, streamStarted)
return return
} }
lastFailoverStatus = failoverErr.StatusCode
switchCount++ switchCount++
log.Printf("Account %d: upstream error %d, switching account %d/%d", account.ID, failoverErr.StatusCode, switchCount, maxAccountSwitches) log.Printf("Account %d: upstream error %d, switching account %d/%d", account.ID, failoverErr.StatusCode, switchCount, maxAccountSwitches)
continue continue
@@ -409,12 +408,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
var failoverErr *service.UpstreamFailoverError var failoverErr *service.UpstreamFailoverError
if errors.As(err, &failoverErr) { if errors.As(err, &failoverErr) {
failedAccountIDs[account.ID] = struct{}{} failedAccountIDs[account.ID] = struct{}{}
lastFailoverStatus = failoverErr.StatusCode
if switchCount >= maxAccountSwitches { if switchCount >= maxAccountSwitches {
lastFailoverStatus = failoverErr.StatusCode
h.handleFailoverExhausted(c, lastFailoverStatus, streamStarted) h.handleFailoverExhausted(c, lastFailoverStatus, streamStarted)
return return
} }
lastFailoverStatus = failoverErr.StatusCode
switchCount++ switchCount++
log.Printf("Account %d: upstream error %d, switching account %d/%d", account.ID, failoverErr.StatusCode, switchCount, maxAccountSwitches) log.Printf("Account %d: upstream error %d, switching account %d/%d", account.ID, failoverErr.StatusCode, switchCount, maxAccountSwitches)
continue continue

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,14 +545,12 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
isCodexCLI := openai.IsCodexCLIRequest(c.GetHeader("User-Agent")) isCodexCLI := openai.IsCodexCLIRequest(c.GetHeader("User-Agent"))
// Apply model mapping (skip for Codex CLI for transparent forwarding) // Apply model mapping for all requests (including Codex CLI)
mappedModel := reqModel mappedModel := account.GetMappedModel(reqModel)
if !isCodexCLI { if mappedModel != reqModel {
mappedModel = account.GetMappedModel(reqModel) log.Printf("[OpenAI] Model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", reqModel, mappedModel, account.Name, isCodexCLI)
if mappedModel != reqModel { reqBody["model"] = mappedModel
reqBody["model"] = mappedModel bodyModified = true
bodyModified = true
}
} }
if account.Type == AccountTypeOAuth && !isCodexCLI { if account.Type == AccountTypeOAuth && !isCodexCLI {
@@ -568,6 +566,44 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
} }
} }
// Handle max_output_tokens based on platform and account type
if !isCodexCLI {
if maxOutputTokens, hasMaxOutputTokens := reqBody["max_output_tokens"]; hasMaxOutputTokens {
switch account.Platform {
case PlatformOpenAI:
// For OpenAI API Key, remove max_output_tokens (not supported)
// For OpenAI OAuth (Responses API), keep it (supported)
if account.Type == AccountTypeAPIKey {
delete(reqBody, "max_output_tokens")
bodyModified = true
}
case PlatformAnthropic:
// For Anthropic (Claude), convert to max_tokens
delete(reqBody, "max_output_tokens")
if _, hasMaxTokens := reqBody["max_tokens"]; !hasMaxTokens {
reqBody["max_tokens"] = maxOutputTokens
}
bodyModified = true
case PlatformGemini:
// For Gemini, remove (will be handled by Gemini-specific transform)
delete(reqBody, "max_output_tokens")
bodyModified = true
default:
// For unknown platforms, remove to be safe
delete(reqBody, "max_output_tokens")
bodyModified = true
}
}
// Also handle max_completion_tokens (similar logic)
if _, hasMaxCompletionTokens := reqBody["max_completion_tokens"]; hasMaxCompletionTokens {
if account.Type == AccountTypeAPIKey || account.Platform != PlatformOpenAI {
delete(reqBody, "max_completion_tokens")
bodyModified = true
}
}
}
// Re-serialize body only if modified // Re-serialize body only if modified
if bodyModified { if bodyModified {
var err error var err error

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

@@ -390,7 +390,7 @@ export default {
opencode: { opencode: {
title: 'OpenCode Example', title: 'OpenCode Example',
subtitle: 'opencode.json', subtitle: 'opencode.json',
hint: 'Config path: ~/.config/opencode/opencode.json (create if not exists). This is an 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',
@@ -1943,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',

View File

@@ -387,7 +387,7 @@ export default {
opencode: { opencode: {
title: 'OpenCode 配置示例', title: 'OpenCode 配置示例',
subtitle: 'opencode.json', subtitle: 'opencode.json',
hint: '配置文件路径:~/.config/opencode/opencode.json,不存在需手动创建。示例仅供参考,模型与选项可按需调整。', hint: '配置文件路径:~/.config/opencode/opencode.json(或 opencode.jsonc不存在需手动创建。可使用默认 provideropenai/anthropic/google或自定义 provider_id。API Key 支持直接配置或通过客户端 /connect 命令配置。示例仅供参考,模型与选项可按需调整。',
}, },
}, },
customKeyLabel: '自定义密钥', customKeyLabel: '自定义密钥',
@@ -2088,6 +2088,9 @@ export default {
'6h': '近6小时', '6h': '近6小时',
'24h': '近24小时' '24h': '近24小时'
}, },
fullscreen: {
enter: '进入全屏'
},
diagnosis: { diagnosis: {
title: '智能诊断', title: '智能诊断',
footer: '基于当前指标的自动诊断建议', footer: '基于当前指标的自动诊断建议',

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

@@ -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"
@@ -22,6 +22,7 @@
:thresholds="metricThresholds" :thresholds="metricThresholds"
:auto-refresh-enabled="autoRefreshEnabled" :auto-refresh-enabled="autoRefreshEnabled"
:auto-refresh-countdown="autoRefreshCountdown" :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"
@@ -31,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 -->
@@ -45,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"
@@ -72,36 +76,37 @@
<!-- 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="onSettingsSaved" /> <template v-if="!isFullscreen">
<OpsSettingsDialog :show="showSettingsDialog" @close="showSettingsDialog = false" @saved="onSettingsSaved" />
<!-- Alert Rules Dialog --> <BaseDialog :show="showAlertRulesCard" :title="t('admin.ops.alertRules.title')" width="extra-wide" @close="showAlertRulesCard = false">
<BaseDialog :show="showAlertRulesCard" :title="t('admin.ops.alertRules.title')" width="extra-wide" @close="showAlertRulesCard = false"> <OpsAlertRulesCard />
<OpsAlertRulesCard /> </BaseDialog>
</BaseDialog>
<OpsErrorDetailsModal <OpsErrorDetailsModal
:show="showErrorDetails" :show="showErrorDetails"
:time-range="timeRange" :time-range="timeRange"
:platform="platform" :platform="platform"
:group-id="groupId" :group-id="groupId"
:error-type="errorDetailsType" :error-type="errorDetailsType"
@update:show="showErrorDetails = $event" @update:show="showErrorDetails = $event"
@openErrorDetail="openError" @openErrorDetail="openError"
/> />
<OpsErrorDetailModal v-model:show="showErrorModal" :error-id="selectedErrorId" /> <OpsErrorDetailModal v-model:show="showErrorModal" :error-id="selectedErrorId" />
<OpsRequestDetailsModal <OpsRequestDetailsModal
v-model="showRequestDetails" v-model="showRequestDetails"
:time-range="timeRange" :time-range="timeRange"
:preset="requestDetailsPreset" :preset="requestDetailsPreset"
:platform="platform" :platform="platform"
:group-id="groupId" :group-id="groupId"
@openErrorDetail="openError" @openErrorDetail="openError"
/> />
</template>
</div> </div>
</AppLayout> </component>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -163,12 +168,36 @@ 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)
// 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 dashboardFetchController: AbortController | null = null
let dashboardFetchSeq = 0 let dashboardFetchSeq = 0
@@ -603,6 +632,9 @@ 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')
@@ -637,6 +669,7 @@ async function loadThresholds() {
} }
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
abortDashboardFetch() abortDashboardFetch()
pauseAutoRefresh() pauseAutoRefresh()
pauseCountdown() pauseCountdown()

View File

@@ -25,6 +25,7 @@ interface Props {
thresholds?: OpsMetricThresholds | null // 阈值配置 thresholds?: OpsMetricThresholds | null // 阈值配置
autoRefreshEnabled?: boolean autoRefreshEnabled?: boolean
autoRefreshCountdown?: number autoRefreshCountdown?: number
fullscreen?: boolean
} }
interface Emits { interface Emits {
@@ -37,6 +38,8 @@ 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>()
@@ -391,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 {
@@ -814,7 +817,7 @@ function handleToolbarRefresh() {
</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>
@@ -830,7 +833,7 @@ function handleToolbarRefresh() {
{{ 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>
@@ -863,28 +866,30 @@ function handleToolbarRefresh() {
</div> </div>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<Select <template v-if="!props.fullscreen">
:model-value="platform" <Select
:options="platformOptions" :model-value="platform"
class="w-full sm:w-[140px]" :options="platformOptions"
@update:model-value="handlePlatformChange" class="w-full sm:w-[140px]"
/> @update:model-value="handlePlatformChange"
/>
<Select <Select
:model-value="groupId" :model-value="groupId"
:options="groupOptions" :options="groupOptions"
class="w-full sm:w-[160px]" class="w-full sm:w-[160px]"
@update:model-value="handleGroupChange" @update:model-value="handleGroupChange"
/> />
<div class="mx-1 hidden h-4 w-[1px] bg-gray-200 dark:bg-dark-700 sm:block"></div> <div class="mx-1 hidden h-4 w-[1px] bg-gray-200 dark:bg-dark-700 sm:block"></div>
<Select <Select
:model-value="timeRange" :model-value="timeRange"
:options="timeRangeOptions" :options="timeRangeOptions"
class="relative w-full sm:w-[150px]" class="relative w-full sm:w-[150px]"
@update:model-value="handleTimeRangeChange" @update:model-value="handleTimeRangeChange"
/> />
</template>
<Select <Select
v-if="false" v-if="false"
@@ -895,6 +900,7 @@ function handleToolbarRefresh() {
/> />
<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"
@@ -911,9 +917,11 @@ function handleToolbarRefresh() {
</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')"
@@ -925,7 +933,9 @@ function handleToolbarRefresh() {
<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')"
@@ -937,13 +947,26 @@ function handleToolbarRefresh() {
</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"
@@ -1026,14 +1049,14 @@ function handleToolbarRefresh() {
</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')" />
@@ -1051,7 +1074,7 @@ function handleToolbarRefresh() {
</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">
@@ -1059,7 +1082,7 @@ function handleToolbarRefresh() {
<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 -->
@@ -1079,18 +1102,18 @@ function handleToolbarRefresh() {
</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>
@@ -1099,8 +1122,8 @@ function handleToolbarRefresh() {
<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">{{ realtimeQpsPeakLabel }}</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>
@@ -1114,8 +1137,8 @@ function handleToolbarRefresh() {
<!-- 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">{{ realtimeQpsAvgLabel }}</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>
@@ -1156,15 +1179,16 @@ function handleToolbarRefresh() {
</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.requestsTitle') }}</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') })"
@@ -1197,10 +1221,11 @@ function handleToolbarRefresh() {
<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="isSLABelowThreshold(slaPercent) ? 'bg-red-500' : (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'), kind: 'error' })" @click="openDetails({ title: t('admin.ops.requestDetails.title'), kind: 'error' })"
@@ -1227,9 +1252,10 @@ function handleToolbarRefresh() {
<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) })"
@@ -1277,9 +1303,10 @@ function handleToolbarRefresh() {
<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', sort: 'duration_desc' })" @click="openDetails({ title: 'TTFT', sort: 'duration_desc' })"
@@ -1327,9 +1354,9 @@ function handleToolbarRefresh() {
<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>
@@ -1353,9 +1380,9 @@ function handleToolbarRefresh() {
<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>
@@ -1383,12 +1410,12 @@ function handleToolbarRefresh() {
<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>
@@ -1397,12 +1424,12 @@ function handleToolbarRefresh() {
<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
? '-' ? '-'
@@ -1415,12 +1442,12 @@ function handleToolbarRefresh() {
<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 ?? '-' }}
@@ -1432,12 +1459,12 @@ function handleToolbarRefresh() {
<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>
@@ -1448,12 +1475,12 @@ function handleToolbarRefresh() {
<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>
@@ -1468,9 +1495,9 @@ function handleToolbarRefresh() {
<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>
@@ -1479,7 +1506,7 @@ function handleToolbarRefresh() {
{{ 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

@@ -19,6 +19,7 @@ interface Props {
timeRange: string timeRange: string
byPlatform?: OpsThroughputPlatformBreakdownItem[] byPlatform?: OpsThroughputPlatformBreakdownItem[]
topGroups?: OpsThroughputGroupBreakdownItem[] topGroups?: OpsThroughputGroupBreakdownItem[]
fullscreen?: boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@@ -179,38 +180,40 @@ function downloadChart() {
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg> </svg>
{{ t('admin.ops.throughputTrend') }} {{ t('admin.ops.throughputTrend') }}
<HelpTooltip :content="t('admin.ops.tooltips.throughputTrend')" /> <HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.throughputTrend')" />
</h3> </h3>
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400"> <div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span class="flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-blue-500"></span>{{ t('admin.ops.qps') }}</span> <span class="flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-blue-500"></span>{{ t('admin.ops.qps') }}</span>
<span class="flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-green-500"></span>{{ t('admin.ops.tpsK') }}</span> <span class="flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-green-500"></span>{{ t('admin.ops.tpsK') }}</span>
<button <template v-if="!props.fullscreen">
type="button" <button
class="ml-2 inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800" type="button"
:disabled="state !== 'ready'" class="ml-2 inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
:title="t('admin.ops.requestDetails.title')" :disabled="state !== 'ready'"
@click="emit('openDetails')" :title="t('admin.ops.requestDetails.title')"
> @click="emit('openDetails')"
{{ t('admin.ops.requestDetails.details') }} >
</button> {{ t('admin.ops.requestDetails.details') }}
<button </button>
type="button" <button
class="ml-2 inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800" type="button"
:disabled="state !== 'ready'" class="ml-2 inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
:title="t('admin.ops.charts.resetZoomHint')" :disabled="state !== 'ready'"
@click="resetZoom" :title="t('admin.ops.charts.resetZoomHint')"
> @click="resetZoom"
{{ t('admin.ops.charts.resetZoom') }} >
</button> {{ t('admin.ops.charts.resetZoom') }}
<button </button>
type="button" <button
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800" type="button"
:disabled="state !== 'ready'" class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
:title="t('admin.ops.charts.downloadChartHint')" :disabled="state !== 'ready'"
@click="downloadChart" :title="t('admin.ops.charts.downloadChartHint')"
> @click="downloadChart"
{{ t('admin.ops.charts.downloadChart') }} >
</button> {{ t('admin.ops.charts.downloadChart') }}
</button>
</template>
</div> </div>
</div> </div>