mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user