feat(stats): 添加 Provider 性能统计分析

This commit is contained in:
Entropy.Xu
2026-05-05 11:24:16 +08:00
parent 627eb4f335
commit ff1ed8b57d
17 changed files with 1683 additions and 22 deletions

View File

@@ -447,6 +447,49 @@ export interface PercentileItem {
p99_first_byte_time_ms?: number | null
}
export interface ProviderPerformanceSummary {
request_count: number
success_rate: number
avg_output_tps: number | null
avg_first_byte_time_ms: number | null
avg_response_time_ms: number | null
}
export interface ProviderPerformanceItem {
provider_id: string
provider: string
request_count: number
success_count: number
error_count: number
success_rate: number
output_tokens: number
avg_output_tps: number | null
avg_first_byte_time_ms: number | null
avg_response_time_ms: number | null
p90_response_time_ms: number | null
p90_first_byte_time_ms: number | null
tps_sample_count: number
first_byte_sample_count: number
}
export interface ProviderPerformanceTimelineItem {
date: string
provider_id: string
provider: string
request_count: number
output_tokens: number
avg_output_tps: number | null
avg_first_byte_time_ms: number | null
avg_response_time_ms: number | null
success_rate: number
}
export interface ProviderPerformanceResponse {
summary: ProviderPerformanceSummary
providers: ProviderPerformanceItem[]
timeline: ProviderPerformanceTimelineItem[]
}
export interface ErrorDistributionItem {
category: string
count: number
@@ -932,6 +975,28 @@ export const adminApi = {
)
},
async getProviderPerformance(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
granularity?: 'day' | 'hour'
limit?: number
}): Promise<ProviderPerformanceResponse> {
const cacheKey = buildCacheKey('admin:stats:performance:providers', params)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<ProviderPerformanceResponse>('/api/admin/stats/performance/providers', {
params
})
return response.data
},
20 * 1000
)
},
async getErrorDistribution(params?: {
start_date?: string
end_date?: string

View File

@@ -491,6 +491,183 @@
</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>
<h3 class="text-sm font-semibold">
Provider 性能
</h3>
<p class="text-xs text-muted-foreground">
{{ providerPerformanceSubtitle }}
</p>
</div>
<Badge variant="outline">
Top {{ providerPerformanceRows.length || 0 }}
</Badge>
</div>
<div
v-if="providerPerformanceLoading"
class="p-6"
>
<LoadingState />
</div>
<div
v-else
class="space-y-4"
>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div
v-for="card in providerPerformanceSummaryCards"
:key="card.title"
class="rounded-xl border border-border/70 bg-card/70 px-4 py-3"
>
<div class="flex items-center justify-between gap-3">
<span class="text-xs text-muted-foreground">{{ card.title }}</span>
<component
:is="card.icon"
class="h-4 w-4"
:class="card.iconClass"
/>
</div>
<div class="mt-3 text-2xl font-semibold tracking-tight">
{{ card.value }}
</div>
<div class="mt-2 text-xs text-muted-foreground">
{{ card.hint }}
</div>
</div>
</div>
<div
v-if="providerPerformanceRows.length"
class="overflow-x-auto rounded-lg border border-border/70"
>
<table class="min-w-full divide-y divide-border/70 text-sm">
<thead class="bg-muted/30 text-xs text-muted-foreground">
<tr>
<th class="px-3 py-2 text-left font-medium">
Provider
</th>
<th class="px-3 py-2 text-right font-medium">
请求
</th>
<th class="px-3 py-2 text-right font-medium">
成功率
</th>
<th class="px-3 py-2 text-right font-medium">
输出 TPS
</th>
<th class="px-3 py-2 text-right font-medium">
平均首字
</th>
<th class="px-3 py-2 text-right font-medium">
平均响应
</th>
<th class="px-3 py-2 text-right font-medium">
P90 响应 / 首字
</th>
<th class="px-3 py-2 text-right font-medium">
样本
</th>
</tr>
</thead>
<tbody class="divide-y divide-border/60">
<tr
v-for="provider in providerPerformanceRows"
:key="provider.provider_id"
class="bg-background/40"
>
<td class="max-w-[220px] px-3 py-2">
<div class="truncate font-medium">
{{ provider.provider }}
</div>
<div class="truncate text-xs text-muted-foreground">
{{ provider.provider_id }}
</div>
</td>
<td class="px-3 py-2 text-right">
{{ formatMetricNumber(provider.request_count) }}
</td>
<td class="px-3 py-2 text-right">
{{ formatProviderPerformanceMetric(provider.success_rate, '%') }}
</td>
<td class="px-3 py-2 text-right">
{{ formatProviderPerformanceMetric(provider.avg_output_tps, '/s') }}
</td>
<td class="px-3 py-2 text-right">
{{ formatProviderPerformanceMetric(provider.avg_first_byte_time_ms, 'ms') }}
</td>
<td class="px-3 py-2 text-right">
{{ formatProviderPerformanceMetric(provider.avg_response_time_ms, 'ms') }}
</td>
<td class="px-3 py-2 text-right">
{{ formatProviderPerformanceMetric(provider.p90_response_time_ms, 'ms', 0) }}
/
{{ formatProviderPerformanceMetric(provider.p90_first_byte_time_ms, 'ms', 0) }}
</td>
<td class="px-3 py-2 text-right text-xs text-muted-foreground">
{{ formatMetricNumber(provider.tps_sample_count) }} /
{{ formatMetricNumber(provider.first_byte_sample_count) }}
</td>
</tr>
</tbody>
</table>
</div>
<div
v-else
class="rounded-lg border border-dashed border-border/70 px-3 py-4 text-sm text-muted-foreground"
>
当前没有 Provider 性能数据
</div>
</div>
</Card>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card class="space-y-3 p-4">
<h3 class="text-sm font-semibold">
输出 TPS 趋势
</h3>
<div
v-if="providerPerformanceLoading"
class="p-6"
>
<LoadingState />
</div>
<div
v-else
class="h-[260px]"
>
<LineChart
:data="providerTpsChartData"
:options="providerTpsChartOptions"
/>
</div>
</Card>
<Card class="space-y-3 p-4">
<h3 class="text-sm font-semibold">
平均首字趋势
</h3>
<div
v-if="providerPerformanceLoading"
class="p-6"
>
<LoadingState />
</div>
<div
v-else
class="h-[260px]"
>
<LineChart
:data="providerFirstByteChartData"
:options="providerLatencyChartOptions"
/>
</div>
</Card>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card class="p-4">
<ErrorDistributionChart
@@ -562,11 +739,20 @@ import {
Activity,
AlertTriangle,
Cable,
CheckCircle2,
GitBranch,
Gauge,
ShieldCheck,
Timer,
Workflow,
Zap,
} from 'lucide-vue-next'
import { adminApi, type ErrorDistributionResponse, type PercentileItem } from '@/api/admin'
import {
adminApi,
type ErrorDistributionResponse,
type PercentileItem,
type ProviderPerformanceResponse,
} from '@/api/admin'
import { dashboardApi, type ProviderStatus } from '@/api/dashboard'
import {
monitoringApi,
@@ -586,6 +772,10 @@ import { getDateRangeFromPeriod } from '@/features/usage/composables'
import type { DateRangeParams } from '@/features/usage/types'
import { formatDate, formatNumber, formatTokens } from '@/utils/format'
import { log } from '@/utils/logger'
import {
buildProviderPerformanceChartData,
formatProviderPerformanceMetric,
} from './performanceAnalysisHelpers'
const LIVE_REFRESH_INTERVAL_MS = 10_000
@@ -601,6 +791,8 @@ const errorLoading = ref(false)
const providerStatus = ref<ProviderStatus[]>([])
const providerLoading = ref(false)
const providerPerformance = ref<ProviderPerformanceResponse | null>(null)
const providerPerformanceLoading = ref(false)
const systemStatus = ref<AdminMonitoringSystemStatus | null>(null)
const resilienceStatus = ref<AdminMonitoringResilienceStatus | null>(null)
@@ -615,6 +807,7 @@ const liveLastUpdatedAt = ref<string | null>(null)
let percentilesRequestId = 0
let errorsRequestId = 0
let providersRequestId = 0
let providerPerformanceRequestId = 0
let liveRequestId = 0
let loadAllPromise: Promise<void> | null = null
let hasPendingLoadAll = false
@@ -699,6 +892,28 @@ async function loadProviders() {
}
}
async function loadProviderPerformance() {
const requestId = ++providerPerformanceRequestId
providerPerformanceLoading.value = true
try {
const data = await adminApi.getProviderPerformance({
...buildTimeRangeParams(),
granularity: 'day',
limit: 8,
})
if (requestId !== providerPerformanceRequestId) return
providerPerformance.value = data
} catch (error) {
if (requestId !== providerPerformanceRequestId) return
providerPerformance.value = null
log.error('加载 Provider 性能统计失败', error)
} finally {
if (requestId === providerPerformanceRequestId) {
providerPerformanceLoading.value = false
}
}
}
async function loadLiveData(options: { silent?: boolean } = {}) {
const requestId = ++liveRequestId
const initialLoad = !liveReady.value
@@ -866,6 +1081,83 @@ const fallbackRows = computed(() => {
}))
})
const providerPerformanceRows = computed(() => providerPerformance.value?.providers ?? [])
const providerPerformanceSubtitle = computed(() => {
const requests = providerPerformance.value?.summary.request_count ?? 0
return `完成窗口内 ${formatMetricNumber(requests)} 个 Provider 请求样本`
})
const providerPerformanceSummaryCards = computed(() => {
const summary = providerPerformance.value?.summary
return [
{
title: '输出 TPS',
value: formatProviderPerformanceMetric(summary?.avg_output_tps, '/s'),
hint: `请求 ${formatMetricNumber(summary?.request_count)}`,
icon: Zap,
iconClass: 'text-amber-500',
},
{
title: '平均首字',
value: formatProviderPerformanceMetric(summary?.avg_first_byte_time_ms, 'ms'),
hint: '成功请求首字样本',
icon: Timer,
iconClass: 'text-sky-500',
},
{
title: '平均响应',
value: formatProviderPerformanceMetric(summary?.avg_response_time_ms, 'ms'),
hint: '成功请求响应耗时',
icon: Gauge,
iconClass: 'text-violet-500',
},
{
title: '成功率',
value: formatProviderPerformanceMetric(summary?.success_rate, '%'),
hint: `${formatMetricNumber(providerPerformanceRows.value.length)} 个 Provider`,
icon: CheckCircle2,
iconClass: 'text-emerald-500',
},
]
})
const providerTpsChartData = computed(() => (
buildProviderPerformanceChartData(
providerPerformance.value?.timeline ?? [],
'avg_output_tps',
providerPerformanceRows.value,
)
))
const providerFirstByteChartData = computed(() => (
buildProviderPerformanceChartData(
providerPerformance.value?.timeline ?? [],
'avg_first_byte_time_ms',
providerPerformanceRows.value,
)
))
const providerTpsChartOptions = computed(() => ({
scales: {
y: {
ticks: {
callback: (value: string | number) => `${value}/s`,
},
},
},
}))
const providerLatencyChartOptions = computed(() => ({
scales: {
y: {
ticks: {
callback: (value: string | number) => `${value}ms`,
},
},
},
}))
const liveSummaryCards = computed(() => [
{
title: '系统健康',
@@ -920,7 +1212,8 @@ const isRefreshing = computed(() => (
liveRefreshing.value ||
percentileLoading.value ||
errorLoading.value ||
providerLoading.value
providerLoading.value ||
providerPerformanceLoading.value
))
async function loadAll() {
@@ -929,7 +1222,12 @@ async function loadAll() {
return loadAllPromise
}
loadAllPromise = Promise.all([loadPercentiles(), loadErrors(), loadProviders()])
loadAllPromise = Promise.all([
loadPercentiles(),
loadErrors(),
loadProviders(),
loadProviderPerformance(),
])
.then(() => undefined)
.finally(() => {
loadAllPromise = null
@@ -983,6 +1281,7 @@ onUnmounted(() => {
percentilesRequestId += 1
errorsRequestId += 1
providersRequestId += 1
providerPerformanceRequestId += 1
liveRequestId += 1
})
</script>

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import {
buildProviderPerformanceChartData,
formatProviderPerformanceMetric,
} from '../performanceAnalysisHelpers'
import type { ProviderPerformanceItem, ProviderPerformanceTimelineItem } from '@/api/admin'
describe('performanceAnalysisHelpers', () => {
it('formats null provider metrics as placeholders', () => {
expect(formatProviderPerformanceMetric(null, 'ms')).toBe('-')
expect(formatProviderPerformanceMetric(undefined, '/s')).toBe('-')
expect(formatProviderPerformanceMetric(Number.NaN)).toBe('-')
expect(formatProviderPerformanceMetric(18.456, '/s')).toBe('18.46/s')
})
it('builds stable provider trend datasets with null gaps', () => {
const providers: Pick<ProviderPerformanceItem, 'provider_id' | 'provider'>[] = [
{ provider_id: 'provider-a', provider: 'OpenAI' },
{ provider_id: 'provider-b', provider: 'Anthropic' },
]
const timeline: ProviderPerformanceTimelineItem[] = [
{
date: '2024-03-21',
provider_id: 'provider-a',
provider: 'OpenAI',
request_count: 2,
output_tokens: 100,
avg_output_tps: 25,
avg_first_byte_time_ms: 120,
avg_response_time_ms: 1000,
success_rate: 100,
},
{
date: '2024-03-22',
provider_id: 'provider-b',
provider: 'Anthropic',
request_count: 1,
output_tokens: 40,
avg_output_tps: null,
avg_first_byte_time_ms: 220,
avg_response_time_ms: 1500,
success_rate: 100,
},
]
const chart = buildProviderPerformanceChartData(timeline, 'avg_output_tps', providers)
expect(chart.labels).toEqual(['2024-03-21', '2024-03-22'])
expect(chart.datasets.map(dataset => dataset.label)).toEqual(['OpenAI', 'Anthropic'])
expect(chart.datasets[0].data).toEqual([25, null])
expect(chart.datasets[1].data).toEqual([null, null])
})
})

View File

@@ -0,0 +1,64 @@
import type { ChartData } from 'chart.js'
import type {
ProviderPerformanceItem,
ProviderPerformanceTimelineItem,
} from '@/api/admin'
export type ProviderPerformanceMetricKey = 'avg_output_tps' | 'avg_first_byte_time_ms'
const PROVIDER_CHART_COLORS = [
'rgb(59, 130, 246)',
'rgb(16, 185, 129)',
'rgb(234, 179, 8)',
'rgb(239, 68, 68)',
'rgb(139, 92, 246)',
'rgb(14, 165, 233)',
'rgb(249, 115, 22)',
'rgb(20, 184, 166)',
]
export function formatProviderPerformanceMetric(
value: number | null | undefined,
suffix = '',
decimals = 2
): string {
if (value == null || Number.isNaN(value)) {
return '-'
}
return `${value.toFixed(decimals)}${suffix}`
}
export function buildProviderPerformanceChartData(
timeline: ProviderPerformanceTimelineItem[],
metric: ProviderPerformanceMetricKey,
providers: Pick<ProviderPerformanceItem, 'provider_id' | 'provider'>[] = []
): ChartData<'line'> {
const labels = Array.from(new Set(timeline.map(item => item.date)))
const providerMap = new Map<string, string>()
for (const provider of providers) {
providerMap.set(provider.provider_id, provider.provider)
}
for (const item of timeline) {
if (!providerMap.has(item.provider_id)) {
providerMap.set(item.provider_id, item.provider)
}
}
return {
labels,
datasets: Array.from(providerMap.entries()).map(([providerId, provider], index) => {
const byDate = new Map(
timeline
.filter(item => item.provider_id === providerId)
.map(item => [item.date, item[metric]] as const)
)
return {
label: provider,
data: labels.map(label => byDate.get(label) ?? null),
borderColor: PROVIDER_CHART_COLORS[index % PROVIDER_CHART_COLORS.length],
tension: 0.25,
pointRadius: 2,
}
}),
}
}