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:
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,7 +251,9 @@ 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 || [])
|
||||||
|
.filter((item) => isUsageProviderVisible(item.provider))
|
||||||
|
.map((item) => ({
|
||||||
provider: item.provider,
|
provider: item.provider,
|
||||||
requests: item.requests || 0,
|
requests: item.requests || 0,
|
||||||
totalTokens: item.total_tokens || 0,
|
totalTokens: item.total_tokens || 0,
|
||||||
@@ -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
|
||||||
|
|
||||||
// 如果需要保护状态,说明本地数据比后端更新,应该保留本地的所有实时更新字段
|
// 如果需要保护状态,说明本地数据比后端更新,应该保留本地的所有实时更新字段
|
||||||
|
|||||||
@@ -342,30 +342,47 @@
|
|||||||
</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>
|
||||||
|
<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">
|
<span class="text-xs text-muted-foreground">
|
||||||
{{ formatMetricNumber(resilienceStatus?.error_statistics.total_errors) }} / 24h
|
{{ formatMetricNumber(resilienceStatus?.error_statistics.total_errors) }} / 24h
|
||||||
</span>
|
</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"
|
||||||
>
|
>
|
||||||
|
<div :class="recentErrorsListClass">
|
||||||
<article
|
<article
|
||||||
v-for="item in recentErrors"
|
v-for="item in visibleRecentErrors"
|
||||||
:key="item.error_id"
|
:key="item.error_id"
|
||||||
class="rounded-lg border border-border/60 bg-background/50 px-3 py-3"
|
class="rounded-lg border border-border/60 bg-background/50 px-3 py-3"
|
||||||
>
|
>
|
||||||
@@ -397,15 +414,16 @@
|
|||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="item.context.error_message"
|
v-if="item.context.error_message"
|
||||||
class="mt-2 break-words text-xs text-muted-foreground"
|
class="mt-2 line-clamp-2 break-words text-xs text-muted-foreground"
|
||||||
>
|
>
|
||||||
{{ item.context.error_message }}
|
{{ item.context.error_message }}
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</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),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -495,11 +495,13 @@ 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()
|
||||||
|
if (updateProviderLabel && !['pending', 'unknown', 'unknow'].includes(updateProviderLabel)) {
|
||||||
record.provider = update.provider
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user