mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
Fix usage provider stats and performance analysis UI
This commit is contained in:
@@ -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