mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Fix usage provider stats and performance analysis UI
This commit is contained in:
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
// 如果需要保护状态,说明本地数据比后端更新,应该保留本地的所有实时更新字段
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user