Fix usage provider stats and performance analysis UI

This commit is contained in:
fawney19
2026-05-06 09:09:55 +08:00
parent 110b02a213
commit 887a58d639
10 changed files with 313 additions and 113 deletions

View File

@@ -163,4 +163,60 @@ describe('useUsageData', () => {
expect(availableModels.value).toEqual(['gpt-5'])
expect(availableProviders.value).toEqual(['OpenAI'])
})
it('filters placeholder providers from admin provider stats', async () => {
const isAdminPage = ref(true)
const { loadStats, providerStats, availableProviders } = useUsageData({ isAdminPage })
const dateRange = { preset: 'last7days', tz_offset_minutes: 0 }
getUsageStatsMock.mockResolvedValueOnce({
total_requests: 4,
total_tokens: 400,
total_cost: 1,
avg_response_time: 0,
})
getUsageByProviderMock.mockResolvedValueOnce([
{
provider: 'OpenAI',
request_count: 3,
total_tokens: 300,
total_cost: 1.23,
actual_cost: 1.5,
avg_response_time_ms: 1250,
success_rate: 100,
},
{
provider: 'Unknown',
request_count: 1,
total_tokens: 100,
total_cost: 0,
actual_cost: 0,
avg_response_time_ms: 0,
success_rate: 100,
},
{
provider: 'unknow',
request_count: 1,
total_tokens: 100,
total_cost: 0,
actual_cost: 0,
avg_response_time_ms: 0,
success_rate: 100,
},
{
provider: 'pending',
request_count: 1,
total_tokens: 100,
total_cost: 0,
actual_cost: 0,
avg_response_time_ms: 0,
success_rate: 100,
},
])
await loadStats(dateRange)
expect(providerStats.value.map(item => item.provider)).toEqual(['OpenAI'])
expect(availableProviders.value).toEqual(['OpenAI'])
})
})

View File

@@ -32,6 +32,11 @@ export interface FilterParams {
status?: string
}
function isUsageProviderVisible(provider: string | undefined | null): provider is string {
const normalized = provider?.trim().toLowerCase()
return !!normalized && !['unknown', 'unknow', 'pending'].includes(normalized)
}
export function useUsageData(options: UseUsageDataOptions) {
const { isAdminPage } = options
@@ -157,7 +162,8 @@ export function useUsageData(options: UseUsageDataOptions) {
return true
}
providerStats.value = providerData.map(item => ({
const visibleProviderData = providerData.filter(item => isUsageProviderVisible(item.provider))
providerStats.value = visibleProviderData.map(item => ({
provider: item.provider,
requests: item.request_count,
totalTokens: item.total_tokens || 0,
@@ -175,7 +181,7 @@ export function useUsageData(options: UseUsageDataOptions) {
: '-'
}))
availableProviders.value = providerData.map(item => item.provider).filter(Boolean).sort()
availableProviders.value = visibleProviderData.map(item => item.provider).sort()
} catch (error) {
if (requestId !== loadStatsRequestId) {
return true
@@ -245,22 +251,24 @@ export function useUsageData(options: UseUsageDataOptions) {
actual_cost: item.actual_total_cost_usd
}))
providerStats.value = (userData.summary_by_provider || []).map((item) => ({
provider: item.provider,
requests: item.requests || 0,
totalTokens: item.total_tokens || 0,
effectiveInputTokens: item.effective_input_tokens || 0,
totalInputContext: item.total_input_context || 0,
outputTokens: item.output_tokens || 0,
cacheReadTokens: item.cache_read_tokens || 0,
cacheCreationTokens: item.cache_creation_tokens || 0,
cacheHitRate: item.cache_hit_rate || 0,
totalCost: item.total_cost_usd || 0,
successRate: item.success_rate || 0,
avgResponseTime: (item.avg_response_time_ms ?? 0) > 0
? `${((item.avg_response_time_ms ?? 0) / 1000).toFixed(2)}s`
: '-'
}))
providerStats.value = (userData.summary_by_provider || [])
.filter((item) => isUsageProviderVisible(item.provider))
.map((item) => ({
provider: item.provider,
requests: item.requests || 0,
totalTokens: item.total_tokens || 0,
effectiveInputTokens: item.effective_input_tokens || 0,
totalInputContext: item.total_input_context || 0,
outputTokens: item.output_tokens || 0,
cacheReadTokens: item.cache_read_tokens || 0,
cacheCreationTokens: item.cache_creation_tokens || 0,
cacheHitRate: item.cache_hit_rate || 0,
totalCost: item.total_cost_usd || 0,
successRate: item.success_rate || 0,
avgResponseTime: (item.avg_response_time_ms ?? 0) > 0
? `${((item.avg_response_time_ms ?? 0) / 1000).toFixed(2)}s`
: '-'
}))
// 用户页面:记录直接从 userData 获取(数量较少)
// 使用 mergeRecordStatus 保护已有的活跃状态,避免轮询更新被覆盖
@@ -273,7 +281,7 @@ export function useUsageData(options: UseUsageDataOptions) {
const providers = new Set<string>()
currentRecords.value.forEach(record => {
if (record.model) models.add(record.model)
if (record.provider) providers.add(record.provider)
if (isUsageProviderVisible(record.provider)) providers.add(record.provider)
})
availableModels.value = Array.from(models).sort()
availableProviders.value = Array.from(providers).sort()
@@ -427,9 +435,9 @@ export function useUsageData(options: UseUsageDataOptions) {
const mergedStatus = statusProgressed ? record.status : existing.status
const protectStatus = mergedStatus !== record.status
// 确定是否需要保护 provider避免 pending/unknown 覆盖已有的正确值)
const isPendingProvider = !record.provider || record.provider === 'pending' || record.provider === 'unknown'
const hasValidExistingProvider = existing.provider && existing.provider !== 'pending' && existing.provider !== 'unknown'
// 确定是否需要保护 provider避免 pending/unknown/unknow 覆盖已有的正确值)
const isPendingProvider = !isUsageProviderVisible(record.provider)
const hasValidExistingProvider = isUsageProviderVisible(existing.provider)
const protectProvider = isPendingProvider && hasValidExistingProvider
// 如果需要保护状态,说明本地数据比后端更新,应该保留本地的所有实时更新字段

View File

@@ -342,70 +342,88 @@
</section>
</div>
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
<section class="rounded-xl border border-border/70 bg-card/60 p-4">
<div class="grid grid-cols-1 items-stretch gap-4 xl:grid-cols-2">
<section class="flex h-full flex-col rounded-xl border border-border/70 bg-card/60 p-4">
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold">
最近错误
</h3>
<span class="text-xs text-muted-foreground">
{{ formatMetricNumber(resilienceStatus?.error_statistics.total_errors) }} / 24h
</span>
<div class="flex items-center gap-2">
<Button
v-if="hasMoreRecentErrors"
variant="ghost"
size="sm"
class="h-7 gap-1 px-2 text-xs"
:title="recentErrorsExpanded ? '收起最近错误' : '展开最近错误'"
@click="recentErrorsExpanded = !recentErrorsExpanded"
>
<component
:is="recentErrorsExpanded ? ChevronUp : ChevronDown"
class="h-3.5 w-3.5"
/>
{{ recentErrorsExpanded ? '收起' : `展开 ${recentErrors.length}` }}
</Button>
<span class="text-xs text-muted-foreground">
{{ formatMetricNumber(resilienceStatus?.error_statistics.total_errors) }} / 24h
</span>
</div>
</div>
<div
v-if="!recentErrors.length"
class="mt-4 rounded-lg border border-dashed border-border/70 px-3 py-4 text-sm text-muted-foreground"
class="mt-4 flex-1 rounded-lg border border-dashed border-border/70 px-3 py-4 text-sm text-muted-foreground"
>
当前没有最近错误
</div>
<div
v-else
class="mt-4 space-y-3"
class="mt-4 min-h-0 flex-1"
>
<article
v-for="item in recentErrors"
:key="item.error_id"
class="rounded-lg border border-border/60 bg-background/50 px-3 py-3"
>
<div class="flex items-start justify-between gap-4">
<div>
<div class="text-sm font-medium">
{{ item.error_type }}
</div>
<div class="mt-1 text-xs text-muted-foreground">
{{ item.operation }}
</div>
</div>
<span class="shrink-0 text-xs text-muted-foreground">
{{ formatDate(item.timestamp) }}
</span>
</div>
<div class="mt-2 flex flex-wrap gap-2">
<Badge variant="outline">
HTTP {{ item.context.status_code ?? '-' }}
</Badge>
<Badge variant="outline">
{{ item.context.provider_name || item.context.provider_id || '未知上游' }}
</Badge>
<Badge variant="outline">
{{ item.context.api_format || item.context.model || '未知格式' }}
</Badge>
</div>
<p
v-if="item.context.error_message"
class="mt-2 break-words text-xs text-muted-foreground"
<div :class="recentErrorsListClass">
<article
v-for="item in visibleRecentErrors"
:key="item.error_id"
class="rounded-lg border border-border/60 bg-background/50 px-3 py-3"
>
{{ item.context.error_message }}
</p>
</article>
<div class="flex items-start justify-between gap-4">
<div>
<div class="text-sm font-medium">
{{ item.error_type }}
</div>
<div class="mt-1 text-xs text-muted-foreground">
{{ item.operation }}
</div>
</div>
<span class="shrink-0 text-xs text-muted-foreground">
{{ formatDate(item.timestamp) }}
</span>
</div>
<div class="mt-2 flex flex-wrap gap-2">
<Badge variant="outline">
HTTP {{ item.context.status_code ?? '-' }}
</Badge>
<Badge variant="outline">
{{ item.context.provider_name || item.context.provider_id || '未知上游' }}
</Badge>
<Badge variant="outline">
{{ item.context.api_format || item.context.model || '未知格式' }}
</Badge>
</div>
<p
v-if="item.context.error_message"
class="mt-2 line-clamp-2 break-words text-xs text-muted-foreground"
>
{{ item.context.error_message }}
</p>
</article>
</div>
</div>
</section>
<section class="rounded-xl border border-border/70 bg-card/60 p-4">
<section class="flex h-full flex-col rounded-xl border border-border/70 bg-card/60 p-4">
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold">
熔断历史与建议
@@ -488,25 +506,6 @@
</div>
</Card>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card class="p-4">
<PercentileChart
title="响应延迟百分位"
:series="percentiles"
mode="response"
:loading="percentileLoading"
/>
</Card>
<Card class="p-4">
<PercentileChart
title="首字节延迟百分位"
:series="percentiles"
mode="ttfb"
:loading="percentileLoading"
/>
</Card>
</div>
<Card class="space-y-4 p-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
@@ -778,6 +777,25 @@
</Card>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card class="p-4">
<PercentileChart
title="响应延迟百分位"
:series="percentiles"
mode="response"
:loading="percentileLoading"
/>
</Card>
<Card class="p-4">
<PercentileChart
title="首字节延迟百分位"
:series="percentiles"
mode="ttfb"
:loading="percentileLoading"
/>
</Card>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card class="p-4">
<ErrorDistributionChart
@@ -815,6 +833,8 @@ import {
AlertTriangle,
Cable,
CheckCircle2,
ChevronDown,
ChevronUp,
FilterX,
GitBranch,
Gauge,
@@ -857,16 +877,18 @@ import { formatDate, formatNumber } from '@/utils/format'
import { log } from '@/utils/logger'
import {
buildProviderPerformanceChartData,
formatDurationMs,
formatProviderPerformanceMetric,
} from './performanceAnalysisHelpers'
const LIVE_REFRESH_INTERVAL_MS = 10_000
const RECENT_ERRORS_COLLAPSED_LIMIT = 3
const DEFAULT_PROVIDER_PERFORMANCE_SLOW_THRESHOLD_MS = 10_000
type ProviderPerformanceBooleanFilter = 'all' | 'true' | 'false'
type ProviderPerformanceParams = NonNullable<Parameters<typeof adminApi.getProviderPerformance>[0]>
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last30days'))
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last7days'))
const { error: showError } = useToast()
const percentiles = ref<PercentileItem[]>([])
@@ -895,6 +917,7 @@ const liveRefreshing = ref(false)
const liveReady = ref(false)
const liveLoadError = ref<string | null>(null)
const liveLastUpdatedAt = ref<string | null>(null)
const recentErrorsExpanded = ref(false)
let percentilesRequestId = 0
let errorsRequestId = 0
@@ -1203,6 +1226,16 @@ const errorTrendChartData = computed(() => ({
}))
const recentErrors = computed(() => resilienceStatus.value?.recent_errors ?? [])
const hasMoreRecentErrors = computed(() => recentErrors.value.length > RECENT_ERRORS_COLLAPSED_LIMIT)
const visibleRecentErrors = computed(() => (
recentErrorsExpanded.value
? recentErrors.value
: recentErrors.value.slice(0, RECENT_ERRORS_COLLAPSED_LIMIT)
))
const recentErrorsListClass = computed(() => [
'space-y-3',
recentErrorsExpanded.value ? 'max-h-[360px] overflow-y-auto pr-1' : '',
])
const resilienceRecommendations = computed(() => resilienceStatus.value?.recommendations ?? [])
const healthStatusVariant = computed<'success' | 'warning' | 'destructive' | 'outline'>(() => {
@@ -1396,7 +1429,7 @@ const providerLatencyChartOptions = computed(() => ({
scales: {
y: {
ticks: {
callback: (value: string | number) => `${value}ms`,
callback: (value: string | number) => formatDurationMs(Number(value), 0),
},
},
},

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
buildProviderPerformanceChartData,
formatDurationMs,
formatProviderPerformanceMetric,
} from '../performanceAnalysisHelpers'
import type { ProviderPerformanceItem, ProviderPerformanceTimelineItem } from '@/api/admin'
@@ -13,6 +14,14 @@ describe('performanceAnalysisHelpers', () => {
expect(formatProviderPerformanceMetric(18.456, '/s')).toBe('18.46/s')
})
it('formats millisecond metrics as seconds above one second', () => {
expect(formatDurationMs(999, 0)).toBe('999ms')
expect(formatDurationMs(1000, 0)).toBe('1.00s')
expect(formatDurationMs(80148, 0)).toBe('80.15s')
expect(formatProviderPerformanceMetric(80148, 'ms', 0)).toBe('80.15s')
expect(formatProviderPerformanceMetric(99.456, 'ms')).toBe('99.46ms')
})
it('builds stable provider trend datasets with null gaps', () => {
const providers: Pick<ProviderPerformanceItem, 'provider_id' | 'provider'>[] = [
{ provider_id: 'provider-a', provider: 'OpenAI' },

View File

@@ -25,9 +25,26 @@ export function formatProviderPerformanceMetric(
if (value == null || Number.isNaN(value)) {
return '-'
}
if (suffix === 'ms') {
return formatDurationMs(value, decimals)
}
return `${value.toFixed(decimals)}${suffix}`
}
export function formatDurationMs(
value: number | null | undefined,
msDecimals = 0,
secondsDecimals = 2
): string {
if (value == null || Number.isNaN(value)) {
return '-'
}
if (Math.abs(value) >= 1000) {
return `${(value / 1000).toFixed(secondsDecimals)}s`
}
return `${value.toFixed(msDecimals)}ms`
}
export function buildProviderPerformanceChartData(
timeline: ProviderPerformanceTimelineItem[],
metric: ProviderPerformanceMetricKey,

View File

@@ -495,10 +495,12 @@ async function pollActiveRequests() {
record.target_model = update.target_model
}
// 管理员接口返回额外字段
// 只有当返回的 provider 不是 pending/unknown 时才更新,避免覆盖已有的正确值
if ('provider' in update && typeof update.provider === 'string' &&
update.provider !== 'pending' && update.provider !== 'unknown') {
record.provider = update.provider
// 只有当返回的 provider 不是 pending/unknown/unknow 时才更新,避免覆盖已有的正确值
if ('provider' in update && typeof update.provider === 'string') {
const updateProviderLabel = update.provider.trim().toLowerCase()
if (updateProviderLabel && !['pending', 'unknown', 'unknow'].includes(updateProviderLabel)) {
record.provider = update.provider
}
}
if ('api_key_name' in update) {
record.api_key_name = typeof update.api_key_name === 'string' ? update.api_key_name : undefined