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 <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-30 18:45:08 +08:00
co-authored by Codex
parent d97c6e064b
commit d10b8890e0
20 changed files with 191 additions and 45 deletions
@@ -809,6 +809,8 @@ pub struct OpsError {
pub provider_account_ref: Option<String>,
pub provider_account_name: Option<String>,
pub provider_account_email: Option<String>,
pub provider_account_plan_type: Option<String>,
pub provider_account_plan_type_display: Option<String>,
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
@@ -382,10 +382,19 @@ impl ObservabilityService for DefaultObservabilityService {
}
async fn ops_errors(&self, query: OpsErrorQuery) -> Result<OpsErrorPage, AdminError> {
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)
}
}
@@ -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,
@@ -792,6 +792,8 @@ pub struct OpsErrorView {
pub account_id: Option<String>,
pub account_name: Option<String>,
pub account_email: Option<String>,
pub account_plan_type: Option<String>,
pub account_plan_type_display: Option<String>,
pub route: String,
pub model: Option<String>,
pub requested_model: Option<String>,
@@ -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",
@@ -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(),
@@ -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<Ops
provider_account_ref: get(row, "provider_account_ref")?,
provider_account_name: get(row, "provider_account_name")?,
provider_account_email: get(row, "provider_account_email")?,
provider_account_plan_type: get(row, "provider_account_plan_type")?,
provider_account_authentication_kind: get(row, "provider_account_authentication_kind")?,
upstream_model_id: get(row, "upstream_model_id")?,
upstream_transport: get(row, "upstream_transport")?,
@@ -722,6 +722,7 @@ pub struct OpsErrorRecord {
pub provider_account_ref: Option<String>,
pub provider_account_name: Option<String>,
pub provider_account_email: Option<String>,
pub provider_account_plan_type: Option<String>,
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
@@ -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(
@@ -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 {
+2 -2
View File
@@ -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`,修改备注不改变历史请求的账号归属
+2
View File
@@ -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
@@ -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)"
/>
<BaseIconButton
v-if="hasForecast"
label="预测周/月额度"
size="sm"
variant="ghost"
aria-haspopup="dialog"
:pressed="forecastOpen"
@click="forecastOpen = true"
>
<ChartNoAxesCombined class="size-3.5" :stroke-width="1.75" />
</BaseIconButton>
<BaseIconButton
v-if="account.capabilities.quotaRefresh"
variant="ghost"
@@ -107,4 +122,10 @@ watch(hasPersonalInfo, (available) => {
v-model="profileOpen"
:account="account"
/>
<AccountQuotaForecastModal
v-if="hasForecast"
v-model="forecastOpen"
:account="account"
@account-updated="emit('accountUpdated', $event)"
/>
</template>
@@ -1,21 +1,15 @@
<script setup lang="ts">
import type { AccountRow } from '../constants'
import { BaseIconButton, BaseTable, defineTableColumns } from '@codex-proxy/ui'
import { ChartNoAxesCombined, Sigma } from '@lucide/vue'
import { computed, ref } from 'vue'
import { BaseTable, defineTableColumns } from '@codex-proxy/ui'
import { Sigma } from '@lucide/vue'
import { computed } from 'vue'
import { modelSuccessRateTextClass } from '../constants'
import AccountQuotaForecastModal from './AccountQuotaForecastModal/index.vue'
const props = defineProps<{
account: AccountRow
}>()
const emit = defineEmits<{
accountUpdated: [account: AccountRow]
}>()
const forecastOpen = ref(false)
type AccountModelUsage = AccountRow['usage']['models'][number]
const totalBilling = computed(() => props.account.usage.costs.find(cost => cost.currency.toUpperCase() === 'USD'))
@@ -80,21 +74,9 @@ const modelUsageColumns = defineTableColumns<AccountModelUsage>([
<div class="min-w-0 pt-4 xl:flex xl:min-h-0 xl:flex-col xl:pt-0 xl:pl-4">
<div class="mb-3 flex shrink-0 flex-wrap items-center gap-x-4 gap-y-2">
<div class="flex shrink-0 items-center gap-1">
<h3 class="m-0 text-cp-lg font-heavy text-cp-text">
模型使用排行
</h3>
<BaseIconButton
v-if="account.authenticationKind !== 'api_key'"
label="预测周/月额度"
size="sm"
class="h-5 w-5"
aria-haspopup="dialog"
@click="forecastOpen = true"
>
<ChartNoAxesCombined class="size-3.5" :stroke-width="1.75" />
</BaseIconButton>
</div>
<h3 class="m-0 shrink-0 text-cp-lg font-heavy text-cp-text">
模型使用排行
</h3>
<div class="ml-auto flex items-baseline gap-4">
<div v-if="hasUsageSummary" class="flex items-baseline gap-1.5 whitespace-nowrap">
@@ -134,11 +116,4 @@ const modelUsageColumns = defineTableColumns<AccountModelUsage>([
</div>
</div>
</section>
<AccountQuotaForecastModal
v-if="account.authenticationKind !== 'api_key'"
v-model="forecastOpen"
:account="account"
@account-updated="emit('accountUpdated', $event)"
/>
</template>
+1 -1
View File
@@ -37,7 +37,7 @@ export const accountColumns = defineTableColumns<AccountRow>([
format: value => accountProviderLabel(typeof value === 'string' ? value : null),
},
{ key: 'status', label: '状态', kind: 'status', align: 'left', sortable: true },
{ key: 'planType', label: '套餐', kind: 'status', sortable: true },
{ key: 'planType', label: '订阅', kind: 'status', sortable: true },
{ key: 'usage', label: '用量', kind: 'custom', size: '2xl', sortable: true },
{ key: 'groups', label: '账号分组', kind: 'status' },
{
+2 -4
View File
@@ -344,13 +344,11 @@ const {
<AccountQuotaPanel
:account="row"
:refreshing="refreshingQuotaAccountIds.has(row.id)"
@account-updated="void replaceAccount($event)"
@quota-reset="handleQuotaReset"
@refresh-quota="handleRefreshQuota"
/>
<AccountUsagePanel
:account="row"
@account-updated="void replaceAccount($event)"
/>
<AccountUsagePanel :account="row" />
</div>
</template>
</BaseTable>
@@ -30,7 +30,7 @@ const { accounts, loading, error, pagination, search, setPage, setPageSize, remo
const columns = defineTableColumns<OutboundProxyAccount>([
{ key: 'identity', label: '账号', kind: 'identity', size: '3xl' },
{ key: 'provider', label: '平台/类型', kind: 'custom', size: 'md', align: 'center' },
{ key: 'plan', label: '套餐', kind: 'custom', size: 'sm', align: 'center' },
{ key: 'plan', label: '订阅', kind: 'custom', size: 'sm', align: 'center' },
{ key: 'groups', label: '分组', kind: 'custom', size: 'lg', align: 'center' },
{ key: 'actions', label: '操作', kind: 'actions', size: 'xs', align: 'center' },
])
@@ -6,6 +6,7 @@ import { BaseIconButton, BaseInput, BaseTable, BaseTableColumnSettings, BaseTabl
import { Eye, RefreshCw, Search } from '@lucide/vue'
import { shallowRef, toRef } from 'vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import AccountPlanBadge from '@/views/accounts/components/AccountPlanBadge.vue'
import { useOpsErrorsTable } from '../composables/useOpsErrorsTable'
import { opsErrorColumns } from '../constants'
import { opsErrorSummary } from '../utils/opsErrorPresentation'
@@ -152,6 +153,15 @@ function upstreamSendStateText(value: string | null | undefined) {
:authentication-kind="row.authenticationKind"
/>
</template>
<template #accountPlanType="{ row }">
<AccountPlanBadge
v-if="row.accountPlanType"
:plan-type="row.accountPlanType"
:plan-type-display="row.accountPlanTypeDisplay || row.accountPlanType"
size="sm"
/>
<span v-else class="text-cp-text-quaternary">—</span>
</template>
<template #message="{ row }">
<div class="min-w-0 py-0.5" :title="row.message || opsErrorSummary(row)">
<div class="flex min-w-0 items-center gap-2">
@@ -56,7 +56,7 @@ const diagnosticColumns = computed(() => defineTableColumns<DiagnosticDisplayIte
},
...(resultDimension.value === 'account'
? defineTableColumns<DiagnosticDisplayItem>([
{ key: 'accountPlanType', label: '套餐', kind: 'status', size: 'sm', fixedWidth: true },
{ key: 'accountPlanType', label: '订阅', kind: 'status', size: 'sm', fixedWidth: true },
])
: []),
{
+2 -1
View File
@@ -14,7 +14,7 @@ export const usageRecordColumns = defineTableColumns<UsageDisplayRecord>([
kind: 'identity',
size: '3xl',
},
{ key: 'accountPlanType', label: '套餐', kind: 'status', size: 'sm', fixedWidth: true },
{ key: 'accountPlanType', label: '订阅', kind: 'status', size: 'sm', fixedWidth: true },
{
key: 'provider',
label: '平台/类型',
@@ -40,6 +40,7 @@ export const usageRecordColumns = defineTableColumns<UsageDisplayRecord>([
export const opsErrorColumns = defineTableColumns<OpsErrorRow>([
{ key: 'clientApiKeyName', label: '密钥名称', kind: 'identity', size: 'xl' },
{ key: 'accountId', label: '账号', kind: 'identity', size: '3xl', emptyText: '未知账号', hideable: false },
{ key: 'accountPlanType', label: '订阅', kind: 'status', size: 'sm', fixedWidth: true },
{ key: 'provider', label: '平台/类型', kind: 'custom', size: 'sm' },
{ key: 'message', label: '错误', kind: 'custom', size: '4xl', hideable: false },
{ key: 'upstreamSendState', label: '发送状态', kind: 'custom', size: 'xl' },