From c3146dc10a733374500ce48d16c0d85abbabc263 Mon Sep 17 00:00:00 2001 From: Zyy Date: Sun, 18 Oct 2026 04:28:41 +0800 Subject: [PATCH] fix(usage): align request tables and detail dialogs Share data columns and status filters between admin and key usage. Keep final error summaries in the error view and focus error details in a separate dialog. Use logical request outcomes for key usage records and keep concurrency lists at a stable height without pagination. Co-authored-by: Codex --- .../gateway-admin/src/model/key_usage.rs | 10 +- .../gateway-admin/src/model/observability.rs | 3 + .../gateway-admin/src/use_case/key_usage.rs | 48 +++---- .../tests/use_case/observability/mod.rs | 3 + .../src/admin/observability/presenter.rs | 2 + .../src/admin/observability/wire.rs | 2 + .../gateway-api/src/key_usage/presenter.rs | 82 +++++------ .../crates/gateway-api/src/key_usage/query.rs | 2 + .../tests/admin/observability/response.rs | 18 ++- .../gateway-api/tests/key_usage/fixtures.rs | 67 +-------- .../crates/gateway-api/tests/key_usage/mod.rs | 78 ++++++++--- .../src/postgres/observability/mapping.rs | 3 + .../postgres/observability/queries/usage.rs | 2 +- .../tests/postgres/usage_paging.rs | 8 +- docs/api.md | 11 +- frontend/src/api/modules/key-usage.ts | 4 +- frontend/src/api/modules/usage.ts | 2 + .../src/components/usage/UsageModelCell.vue | 4 +- .../usage/UsageReasoningEffortCell.vue | 4 +- .../usage/UsageRecordStatusFilter.vue | 16 +++ .../components/usage/UsageRecordsTable.vue | 28 ++-- .../src/components/usage/UsageRouteCell.vue | 19 +++ .../src/components/usage/UsageTimeCell.vue | 9 ++ .../src/components/usage/shared/columns.ts | 29 ++-- .../src/components/usage/shared/presenter.ts | 9 +- .../components/AccountConcurrencyModal.vue | 26 ++-- .../components/AccountConcurrencyTable.vue | 11 +- .../ResetCreditAutomation.vue | 2 +- .../composables/useAccountConcurrency.ts | 67 ++++----- .../key-usage/components/KeyUsageRecords.vue | 47 +++---- .../src/views/theme/fixtures/dashboard.ts | 2 + .../components/UsageErrorDetailModal.vue | 131 ++++++++++++++++++ .../components/UsageRecordDetailModal.vue | 99 +++---------- frontend/src/views/usage/index.vue | 33 +++-- frontend/src/views/usage/utils/detail.ts | 23 +++ 35 files changed, 516 insertions(+), 388 deletions(-) create mode 100644 frontend/src/components/usage/UsageRecordStatusFilter.vue create mode 100644 frontend/src/components/usage/UsageRouteCell.vue create mode 100644 frontend/src/components/usage/UsageTimeCell.vue create mode 100644 frontend/src/views/usage/components/UsageErrorDetailModal.vue diff --git a/backend/crates/gateway-admin/src/model/key_usage.rs b/backend/crates/gateway-admin/src/model/key_usage.rs index 923739d14..2559fe872 100644 --- a/backend/crates/gateway-admin/src/model/key_usage.rs +++ b/backend/crates/gateway-admin/src/model/key_usage.rs @@ -3,8 +3,8 @@ use super::{ client_keys::ClientKeyRecord, observability::{ - HealthTimeline, ObservabilityPageSize, OpsErrorPage, RequestMetricPoint, TimeRange, - UsageOverview, UsagePage, + HealthTimeline, ObservabilityPageSize, RequestMetricPoint, TimeRange, UsageOverview, + UsagePage, }, }; @@ -18,6 +18,7 @@ pub struct KeyUsageQuery { pub enum KeyUsageRecordKind { Success, Error, + All, } #[derive(Debug, Clone)] @@ -35,7 +36,4 @@ pub struct KeyUsageOverview { pub health_timeline: HealthTimeline, } -pub enum KeyUsageRecords { - Success(UsagePage), - Error(OpsErrorPage), -} +pub type KeyUsageRecords = UsagePage; diff --git a/backend/crates/gateway-admin/src/model/observability.rs b/backend/crates/gateway-admin/src/model/observability.rs index 1e0f48d61..4a5186eff 100644 --- a/backend/crates/gateway-admin/src/model/observability.rs +++ b/backend/crates/gateway-admin/src/model/observability.rs @@ -664,6 +664,9 @@ pub struct DashboardObservation { #[derive(Debug, Clone, PartialEq)] pub struct UsageListRecord { pub outcome: RequestOutcome, + pub error_code: Option, + pub error_message: Option, + pub client_status_code: Option, pub client_api_key_name: Option, pub id: String, pub endpoint: String, diff --git a/backend/crates/gateway-admin/src/use_case/key_usage.rs b/backend/crates/gateway-admin/src/use_case/key_usage.rs index b99f6a12d..de65dbfaf 100644 --- a/backend/crates/gateway-admin/src/use_case/key_usage.rs +++ b/backend/crates/gateway-admin/src/use_case/key_usage.rs @@ -14,9 +14,7 @@ use crate::{ KeyUsageOverview, KeyUsageQuery, KeyUsageRecordKind, KeyUsageRecords, KeyUsageRecordsQuery, }, - observability::{ - Granularity, OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery, - }, + observability::{Granularity, TimeRange, UsageFilter, UsageQuery, UsageRecordStatus}, system::SystemVersion, }, ports::store::{ClientKeyStore, ObservabilityStore}, @@ -221,35 +219,21 @@ impl KeyUsageService for DefaultKeyUsageService { let Some(id) = self.key_id(session_id).await? else { return Ok(None); }; - let result = match query.kind { - KeyUsageRecordKind::Success => KeyUsageRecords::Success( - self.observations - .list_usage_records(UsageQuery { - status: Default::default(), - range: query.usage.range, - filter: usage_filter(&id, query.usage.model), - current_page: query.current_page, - page_size: query.page_size, - }) - .await - .map_err(|error| map_store_error(error, "key usage records"))?, - ), - KeyUsageRecordKind::Error => KeyUsageRecords::Error( - self.observations - .list_ops_errors(OpsErrorQuery { - range: query.usage.range, - filter: OpsErrorFilter { - client_api_key_ref: Some(id.as_str().to_owned()), - model: query.usage.model, - ..OpsErrorFilter::default() - }, - current_page: query.current_page, - page_size: query.page_size, - }) - .await - .map_err(|error| map_store_error(error, "key usage errors"))?, - ), - }; + let result = self + .observations + .list_usage_records(UsageQuery { + status: match query.kind { + KeyUsageRecordKind::Success => UsageRecordStatus::Success, + KeyUsageRecordKind::Error => UsageRecordStatus::Error, + KeyUsageRecordKind::All => UsageRecordStatus::All, + }, + range: query.usage.range, + filter: usage_filter(&id, query.usage.model), + current_page: query.current_page, + page_size: query.page_size, + }) + .await + .map_err(|error| map_store_error(error, "key usage records"))?; Ok(Some(result)) } } diff --git a/backend/crates/gateway-admin/tests/use_case/observability/mod.rs b/backend/crates/gateway-admin/tests/use_case/observability/mod.rs index d3119e9e9..a89ee5cb4 100644 --- a/backend/crates/gateway-admin/tests/use_case/observability/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/observability/mod.rs @@ -1219,6 +1219,9 @@ fn total_record( now: DateTime, ) -> UsageListRecord { UsageListRecord { + error_code: None, + error_message: None, + client_status_code: None, outcome: gateway_admin::model::observability::RequestOutcome::Succeeded, client_api_key_name: Some("Production".to_owned()), id: id.to_owned(), diff --git a/backend/crates/gateway-api/src/admin/observability/presenter.rs b/backend/crates/gateway-api/src/admin/observability/presenter.rs index 11dcabef2..a67dbcce0 100644 --- a/backend/crates/gateway-api/src/admin/observability/presenter.rs +++ b/backend/crates/gateway-api/src/admin/observability/presenter.rs @@ -262,6 +262,8 @@ pub(crate) fn usage_list_record_view( .clone() .or_else(|| record.requested_model_id.clone()); UsageListRecordView { + error_code: record.error_code, + error_message: record.error_message, logical_outcome: record.outcome.as_str().to_owned(), client_api_key_name: record.client_api_key_name, id: record.id, diff --git a/backend/crates/gateway-api/src/admin/observability/wire.rs b/backend/crates/gateway-api/src/admin/observability/wire.rs index 42e3afc05..965de96a3 100644 --- a/backend/crates/gateway-api/src/admin/observability/wire.rs +++ b/backend/crates/gateway-api/src/admin/observability/wire.rs @@ -86,6 +86,8 @@ pub struct ImageBillingView { #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct UsageListRecordView { + pub error_code: Option, + pub error_message: Option, pub logical_outcome: String, pub client_api_key_name: Option, pub id: String, diff --git a/backend/crates/gateway-api/src/key_usage/presenter.rs b/backend/crates/gateway-api/src/key_usage/presenter.rs index e731b3550..5f4f6781f 100644 --- a/backend/crates/gateway-api/src/key_usage/presenter.rs +++ b/backend/crates/gateway-api/src/key_usage/presenter.rs @@ -5,7 +5,7 @@ use gateway_admin::model::{ client_keys::ClientKeySecret, key_usage::{KeyUsageOverview, KeyUsageRecords}, observability::{ - CostCoverage, CurrencyCost, Granularity, OpsError, RequestMetrics, UsageListRecord, + CostCoverage, CurrencyCost, Granularity, RequestMetrics, RequestOutcome, UsageListRecord, }, system::SystemVersion, }; @@ -228,8 +228,20 @@ struct OutputTimingView { first_text_ms: Option, } -fn success_record(value: UsageListRecord, time: crate::time::TimePresenter) -> RecordView { - let token_details = Some(usage_list_token_details(&value)); +fn usage_record(value: UsageListRecord, time: crate::time::TimePresenter) -> RecordView { + let token_details = [ + value.input_tokens, + value.output_tokens, + value.cached_input_tokens, + value.cache_write_input_tokens, + value.reasoning_output_tokens, + value.image_input_tokens, + value.image_output_tokens, + value.total_tokens, + ] + .iter() + .any(Option::is_some) + .then(|| usage_list_token_details(&value)); let billing = billing_view(value.billing.as_ref()); RecordView { id: value.id, @@ -258,58 +270,30 @@ fn success_record(value: UsageListRecord, time: crate::time::TimePresenter) -> R }, client_ip: value.client_ip, user_agent: value.user_agent, - status: "success", - // 成功记录不保存 HTTP 状态,不能用 200 伪造缺失的原始事实 - status_code: None, - } -} - -fn error_record(value: OpsError, time: crate::time::TimePresenter) -> RecordView { - RecordView { - id: value.event_id, - created_at: value.occurred_at, - created_at_display: time.datetime(&value.occurred_at), - model: value.requested_model_id, - route: value.endpoint, - reasoning_effort: value.reasoning_effort, - client_transport: value.client_transport, - upstream_transport: value.upstream_transport, - token_details: None, - billing: None, - latency_ms: value.latency_ms, - first_token_latency_ms: None, - latency_details: OutputTimingView::default(), - client_ip: value.client_ip, - user_agent: value.user_agent, - status: "error", + status: match value.outcome { + RequestOutcome::Succeeded => "success", + RequestOutcome::Failed => "error", + RequestOutcome::Incomplete => "incomplete", + RequestOutcome::Cancelled => "cancelled", + RequestOutcome::Running => "running", + RequestOutcome::Other(_) => "unknown", + }, status_code: value.client_status_code, } } pub(super) fn records( - value: KeyUsageRecords, + page: KeyUsageRecords, time: crate::time::TimePresenter, ) -> PageData { - match value { - KeyUsageRecords::Success(page) => PageData { - items: page - .items - .into_iter() - .map(|value| success_record(value, time)) - .collect(), - current_page: page.current_page, - page_size: page.page_size, - total: page.total, - }, - KeyUsageRecords::Error(page) => PageData { - items: page - .items - .into_iter() - .map(|value| error_record(value, time)) - .collect(), - current_page: page.current_page, - page_size: page.page_size, - total: page.total, - }, + PageData { + items: page + .items + .into_iter() + .map(|value| usage_record(value, time)) + .collect(), + current_page: page.current_page, + page_size: page.page_size, + total: page.total, } } diff --git a/backend/crates/gateway-api/src/key_usage/query.rs b/backend/crates/gateway-api/src/key_usage/query.rs index 83d68ba01..44bfcd3f5 100644 --- a/backend/crates/gateway-api/src/key_usage/query.rs +++ b/backend/crates/gateway-api/src/key_usage/query.rs @@ -67,6 +67,7 @@ enum RecordKind { #[default] Success, Error, + All, } #[derive(Deserialize)] @@ -109,6 +110,7 @@ impl RecordsQuery { kind: match self.kind { RecordKind::Success => KeyUsageRecordKind::Success, RecordKind::Error => KeyUsageRecordKind::Error, + RecordKind::All => KeyUsageRecordKind::All, }, current_page, page_size, diff --git a/backend/crates/gateway-api/tests/admin/observability/response.rs b/backend/crates/gateway-api/tests/admin/observability/response.rs index 218ba9a69..2e7b543ef 100644 --- a/backend/crates/gateway-api/tests/admin/observability/response.rs +++ b/backend/crates/gateway-api/tests/admin/observability/response.rs @@ -801,6 +801,9 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() { .lock() .expect("usage records") .push(UsageListRecord { + error_code: None, + error_message: None, + client_status_code: None, outcome: gateway_admin::model::observability::RequestOutcome::Succeeded, client_api_key_name: Some("Production".to_owned()), id: "request_endpoint".to_owned(), @@ -892,12 +895,18 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() { }); records.push(image); } + let mut failed = records[0].clone(); + failed.id = "request_failed".to_owned(); + failed.outcome = gateway_admin::model::observability::RequestOutcome::Failed; + failed.error_code = Some("server_error".to_owned()); + failed.error_message = Some("The upstream service is temporarily unavailable.".to_owned()); + records.push(failed); } let response = observability::router::() .with_state(fixture.state()) .oneshot( Request::builder() - .uri("/api/admin/usage/records") + .uri("/api/admin/usage/records?status=all") .header(header::COOKIE, "cpr_session=valid-session") .header("x-request-id", "req_usage_endpoint") .body(Body::empty()) @@ -911,6 +920,13 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() { .expect("usage response body"); let value: serde_json::Value = serde_json::from_slice(&body).expect("usage response JSON"); + assert!(value["data"]["items"][0]["errorCode"].is_null()); + assert!(value["data"]["items"][0]["errorMessage"].is_null()); + assert_eq!(value["data"]["items"][3]["errorCode"], "server_error"); + assert_eq!( + value["data"]["items"][3]["errorMessage"], + "The upstream service is temporarily unavailable." + ); assert_eq!(value["data"]["items"][0]["clientApiKeyName"], "Production"); assert_eq!(value["data"]["items"][0]["accountNotes"], "Team workspace"); assert_eq!( diff --git a/backend/crates/gateway-api/tests/key_usage/fixtures.rs b/backend/crates/gateway-api/tests/key_usage/fixtures.rs index 4f392544c..303d041da 100644 --- a/backend/crates/gateway-api/tests/key_usage/fixtures.rs +++ b/backend/crates/gateway-api/tests/key_usage/fixtures.rs @@ -5,9 +5,8 @@ use chrono::{Duration, Utc}; use gateway_admin::model::{ client_keys::ClientKeyRecord, observability::{ - AttemptMetrics, CostCoverage, CurrencyCost, DecimalAmount, Granularity, OpsError, - RequestMetricPoint, RequestMetrics, TimeRange, UsageBilling, UsageListRecord, - UsageOverview, + AttemptMetrics, CostCoverage, CurrencyCost, DecimalAmount, Granularity, RequestMetricPoint, + RequestMetrics, TimeRange, UsageBilling, UsageListRecord, UsageOverview, }, }; use gateway_core::{ @@ -87,12 +86,14 @@ pub(super) async fn fixture() -> AdminTestFixture { }]); } fixture.usage_records.lock().unwrap().push(usage_record()); - fixture.ops_errors.lock().unwrap().push(error_record()); fixture } fn usage_record() -> UsageListRecord { UsageListRecord { + error_code: None, + error_message: None, + client_status_code: None, outcome: gateway_admin::model::observability::RequestOutcome::Succeeded, client_api_key_name: Some("Production".to_owned()), id: "req-visible".to_owned(), @@ -158,61 +159,3 @@ fn usage_record() -> UsageListRecord { started_at: Utc::now(), } } - -fn error_record() -> OpsError { - OpsError { - client_api_key_name: Some("Production".to_owned()), - source: "private-sentinel".to_owned(), - event_id: "error-visible".to_owned(), - request_id: Some("private-sentinel".to_owned()), - attempt_index: None, - client_api_key_ref: Some("private-sentinel".to_owned()), - component: "private-sentinel".to_owned(), - operation: "private-sentinel".to_owned(), - protocol: Some("private-sentinel".to_owned()), - client_transport: Some("http_sse".to_owned()), - requested_model_id: Some("coding".to_owned()), - service_tier: Some("private-sentinel".to_owned()), - endpoint: Some("/v1/responses".to_owned()), - provider_kind: Some("private-sentinel".to_owned()), - provider_account_ref: Some("private-sentinel".to_owned()), - provider_account_name: Some("private-sentinel".to_owned()), - provider_account_email: Some("private-sentinel".to_owned()), - provider_account_authentication_kind: Some("private-sentinel".to_owned()), - provider_account_plan_type: Some("private-sentinel".to_owned()), - provider_account_plan_type_display: Some("private-sentinel".to_owned()), - upstream_model_id: Some("private-sentinel".to_owned()), - upstream_transport: Some("websocket".to_owned()), - failure_kind: "private-sentinel".to_owned(), - upstream_send_state: Some("private-sentinel".to_owned()), - client_status_code: Some(502), - upstream_status_code: Some(503), - provider_error_code: Some("private-sentinel".to_owned()), - client_response_id: Some("private-sentinel".to_owned()), - upstream_request_id: Some("private-sentinel".to_owned()), - latency_ms: Some(420), - message: "private-sentinel".to_owned(), - error_details: Some("private-sentinel".to_owned()), - client_ip: Some("192.0.2.42".to_owned()), - user_agent: Some("key-usage-test/1.0".to_owned()), - reasoning_effort: Some("xhigh".to_owned()), - reasoning_preset: Some("private-sentinel".to_owned()), - request_kind: Some("private-sentinel".to_owned()), - subagent_kind: Some("private-sentinel".to_owned()), - compact: None, - continuation_affinity_hash: Some("private-sentinel".to_owned()), - continuation_previous_response_id_hash: Some("private-sentinel".to_owned()), - continuation_unavailable_reason: Some("private-sentinel".to_owned()), - upstream_connection_id: Some("private-sentinel".to_owned()), - upstream_connection_exit_reason: Some("private-sentinel".to_owned()), - upstream_connection_age_ms: None, - upstream_connection_idle_ms: None, - recovery_request_id: Some("private-sentinel".to_owned()), - recovered_at: None, - recovery_attempt_count: 0, - recovery_retry_delay_ms: None, - recovery_total_latency_ms: None, - occurred_at: Utc::now(), - stable_sort_id: "private-sentinel".to_owned(), - } -} diff --git a/backend/crates/gateway-api/tests/key_usage/mod.rs b/backend/crates/gateway-api/tests/key_usage/mod.rs index f7b2ca6ae..5e67149f4 100644 --- a/backend/crates/gateway-api/tests/key_usage/mod.rs +++ b/backend/crates/gateway-api/tests/key_usage/mod.rs @@ -8,7 +8,7 @@ use axum::{ response::Response, }; use chrono::Utc; -use gateway_admin::model::observability::RequestMetrics; +use gateway_admin::model::observability::{RequestMetrics, RequestOutcome, UsageRecordStatus}; use serde_json::{Value, json}; use tower::ServiceExt as _; @@ -255,7 +255,16 @@ async fn records_keep_pagination_and_hide_admin_and_upstream_data() { let fixture = fixtures::fixture().await; let app = crate::openai::api_router_with_admin(fixture.services.clone()); let cookie = login(&app, "key").await; - for kind in ["success", "error"] { + for (kind, outcome) in [ + ("success", RequestOutcome::Succeeded), + ("error", RequestOutcome::Failed), + ("all", RequestOutcome::Incomplete), + ] { + { + let mut records = fixture.usage_records.lock().unwrap(); + records[0].outcome = outcome; + records[0].client_status_code = (kind != "success").then_some(502); + } let response = get( &app, "records", @@ -291,7 +300,10 @@ async fn records_keep_pagination_and_hide_admin_and_upstream_data() { "statusCode", ], ); - assert_eq!(record["status"], kind); + assert_eq!( + record["status"], + if kind == "all" { "incomplete" } else { kind } + ); assert_eq!(record["model"], "coding"); assert_eq!(record["reasoningEffort"], "xhigh"); assert_eq!(record["clientTransport"], "http_sse"); @@ -323,22 +335,54 @@ async fn records_keep_pagination_and_hide_admin_and_upstream_data() { assert!(record["statusCode"].is_null()); } else { assert_eq!(record["statusCode"], 502); - assert!(record["billing"].is_null()); - assert!(record["tokenDetails"].is_null()); - assert_eq!(record["latencyDetails"], json!({})); + assert_eq!(record["billing"]["totalAmountDisplay"], "$0.1235"); + assert_eq!(record["tokenDetails"]["totalTokens"], 1100); } } let data = fixture.observations.lock().unwrap(); - assert_eq!( - data.records[0].filter.client_api_key_ref.as_deref(), - Some("key-42") - ); - assert_eq!( - data.errors[0].filter.client_api_key_ref.as_deref(), - Some("key-42") - ); - assert_eq!(data.records[0].filter.model.as_deref(), Some("coding")); - assert_eq!(data.errors[0].filter.model.as_deref(), Some("coding")); + assert!(data.errors.is_empty()); + assert_eq!(data.records.len(), 3); + for (query, status) in data.records.iter().zip([ + UsageRecordStatus::Success, + UsageRecordStatus::Error, + UsageRecordStatus::All, + ]) { + assert_eq!(query.status, status); + assert_eq!(query.filter.client_api_key_ref.as_deref(), Some("key-42")); + assert_eq!(query.filter.model.as_deref(), Some("coding")); + assert!(query.filter.provider_account_ref.is_none()); + assert!(query.filter.provider_kind.is_none()); + } +} + +#[tokio::test] +async fn failed_records_without_usage_keep_token_and_billing_details_unknown() { + let fixture = fixtures::fixture().await; + { + let mut records = fixture.usage_records.lock().unwrap(); + let record = &mut records[0]; + record.outcome = RequestOutcome::Failed; + record.client_status_code = Some(502); + record.input_tokens = None; + record.output_tokens = None; + record.cached_input_tokens = None; + record.cache_write_input_tokens = None; + record.reasoning_output_tokens = None; + record.image_input_tokens = None; + record.image_output_tokens = None; + record.total_tokens = None; + record.billing = None; + } + let app = crate::openai::api_router_with_admin(fixture.services.clone()); + let cookie = login(&app, "key").await; + let response = get(&app, "records", "&kind=error", &cookie).await; + assert_eq!(response.status(), StatusCode::OK); + let data = response_json(response).await; + let record = &data["data"]["items"][0]; + assert_eq!(record["status"], "error"); + assert_eq!(record["statusCode"], 502); + assert!(record["tokenDetails"].is_null()); + assert!(record["billing"].is_null()); } #[tokio::test] @@ -392,7 +436,7 @@ async fn unknown_scope_fields_and_unbounded_queries_are_rejected() { "¤tPage=0", "&pageSize=0", "&pageSize=101", - "&kind=all", + "&kind=unknown", "¤tPage=-1", ] { assert!( diff --git a/backend/crates/gateway-store/src/postgres/observability/mapping.rs b/backend/crates/gateway-store/src/postgres/observability/mapping.rs index 6b581c89a..bc1b1df12 100644 --- a/backend/crates/gateway-store/src/postgres/observability/mapping.rs +++ b/backend/crates/gateway-store/src/postgres/observability/mapping.rs @@ -303,6 +303,9 @@ pub(crate) fn usage_list_record_from_row( cost_currency.as_deref(), )?; Ok(UsageListRecord { + error_code: get(row, "error_code")?, + error_message: get(row, "error_message")?, + client_status_code: optional_status(row, "client_status_code")?, outcome: admin_observability::RequestOutcome::new(get::(row, "outcome")?) .map_err(|_| invalid("invalid request outcome"))?, client_api_key_name: get(row, "client_api_key_name")?, diff --git a/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs b/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs index fd2dade64..f4661b579 100644 --- a/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs +++ b/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs @@ -113,7 +113,7 @@ pub(crate) fn literal_prefix_pattern(value: &str) -> String { } pub(crate) const USAGE_LIST_RECORD_SELECT: &str = - "select mr.id, mr.outcome, mr.client_api_key_name_snapshot as client_api_key_name, mr.endpoint, mr.client_transport, mr.requested_model_id, + "select mr.id, mr.outcome, mr.provider_error_code as error_code, mr.error_message, mr.client_status_code, mr.client_api_key_name_snapshot as client_api_key_name, mr.endpoint, mr.client_transport, mr.requested_model_id, mr.provider_kind, mr.provider_account_ref, mr.provider_account_name_snapshot as provider_account_name, mr.provider_account_email_snapshot as provider_account_email, diff --git a/backend/crates/gateway-store/tests/postgres/usage_paging.rs b/backend/crates/gateway-store/tests/postgres/usage_paging.rs index f77881c1c..6c0dda31b 100644 --- a/backend/crates/gateway-store/tests/postgres/usage_paging.rs +++ b/backend/crates/gateway-store/tests/postgres/usage_paging.rs @@ -311,7 +311,7 @@ async fn request_status_pages_share_one_request_timeline() { .bind(id).bind(outcome).execute(&database.pool).await.unwrap(); } sqlx::query( - "update model_requests set requested_model_id='model_status_b' where id='status_b'", + r#"update model_requests set requested_model_id='model_status_b',request_observation_json=jsonb_set(request_observation_json,'{error}','{"providerErrorCode":"server_error","message":"The upstream service is temporarily unavailable."}'::jsonb),client_status_code=502 where id='status_b'"#, ) .execute(&database.pool) .await @@ -377,6 +377,12 @@ async fn request_status_pages_share_one_request_timeline() { .unwrap(); assert_eq!(page.total, 1); assert_eq!(page.items[0].id, "status_b"); + assert_eq!(page.items[0].client_status_code, Some(502)); + assert_eq!(page.items[0].error_code.as_deref(), Some("server_error")); + assert_eq!( + page.items[0].error_message.as_deref(), + Some("The upstream service is temporarily unavailable.") + ); // 诊断包仍可按请求 ID 关联中间事件,不受列表时间范围或最终结果限制 let errors = super::admin_observability_store(&database.pool) .diagnostic_errors(&DiagnosticTarget::Request("status_a".into()), 100) diff --git a/docs/api.md b/docs/api.md index 8f3140128..1b9bf742e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -656,13 +656,14 @@ call 与账号的绑定保存在网关进程内,未连接时一小时过期; | 方法 | 路由 | 查询 | 说明 | | --- | --- | --- | --- | | `GET` | `/api/key-usage/overview` | `period?`、`asOf?` 或 `startTime?`、`endTime?`,另含 `model?` | 用量汇总、趋势、当前额度和锚点所在日期的健康时间线 | -| `GET` | `/api/key-usage/records` | 同上,另含 `kind?`、`currentPage?`、`pageSize?` | 当前 Key 的成功请求或错误记录 | +| `GET` | `/api/key-usage/records` | 同上,另含 `kind?`、`currentPage?`、`pageSize?` | 当前 Key 的请求记录 | | `GET` | `/api/key-usage/config` | 无 | 当前 Key 的客户端配置凭据 | | `GET` | `/api/key-usage/version` | 无 | “关于”弹窗使用的当前版本号和提交号 | 用量查询支持[页面时间合同](#页面时间合同),默认 `period=7d`,一次最多 31 天。模型按完整名称匹配; 不接受 Key ID、账号、Provider 等范围参数或其他未知字段。页码默认 1,每页默认 20,允许 1–100 条; -`kind` 为 `success`(默认)或 `error`。分页响应为 `{ items, currentPage, pageSize, total }` +`kind` 为 `success`(默认)、`error` 或 `all`,沿用使用统计的请求结果筛选:错误包括失败和未完成请求, +全部还包括取消与进行中的请求;一次请求只占一条记录。分页响应为 `{ items, currentPage, pageSize, total }` overview 返回 `asOf`、`asOfDisplay`、`startTime`、`endTime`、`key`、`summary`、`trend`、`healthTimeline`。 `key` 仅包含名称、掩码前缀、并发/RPM、日与周限额、已用 USD 及重置时间;零限额表示不限, @@ -677,8 +678,8 @@ overview 返回 `asOf`、`asOfDisplay`、`startTime`、`endTime`、`key`、`summ 日志只返回时间、公开请求模型、推理强度、接口、上下游传输方式、当前请求的 IP / User-Agent、 Token 明细、费用明细、用时/首字与状态。Token 和费用复用现有展示合同;延迟仅包含当前请求的首事件、 首推理、首文本、总耗时与上游性能指标,口径见[记录范围与统计口径](#记录范围与统计口径),不含账号容量或调度诊断。 -成功记录的 `status` 为 `success`,不伪造未保存的 HTTP 状态;错误记录为 `error`,只返回客户端状态码, -缺失的 Token/费用明细为 null。不返回账号资料、Key ID、上游模型或请求标识、原始错误正文或诊断内容 +`status` 为 `success`、`error`、`incomplete`、`cancelled`、`running` 或 `unknown`,`statusCode` 只返回已记录的客户端状态码。 +各结果均保留已采集的 Token/费用明细,缺失时为 null。不返回账号资料、Key ID、上游模型或请求标识、原始错误正文或诊断内容 version 返回 `{ version, gitSha }`,不接受查询参数,不包含部署模式、更新状态或内部诊断 @@ -1974,6 +1975,8 @@ request/response/upstream ID、outcome 与搜索文本。`search` 仅供明细 重试中间错误不改变请求最终分类;`logicalOutcome` 保留真实结果。状态筛选只影响明细列表, 不改变顶部统计,独立运维事件仍通过 `/api/admin/operations/errors` 查询 +请求列表的 `errorCode` / `errorMessage` 提供最终请求的错误码与错误信息,未记录时为 `null` + 诊断 `dimension` 可取 `model`、`account`、 `apiKey`、`provider`、`transport`、`failureClass`、`status`。诊断按请求量降序返回最多 100 项, 同请求量按维度标识稳定排列 diff --git a/frontend/src/api/modules/key-usage.ts b/frontend/src/api/modules/key-usage.ts index 1cb65f3b7..6c878c6f2 100644 --- a/frontend/src/api/modules/key-usage.ts +++ b/frontend/src/api/modules/key-usage.ts @@ -59,7 +59,7 @@ export interface KeyUsageOverview { healthTimeline: DashboardHealthTimeline } -export type KeyUsageRecordKind = 'success' | 'error' +export type KeyUsageRecordKind = 'success' | 'error' | 'all' export interface KeyUsageRecord { id: string @@ -77,7 +77,7 @@ export interface KeyUsageRecord { latencyDetails: Pick clientIp: string | null userAgent: string | null - status: KeyUsageRecordKind + status: 'success' | 'error' | 'incomplete' | 'cancelled' | 'running' | 'unknown' statusCode: number | null } diff --git a/frontend/src/api/modules/usage.ts b/frontend/src/api/modules/usage.ts index 634224431..6ce2ff6bf 100644 --- a/frontend/src/api/modules/usage.ts +++ b/frontend/src/api/modules/usage.ts @@ -85,6 +85,8 @@ export type UsageRecordStatus = 'success' | 'error' | 'all' export interface UsageListRecord { logicalOutcome: string + errorCode: string | null + errorMessage: string | null clientApiKeyName: string | null id: string provider: string | null diff --git a/frontend/src/components/usage/UsageModelCell.vue b/frontend/src/components/usage/UsageModelCell.vue index f72a81b74..41c097f27 100644 --- a/frontend/src/components/usage/UsageModelCell.vue +++ b/frontend/src/components/usage/UsageModelCell.vue @@ -1,5 +1,5 @@ diff --git a/frontend/src/components/usage/UsageRecordStatusFilter.vue b/frontend/src/components/usage/UsageRecordStatusFilter.vue new file mode 100644 index 000000000..11b9e343c --- /dev/null +++ b/frontend/src/components/usage/UsageRecordStatusFilter.vue @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/components/usage/UsageRecordsTable.vue b/frontend/src/components/usage/UsageRecordsTable.vue index 8ae0d9beb..52d45fb97 100644 --- a/frontend/src/components/usage/UsageRecordsTable.vue +++ b/frontend/src/components/usage/UsageRecordsTable.vue @@ -3,7 +3,6 @@ import type { TableColumn } from '@codex-proxy/ui' import type { UsageListRecord } from '@/api' import { ZTable } from '@codex-proxy/ui' -import { Minimize2 } from '@lucide/vue' import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue' import ProviderIconGroup from '@/components/ProviderIconGroup.vue' import UsageBillingCell from '@/components/usage/UsageBillingCell.vue' @@ -12,6 +11,8 @@ import UsageLatencyCell from '@/components/usage/UsageLatencyCell.vue' import UsageModelCell from '@/components/usage/UsageModelCell.vue' import UsagePerformanceCell from '@/components/usage/UsagePerformanceCell.vue' import UsageReasoningEffortCell from '@/components/usage/UsageReasoningEffortCell.vue' +import UsageRouteCell from '@/components/usage/UsageRouteCell.vue' +import UsageTimeCell from '@/components/usage/UsageTimeCell.vue' import UsageTokenCell from '@/components/usage/UsageTokenCell.vue' import UsageTransportBadge from '@/components/usage/UsageTransportBadge.vue' import { @@ -83,6 +84,10 @@ withDefaults( — + + @@ -102,16 +107,17 @@ withDefaults( + + diff --git a/frontend/src/components/usage/UsageRouteCell.vue b/frontend/src/components/usage/UsageRouteCell.vue new file mode 100644 index 000000000..4dc1c6ec4 --- /dev/null +++ b/frontend/src/components/usage/UsageRouteCell.vue @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/components/usage/UsageTimeCell.vue b/frontend/src/components/usage/UsageTimeCell.vue new file mode 100644 index 000000000..9e28b23e9 --- /dev/null +++ b/frontend/src/components/usage/UsageTimeCell.vue @@ -0,0 +1,9 @@ + + + diff --git a/frontend/src/components/usage/shared/columns.ts b/frontend/src/components/usage/shared/columns.ts index 9d9a09a16..9cb55fcfc 100644 --- a/frontend/src/components/usage/shared/columns.ts +++ b/frontend/src/components/usage/shared/columns.ts @@ -1,7 +1,23 @@ +import type { TableColumn } from '@codex-proxy/ui' import type { UsageListRecord } from '@/api' import { defineTableColumns } from '@codex-proxy/ui' import { formatProviderLabel } from '@/utils/providers' +export const usageDataColumns = [ + { key: 'model', label: '模型', kind: 'custom', size: 'xl' }, + { key: 'reasoningEffort', label: '推理强度', kind: 'status', size: 'md', align: 'left' }, + { key: 'route', label: '端点', kind: 'mono' }, + { key: 'upstreamTransport', label: '上游', kind: 'status', size: 'md' }, + { key: 'clientTransport', label: '接入', kind: 'status', size: 'md' }, + { key: 'tokenDetails', label: 'TOKEN', kind: 'numeric', size: 'xl' }, + { key: 'billing', label: '费用', kind: 'numeric', size: 'xl' }, + { key: 'latency', label: '延迟', kind: 'numeric', size: 'xl' }, + { key: 'performance', label: '速率', kind: 'numeric', size: 'lg' }, + { key: 'createdAtDisplay', label: '时间', kind: 'datetime' }, + { key: 'clientIp', label: 'IP', kind: 'custom', size: '3xl' }, + { key: 'userAgent', label: 'User-Agent', kind: 'custom', size: '4xl' }, +] satisfies TableColumn[] + export const usageRecordColumns = defineTableColumns([ { key: 'clientApiKeyName', label: '密钥名称', kind: 'identity', size: 'xl' }, { @@ -20,17 +36,6 @@ export const usageRecordColumns = defineTableColumns([ fixedWidth: true, formatter: (value: unknown) => formatProviderLabel(typeof value === 'string' ? value : null), }, - { key: 'model', label: '模型', kind: 'custom', size: 'xl' }, - { key: 'reasoningEffort', label: '推理强度', kind: 'status', size: 'md', align: 'left' }, - { key: 'route', label: '端点', kind: 'mono' }, - { key: 'upstreamTransport', label: '上游', kind: 'status', size: 'md' }, - { key: 'clientTransport', label: '接入', kind: 'status', size: 'md' }, - { key: 'tokenDetails', label: 'TOKEN', kind: 'numeric', size: 'xl' }, - { key: 'billing', label: '费用', kind: 'numeric', size: 'xl' }, - { key: 'latency', label: '延迟', kind: 'numeric', size: 'xl' }, - { key: 'performance', label: '速率', kind: 'numeric', size: 'lg' }, - { key: 'createdAtDisplay', label: '时间', kind: 'datetime' }, - { key: 'clientIp', label: 'IP', kind: 'custom', size: '3xl' }, - { key: 'userAgent', label: 'User-Agent', kind: 'custom', size: '4xl' }, + ...usageDataColumns, { key: 'actions', label: '操作', kind: 'actions', size: 'sm', hideable: false }, ]) diff --git a/frontend/src/components/usage/shared/presenter.ts b/frontend/src/components/usage/shared/presenter.ts index 00f8ea989..3e2da867d 100644 --- a/frontend/src/components/usage/shared/presenter.ts +++ b/frontend/src/components/usage/shared/presenter.ts @@ -9,6 +9,9 @@ export type UsagePerformanceRecord = Pick | null } +export type UsageModelRecord = Pick & Partial> +export type UsageReasoningRecord = Pick & Partial> + interface UsageTimingItem { label: string value: string @@ -58,7 +61,7 @@ export function usageTransportTypeClass(transport?: string | null) { return 'bg-cp-fill-tertiary text-cp-text-secondary' } -export function usageAccountText(record: UsageCommonRecord) { +export function usageAccountText(record: Pick) { return record.accountEmail || record.accountName || record.accountId || '—' } @@ -70,14 +73,14 @@ export function usageUserAgent(record: { userAgent?: string | null }) { return record.userAgent || '—' } -export function usageReasoningEffort(record: UsageCommonRecord) { +export function usageReasoningEffort(record: UsageReasoningRecord) { const reasoningEffort = record.reasoningEffort || '—' if (record.subagentKind) return reasoningEffort return record.reasoningPreset || reasoningEffort } -export function usageModelDisplay(record: UsageCommonRecord) { +export function usageModelDisplay(record: UsageModelRecord) { const requestedModel = record.requestedModel || '' const upstreamModel = record.upstreamModel || '' const storedModel = record.model || '' diff --git a/frontend/src/views/accounts/components/AccountConcurrencyModal.vue b/frontend/src/views/accounts/components/AccountConcurrencyModal.vue index a9fa37198..cf7fc2abb 100644 --- a/frontend/src/views/accounts/components/AccountConcurrencyModal.vue +++ b/frontend/src/views/accounts/components/AccountConcurrencyModal.vue @@ -1,48 +1,38 @@ diff --git a/frontend/src/views/accounts/components/AccountConcurrencyTable.vue b/frontend/src/views/accounts/components/AccountConcurrencyTable.vue index 4937a82c1..e6d19be16 100644 --- a/frontend/src/views/accounts/components/AccountConcurrencyTable.vue +++ b/frontend/src/views/accounts/components/AccountConcurrencyTable.vue @@ -1,14 +1,11 @@