From d10b8890e001caf0a670d8872ea9f63538fc7e65 Mon Sep 17 00:00:00 2001 From: Zyy Date: Wed, 30 Sep 2026 18:45:08 +0800 Subject: [PATCH] fix(admin): improve quota forecast and subscription display Move the quota forecast entry after reset credits and propagate account updates from the quota panel. Include each error account current subscription in troubleshooting and unify subscription labels. Co-authored-by: Codex --- .../gateway-admin/src/model/observability.rs | 2 + .../src/use_case/observability.rs | 13 ++- .../src/admin/observability/presenter.rs | 2 + .../src/admin/observability/wire.rs | 2 + .../tests/admin/observability/response.rs | 6 + .../gateway-api/tests/key_usage/fixtures.rs | 2 + .../src/postgres/observability/mapping.rs | 3 + .../src/postgres/observability/model.rs | 1 + .../src/postgres/observability/queries/ops.rs | 4 + .../tests/postgres/observability/mod.rs | 108 ++++++++++++++++++ docs/api.md | 4 +- frontend/src/api/modules/usage.ts | 2 + .../components/AccountQuotaPanel/index.vue | 25 +++- .../accounts/components/AccountUsagePanel.vue | 37 +----- frontend/src/views/accounts/constants.ts | 2 +- frontend/src/views/accounts/index.vue | 6 +- .../proxies/components/ProxyAccountsModal.vue | 2 +- .../views/usage/components/OpsErrorPanel.vue | 10 ++ .../usage/components/UsageDiagnosticCard.vue | 2 +- frontend/src/views/usage/constants.ts | 3 +- 20 files changed, 191 insertions(+), 45 deletions(-) diff --git a/backend/crates/gateway-admin/src/model/observability.rs b/backend/crates/gateway-admin/src/model/observability.rs index 7bfc12983..bde9385e2 100644 --- a/backend/crates/gateway-admin/src/model/observability.rs +++ b/backend/crates/gateway-admin/src/model/observability.rs @@ -809,6 +809,8 @@ pub struct OpsError { pub provider_account_ref: Option, pub provider_account_name: Option, pub provider_account_email: Option, + pub provider_account_plan_type: Option, + pub provider_account_plan_type_display: Option, pub provider_account_authentication_kind: Option, pub upstream_model_id: Option, pub upstream_transport: Option, diff --git a/backend/crates/gateway-admin/src/use_case/observability.rs b/backend/crates/gateway-admin/src/use_case/observability.rs index 38047327a..30a33eb65 100644 --- a/backend/crates/gateway-admin/src/use_case/observability.rs +++ b/backend/crates/gateway-admin/src/use_case/observability.rs @@ -382,10 +382,19 @@ impl ObservabilityService for DefaultObservabilityService { } async fn ops_errors(&self, query: OpsErrorQuery) -> Result { - self.store + let mut page = self + .store .list_ops_errors(query) .await - .map_err(|error| map_store_error(error, "operations errors")) + .map_err(|error| map_store_error(error, "operations errors"))?; + for error in &mut page.items { + error.provider_account_plan_type_display = + error.provider_kind.as_deref().and_then(|provider| { + self.providers + .plan_type_display(provider, error.provider_account_plan_type.as_deref()) + }); + } + Ok(page) } } diff --git a/backend/crates/gateway-api/src/admin/observability/presenter.rs b/backend/crates/gateway-api/src/admin/observability/presenter.rs index afa9ab724..965def980 100644 --- a/backend/crates/gateway-api/src/admin/observability/presenter.rs +++ b/backend/crates/gateway-api/src/admin/observability/presenter.rs @@ -583,6 +583,8 @@ pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView { account_id: error.provider_account_ref, account_name: error.provider_account_name, account_email: error.provider_account_email, + account_plan_type: error.provider_account_plan_type, + account_plan_type_display: error.provider_account_plan_type_display, route: error.endpoint.unwrap_or_else(|| "—".to_owned()), model, requested_model: error.requested_model_id, diff --git a/backend/crates/gateway-api/src/admin/observability/wire.rs b/backend/crates/gateway-api/src/admin/observability/wire.rs index 05542ce5a..be27bec34 100644 --- a/backend/crates/gateway-api/src/admin/observability/wire.rs +++ b/backend/crates/gateway-api/src/admin/observability/wire.rs @@ -792,6 +792,8 @@ pub struct OpsErrorView { pub account_id: Option, pub account_name: Option, pub account_email: Option, + pub account_plan_type: Option, + pub account_plan_type_display: Option, pub route: String, pub model: Option, pub requested_model: Option, diff --git a/backend/crates/gateway-api/tests/admin/observability/response.rs b/backend/crates/gateway-api/tests/admin/observability/response.rs index e2a9c16e1..89e1aa35b 100644 --- a/backend/crates/gateway-api/tests/admin/observability/response.rs +++ b/backend/crates/gateway-api/tests/admin/observability/response.rs @@ -447,6 +447,8 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() { provider_account_ref: Some("acct_err".to_owned()), provider_account_name: None, provider_account_email: Some("err@example.invalid".to_owned()), + provider_account_plan_type: Some("pro".to_owned()), + provider_account_plan_type_display: Some("Pro".to_owned()), provider_account_authentication_kind: Some("api_key".to_owned()), upstream_model_id: Some("upstream-err".to_owned()), upstream_transport: Some("http_sse".to_owned()), @@ -509,6 +511,8 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() { "kind": value["data"]["items"][0]["kind"], "accountId": value["data"]["items"][0]["accountId"], "accountLabel": value["data"]["items"][0]["metadata"]["accountLabel"], + "accountPlanType": value["data"]["items"][0]["accountPlanType"], + "accountPlanTypeDisplay": value["data"]["items"][0]["accountPlanTypeDisplay"], "clientStatusCode": value["data"]["items"][0]["clientStatusCode"], "route": value["data"]["items"][0]["route"], "requestedModel": value["data"]["items"][0]["requestedModel"], @@ -528,6 +532,8 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() { "kind": "model_request", "accountId": "acct_err", "accountLabel": "err@example.invalid", + "accountPlanType": "pro", + "accountPlanTypeDisplay": "Pro", "clientStatusCode": 502, "route": "/v1/responses", "requestedModel": "gpt-5.4", diff --git a/backend/crates/gateway-api/tests/key_usage/fixtures.rs b/backend/crates/gateway-api/tests/key_usage/fixtures.rs index dd937c13e..bb3360e3f 100644 --- a/backend/crates/gateway-api/tests/key_usage/fixtures.rs +++ b/backend/crates/gateway-api/tests/key_usage/fixtures.rs @@ -168,6 +168,8 @@ fn error_record() -> OpsError { 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(), diff --git a/backend/crates/gateway-store/src/postgres/observability/mapping.rs b/backend/crates/gateway-store/src/postgres/observability/mapping.rs index 1a8352b26..bf41391f4 100644 --- a/backend/crates/gateway-store/src/postgres/observability/mapping.rs +++ b/backend/crates/gateway-store/src/postgres/observability/mapping.rs @@ -757,6 +757,8 @@ pub(crate) fn admin_ops_error(error: OpsErrorRecord) -> admin_observability::Ops provider_account_ref: error.provider_account_ref, provider_account_name: error.provider_account_name, provider_account_email: error.provider_account_email, + provider_account_plan_type: error.provider_account_plan_type, + provider_account_plan_type_display: None, provider_account_authentication_kind: error.provider_account_authentication_kind, upstream_model_id: error.upstream_model_id, upstream_transport: error.upstream_transport, @@ -955,6 +957,7 @@ pub(crate) fn ops_error_from_row(row: &sqlx::postgres::PgRow) -> StoreResult, pub provider_account_name: Option, pub provider_account_email: Option, + pub provider_account_plan_type: Option, pub provider_account_authentication_kind: Option, pub upstream_model_id: Option, pub upstream_transport: Option, diff --git a/backend/crates/gateway-store/src/postgres/observability/queries/ops.rs b/backend/crates/gateway-store/src/postgres/observability/queries/ops.rs index 4f96344b1..75cb55c6e 100644 --- a/backend/crates/gateway-store/src/postgres/observability/queries/ops.rs +++ b/backend/crates/gateway-store/src/postgres/observability/queries/ops.rs @@ -10,6 +10,7 @@ const REQUEST_ERROR_SELECT: &str = "select 'model_request'::text as source, mr.endpoint, 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, + account.plan_type as provider_account_plan_type, mr.provider_account_authentication_kind_snapshot as provider_account_authentication_kind, mr.upstream_model_id, mr.upstream_transport, @@ -33,6 +34,7 @@ const REQUEST_ERROR_SELECT: &str = "select 'model_request'::text as source, 'model_request:' || mr.id as stable_sort_id from model_requests mr left join client_api_keys client_key on client_key.id = mr.client_api_key_ref +left join provider_accounts account on account.id = mr.provider_account_ref where true"; const OPS_EVENT_SELECT: &str = "select 'ops_event'::text as source, @@ -42,6 +44,7 @@ const OPS_EVENT_SELECT: &str = "select 'ops_event'::text as source, mr.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, + account.plan_type as provider_account_plan_type, oe.provider_account_authentication_kind_snapshot as provider_account_authentication_kind, oe.upstream_model_id, null::text as upstream_transport, oe.failure_kind, @@ -66,6 +69,7 @@ const OPS_EVENT_SELECT: &str = "select 'ops_event'::text as source, from ops_events oe left join model_requests mr on mr.id = oe.model_request_id left join client_api_keys client_key on client_key.id = mr.client_api_key_ref +left join provider_accounts account on account.id = oe.provider_account_ref where true"; pub(crate) async fn list_ops_errors( diff --git a/backend/crates/gateway-store/tests/postgres/observability/mod.rs b/backend/crates/gateway-store/tests/postgres/observability/mod.rs index b79f24d8a..f02233637 100644 --- a/backend/crates/gateway-store/tests/postgres/observability/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/observability/mod.rs @@ -653,6 +653,114 @@ async fn ops_search_should_treat_sql_wildcards_as_literals() { database.close().await; } +#[tokio::test] +async fn ops_errors_should_use_each_event_accounts_current_subscription() { + let Some(database) = TestDatabase::create("ops_account_subscription").await else { + return; + }; + let now = Utc::now(); + seed_observability_facts(&database.pool, now) + .await + .expect("seed observability facts"); + sqlx::query( + "insert into provider_accounts ( + id, provider_kind, name, email, upstream_user_id, upstream_account_id, + plan_type, authentication_kind, provider_credentials_json, + credential_revision, credential_observed_at, has_refresh_token, + created_at, updated_at + ) select 'acct_retry', provider_kind, name, email, upstream_user_id, + 'retry-workspace', 'team', authentication_kind, + provider_credentials_json, credential_revision, credential_observed_at, + has_refresh_token, created_at, updated_at + from provider_accounts where id = 'acct_observe'", + ) + .execute(&database.pool) + .await + .expect("seed same-email retry account"); + sqlx::query( + "update ops_events set provider_account_id = 'acct_retry', + provider_account_ref = 'acct_retry' + where id = 'ops_observe_retry'", + ) + .execute(&database.pool) + .await + .expect("assign retry event to its own account"); + let query = admin_observability::OpsErrorQuery { + range: admin_observability::TimeRange::new( + now - TimeDelta::hours(1), + now + TimeDelta::hours(1), + ) + .expect("ops range"), + filter: admin_observability::OpsErrorFilter::default(), + current_page: 1, + page_size: PageSize::new(10).expect("page size"), + }; + let store = admin_observability_store(&database.pool); + for plan in [Some("pro"), Some("plus"), None] { + sqlx::query("update provider_accounts set plan_type = $1 where id = 'acct_observe'") + .bind(plan) + .execute(&database.pool) + .await + .expect("update current subscription"); + let page = store + .list_ops_errors(query.clone()) + .await + .expect("ops page"); + assert_eq!(page.total, 2); + assert_eq!(page.items.len(), 2); + let request = page + .items + .iter() + .find(|error| error.source == "model_request") + .expect("request error"); + let event = page + .items + .iter() + .find(|error| error.source == "ops_event") + .expect("retry event"); + assert_eq!(request.provider_account_email, event.provider_account_email); + assert_eq!(request.provider_account_plan_type.as_deref(), plan); + assert_eq!(event.provider_account_plan_type.as_deref(), Some("team")); + } + sqlx::query( + "update ops_events set model_request_id = null, attempt_index = null + where id = 'ops_observe_retry'", + ) + .execute(&database.pool) + .await + .expect("detach event from request"); + let page = store + .list_ops_errors(query.clone()) + .await + .expect("ops page"); + assert_eq!( + page.items + .iter() + .find(|error| error.source == "ops_event") + .expect("standalone event") + .provider_account_plan_type + .as_deref(), + Some("team") + ); + sqlx::query("delete from provider_accounts where id = 'acct_retry'") + .execute(&database.pool) + .await + .expect("delete retry account"); + let page = store + .list_ops_errors(query) + .await + .expect("deleted account history"); + assert_eq!(page.total, 2); + let event = page + .items + .iter() + .find(|error| error.source == "ops_event") + .expect("deleted account event"); + assert_eq!(event.provider_account_ref.as_deref(), Some("acct_retry")); + assert_eq!(event.provider_account_plan_type, None); + database.close().await; +} + #[tokio::test] async fn ops_should_include_incomplete_upstream_errors() { let Some(database) = TestDatabase::create("ops_incomplete_error").await else { diff --git a/docs/api.md b/docs/api.md index b86fd2bd0..702fdd72a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1562,8 +1562,8 @@ request/response/upstream ID、outcome 与搜索文本。诊断 `dimension` 可 `requestShare` 的分母为该维度筛选后、截取前的全部请求数;`failureClass` 只在带错误类型的请求内计算占比 `retryCount` 为额外执行尝试次数之和,`retryRate` 为发生过重试的请求数占该组请求数的比例,同一请求多次重试只计一次 -账号维度诊断项和使用记录列表的 `accountPlanType` / `accountPlanTypeDisplay` 返回账号当前套餐及展示名称, -按内部账号 ID 关联;套餐未知或账号已删除时为 `null`,不作为请求发生时的套餐快照 +账号维度诊断项、使用记录列表和错误排查列表的 `accountPlanType` / `accountPlanTypeDisplay` 返回账号当前订阅及展示名称, +按各记录的内部账号 ID 关联;订阅未知或账号已删除时为 `null`,不作为请求发生时的订阅快照 管理端请求列表及 Dashboard 最近请求中的 `accountNotes` 为账号当前备注,按内部账号 ID 关联。 备注不写入请求历史快照;无备注或账号已删除时返回 `null`,修改备注不改变历史请求的账号归属 diff --git a/frontend/src/api/modules/usage.ts b/frontend/src/api/modules/usage.ts index 13cbe5a5c..97250a1eb 100644 --- a/frontend/src/api/modules/usage.ts +++ b/frontend/src/api/modules/usage.ts @@ -278,6 +278,8 @@ export interface OpsError { accountId: string | null accountName: string | null accountEmail: string | null + accountPlanType: string | null + accountPlanTypeDisplay: string | null route: string model: string | null requestedModel: string | null diff --git a/frontend/src/views/accounts/components/AccountQuotaPanel/index.vue b/frontend/src/views/accounts/components/AccountQuotaPanel/index.vue index 5af5835ae..d41089e56 100644 --- a/frontend/src/views/accounts/components/AccountQuotaPanel/index.vue +++ b/frontend/src/views/accounts/components/AccountQuotaPanel/index.vue @@ -2,12 +2,13 @@ import type { AccountRow } from '../../constants' import { BaseEmpty, BaseIconButton } from '@codex-proxy/ui' -import { RefreshCw, UserRound } from '@lucide/vue' +import { ChartNoAxesCombined, RefreshCw, UserRound } from '@lucide/vue' import { computed, shallowRef, watch } from 'vue' import { formatProviderLabel } from '@/utils/providers' import { groupedAccountQuotaWindows, orderedPanelQuotaWindows } from '../../constants' import AccountPlanBadge from '../AccountPlanBadge.vue' import AccountProfileModal from '../AccountProfileModal/index.vue' +import AccountQuotaForecastModal from '../AccountQuotaForecastModal/index.vue' import AccountQuotaPanelEntry from './Entry.vue' import AccountResetCredits from './ResetCredits.vue' @@ -17,6 +18,7 @@ const props = defineProps<{ }>() const emit = defineEmits<{ + accountUpdated: [account: AccountRow] refreshQuota: [accountId: string] quotaReset: [accountId: string] }>() @@ -25,8 +27,10 @@ const quotaEntries = computed(() => groupedAccountQuotaWindows( orderedPanelQuotaWindows(props.account.quota.windows), )) const profileOpen = shallowRef(false) +const forecastOpen = shallowRef(false) +const hasForecast = computed(() => props.account.authenticationKind !== 'api_key') const hasPersonalInfo = computed(() => props.account.capabilities.profile || props.account.capabilities.subscription) -const hasActions = computed(() => hasPersonalInfo.value || props.account.capabilities.quotaRefresh || props.account.capabilities.resetCredits) +const hasActions = computed(() => hasPersonalInfo.value || hasForecast.value || props.account.capabilities.quotaRefresh || props.account.capabilities.resetCredits) watch(hasPersonalInfo, (available) => { if (!available) profileOpen.value = false @@ -69,6 +73,17 @@ watch(hasPersonalInfo, (available) => { :account="account" @consumed="emit('quotaReset', $event)" /> + + + { v-model="profileOpen" :account="account" /> + diff --git a/frontend/src/views/accounts/components/AccountUsagePanel.vue b/frontend/src/views/accounts/components/AccountUsagePanel.vue index 7523a8cd2..25659951a 100644 --- a/frontend/src/views/accounts/components/AccountUsagePanel.vue +++ b/frontend/src/views/accounts/components/AccountUsagePanel.vue @@ -1,21 +1,15 @@