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

@@ -60,9 +60,15 @@ fn admin_usage_aggregation_by_provider_json(
} else { } else {
round_to(success_count as f64 / row.request_count as f64 * 100.0, 2) round_to(success_count as f64 / row.request_count as f64 * 100.0, 2)
}; };
let provider_name = row
.display_name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(row.group_key.as_str());
json!({ json!({
"provider_id": row.group_key, "provider_id": row.group_key,
"provider": row.display_name.clone().unwrap_or_else(|| "Unknown".to_string()), "provider": provider_name,
"request_count": row.request_count, "request_count": row.request_count,
"total_tokens": row.total_tokens, "total_tokens": row.total_tokens,
"effective_input_tokens": row.effective_input_tokens, "effective_input_tokens": row.effective_input_tokens,

View File

@@ -589,15 +589,20 @@ fn usage_matches_performance_percentiles_query(
&& item.status == "completed" && item.status == "completed"
} }
fn usage_reserved_provider_label(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"unknown" | "unknow" | "pending"
)
}
fn usage_provider_performance_identity(item: &StoredRequestUsageAudit) -> Option<(String, String)> { fn usage_provider_performance_identity(item: &StoredRequestUsageAudit) -> Option<(String, String)> {
let provider_id = item.provider_id.as_deref()?.trim(); let provider_id = item.provider_id.as_deref()?.trim();
let provider_id_status = provider_id.to_ascii_lowercase(); if provider_id.is_empty() || usage_reserved_provider_label(provider_id) {
if provider_id.is_empty() || matches!(provider_id_status.as_str(), "unknown" | "pending") {
return None; return None;
} }
let provider_name = item.provider_name.trim(); let provider_name = item.provider_name.trim();
let provider_name_status = provider_name.to_ascii_lowercase(); if usage_reserved_provider_label(provider_name) {
if matches!(provider_name_status.as_str(), "unknown" | "pending") {
return None; return None;
} }
let display_name = if provider_name.is_empty() { let display_name = if provider_name.is_empty() {
@@ -960,10 +965,10 @@ fn usage_output_tps_duration_ms(item: &StoredRequestUsageAudit) -> Option<u64> {
fn usage_provider_display_name(item: &StoredRequestUsageAudit) -> Option<String> { fn usage_provider_display_name(item: &StoredRequestUsageAudit) -> Option<String> {
let provider_name = item.provider_name.trim(); let provider_name = item.provider_name.trim();
if provider_name.is_empty() || matches!(provider_name, "unknown" | "pending") { if provider_name.is_empty() || usage_reserved_provider_label(provider_name) {
None None
} else { } else {
Some(item.provider_name.clone()) Some(provider_name.to_string())
} }
} }
@@ -1154,12 +1159,31 @@ impl UsageReadRepository for InMemoryUsageReadRepository {
continue; continue;
} }
let provider_display_name =
if matches!(query.group_by, UsageAuditAggregationGroupBy::Provider) {
match usage_provider_display_name(item) {
Some(value) => Some(value),
None => continue,
}
} else {
None
};
let group_key = match query.group_by { let group_key = match query.group_by {
UsageAuditAggregationGroupBy::Model => item.model.clone(), UsageAuditAggregationGroupBy::Model => item.model.clone(),
UsageAuditAggregationGroupBy::Provider => item UsageAuditAggregationGroupBy::Provider => {
.provider_id let display_name = provider_display_name
.clone() .as_deref()
.unwrap_or_else(|| "unknown".to_string()), .expect("provider display name is set for provider aggregation");
item.provider_id
.as_deref()
.map(str::trim)
.filter(|provider_id| {
!provider_id.is_empty() && !usage_reserved_provider_label(provider_id)
})
.unwrap_or(display_name)
.to_string()
}
UsageAuditAggregationGroupBy::ApiFormat => item UsageAuditAggregationGroupBy::ApiFormat => item
.api_format .api_format
.clone() .clone()
@@ -1174,8 +1198,7 @@ impl UsageReadRepository for InMemoryUsageReadRepository {
&& (bucket.display_name.is_none() && (bucket.display_name.is_none()
|| bucket.display_name.as_deref() == Some("Unknown")) || bucket.display_name.as_deref() == Some("Unknown"))
{ {
bucket.display_name = bucket.display_name = provider_display_name;
usage_provider_display_name(item).or(Some("Unknown".to_string()));
} }
bucket.request_count = bucket.request_count.saturating_add(1); bucket.request_count = bucket.request_count.saturating_add(1);
bucket.total_tokens = bucket.total_tokens.saturating_add(item.total_tokens); bucket.total_tokens = bucket.total_tokens.saturating_add(item.total_tokens);
@@ -2909,7 +2932,8 @@ mod tests {
UsageWriteRepository, UsageWriteRepository,
}; };
use aether_data_contracts::repository::usage::{ use aether_data_contracts::repository::usage::{
usage_body_ref, UsageBodyField, UsageProviderPerformanceQuery, UsageTimeSeriesGranularity, usage_body_ref, UsageAuditAggregationGroupBy, UsageAuditAggregationQuery, UsageBodyField,
UsageProviderPerformanceQuery, UsageTimeSeriesGranularity,
}; };
use serde_json::json; use serde_json::json;
@@ -3044,6 +3068,37 @@ mod tests {
assert_eq!(usage.total_tokens, 150); assert_eq!(usage.total_tokens, 150);
} }
#[tokio::test]
async fn provider_aggregation_skips_unknown_provider_labels() {
let mut unknown = sample_usage("req-unknown-provider", 100);
unknown.provider_id = None;
unknown.provider_name = "unknown".to_string();
let mut typo_unknown = sample_usage("req-unknow-provider", 200);
typo_unknown.provider_id = Some("unknow".to_string());
typo_unknown.provider_name = "unknow".to_string();
let repository = InMemoryUsageReadRepository::seed(vec![
sample_usage("req-valid-provider", 300),
unknown,
typo_unknown,
]);
let rows = repository
.aggregate_usage_audits(&UsageAuditAggregationQuery {
created_from_unix_secs: 0,
created_until_unix_secs: 1_000,
group_by: UsageAuditAggregationGroupBy::Provider,
limit: 10,
})
.await
.expect("aggregation should succeed");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].group_key, "provider-1");
assert_eq!(rows[0].display_name.as_deref(), Some("OpenAI"));
}
#[tokio::test] #[tokio::test]
async fn stale_pending_update_does_not_regress_finalized_usage() { async fn stale_pending_update_does_not_regress_finalized_usage() {
let repository = InMemoryUsageReadRepository::default(); let repository = InMemoryUsageReadRepository::default();

View File

@@ -1363,12 +1363,11 @@ fn usage_audit_aggregation_sql_fragments(
success_count_expr: "NULL::BIGINT", success_count_expr: "NULL::BIGINT",
}, },
UsageAuditAggregationGroupBy::Provider => UsageAuditAggregationSqlFragments { UsageAuditAggregationGroupBy::Provider => UsageAuditAggregationSqlFragments {
filtered_extra_where: "", filtered_extra_where: " AND BTRIM(COALESCE(\"usage\".provider_name, '')) <> '' AND lower(BTRIM(COALESCE(\"usage\".provider_name, ''))) NOT IN ('unknown', 'unknow', 'pending')",
group_key_expr: "provider_group_key", group_key_expr: "provider_group_key",
display_name_expr: "provider_display_name", display_name_expr: "provider_display_name",
secondary_name_expr: "NULL::varchar", secondary_name_expr: "NULL::varchar",
aggregate_display_name_expr: aggregate_display_name_expr: "MAX(display_name)",
"COALESCE(MAX(NULLIF(display_name, 'Unknown')), 'Unknown')",
aggregate_secondary_name_expr: "NULL::varchar", aggregate_secondary_name_expr: "NULL::varchar",
avg_response_time_expr: "AVG(response_time_ms::DOUBLE PRECISION)", avg_response_time_expr: "AVG(response_time_ms::DOUBLE PRECISION)",
success_count_expr: "COALESCE(SUM(success_flag), 0)::BIGINT", success_count_expr: "COALESCE(SUM(success_flag), 0)::BIGINT",
@@ -6474,12 +6473,17 @@ WITH filtered_usage AS (
SELECT SELECT
"usage".model AS model, "usage".model AS model,
"usage".user_id AS user_id, "usage".user_id AS user_id,
COALESCE("usage".provider_id, 'unknown') AS provider_group_key, CASE
WHEN BTRIM(COALESCE("usage".provider_id, '')) = ''
OR lower(BTRIM(COALESCE("usage".provider_id, ''))) IN ('unknown', 'unknow', 'pending')
THEN BTRIM("usage".provider_name)
ELSE BTRIM("usage".provider_id)
END AS provider_group_key,
CASE CASE
WHEN BTRIM(COALESCE("usage".provider_name, '')) = '' WHEN BTRIM(COALESCE("usage".provider_name, '')) = ''
OR "usage".provider_name IN ('unknown', 'pending') OR lower(BTRIM(COALESCE("usage".provider_name, ''))) IN ('unknown', 'unknow', 'pending')
THEN 'Unknown' THEN NULL
ELSE "usage".provider_name ELSE BTRIM("usage".provider_name)
END AS provider_display_name, END AS provider_display_name,
COALESCE("usage".api_format, 'unknown') AS api_format_group_key, COALESCE("usage".api_format, 'unknown') AS api_format_group_key,
GREATEST(COALESCE("usage".input_tokens, 0), 0) AS input_tokens, GREATEST(COALESCE("usage".input_tokens, 0), 0) AS input_tokens,

View File

@@ -414,6 +414,16 @@ fn usage_sql_aggregate_usage_audits_supports_daily_model_and_provider_aggregates
); );
} }
#[test]
fn usage_sql_provider_aggregation_excludes_unknown_provider_labels() {
let source = include_str!("mod.rs");
assert!(source.contains(
r#"lower(BTRIM(COALESCE(\"usage\".provider_name, ''))) NOT IN ('unknown', 'unknow', 'pending')"#
));
assert!(source.contains("MAX(display_name)"));
assert!(!source.contains("COALESCE(MAX(NULLIF(display_name, 'Unknown')), 'Unknown')"));
}
#[test] #[test]
fn usage_sql_summarize_total_tokens_by_api_key_ids_supports_daily_aggregates() { fn usage_sql_summarize_total_tokens_by_api_key_ids_supports_daily_aggregates() {
let source = include_str!("mod.rs"); let source = include_str!("mod.rs");

View File

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

View File

@@ -342,70 +342,88 @@
</section> </section>
</div> </div>
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2"> <div class="grid grid-cols-1 items-stretch gap-4 xl:grid-cols-2">
<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"> <div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold"> <h3 class="text-sm font-semibold">
最近错误 最近错误
</h3> </h3>
<span class="text-xs text-muted-foreground"> <div class="flex items-center gap-2">
{{ formatMetricNumber(resilienceStatus?.error_statistics.total_errors) }} / 24h <Button
</span> 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>
<div <div
v-if="!recentErrors.length" 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>
<div <div
v-else v-else
class="mt-4 space-y-3" class="mt-4 min-h-0 flex-1"
> >
<article <div :class="recentErrorsListClass">
v-for="item in recentErrors" <article
:key="item.error_id" v-for="item in visibleRecentErrors"
class="rounded-lg border border-border/60 bg-background/50 px-3 py-3" :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"
> >
{{ item.context.error_message }} <div class="flex items-start justify-between gap-4">
</p> <div>
</article> <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> </div>
</section> </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"> <div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold"> <h3 class="text-sm font-semibold">
熔断历史与建议 熔断历史与建议
@@ -488,25 +506,6 @@
</div> </div>
</Card> </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"> <Card class="space-y-4 p-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
@@ -778,6 +777,25 @@
</Card> </Card>
</div> </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"> <div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card class="p-4"> <Card class="p-4">
<ErrorDistributionChart <ErrorDistributionChart
@@ -815,6 +833,8 @@ import {
AlertTriangle, AlertTriangle,
Cable, Cable,
CheckCircle2, CheckCircle2,
ChevronDown,
ChevronUp,
FilterX, FilterX,
GitBranch, GitBranch,
Gauge, Gauge,
@@ -857,16 +877,18 @@ import { formatDate, formatNumber } from '@/utils/format'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
import { import {
buildProviderPerformanceChartData, buildProviderPerformanceChartData,
formatDurationMs,
formatProviderPerformanceMetric, formatProviderPerformanceMetric,
} from './performanceAnalysisHelpers' } from './performanceAnalysisHelpers'
const LIVE_REFRESH_INTERVAL_MS = 10_000 const LIVE_REFRESH_INTERVAL_MS = 10_000
const RECENT_ERRORS_COLLAPSED_LIMIT = 3
const DEFAULT_PROVIDER_PERFORMANCE_SLOW_THRESHOLD_MS = 10_000 const DEFAULT_PROVIDER_PERFORMANCE_SLOW_THRESHOLD_MS = 10_000
type ProviderPerformanceBooleanFilter = 'all' | 'true' | 'false' type ProviderPerformanceBooleanFilter = 'all' | 'true' | 'false'
type ProviderPerformanceParams = NonNullable<Parameters<typeof adminApi.getProviderPerformance>[0]> 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 { error: showError } = useToast()
const percentiles = ref<PercentileItem[]>([]) const percentiles = ref<PercentileItem[]>([])
@@ -895,6 +917,7 @@ const liveRefreshing = ref(false)
const liveReady = ref(false) const liveReady = ref(false)
const liveLoadError = ref<string | null>(null) const liveLoadError = ref<string | null>(null)
const liveLastUpdatedAt = ref<string | null>(null) const liveLastUpdatedAt = ref<string | null>(null)
const recentErrorsExpanded = ref(false)
let percentilesRequestId = 0 let percentilesRequestId = 0
let errorsRequestId = 0 let errorsRequestId = 0
@@ -1203,6 +1226,16 @@ const errorTrendChartData = computed(() => ({
})) }))
const recentErrors = computed(() => resilienceStatus.value?.recent_errors ?? []) 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 resilienceRecommendations = computed(() => resilienceStatus.value?.recommendations ?? [])
const healthStatusVariant = computed<'success' | 'warning' | 'destructive' | 'outline'>(() => { const healthStatusVariant = computed<'success' | 'warning' | 'destructive' | 'outline'>(() => {
@@ -1396,7 +1429,7 @@ const providerLatencyChartOptions = computed(() => ({
scales: { scales: {
y: { y: {
ticks: { 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 { describe, expect, it } from 'vitest'
import { import {
buildProviderPerformanceChartData, buildProviderPerformanceChartData,
formatDurationMs,
formatProviderPerformanceMetric, formatProviderPerformanceMetric,
} from '../performanceAnalysisHelpers' } from '../performanceAnalysisHelpers'
import type { ProviderPerformanceItem, ProviderPerformanceTimelineItem } from '@/api/admin' import type { ProviderPerformanceItem, ProviderPerformanceTimelineItem } from '@/api/admin'
@@ -13,6 +14,14 @@ describe('performanceAnalysisHelpers', () => {
expect(formatProviderPerformanceMetric(18.456, '/s')).toBe('18.46/s') 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', () => { it('builds stable provider trend datasets with null gaps', () => {
const providers: Pick<ProviderPerformanceItem, 'provider_id' | 'provider'>[] = [ const providers: Pick<ProviderPerformanceItem, 'provider_id' | 'provider'>[] = [
{ provider_id: 'provider-a', provider: 'OpenAI' }, { provider_id: 'provider-a', provider: 'OpenAI' },

View File

@@ -25,9 +25,26 @@ export function formatProviderPerformanceMetric(
if (value == null || Number.isNaN(value)) { if (value == null || Number.isNaN(value)) {
return '-' return '-'
} }
if (suffix === 'ms') {
return formatDurationMs(value, decimals)
}
return `${value.toFixed(decimals)}${suffix}` 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( export function buildProviderPerformanceChartData(
timeline: ProviderPerformanceTimelineItem[], timeline: ProviderPerformanceTimelineItem[],
metric: ProviderPerformanceMetricKey, metric: ProviderPerformanceMetricKey,

View File

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