From 76eecd771eed347a0730775e85be83f6fdc13212 Mon Sep 17 00:00:00 2001 From: Zyy Date: Sat, 31 Oct 2026 12:06:16 +0800 Subject: [PATCH] fix(ui): correct account subscription and quota displays Preserve authentication snapshots for diagnostic account badges and align account note typography. Hide unavailable dashboard quotas consistently and show available quota rates independently of local request counts. Co-authored-by: Codex --- .../gateway-admin/src/model/observability.rs | 2 + .../src/use_case/observability/mod.rs | 1 + .../tests/use_case/observability/mod.rs | 1 + .../src/admin/observability/presenter.rs | 1 + .../src/admin/observability/wire.rs | 1 + .../tests/admin/observability/response.rs | 139 ++++++++++-------- .../postgres/observability/analytics/query.rs | 6 +- .../src/postgres/observability/mapping.rs | 1 + .../src/postgres/observability/model.rs | 1 + .../tests/postgres/observability/mod.rs | 38 +++++ docs/api.md | 2 +- frontend/src/api/modules/usage.ts | 1 + .../components/usage/UsageRecordsTable.vue | 5 +- .../components/AccountNotesPopover.vue | 2 +- .../account-quota-summary-cell/index.vue | 7 +- .../components/AccountOverviewCard.vue | 3 +- .../usage/components/UsageDiagnosticCard.vue | 5 +- 17 files changed, 143 insertions(+), 73 deletions(-) diff --git a/backend/crates/gateway-admin/src/model/observability.rs b/backend/crates/gateway-admin/src/model/observability.rs index 2d4540a48..177f5b0c9 100644 --- a/backend/crates/gateway-admin/src/model/observability.rs +++ b/backend/crates/gateway-admin/src/model/observability.rs @@ -965,6 +965,7 @@ pub struct DiagnosticObservation { pub key: String, pub name: String, pub account_provider_kind: Option, + pub account_authentication_kind: Option, pub account_plan_type: Option, pub request_count: u64, pub success_count: u64, @@ -1435,6 +1436,7 @@ pub struct UsageInsight { pub struct DiagnosticsItem { pub key: String, pub name: String, + pub account_authentication_kind: Option, pub account_plan_type: Option, pub account_plan_type_display: Option, pub request_count: u64, diff --git a/backend/crates/gateway-admin/src/use_case/observability/mod.rs b/backend/crates/gateway-admin/src/use_case/observability/mod.rs index 9f9fe81b8..6d4d38fa8 100644 --- a/backend/crates/gateway-admin/src/use_case/observability/mod.rs +++ b/backend/crates/gateway-admin/src/use_case/observability/mod.rs @@ -344,6 +344,7 @@ impl ObservabilityService for DefaultObservabilityService { DiagnosticsItem { key: item.key, name: item.name, + account_authentication_kind: item.account_authentication_kind, account_plan_type_display: item.account_provider_kind.as_deref().and_then( |provider| { self.providers 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 831b6bbef..02825a9ac 100644 --- a/backend/crates/gateway-admin/tests/use_case/observability/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/observability/mod.rs @@ -1559,6 +1559,7 @@ fn diagnostic(name: &str, request_count: u64) -> DiagnosticObservation { retry_count: 0, retried_request_count: 0, account_provider_kind: None, + account_authentication_kind: None, account_plan_type: None, key: name.to_owned(), name: name.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 8783554fa..227a9df80 100644 --- a/backend/crates/gateway-api/src/admin/observability/presenter.rs +++ b/backend/crates/gateway-api/src/admin/observability/presenter.rs @@ -1339,6 +1339,7 @@ pub(crate) fn diagnostics_view( .into_iter() .map(|item| DiagnosticItemView { key: item.key, + authentication_kind: item.account_authentication_kind, name: if item.name == "__none__" { "未知".to_owned() } else { diff --git a/backend/crates/gateway-api/src/admin/observability/wire.rs b/backend/crates/gateway-api/src/admin/observability/wire.rs index ee72034db..47215d31c 100644 --- a/backend/crates/gateway-api/src/admin/observability/wire.rs +++ b/backend/crates/gateway-api/src/admin/observability/wire.rs @@ -845,6 +845,7 @@ pub struct UsageCacheView { pub struct DiagnosticItemView { pub key: String, pub name: String, + pub authentication_kind: Option, pub account_plan_type: Option, pub account_plan_type_display: Option, pub request_count: u64, diff --git a/backend/crates/gateway-api/tests/admin/observability/response.rs b/backend/crates/gateway-api/tests/admin/observability/response.rs index 200e82044..07dc65272 100644 --- a/backend/crates/gateway-api/tests/admin/observability/response.rs +++ b/backend/crates/gateway-api/tests/admin/observability/response.rs @@ -670,67 +670,84 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() { use crate::admin::{AdminTestFixture, AdminTestState}; - let fixture = AdminTestFixture::new().await; - fixture.auth.insert_session("valid-session"); - *fixture.diagnostics.lock().expect("diagnostics") = DiagnosticsObservation { - analytics: Default::default(), - total_request_count: 4, - items: vec![DiagnosticObservation { - key: "acct_diag".to_owned(), - account_provider_kind: Some("openai".to_owned()), - account_plan_type: Some("pro".to_owned()), - name: "diag@example.invalid".to_owned(), - request_count: 2, - success_count: 2, - failure_count: 0, - attempt_count: 5, - total_tokens: 200, - average_latency_ms: Some(100), - latency_p95_ms: Some(3800), - first_token_p95_ms: Some(1200), - non_completion_count: 0, - retry_count: 3, - retried_request_count: 1, - cost_coverage: CostCoverage::default(), - costs: Vec::new(), - }], - }; - let response = observability::router::() - .with_state(fixture.state()) - .oneshot( - Request::builder() - .uri("/api/admin/usage/insights/diagnostics?dimension=account") - .header(header::COOKIE, "cpr_session=valid-session") - .header("x-request-id", "req_diagnostics_snapshot") - .body(Body::empty()) - .expect("diagnostics request"), - ) - .await - .expect("diagnostics response"); - assert_eq!(response.status(), StatusCode::OK); - let body = to_bytes(response.into_body(), 1024 * 1024) - .await - .expect("diagnostics body"); - let value: serde_json::Value = serde_json::from_slice(&body).expect("diagnostics JSON"); - assert_eq!(value["data"]["items"][0]["requestShare"], 0.5); - assert_eq!(value["data"]["items"][0]["retryCount"], 3); - assert_eq!(value["data"]["items"][0]["retryRate"], 0.5); - assert_eq!(value["data"]["items"][0]["accountPlanType"], "pro"); - assert_eq!(value["data"]["items"][0]["accountPlanTypeDisplay"], "Pro"); - assert_eq!( - ( - &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"), - ) - ); + for (authentication_kind, plan_type, plan_display) in [ + (Some("oauth"), Some("pro"), Some("Pro")), + (Some("api_key"), None, None), + (None, None, None), + ] { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + *fixture.diagnostics.lock().expect("diagnostics") = DiagnosticsObservation { + analytics: Default::default(), + total_request_count: 4, + items: vec![DiagnosticObservation { + key: "acct_diag".to_owned(), + account_provider_kind: Some("openai".to_owned()), + account_authentication_kind: authentication_kind.map(str::to_owned), + account_plan_type: plan_type.map(str::to_owned), + name: "diag@example.invalid".to_owned(), + request_count: 2, + success_count: 2, + failure_count: 0, + attempt_count: 5, + total_tokens: 200, + average_latency_ms: Some(100), + latency_p95_ms: Some(3800), + first_token_p95_ms: Some(1200), + non_completion_count: 0, + retry_count: 3, + retried_request_count: 1, + cost_coverage: CostCoverage::default(), + costs: Vec::new(), + }], + }; + let response = observability::router::() + .with_state(fixture.state()) + .oneshot( + Request::builder() + .uri("/api/admin/usage/insights/diagnostics?dimension=account") + .header(header::COOKIE, "cpr_session=valid-session") + .header("x-request-id", "req_diagnostics_snapshot") + .body(Body::empty()) + .expect("diagnostics request"), + ) + .await + .expect("diagnostics response"); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("diagnostics body"); + let value: serde_json::Value = serde_json::from_slice(&body).expect("diagnostics JSON"); + assert_eq!(value["data"]["items"][0]["requestShare"], 0.5); + assert_eq!(value["data"]["items"][0]["retryCount"], 3); + assert_eq!(value["data"]["items"][0]["retryRate"], 0.5); + assert_eq!( + value["data"]["items"][0]["authenticationKind"], + serde_json::json!(authentication_kind) + ); + assert_eq!( + value["data"]["items"][0]["accountPlanType"], + serde_json::json!(plan_type) + ); + assert_eq!( + value["data"]["items"][0]["accountPlanTypeDisplay"], + serde_json::json!(plan_display) + ); + assert_eq!( + ( + &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"), + ) + ); + } } pub(super) fn usage_record_with_account( diff --git a/backend/crates/gateway-store/src/postgres/observability/analytics/query.rs b/backend/crates/gateway-store/src/postgres/observability/analytics/query.rs index de8f20820..de54dd4a7 100644 --- a/backend/crates/gateway-store/src/postgres/observability/analytics/query.rs +++ b/backend/crates/gateway-store/src/postgres/observability/analytics/query.rs @@ -634,6 +634,7 @@ impl<'a> AnalyticsSnapshot<'a> { key: key.clone(), name: key, account_provider_kind: None, + account_authentication_kind: None, account_plan_type: None, request_count: metrics.request_count, success_count: metrics.success_count, @@ -669,9 +670,9 @@ impl<'a> AnalyticsSnapshot<'a> { .map(|item| item.key.clone()) .collect::>(); let statement = if dimension == DiagnosticDimension::Account { - "select requested.id, historical.provider_kind, historical.provider_account_plan_type_snapshot as plan_type, coalesce(historical.provider_account_email_snapshot,historical.provider_account_name_snapshot,requested.id) as name from unnest($1::text[]) requested(id) left join lateral (select provider_kind,provider_account_plan_type_snapshot,provider_account_email_snapshot,provider_account_name_snapshot from model_request_observations mr where mr.provider_account_ref=requested.id order by mr.started_at desc,mr.id desc limit 1) historical on true" + "select requested.id, historical.provider_kind, historical.provider_account_authentication_kind_snapshot as authentication_kind, historical.provider_account_plan_type_snapshot as plan_type, coalesce(historical.provider_account_email_snapshot,historical.provider_account_name_snapshot,requested.id) as name from unnest($1::text[]) requested(id) left join lateral (select provider_kind,provider_account_authentication_kind_snapshot,provider_account_plan_type_snapshot,provider_account_email_snapshot,provider_account_name_snapshot from model_request_observations mr where mr.provider_account_ref=requested.id order by mr.started_at desc,mr.id desc limit 1) historical on true" } else { - "select requested.id, null::text as provider_kind, null::text as plan_type,coalesce(historical.client_api_key_name_snapshot,requested.id) as name from unnest($1::text[]) requested(id) left join lateral (select client_api_key_name_snapshot from model_request_observations mr where mr.client_api_key_ref=requested.id order by mr.started_at desc,mr.id desc limit 1) historical on true" + "select requested.id, null::text as provider_kind, null::text as authentication_kind, null::text as plan_type,coalesce(historical.client_api_key_name_snapshot,requested.id) as name from unnest($1::text[]) requested(id) left join lateral (select client_api_key_name_snapshot from model_request_observations mr where mr.client_api_key_ref=requested.id order by mr.started_at desc,mr.id desc limit 1) historical on true" }; let labels = sqlx::query(statement) .bind(ids) @@ -683,6 +684,7 @@ impl<'a> AnalyticsSnapshot<'a> { if let Some(item) = observations.iter_mut().find(|item| item.key == id) { item.name = get(&row, "name")?; item.account_provider_kind = get(&row, "provider_kind")?; + item.account_authentication_kind = get(&row, "authentication_kind")?; item.account_plan_type = get(&row, "plan_type")?; } } diff --git a/backend/crates/gateway-store/src/postgres/observability/mapping.rs b/backend/crates/gateway-store/src/postgres/observability/mapping.rs index 9b4b0bee3..053ade186 100644 --- a/backend/crates/gateway-store/src/postgres/observability/mapping.rs +++ b/backend/crates/gateway-store/src/postgres/observability/mapping.rs @@ -279,6 +279,7 @@ fn admin_diagnostic_observation( key: observation.key, name: observation.name, account_provider_kind: observation.account_provider_kind, + account_authentication_kind: observation.account_authentication_kind, account_plan_type: observation.account_plan_type, request_count: observation.request_count, success_count: observation.success_count, diff --git a/backend/crates/gateway-store/src/postgres/observability/model.rs b/backend/crates/gateway-store/src/postgres/observability/model.rs index a4ce35e3a..9549b5265 100644 --- a/backend/crates/gateway-store/src/postgres/observability/model.rs +++ b/backend/crates/gateway-store/src/postgres/observability/model.rs @@ -378,6 +378,7 @@ pub struct DiagnosticObservation { pub key: String, pub name: String, pub account_provider_kind: Option, + pub account_authentication_kind: Option, pub account_plan_type: Option, pub request_count: u64, pub success_count: u64, diff --git a/backend/crates/gateway-store/tests/postgres/observability/mod.rs b/backend/crates/gateway-store/tests/postgres/observability/mod.rs index ce99b3b75..e3f6fd684 100644 --- a/backend/crates/gateway-store/tests/postgres/observability/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/observability/mod.rs @@ -497,6 +497,44 @@ async fn usage_list_and_diagnostics_preserve_account_snapshots_by_id() { diagnostics.items[0].account_plan_type.as_deref(), Some("team") ); + for (authentication_kind, plan_type) in [ + (Some("api_key"), None), + (Some("oauth"), Some("team")), + (None, None), + ] { + sqlx::query( + "update model_requests set request_observation_json = jsonb_set( + request_observation_json, '{account}', + (request_observation_json->'account') || jsonb_build_object( + 'authenticationKind', $1::text, 'planTypeSnapshot', $2::text + ) + ) where id = 'req_observe_uncommitted'", + ) + .bind(authentication_kind) + .bind(plan_type) + .execute(&database.pool) + .await + .expect("update request authentication snapshot"); + super::drain_analytics(&database.pool).await; + let diagnostics = store + .usage_diagnostics( + range, + admin_observability::UsageFilter { + provider_account_ref: Some("acct_team".to_owned()), + ..Default::default() + }, + admin_observability::DiagnosticDimension::Account, + 100, + ) + .await + .expect("deleted account authentication snapshot"); + assert_eq!(diagnostics.items.len(), 1); + assert_eq!( + diagnostics.items[0].account_authentication_kind.as_deref(), + authentication_kind + ); + assert_eq!(diagnostics.items[0].account_plan_type.as_deref(), plan_type); + } database.close().await; } diff --git a/docs/api.md b/docs/api.md index 911816965..e49db8041 100644 --- a/docs/api.md +++ b/docs/api.md @@ -2250,7 +2250,7 @@ Dashboard 返回原始 `asOf` 及 `asOfDisplay`,作为当前数据的查询锚 `retryCount` 为额外执行尝试次数之和,`retryRate` 为发生过重试的请求数占该组请求数的比例,同一请求多次重试只计一次 使用记录列表和错误排查列表的 `accountPlanType` / `accountPlanTypeDisplay` 来自记录保存的账号订阅快照。 -账号维度诊断使用该账号最近请求的订阅快照;未保存订阅时为 `null` +账号维度诊断使用该账号最近请求的订阅快照,并通过 `authenticationKind` 返回同一请求的认证类型快照;未保存的字段为 `null`,非账号维度的认证类型为 `null` 管理端请求列表及 Dashboard 最近请求中的 `accountNotes` 来自请求保存的账号备注快照,未保存时为 `null` diff --git a/frontend/src/api/modules/usage.ts b/frontend/src/api/modules/usage.ts index 589cf4e5e..97edde031 100644 --- a/frontend/src/api/modules/usage.ts +++ b/frontend/src/api/modules/usage.ts @@ -361,6 +361,7 @@ export type UsageMetricResponse = { export interface UsageDiagnosticItem { key: string name: string + authenticationKind: string | null accountPlanType: string | null accountPlanTypeDisplay: string | null requestCount: number diff --git a/frontend/src/components/usage/UsageRecordsTable.vue b/frontend/src/components/usage/UsageRecordsTable.vue index 06734de55..715e2c6ab 100644 --- a/frontend/src/components/usage/UsageRecordsTable.vue +++ b/frontend/src/components/usage/UsageRecordsTable.vue @@ -79,9 +79,10 @@ const { t } = useI18n({ useScope: 'global' })