feat(observability): polish usage detail and error diagnostics

Show attempt chains and P95 latency, surface client status/component/endpoint, and remove unused dashboard summary fields.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-07-31 22:28:24 +08:00
co-authored by Codex
parent f583e50e88
commit 01b4029998
15 changed files with 184 additions and 119 deletions
@@ -720,6 +720,7 @@ pub struct DiagnosticObservation {
pub attempt_count: u64,
pub total_tokens: u64,
pub average_latency_ms: Option<u64>,
pub latency_p95_ms: Option<u64>,
pub cost_coverage: CostCoverage,
pub costs: Vec<CurrencyCost>,
}
@@ -734,6 +735,7 @@ pub struct OpsError {
pub client_api_key_ref: Option<String>,
pub component: String,
pub operation: String,
pub endpoint: Option<String>,
pub provider_kind: Option<String>,
pub provider_account_ref: Option<String>,
pub provider_account_name: Option<String>,
@@ -1040,6 +1042,7 @@ pub struct DiagnosticsItem {
pub error_rate: f64,
pub request_share: f64,
pub average_latency_ms: Option<u64>,
pub latency_p95_ms: Option<u64>,
pub estimated_cost: Option<DecimalAmount>,
pub attempt_count: u64,
pub total_tokens: u64,
@@ -255,6 +255,7 @@ impl ObservabilityService for DefaultObservabilityService {
error_rate: rate_or_zero(item.failure_count, item.request_count),
request_share: rate_or_zero(item.request_count, total_requests),
average_latency_ms: item.average_latency_ms,
latency_p95_ms: item.latency_p95_ms,
estimated_cost: usd_cost(&item.costs),
attempt_count: item.attempt_count,
total_tokens: item.total_tokens,
@@ -703,6 +703,7 @@ fn diagnostic(name: &str, request_count: u64) -> DiagnosticObservation {
attempt_count: request_count,
total_tokens: request_count.saturating_mul(100),
average_latency_ms: Some(100),
latency_p95_ms: Some(200),
cost_coverage: CostCoverage::default(),
costs: Vec::new(),
}
@@ -771,8 +771,6 @@ pub struct TrendPointView {
pub errors_value: u64,
pub latency: String,
pub latency_value: Option<u64>,
pub first_token_latency: String,
pub first_token_latency_value: Option<u64>,
pub max_latency: String,
pub max_latency_value: Option<u64>,
pub min_latency: String,
@@ -844,21 +842,6 @@ pub struct DashboardCacheCardView {
pub average_first_token_latency_ms: String,
}
/// Dashboard 凭据用量摘要。
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardCredentialUsageView {
pub id: String,
pub display_name: String,
pub plan_type: Option<String>,
pub tokens: String,
pub tokens_value: Option<u64>,
pub last_used: String,
pub provider: String,
pub availability: String,
pub request_count: u64,
}
/// 旧 Dashboard 账号概览卡片所需的 Provider 安全投影。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -1016,18 +999,6 @@ pub struct DashboardDesktopReleaseView {
#[serde(skip_serializing_if = "Option::is_none")]
pub latest_build: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub published_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub minimum_system_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hardware_requirements: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub download_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub download_size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub signature_present: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
@@ -1061,14 +1032,10 @@ pub struct DashboardDataView {
pub health_timeline: HealthTimelineView,
pub wire_profiles: Vec<DashboardWireProfileView>,
pub account_usage: Vec<DashboardAccountUsageView>,
pub credential_usage: Vec<DashboardCredentialUsageView>,
pub usage_records: Vec<UsageRecordView>,
pub pool_summary: DashboardPoolSummaryView,
pub capacity_info: DashboardCapacityInfoView,
pub rotation_strategy: String,
pub logical_requests: RequestMetricsView,
pub attempts: AttemptMetricsView,
pub costs: Vec<CostView>,
}
/// 用量汇总响应数据。
@@ -1215,6 +1182,7 @@ pub struct DiagnosticItemView {
pub error_rate: f64,
pub request_share: f64,
pub average_latency_ms: Option<u64>,
pub latency_p95_ms: Option<u64>,
pub estimated_cost: Option<String>,
pub attempt_count: u64,
pub total_tokens: u64,
@@ -1973,11 +1941,11 @@ fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
id: error.event_id,
request_id: error.request_id,
client_api_key_id: error.client_api_key_ref,
kind: error.operation.clone(),
kind: error.component.clone(),
provider: error.provider_kind,
authentication_kind: error.provider_account_authentication_kind,
account_id: error.provider_account_ref,
route: error.operation,
route: error.endpoint.unwrap_or_else(|| "—".to_owned()),
model: error.upstream_model_id,
client_status_code: error.client_status_code.map(i64::from),
upstream_status_code: error.upstream_status_code.map(i64::from),
@@ -2037,8 +2005,6 @@ fn trend_point_view(point: domain::TrendPoint) -> TrendPointView {
errors_value: point.service_failure_count,
latency: display_duration(point.average_latency_ms),
latency_value: point.average_latency_ms,
first_token_latency: display_duration(point.average_first_token_latency_ms),
first_token_latency_value: point.average_first_token_latency_ms,
max_latency: display_duration(point.metrics.max_latency_ms),
max_latency_value: point.metrics.max_latency_ms,
min_latency: display_duration(point.metrics.min_latency_ms),
@@ -2189,12 +2155,6 @@ fn wire_profile_view(profile: domain::DashboardWireProfile) -> DashboardWireProf
checked_at: release.checked_at,
latest_version: release.latest_version,
latest_build: release.latest_build,
published_at: release.published_at,
minimum_system_version: release.minimum_system_version,
hardware_requirements: release.hardware_requirements,
download_url: release.download_url,
download_size: release.download_size,
signature_present: release.signature_present,
error: release.error,
}),
}
@@ -2250,14 +2210,12 @@ fn dashboard_view(result: domain::DashboardResult, kind: TrendKind) -> Dashboard
let domain::DashboardObservation {
range,
requests,
attempts,
provider_accounts,
trend: _,
account_usage,
recent_requests,
..
} = observation;
let mut account_usage_views = Vec::with_capacity(account_usage.len());
let mut credential_usage_views = Vec::with_capacity(account_usage.len());
for credential in account_usage {
account_usage_views.push(DashboardAccountUsageView {
id: credential.account_id.clone(),
@@ -2283,21 +2241,6 @@ fn dashboard_view(result: domain::DashboardResult, kind: TrendKind) -> Dashboard
quota_used_percent: credential.quota_used_percent,
last_used: relative_time(credential.last_used_at, range.end),
});
credential_usage_views.push(DashboardCredentialUsageView {
id: credential.account_id,
display_name: credential.email.unwrap_or(credential.name),
plan_type: credential.plan_type,
tokens: credential
.total_tokens
.map_or_else(|| "-".to_owned(), format_compact_number),
tokens_value: credential.total_tokens,
last_used: credential
.last_used_at
.map_or_else(|| "-".to_owned(), |value| china_datetime(&value)),
provider: credential.provider_kind,
availability: credential.availability,
request_count: credential.request_count,
});
}
DashboardDataView {
cards: DashboardCardsView {
@@ -2337,7 +2280,6 @@ fn dashboard_view(result: domain::DashboardResult, kind: TrendKind) -> Dashboard
health_timeline: health_timeline_view(health_timeline),
wire_profiles: wire_profiles.into_iter().map(wire_profile_view).collect(),
account_usage: account_usage_views,
credential_usage: credential_usage_views,
usage_records: recent_requests.into_iter().map(usage_record_view).collect(),
pool_summary: DashboardPoolSummaryView {
total: provider_accounts.total,
@@ -2355,9 +2297,6 @@ fn dashboard_view(result: domain::DashboardResult, kind: TrendKind) -> Dashboard
available_slots: capacity.available_slots,
},
rotation_strategy: rotation_strategy_name(rotation_strategy).to_owned(),
logical_requests: request_metrics_view(&requests),
attempts: attempt_metrics_view(&attempts),
costs: cost_views(&attempts.costs),
}
}
@@ -2554,6 +2493,7 @@ fn diagnostics_view(
error_rate: item.error_rate,
request_share: item.request_share,
average_latency_ms: item.average_latency_ms,
latency_p95_ms: item.latency_p95_ms,
estimated_cost: item.estimated_cost.as_ref().map(ToString::to_string),
attempt_count: item.attempt_count,
total_tokens: item.total_tokens,
@@ -167,8 +167,6 @@ mod response {
errors_value: 0,
latency: "1 ms".to_owned(),
latency_value: Some(1),
first_token_latency: "1 ms".to_owned(),
first_token_latency_value: Some(1),
max_latency: "1 ms".to_owned(),
max_latency_value: Some(1),
min_latency: "1 ms".to_owned(),
@@ -468,6 +466,7 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
client_api_key_ref: Some("key_err".to_owned()),
component: "model_request".to_owned(),
operation: "responses".to_owned(),
endpoint: Some("/v1/responses".to_owned()),
provider_kind: Some("openai".to_owned()),
provider_account_ref: Some("acct_err".to_owned()),
provider_account_name: None,
@@ -508,14 +507,20 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
serde_json::json!({
"provider": value["data"]["items"][0]["provider"],
"authenticationKind": value["data"]["items"][0]["authenticationKind"],
"kind": value["data"]["items"][0]["kind"],
"accountId": value["data"]["items"][0]["accountId"],
"accountLabel": value["data"]["items"][0]["metadata"]["accountLabel"],
"clientStatusCode": value["data"]["items"][0]["clientStatusCode"],
"route": value["data"]["items"][0]["route"],
}),
serde_json::json!({
"provider": "openai",
"authenticationKind": "api_key",
"kind": "model_request",
"accountId": "acct_err",
"accountLabel": "err@example.invalid",
"clientStatusCode": 502,
"route": "/v1/responses",
})
);
}
@@ -547,6 +552,7 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
attempt_count: 2,
total_tokens: 200,
average_latency_ms: Some(100),
latency_p95_ms: Some(3800),
cost_coverage: CostCoverage::default(),
costs: Vec::new(),
});
@@ -571,11 +577,13 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
(
&value["data"]["items"][0]["key"],
&value["data"]["items"][0]["name"],
&value["data"]["items"][0]["latencyP95Ms"],
&value["data"]["dimension"],
),
(
&serde_json::json!("acct_diag"),
&serde_json::json!("diag@example.invalid"),
&serde_json::json!(3800),
&serde_json::json!("account"),
)
);
@@ -647,6 +647,7 @@ pub struct DiagnosticObservation {
pub attempt_count: u64,
pub total_tokens: u64,
pub average_latency_ms: Option<u64>,
pub latency_p95_ms: Option<u64>,
pub cost_coverage: CostCoverage,
pub costs: Vec<CurrencyCostTotal>,
}
@@ -660,6 +661,7 @@ pub struct OpsErrorRecord {
pub client_api_key_ref: Option<String>,
pub component: String,
pub operation: String,
pub endpoint: Option<String>,
pub provider_kind: Option<String>,
pub provider_account_ref: Option<String>,
pub provider_account_name: Option<String>,
@@ -1614,6 +1616,7 @@ fn admin_diagnostic_observation(
attempt_count: observation.attempt_count,
total_tokens: observation.total_tokens,
average_latency_ms: observation.average_latency_ms,
latency_p95_ms: observation.latency_p95_ms,
cost_coverage: admin_cost_coverage(observation.cost_coverage),
costs: admin_currency_costs(observation.costs)?,
})
@@ -1636,6 +1639,7 @@ fn admin_ops_error(error: OpsErrorRecord) -> admin_observability::OpsError {
client_api_key_ref: error.client_api_key_ref,
component: error.component,
operation: error.operation,
endpoint: error.endpoint,
provider_kind: error.provider_kind,
provider_account_ref: error.provider_account_ref,
provider_account_name: error.provider_account_name,
@@ -2887,6 +2891,8 @@ async fn usage_diagnostics(
coalesce(sum(mr.attempt_count), 0)::bigint as attempt_count,
coalesce(sum(mr.total_tokens), 0)::bigint as total_tokens,
round(avg(mr.latency_ms))::bigint as average_latency_ms,
round(percentile_cont(0.95) within group (order by mr.latency_ms))::bigint
as latency_p95_ms,
count(*) filter (where mr.cost_source = 'provider_reported')::bigint
as provider_reported_count,
count(*) filter (where mr.cost_source = 'calculated')::bigint
@@ -2919,6 +2925,7 @@ async fn usage_diagnostics(
attempt_count: unsigned(row, "attempt_count")?,
total_tokens: unsigned(row, "total_tokens")?,
average_latency_ms: optional_unsigned(row, "average_latency_ms")?,
latency_p95_ms: optional_unsigned(row, "latency_p95_ms")?,
cost_coverage: coverage_from_row(row)?,
costs: Vec::new(),
})
@@ -3090,6 +3097,7 @@ const OPS_ERRORS_CTE: &str = "with errors as (
mr.id as event_id, mr.id as request_id,
nullif(mr.attempt_count, 0) as attempt_index,
mr.client_api_key_ref, 'model_request'::text as component, mr.operation,
mr.endpoint,
mr.provider_kind,
mr.provider_account_ref,
mr.provider_account_name_snapshot as provider_account_name,
@@ -3108,6 +3116,7 @@ const OPS_ERRORS_CTE: &str = "with errors as (
union all
select 'ops_event'::text, oe.id, oe.model_request_id, oe.attempt_index,
mr.client_api_key_ref, oe.component, oe.operation,
null::text as endpoint,
oe.provider_kind, oe.provider_account_ref,
oe.provider_account_name_snapshot as provider_account_name,
oe.provider_account_email_snapshot as provider_account_email,
@@ -3127,7 +3136,7 @@ async fn list_ops_errors(pool: &PgPool, query: OpsErrorQuery) -> StoreResult<Ops
let mut statement = QueryBuilder::<Postgres>::new(OPS_ERRORS_CTE);
statement.push(
" select source, event_id, request_id, attempt_index, client_api_key_ref,
component, operation, provider_kind, provider_account_ref,
component, operation, endpoint, provider_kind, provider_account_ref,
provider_account_name, provider_account_email,
provider_account_authentication_kind,
upstream_model_id, upstream_transport,
@@ -3331,6 +3340,7 @@ fn ops_error_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<OpsErrorRecord
client_api_key_ref: get(row, "client_api_key_ref")?,
component: get(row, "component")?,
operation: get(row, "operation")?,
endpoint: get(row, "endpoint")?,
provider_kind: get(row, "provider_kind")?,
provider_account_ref: get(row, "provider_account_ref")?,
provider_account_name: get(row, "provider_account_name")?,
@@ -316,6 +316,7 @@ async fn ops_errors_should_keep_request_and_event_snapshots_after_account_deleti
Some("oauth"),
)
);
assert_eq!(request_error.endpoint.as_deref(), Some("/v1/responses"));
let probe_error = errors
.items
.iter()
@@ -333,6 +334,7 @@ async fn ops_errors_should_keep_request_and_event_snapshots_after_account_deleti
Some("api_key"),
)
);
assert_eq!(probe_error.endpoint, None);
database.close().await;
}
@@ -565,6 +567,14 @@ async fn api_key_diagnostics_should_display_key_name_and_fallback_to_ref() {
.expect("insert api key request");
finalize_request(&database.pool, id, started_at).await;
}
sqlx::query("update model_requests set latency_ms = 1000 where id = 'req_key_a'")
.execute(&database.pool)
.await
.expect("set request a latency");
sqlx::query("update model_requests set latency_ms = 2000 where id = 'req_key_b'")
.execute(&database.pool)
.await
.expect("set request b latency");
let repository = PgObservabilityRepository::new(database.pool.clone());
let diagnostics = repository
@@ -579,6 +589,7 @@ async fn api_key_diagnostics_should_display_key_name_and_fallback_to_ref() {
assert_eq!(diagnostics[0].key, "key_diag");
assert_eq!(diagnostics[0].name, "My Key");
assert_eq!(diagnostics[0].request_count, 2);
assert_eq!(diagnostics[0].latency_p95_ms, Some(1950));
sqlx::query("delete from client_api_keys where id = 'key_diag'")
.execute(&database.pool)
-37
View File
@@ -24,8 +24,6 @@ export interface DashboardTrendPoint {
errorsValue: number
latency: string
latencyValue: number | null
firstTokenLatency: string
firstTokenLatencyValue: number | null
maxLatency: string
maxLatencyValue: number | null
minLatency: string
@@ -121,12 +119,6 @@ export interface DashboardWireProfile {
checkedAt?: string
latestVersion?: string
latestBuild?: string
publishedAt?: string
minimumSystemVersion?: string
hardwareRequirements?: string
downloadUrl?: string
downloadSize?: number
signaturePresent?: boolean
error?: string
}
}
@@ -149,18 +141,6 @@ export interface DashboardAccountUsage {
lastUsed: string
}
export interface DashboardCredentialUsage {
id: string
displayName: string
planType: string | null
tokens: string
tokensValue: number | null
lastUsed: string
provider: string
availability: string
requestCount: number
}
export interface DashboardPoolSummary {
total: number
active: number
@@ -178,33 +158,16 @@ export interface DashboardCapacityInfo {
availableSlots: number | null
}
export interface DashboardRequestMetrics {
requestCount: number
successCount: number
failureCount: number
cancelledCount: number
incompleteCount: number
callerErrorCount: number
inputTokens: number
outputTokens: number
cachedTokens: number
cacheWriteTokens: number
reasoningTokens: number
totalTokens: number
}
export interface DashboardSummaryResponse {
cards: DashboardCards
trend: DashboardTrendResponse
healthTimeline: DashboardHealthTimeline
wireProfiles: DashboardWireProfile[]
accountUsage: DashboardAccountUsage[]
credentialUsage: DashboardCredentialUsage[]
usageRecords: UsageRecord[]
poolSummary: DashboardPoolSummary
capacityInfo: DashboardCapacityInfo
rotationStrategy: string
logicalRequests: DashboardRequestMetrics
}
export function getDashboardSummary(data: object) {
+1
View File
@@ -320,6 +320,7 @@ export interface UsageDiagnosticItem {
errorRate: number
requestShare: number
averageLatencyMs: number | null
latencyP95Ms: number | null
estimatedCost: string | null
attemptCount: number
totalTokens: number
@@ -17,7 +17,7 @@ const fields = computed(() => [
{ label: '时间', value: props.record?.createdAtDisplay },
{ label: '事件', value: props.record?.kind },
{ label: '失败分类', value: props.record?.failureClass },
{ label: 'Provider', value: props.record?.provider },
{ label: '平台/类型', value: providerKindLabel(props.record) },
{ label: '端点', value: props.record?.route },
{ label: '模型', value: props.record?.model },
{ label: '账号 ID', value: props.record?.accountId },
@@ -44,6 +44,14 @@ function display(value: unknown) {
function latencyDisplay(value: unknown) {
return typeof value === 'number' ? `${value} ms` : '—'
}
function providerKindLabel(record: Awaited<ReturnType<typeof getOpsErrors>>['items'][number] | null) {
if (!record?.provider)
return '—'
return record.authenticationKind
? `${record.provider} · ${record.authenticationKind}`
: record.provider
}
</script>
<template>
@@ -58,7 +66,7 @@ function latencyDisplay(value: unknown) {
<template v-if="record">
<section class="rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5">
<div class="flex flex-wrap items-center gap-3">
<UsageStatusCodeBadge :status-code="record.upstreamStatusCode" />
<UsageStatusCodeBadge :status-code="record.clientStatusCode" />
<span class="text-[12px] font-bold text-(--cp-text-secondary)">
客户端 {{ display(record.clientStatusCode) }}
</span>
@@ -93,6 +93,11 @@ function showDetail(record: Awaited<ReturnType<typeof getOpsErrors>>['items'][nu
:status-code="typeof row.upstreamStatusCode === 'number' ? row.upstreamStatusCode : null"
/>
</template>
<template #clientStatusCode="{ row }">
<UsageStatusCodeBadge
:status-code="typeof row.clientStatusCode === 'number' ? row.clientStatusCode : null"
/>
</template>
<template #failureClass="{ row }">
<span class="font-mono text-[12px] font-bold text-(--cp-danger-text)">
{{ row.failureClass || '—' }}
@@ -3,8 +3,18 @@ import { displayValue, fieldLabelClass, fieldValueClass } from '../utils/detail'
// 详情弹窗的两列字段网格:label + 值(可选等宽字体),空值以“—”占位。
defineProps<{
items: Array<{ label: string, value: unknown, mono?: boolean }>
items: Array<{ label: string, value: unknown, mono?: boolean, wrap?: boolean }>
}>()
function valueClass(item: { mono?: boolean, wrap?: boolean }) {
if (item.wrap) {
return [
'mt-1.5 mb-0 min-w-0 text-[12px] leading-snug font-bold text-(--cp-text-primary)',
item.mono ? 'font-mono tabular-nums break-words' : undefined,
]
}
return fieldValueClass(item.mono)
}
</script>
<template>
@@ -13,7 +23,7 @@ defineProps<{
<dt :class="fieldLabelClass">
{{ item.label }}
</dt>
<dd :class="fieldValueClass(item.mono)" :title="displayValue(item.value)">
<dd :class="valueClass(item)" :title="displayValue(item.value)">
{{ displayValue(item.value) }}
</dd>
</div>
@@ -82,10 +82,8 @@ const sortedItems = computed(() =>
),
)
// P95 列消费的 latencyP95Ms 目前不在诊断聚合响应中,缺失时渲染占位符。
type DiagnosticDisplayItem = Diagnostics['items'][number] & {
nameDisplay: ReturnType<typeof diagnosticNameDisplay>
latencyP95Ms?: number
}
const displayItems = computed<DiagnosticDisplayItem[]>(() =>
@@ -5,9 +5,11 @@ import type { getUsageRecordDetail } from '@/api'
import { computed } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseTable from '@/components/base/BaseTable/index.vue'
import BaseChart from '@/components/charts/BaseChart.vue'
import { useThemeColor } from '@/composables/useThemeColor'
import { displayValue, fieldLabelClass, fieldValueBaseClass, fieldValueClass } from '../utils/detail'
import { formatDuration } from '../utils/format'
import {
usageAccountText,
usageBilling,
@@ -47,6 +49,11 @@ const panelClass = 'rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5'
const panelTitleClass = 'm-0 text-[12px] leading-none font-heavy text-(--cp-text-secondary)'
const accountDisplay = computed(() => props.record ? usageAccountText(props.record) : '—')
const finalAttemptIndex = computed(() => {
const attempts = props.record?.attempts ?? []
const last = attempts[attempts.length - 1]
return last?.attemptIndex ?? props.record?.attemptCount
})
const overviewItems = computed(() => [
{ label: '时间', value: props.record?.createdAtDisplay, mono: true },
{ label: '类型', value: props.record ? usageRecordType(props.record) : '—' },
@@ -84,10 +91,15 @@ const detailGroups = computed(() => [
title: '客户端与上游',
items: [
{ label: '客户端 IP', value: props.record ? usageClientIp(props.record) : '—', mono: true },
{ label: 'User-Agent', value: props.record ? usageUserAgent(props.record) : '', mono: true },
{
label: 'User-Agent',
value: props.record ? usageUserAgent(props.record) : '',
mono: true,
wrap: true,
},
{ label: '传输方式', value: props.record?.transport, mono: true },
{ label: '事件类型', value: props.record?.kind, mono: true },
{ label: '尝试序号', value: props.record?.attemptIndex },
{ label: '尝试序号', value: finalAttemptIndex.value },
{ label: '服务层级', value: props.record?.serviceTier, mono: true },
{ label: '客户端 Key ID', value: props.record?.clientApiKeyId, mono: true },
],
@@ -96,6 +108,61 @@ const detailGroups = computed(() => [
const modelRouteGroup = computed(() => detailGroups.value[0])
const clientUpstreamGroup = computed(() => detailGroups.value[1])
const attemptColumns = [
{
key: 'attemptIndex',
label: '序号',
width: '56px',
align: 'center' as const,
},
{ key: 'outcome', label: '结果', width: '76px' },
{ key: 'provider', label: '平台', width: '72px' },
{ key: 'model', label: '模型', width: '140px' },
{ key: 'transport', label: '传输', width: '84px' },
{ key: 'statusCode', label: '状态', width: '84px' },
{ key: 'accountLabel', label: '账号', width: '200px' },
{
key: 'latencyMs',
label: '耗时',
width: '84px',
align: 'right' as const,
},
]
const attemptRows = computed(() =>
(props.record?.attempts ?? []).map(attempt => ({
id: attempt.id,
attemptIndex: attempt.attemptIndex,
outcome: attempt.outcome,
provider: attempt.provider,
model: attempt.model,
transport: attempt.transport,
statusCode: attempt.statusCode,
accountLabel: attempt.accountEmail || attempt.accountName || attempt.accountId || '—',
accountId: attempt.accountId,
latencyMs: attempt.latencyMs,
})),
)
function attemptOutcomeText(outcome: string) {
const labels: Record<string, string> = {
succeeded: '成功',
failed: '失败',
cancelled: '取消',
incomplete: '未完成',
running: '进行中',
}
return labels[outcome] ?? outcome
}
function attemptOutcomeClass(outcome: string) {
if (outcome === 'succeeded')
return 'text-(--cp-success-text)'
if (outcome === 'failed')
return 'text-(--cp-danger-text)'
return 'text-(--cp-text-secondary)'
}
const billingItems = computed(() => {
const value = billing.value
if (!value) {
@@ -334,6 +401,44 @@ const tokenDonutOption = computed<EChartsOption>(() => {
</div>
</section>
<section v-if="attemptRows.length" :class="panelClass">
<h3 :class="panelTitleClass">
尝试链路
</h3>
<BaseTable
class="mt-3 min-w-0"
:columns="attemptColumns"
:rows="attemptRows"
compact
row-key="id"
min-width="640px"
>
<template #outcome="{ row }">
<span :class="attemptOutcomeClass(row.outcome)">
{{ attemptOutcomeText(row.outcome) }}
</span>
</template>
<template #statusCode="{ row }">
<UsageStatusCodeBadge
:status-code="typeof row.statusCode === 'number' ? row.statusCode : null"
/>
</template>
<template #accountLabel="{ row }">
<span
class="block max-w-full truncate font-mono text-[12px] font-bold text-(--cp-text-primary)"
:title="row.accountId || ''"
>
{{ row.accountLabel }}
</span>
</template>
<template #latencyMs="{ row }">
<span class="font-mono font-bold tabular-nums text-(--cp-text-primary)">
{{ formatDuration(row.latencyMs) }}
</span>
</template>
</BaseTable>
</section>
<section
v-if="requestText || responseText"
class="grid min-h-0 grid-cols-1 gap-3 lg:grid-cols-2"
+7 -6
View File
@@ -96,6 +96,12 @@ export const usageRecordColumns = [
]
export const opsErrorColumns = [
{
key: 'accountId',
label: '账号',
width: '230px',
cellClass: 'font-mono text-[12px] font-emphasis',
},
{
key: 'createdAtDisplay',
label: '时间',
@@ -104,6 +110,7 @@ export const opsErrorColumns = [
'whitespace-nowrap font-mono text-[12px] font-emphasis tabular-nums text-(--cp-text-secondary)',
},
{ key: 'upstreamStatusCode', label: '上游状态', width: '96px', align: 'center' as const },
{ key: 'clientStatusCode', label: '客户端状态', width: '96px', align: 'center' as const },
{
key: 'failureClass',
label: '失败分类',
@@ -128,12 +135,6 @@ export const opsErrorColumns = [
width: '180px',
cellClass: 'font-mono text-[12px] font-emphasis',
},
{
key: 'accountId',
label: '账号',
width: '230px',
cellClass: 'font-mono text-[12px] font-emphasis',
},
{
key: 'requestId',
label: '请求 ID',