Commit af9c4a7d authored by Peter's avatar Peter
Browse files

feat(ops): make openai token stats optional

parent 826090e0
...@@ -371,6 +371,7 @@ func defaultOpsAdvancedSettings() *OpsAdvancedSettings { ...@@ -371,6 +371,7 @@ func defaultOpsAdvancedSettings() *OpsAdvancedSettings {
IgnoreCountTokensErrors: true, // count_tokens 404 是预期行为,默认忽略 IgnoreCountTokensErrors: true, // count_tokens 404 是预期行为,默认忽略
IgnoreContextCanceled: true, // Default to true - client disconnects are not errors IgnoreContextCanceled: true, // Default to true - client disconnects are not errors
IgnoreNoAvailableAccounts: false, // Default to false - this is a real routing issue IgnoreNoAvailableAccounts: false, // Default to false - this is a real routing issue
DisplayOpenAITokenStats: false,
AutoRefreshEnabled: false, AutoRefreshEnabled: false,
AutoRefreshIntervalSec: 30, AutoRefreshIntervalSec: 30,
} }
......
package service
import (
"context"
"testing"
)
func TestGetOpsAdvancedSettings_DefaultHidesOpenAITokenStats(t *testing.T) {
repo := newRuntimeSettingRepoStub()
svc := &OpsService{settingRepo: repo}
cfg, err := svc.GetOpsAdvancedSettings(context.Background())
if err != nil {
t.Fatalf("GetOpsAdvancedSettings() error = %v", err)
}
if cfg.DisplayOpenAITokenStats {
t.Fatalf("DisplayOpenAITokenStats = true, want false by default")
}
if repo.setCalls != 1 {
t.Fatalf("expected defaults to be persisted once, got %d", repo.setCalls)
}
}
func TestUpdateOpsAdvancedSettings_PersistsOpenAITokenStatsVisibility(t *testing.T) {
repo := newRuntimeSettingRepoStub()
svc := &OpsService{settingRepo: repo}
cfg := defaultOpsAdvancedSettings()
cfg.DisplayOpenAITokenStats = true
updated, err := svc.UpdateOpsAdvancedSettings(context.Background(), cfg)
if err != nil {
t.Fatalf("UpdateOpsAdvancedSettings() error = %v", err)
}
if !updated.DisplayOpenAITokenStats {
t.Fatalf("DisplayOpenAITokenStats = false, want true")
}
reloaded, err := svc.GetOpsAdvancedSettings(context.Background())
if err != nil {
t.Fatalf("GetOpsAdvancedSettings() after update error = %v", err)
}
if !reloaded.DisplayOpenAITokenStats {
t.Fatalf("reloaded DisplayOpenAITokenStats = false, want true")
}
}
...@@ -98,6 +98,7 @@ type OpsAdvancedSettings struct { ...@@ -98,6 +98,7 @@ type OpsAdvancedSettings struct {
IgnoreContextCanceled bool `json:"ignore_context_canceled"` IgnoreContextCanceled bool `json:"ignore_context_canceled"`
IgnoreNoAvailableAccounts bool `json:"ignore_no_available_accounts"` IgnoreNoAvailableAccounts bool `json:"ignore_no_available_accounts"`
IgnoreInvalidApiKeyErrors bool `json:"ignore_invalid_api_key_errors"` IgnoreInvalidApiKeyErrors bool `json:"ignore_invalid_api_key_errors"`
DisplayOpenAITokenStats bool `json:"display_openai_token_stats"`
AutoRefreshEnabled bool `json:"auto_refresh_enabled"` AutoRefreshEnabled bool `json:"auto_refresh_enabled"`
AutoRefreshIntervalSec int `json:"auto_refresh_interval_seconds"` AutoRefreshIntervalSec int `json:"auto_refresh_interval_seconds"`
} }
......
...@@ -841,6 +841,7 @@ export interface OpsAdvancedSettings { ...@@ -841,6 +841,7 @@ export interface OpsAdvancedSettings {
ignore_context_canceled: boolean ignore_context_canceled: boolean
ignore_no_available_accounts: boolean ignore_no_available_accounts: boolean
ignore_invalid_api_key_errors: boolean ignore_invalid_api_key_errors: boolean
display_openai_token_stats: boolean
auto_refresh_enabled: boolean auto_refresh_enabled: boolean
auto_refresh_interval_seconds: number auto_refresh_interval_seconds: number
} }
......
...@@ -3651,6 +3651,9 @@ export default { ...@@ -3651,6 +3651,9 @@ export default {
refreshInterval15s: '15 seconds', refreshInterval15s: '15 seconds',
refreshInterval30s: '30 seconds', refreshInterval30s: '30 seconds',
refreshInterval60s: '60 seconds', refreshInterval60s: '60 seconds',
dashboardCards: 'Dashboard Cards',
displayOpenAITokenStats: 'Display OpenAI token request stats',
displayOpenAITokenStatsHint: 'Show or hide the OpenAI token request stats card on the ops dashboard. Hidden by default.',
autoRefreshCountdown: 'Auto refresh: {seconds}s', autoRefreshCountdown: 'Auto refresh: {seconds}s',
validation: { validation: {
title: 'Please fix the following issues', title: 'Please fix the following issues',
......
...@@ -3825,6 +3825,9 @@ export default { ...@@ -3825,6 +3825,9 @@ export default {
refreshInterval15s: '15 秒', refreshInterval15s: '15 秒',
refreshInterval30s: '30 秒', refreshInterval30s: '30 秒',
refreshInterval60s: '60 秒', refreshInterval60s: '60 秒',
dashboardCards: '仪表盘卡片',
displayOpenAITokenStats: '展示 OpenAI Token 请求统计',
displayOpenAITokenStatsHint: '控制运维监控仪表盘中 OpenAI Token 请求统计卡片是否显示,默认关闭。',
autoRefreshCountdown: '自动刷新:{seconds}s', autoRefreshCountdown: '自动刷新:{seconds}s',
validation: { validation: {
title: '请先修正以下问题', title: '请先修正以下问题',
......
...@@ -85,7 +85,7 @@ ...@@ -85,7 +85,7 @@
</div> </div>
<!-- Row: OpenAI Token Stats --> <!-- Row: OpenAI Token Stats -->
<div v-if="opsEnabled && !(loading && !hasLoadedOnce)" class="grid grid-cols-1 gap-6"> <div v-if="opsEnabled && showOpenAITokenStats && !(loading && !hasLoadedOnce)" class="grid grid-cols-1 gap-6">
<OpsOpenAITokenStatsCard <OpsOpenAITokenStatsCard
:platform-filter="platform" :platform-filter="platform"
:group-id-filter="groupId" :group-id-filter="groupId"
...@@ -381,6 +381,7 @@ const showSettingsDialog = ref(false) ...@@ -381,6 +381,7 @@ const showSettingsDialog = ref(false)
const showAlertRulesCard = ref(false) const showAlertRulesCard = ref(false)
// Auto refresh settings // Auto refresh settings
const showOpenAITokenStats = ref(false)
const autoRefreshEnabled = ref(false) const autoRefreshEnabled = ref(false)
const autoRefreshIntervalMs = ref(30000) // default 30 seconds const autoRefreshIntervalMs = ref(30000) // default 30 seconds
const autoRefreshCountdown = ref(0) const autoRefreshCountdown = ref(0)
...@@ -408,15 +409,20 @@ const { pause: pauseCountdown, resume: resumeCountdown } = useIntervalFn( ...@@ -408,15 +409,20 @@ const { pause: pauseCountdown, resume: resumeCountdown } = useIntervalFn(
{ immediate: false } { immediate: false }
) )
// Load auto refresh settings from backend // Load ops dashboard presentation settings from backend.
async function loadAutoRefreshSettings() { async function loadDashboardAdvancedSettings() {
try { try {
const settings = await opsAPI.getAdvancedSettings() const settings = await opsAPI.getAdvancedSettings()
showOpenAITokenStats.value = settings.display_openai_token_stats
autoRefreshEnabled.value = settings.auto_refresh_enabled autoRefreshEnabled.value = settings.auto_refresh_enabled
autoRefreshIntervalMs.value = settings.auto_refresh_interval_seconds * 1000 autoRefreshIntervalMs.value = settings.auto_refresh_interval_seconds * 1000
autoRefreshCountdown.value = settings.auto_refresh_interval_seconds autoRefreshCountdown.value = settings.auto_refresh_interval_seconds
} catch (err) { } catch (err) {
console.error('[OpsDashboard] Failed to load auto refresh settings', err) console.error('[OpsDashboard] Failed to load dashboard advanced settings', err)
showOpenAITokenStats.value = false
autoRefreshEnabled.value = false
autoRefreshIntervalMs.value = 30000
autoRefreshCountdown.value = 0
} }
} }
...@@ -464,7 +470,8 @@ function onCustomTimeRangeChange(startTime: string, endTime: string) { ...@@ -464,7 +470,8 @@ function onCustomTimeRangeChange(startTime: string, endTime: string) {
customEndTime.value = endTime customEndTime.value = endTime
} }
function onSettingsSaved() { async function onSettingsSaved() {
await loadDashboardAdvancedSettings()
loadThresholds() loadThresholds()
fetchData() fetchData()
} }
...@@ -774,7 +781,7 @@ onMounted(async () => { ...@@ -774,7 +781,7 @@ onMounted(async () => {
loadThresholds() loadThresholds()
// Load auto refresh settings // Load auto refresh settings
await loadAutoRefreshSettings() await loadDashboardAdvancedSettings()
if (opsEnabled.value) { if (opsEnabled.value) {
await fetchData() await fetchData()
...@@ -816,7 +823,7 @@ watch(autoRefreshEnabled, (enabled) => { ...@@ -816,7 +823,7 @@ watch(autoRefreshEnabled, (enabled) => {
// Reload auto refresh settings after settings dialog is closed // Reload auto refresh settings after settings dialog is closed
watch(showSettingsDialog, async (show) => { watch(showSettingsDialog, async (show) => {
if (!show) { if (!show) {
await loadAutoRefreshSettings() await loadDashboardAdvancedSettings()
} }
}) })
</script> </script>
...@@ -208,35 +208,39 @@ function onNextPage() { ...@@ -208,35 +208,39 @@ function onNextPage() {
:description="t('admin.ops.openaiTokenStats.empty')" :description="t('admin.ops.openaiTokenStats.empty')"
/> />
<div v-else class="overflow-x-auto"> <div v-else class="space-y-3">
<table class="min-w-full text-left text-xs md:text-sm"> <div class="overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
<thead> <div class="max-h-[420px] overflow-auto">
<tr class="border-b border-gray-200 text-gray-500 dark:border-dark-700 dark:text-gray-400"> <table class="min-w-full text-left text-xs md:text-sm">
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.model') }}</th> <thead class="sticky top-0 z-10 bg-white dark:bg-dark-800">
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.requestCount') }}</th> <tr class="border-b border-gray-200 text-gray-500 dark:border-dark-700 dark:text-gray-400">
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.avgTokensPerSec') }}</th> <th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.model') }}</th>
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.avgFirstTokenMs') }}</th> <th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.requestCount') }}</th>
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.totalOutputTokens') }}</th> <th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.avgTokensPerSec') }}</th>
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.avgDurationMs') }}</th> <th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.avgFirstTokenMs') }}</th>
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.requestsWithFirstToken') }}</th> <th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.totalOutputTokens') }}</th>
</tr> <th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.avgDurationMs') }}</th>
</thead> <th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.requestsWithFirstToken') }}</th>
<tbody> </tr>
<tr </thead>
v-for="row in items" <tbody>
:key="row.model" <tr
class="border-b border-gray-100 text-gray-700 dark:border-dark-800 dark:text-gray-200" v-for="row in items"
> :key="row.model"
<td class="px-2 py-2 font-medium">{{ row.model }}</td> class="border-b border-gray-100 text-gray-700 last:border-b-0 dark:border-dark-800 dark:text-gray-200"
<td class="px-2 py-2">{{ formatInt(row.request_count) }}</td> >
<td class="px-2 py-2">{{ formatRate(row.avg_tokens_per_sec) }}</td> <td class="px-2 py-2 font-medium">{{ row.model }}</td>
<td class="px-2 py-2">{{ formatRate(row.avg_first_token_ms) }}</td> <td class="px-2 py-2">{{ formatInt(row.request_count) }}</td>
<td class="px-2 py-2">{{ formatInt(row.total_output_tokens) }}</td> <td class="px-2 py-2">{{ formatRate(row.avg_tokens_per_sec) }}</td>
<td class="px-2 py-2">{{ formatInt(row.avg_duration_ms) }}</td> <td class="px-2 py-2">{{ formatRate(row.avg_first_token_ms) }}</td>
<td class="px-2 py-2">{{ formatInt(row.requests_with_first_token) }}</td> <td class="px-2 py-2">{{ formatInt(row.total_output_tokens) }}</td>
</tr> <td class="px-2 py-2">{{ formatInt(row.avg_duration_ms) }}</td>
</tbody> <td class="px-2 py-2">{{ formatInt(row.requests_with_first_token) }}</td>
</table> </tr>
</tbody>
</table>
</div>
</div>
<div v-if="viewMode === 'topn'" class="mt-3 text-xs text-gray-500 dark:text-gray-400"> <div v-if="viewMode === 'topn'" class="mt-3 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.ops.openaiTokenStats.totalModels', { total }) }} {{ t('admin.ops.openaiTokenStats.totalModels', { total }) }}
</div> </div>
......
...@@ -543,6 +543,21 @@ async function saveAllSettings() { ...@@ -543,6 +543,21 @@ async function saveAllSettings() {
/> />
</div> </div>
</div> </div>
<!-- Dashboard Cards -->
<div class="space-y-3">
<h5 class="text-xs font-semibold text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.dashboardCards') }}</h5>
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.displayOpenAITokenStats') }}</label>
<p class="mt-1 text-xs text-gray-500">
{{ t('admin.ops.settings.displayOpenAITokenStatsHint') }}
</p>
</div>
<Toggle v-model="advancedSettings.display_openai_token_stats" />
</div>
</div>
</div> </div>
</details> </details>
</div> </div>
......
...@@ -196,6 +196,23 @@ describe('OpsOpenAITokenStatsCard', () => { ...@@ -196,6 +196,23 @@ describe('OpsOpenAITokenStatsCard', () => {
expect(wrapper.find('.empty-state').exists()).toBe(true) expect(wrapper.find('.empty-state').exists()).toBe(true)
}) })
it('数据表使用固定高度滚动容器,避免纵向无限增长', async () => {
mockGetOpenAITokenStats.mockResolvedValue(sampleResponse)
const wrapper = mount(OpsOpenAITokenStatsCard, {
props: { refreshToken: 0 },
global: {
stubs: {
Select: SelectStub,
EmptyState: EmptyStateStub,
},
},
})
await flushPromises()
expect(wrapper.find('.max-h-\\[420px\\]').exists()).toBe(true)
})
it('接口异常时显示错误提示', async () => { it('接口异常时显示错误提示', async () => {
mockGetOpenAITokenStats.mockRejectedValue(new Error('加载失败')) mockGetOpenAITokenStats.mockRejectedValue(new Error('加载失败'))
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment