diff --git a/src/admin/accounts/routes.rs b/src/admin/accounts/routes.rs index 43a806c3e..020c56866 100644 --- a/src/admin/accounts/routes.rs +++ b/src/admin/accounts/routes.rs @@ -7,23 +7,36 @@ use axum::{ response::IntoResponse, Extension, Json, }; +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; use secrecy::ExposeSecret; use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::{ - admin::accounts::service::{AdminAccountError, AdminAccountMetadata}, admin::auth::session::require_admin_session, admin::response::{AdminEnvelope, AdminError, AdminPageEnvelope, AdminResponse}, + admin::{ + accounts::service::{AdminAccountError, AdminAccountMetadata}, + monitoring::{ + billing, + event_store::AdminLogFilter, + events::{EventLevel, EventLog}, + service::AdminUsageRecord, + }, + }, http::middleware::request_id::RequestId, infra::{ json::{clamp_limit, clamp_page, Page}, - time::{china_datetime, china_rfc3339, china_rfc3339_str}, + time::{china_datetime, china_relative_time, china_rfc3339, china_rfc3339_str}, }, runtime::state::AppState, upstream::accounts::store::StoredAccount, }; +const ACCOUNT_STATS_PAGE_LIMIT: u32 = 200; + // ============================================================================ // Query / Request types // ============================================================================ @@ -105,10 +118,23 @@ pub struct AdminAccountData { pub added_at_display: String, pub updated_at: String, pub updated_at_display: String, + pub quota: AdminAccountQuotaData, + pub usage: AdminAccountUsageData, } impl From for AdminAccountData { fn from(a: AdminAccountMetadata) -> Self { + Self::from_parts(a, None, None, None) + } +} + +impl AdminAccountData { + fn from_parts( + a: AdminAccountMetadata, + usage: Option<&AdminUsageRecord>, + quota: Option, + model_top: Option, + ) -> Self { let access_token_expires_at = a.access_token_expires_at.as_ref().map(china_rfc3339); let access_token_expires_at_display = a.access_token_expires_at.as_ref().map(china_datetime); @@ -126,10 +152,155 @@ impl From for AdminAccountData { added_at_display: china_datetime(&a.added_at), updated_at: china_rfc3339(&a.updated_at), updated_at_display: china_datetime(&a.updated_at), + quota: quota.unwrap_or_default(), + usage: AdminAccountUsageData::from_usage(usage, model_top), } } } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminAccountQuotaData { + pub used_percent: Option, + pub used_percent_display: String, + pub reset_at_display: String, + pub refreshed_at_display: String, + pub window_used_display: String, +} + +impl Default for AdminAccountQuotaData { + fn default() -> Self { + Self { + used_percent: None, + used_percent_display: "-".to_string(), + reset_at_display: "-".to_string(), + refreshed_at_display: "-".to_string(), + window_used_display: "-".to_string(), + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminAccountUsageData { + pub request_count: i64, + pub request_count_display: String, + pub empty_response_count: i64, + pub input_tokens: i64, + pub input_tokens_display: String, + pub output_tokens: i64, + pub output_tokens_display: String, + pub cached_tokens: i64, + pub cached_tokens_display: String, + pub reasoning_tokens: i64, + pub total_tokens: i64, + pub total_tokens_display: String, + pub image_input_tokens: i64, + pub image_output_tokens: i64, + pub image_tokens_display: String, + pub image_request_count: i64, + pub image_request_failed_count: i64, + pub created_tokens: i64, + pub created_tokens_display: String, + pub read_tokens: i64, + pub read_tokens_display: String, + pub last_used_at: Option, + pub last_used_at_display: String, + pub model_top: Option, +} + +impl AdminAccountUsageData { + fn from_usage( + usage: Option<&AdminUsageRecord>, + model_top: Option, + ) -> Self { + let request_count = usage.map_or(0, |usage| usage.request_count); + let empty_response_count = usage.map_or(0, |usage| usage.empty_response_count); + let input_tokens = usage.map_or(0, |usage| usage.input_tokens); + let output_tokens = usage.map_or(0, |usage| usage.output_tokens); + let cached_tokens = usage.map_or(0, |usage| usage.cached_tokens); + let reasoning_tokens = usage.map_or(0, |usage| usage.reasoning_tokens); + let total_tokens = usage.map_or(0, |usage| usage.total_tokens); + let image_input_tokens = usage.map_or(0, |usage| usage.image_input_tokens); + let image_output_tokens = usage.map_or(0, |usage| usage.image_output_tokens); + let image_request_count = usage.map_or(0, |usage| usage.image_request_count); + let image_request_failed_count = usage.map_or(0, |usage| usage.image_request_failed_count); + let created_tokens = input_tokens.saturating_sub(cached_tokens); + let read_tokens = cached_tokens; + let last_used_at = usage.and_then(|usage| usage.last_used_at); + + Self { + request_count, + request_count_display: format_count(nonnegative_i64_to_u64(request_count)), + empty_response_count, + input_tokens, + input_tokens_display: format_tokens(nonnegative_i64_to_u64(input_tokens)), + output_tokens, + output_tokens_display: format_tokens(nonnegative_i64_to_u64(output_tokens)), + cached_tokens, + cached_tokens_display: format_tokens(nonnegative_i64_to_u64(cached_tokens)), + reasoning_tokens, + total_tokens, + total_tokens_display: format_tokens(nonnegative_i64_to_u64(total_tokens)), + image_input_tokens, + image_output_tokens, + image_tokens_display: format_tokens(nonnegative_i64_to_u64( + image_input_tokens + image_output_tokens, + )), + image_request_count, + image_request_failed_count, + created_tokens, + created_tokens_display: format_tokens(nonnegative_i64_to_u64(created_tokens)), + read_tokens, + read_tokens_display: format_tokens(nonnegative_i64_to_u64(read_tokens)), + last_used_at: last_used_at.map(|value| china_rfc3339(&value)), + last_used_at_display: china_relative_time(last_used_at, Utc::now()), + model_top, + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminAccountModelUsageData { + pub model: String, + pub request_count: u64, + pub request_count_display: String, + pub success_rate: f64, + pub success_rate_display: String, + pub input_tokens: u64, + pub input_tokens_display: String, + pub output_tokens: u64, + pub output_tokens_display: String, + pub cached_tokens: u64, + pub cached_tokens_display: String, + pub total_tokens: u64, + pub total_tokens_display: String, + pub total_cost_usd: f64, + pub total_cost_usd_display: String, + pub last_used_at: Option, + pub last_used_at_display: String, +} + +#[derive(Debug, Clone, Default)] +struct AccountListStats { + usage_by_account: HashMap, + quota_by_account: HashMap, + model_top_by_account: HashMap, +} + +impl AccountListStats { + fn data_for(&self, account: AdminAccountMetadata) -> AdminAccountData { + let account_id = account.id.clone(); + AdminAccountData::from_parts( + account, + self.usage_by_account.get(&account_id), + self.quota_by_account.get(&account_id).cloned(), + self.model_top_by_account.get(&account_id).cloned(), + ) + } +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct BatchDeleteAccountsData { @@ -246,6 +417,7 @@ pub async fn accounts( require_admin_session(&state, &headers, &request_id).await?; let limit = clamp_limit(params.page_size.or(params.limit).unwrap_or(50)); let use_numbered_page = params.page.is_some() || params.page_size.is_some(); + let stats = account_list_stats(&state).await; if use_numbered_page { return match state @@ -256,7 +428,11 @@ pub async fn accounts( { Ok(page) => { let page = crate::infra::json::NumberedPage { - items: page.items.into_iter().map(AdminAccountData::from).collect(), + items: page + .items + .into_iter() + .map(|item| stats.data_for(item)) + .collect(), total: page.total, page: page.page, page_size: page.page_size, @@ -278,7 +454,11 @@ pub async fn accounts( { Ok(page) => { let page = Page { - items: page.items.into_iter().map(AdminAccountData::from).collect(), + items: page + .items + .into_iter() + .map(|item| stats.data_for(item)) + .collect(), next_cursor: page.next_cursor, }; Ok(AdminResponse::new( @@ -667,6 +847,270 @@ pub async fn update_account( } } +async fn account_list_stats(state: &AppState) -> AccountListStats { + let usage_records = list_all_usage_records(state).await; + let quota_snapshots = state + .services + .background_tasks + .accounts + .list_quota_snapshots() + .await + .unwrap_or_default(); + let logs = list_all_event_logs(state).await; + + AccountListStats { + usage_by_account: usage_records + .into_iter() + .map(|usage| (usage.account_id.clone(), usage)) + .collect(), + quota_by_account: quota_snapshots + .into_iter() + .map(|snapshot| { + ( + snapshot.account_id, + quota_data(&snapshot.quota_json, snapshot.quota_fetched_at), + ) + }) + .collect(), + model_top_by_account: model_top_by_account(&logs), + } +} + +async fn list_all_usage_records(state: &AppState) -> Vec { + let mut cursor = None; + let mut records = Vec::new(); + loop { + let Ok(page) = state + .services + .usage + .list(cursor, ACCOUNT_STATS_PAGE_LIMIT) + .await + else { + return Vec::new(); + }; + records.extend(page.items); + let Some(next_cursor) = page.next_cursor else { + return records; + }; + cursor = Some(next_cursor); + } +} + +async fn list_all_event_logs(state: &AppState) -> Vec { + let mut cursor = None; + let mut logs = Vec::new(); + loop { + let Ok(page) = state + .services + .logs + .list(cursor, ACCOUNT_STATS_PAGE_LIMIT, AdminLogFilter::default()) + .await + else { + return Vec::new(); + }; + logs.extend(page.items); + let Some(next_cursor) = page.next_cursor else { + return logs; + }; + cursor = Some(next_cursor); + } +} + +#[derive(Debug, Clone, Default)] +struct ModelUsageAggregate { + model: String, + request_count: u64, + error_count: u64, + input_tokens: u64, + output_tokens: u64, + cached_tokens: u64, + last_used_at: Option>, +} + +fn model_top_by_account(logs: &[EventLog]) -> HashMap { + let mut by_account_model = HashMap::<(String, String), ModelUsageAggregate>::new(); + for log in logs { + let Some(account_id) = log + .account_id + .as_ref() + .filter(|value| !value.trim().is_empty()) + else { + continue; + }; + let Some(model) = log.model.as_ref().filter(|value| !value.trim().is_empty()) else { + continue; + }; + let aggregate = by_account_model + .entry((account_id.clone(), model.clone())) + .or_insert_with(|| ModelUsageAggregate { + model: model.clone(), + ..ModelUsageAggregate::default() + }); + aggregate.request_count += 1; + if log.level == EventLevel::Error || log.status_code.is_some_and(|status| status >= 400) { + aggregate.error_count += 1; + } + aggregate.input_tokens += metadata_usage_number(&log.metadata, "inputTokens"); + aggregate.output_tokens += metadata_usage_number(&log.metadata, "outputTokens"); + aggregate.cached_tokens += metadata_usage_number(&log.metadata, "cachedTokens"); + aggregate.last_used_at = Some( + aggregate + .last_used_at + .map_or(log.created_at, |last| last.max(log.created_at)), + ); + } + + let mut by_account = HashMap::::new(); + for ((account_id, _), aggregate) in by_account_model { + let replace = by_account + .get(&account_id) + .is_none_or(|current| aggregate.request_count > current.request_count); + if replace { + by_account.insert(account_id, aggregate); + } + } + + by_account + .into_iter() + .map(|(account_id, aggregate)| (account_id, model_usage_data(aggregate))) + .collect() +} + +fn model_usage_data(usage: ModelUsageAggregate) -> AdminAccountModelUsageData { + let total_tokens = usage.input_tokens + usage.output_tokens; + let success_rate = if usage.request_count > 0 { + ((usage.request_count - usage.error_count) as f64 / usage.request_count as f64 * 1000.0) + .round() + / 10.0 + } else { + 0.0 + }; + let total_cost_usd = billing::calculate_cost( + usage.input_tokens, + usage.output_tokens, + usage.cached_tokens, + &usage.model, + None, + ); + + AdminAccountModelUsageData { + model: usage.model, + request_count: usage.request_count, + request_count_display: format_count(usage.request_count), + success_rate, + success_rate_display: format_percent(success_rate), + input_tokens: usage.input_tokens, + input_tokens_display: format_tokens(usage.input_tokens), + output_tokens: usage.output_tokens, + output_tokens_display: format_tokens(usage.output_tokens), + cached_tokens: usage.cached_tokens, + cached_tokens_display: format_tokens(usage.cached_tokens), + total_tokens, + total_tokens_display: format_tokens(total_tokens), + total_cost_usd, + total_cost_usd_display: format_cost(total_cost_usd), + last_used_at: usage.last_used_at.map(|value| china_rfc3339(&value)), + last_used_at_display: china_relative_time(usage.last_used_at, Utc::now()), + } +} + +fn quota_data(quota_json: &str, fetched_at: Option>) -> AdminAccountQuotaData { + let quota = serde_json::from_str::(quota_json).unwrap_or(Value::Null); + let used_percent = quota + .pointer("/rate_limit/used_percent") + .and_then(number_value) + .map(|value| value.clamp(0.0, 100.0)); + let reset_at = quota + .pointer("/rate_limit/reset_at") + .and_then(Value::as_i64) + .and_then(|value| DateTime::::from_timestamp(value, 0)); + let window_seconds = quota + .pointer("/rate_limit/limit_window_seconds") + .and_then(Value::as_u64); + + AdminAccountQuotaData { + used_percent, + used_percent_display: used_percent + .map(format_percent) + .unwrap_or_else(|| "-".to_string()), + reset_at_display: reset_at + .as_ref() + .map(china_datetime) + .unwrap_or_else(|| "-".to_string()), + refreshed_at_display: china_relative_time(fetched_at, Utc::now()), + window_used_display: quota_window_used_display(reset_at, window_seconds), + } +} + +fn quota_window_used_display( + reset_at: Option>, + window_seconds: Option, +) -> String { + let (Some(reset_at), Some(window_seconds)) = (reset_at, window_seconds) else { + return "-".to_string(); + }; + let remaining = reset_at + .signed_duration_since(Utc::now()) + .num_seconds() + .max(0) as u64; + let used = window_seconds.saturating_sub(remaining); + format!( + "{} / {}", + format_duration_days(used), + format_duration_days(window_seconds) + ) +} + +fn format_duration_days(seconds: u64) -> String { + let days = seconds as f64 / 86_400.0; + if days >= 1.0 { + format!("{days:.1}d") + } else { + format!("{:.1}h", seconds as f64 / 3_600.0) + } +} + +fn metadata_usage_number(metadata: &Value, field: &str) -> u64 { + metadata + .get("usage") + .and_then(|usage| usage.get(field)) + .or_else(|| metadata.get(field)) + .and_then(Value::as_u64) + .unwrap_or(0) +} + +fn number_value(value: &Value) -> Option { + value + .as_f64() + .or_else(|| value.as_str().and_then(|value| value.parse::().ok())) +} + +fn nonnegative_i64_to_u64(value: i64) -> u64 { + u64::try_from(value).unwrap_or(0) +} + +fn format_count(value: u64) -> String { + value.to_string() +} + +fn format_tokens(value: u64) -> String { + if value >= 1_000_000 { + format!("{:.1}M", value as f64 / 1_000_000.0) + } else if value >= 1_000 { + format!("{:.1}K", value as f64 / 1_000.0) + } else { + value.to_string() + } +} + +fn format_percent(value: f64) -> String { + format!("{value:.1}%") +} + +fn format_cost(value: f64) -> String { + format!("${value:.2}") +} + // ============================================================================ // Error handling // ============================================================================ diff --git a/src/admin/monitoring/mod.rs b/src/admin/monitoring/mod.rs index 13061d109..da14a79a2 100644 --- a/src/admin/monitoring/mod.rs +++ b/src/admin/monitoring/mod.rs @@ -1,6 +1,6 @@ //! 管理面监控模块:日志、用量统计和 Dashboard 聚合。 -mod billing; +pub(crate) mod billing; pub mod dashboard; pub mod diagnostics; pub mod event_store; diff --git a/src/admin/monitoring/usage.rs b/src/admin/monitoring/usage.rs index d98cce18d..beaf0c782 100644 --- a/src/admin/monitoring/usage.rs +++ b/src/admin/monitoring/usage.rs @@ -15,7 +15,7 @@ use crate::{ http::middleware::request_id::RequestId, infra::{ json::{clamp_limit, clamp_page, Page}, - time::china_rfc3339, + time::{china_relative_time, china_rfc3339}, }, runtime::state::AppState, }; @@ -50,6 +50,7 @@ pub struct AdminUsageStatsData { pub image_request_count: i64, pub image_request_failed_count: i64, pub last_used_at: Option, + pub last_used_at_display: String, } /// 管理端账号用量汇总响应。 @@ -174,6 +175,7 @@ impl From for AdminUsageStatsData { image_output_tokens: usage.image_output_tokens, image_request_count: usage.image_request_count, image_request_failed_count: usage.image_request_failed_count, + last_used_at_display: china_relative_time(usage.last_used_at, chrono::Utc::now()), last_used_at: usage.last_used_at.map(|value| china_rfc3339(&value)), } } diff --git a/tests/admin/accounts/list.rs b/tests/admin/accounts/list.rs index 89851406f..adab85f93 100644 --- a/tests/admin/accounts/list.rs +++ b/tests/admin/accounts/list.rs @@ -1,4 +1,5 @@ use super::*; +use codex_proxy_rs::admin::monitoring::events::{EventLevel, EventLog}; #[tokio::test] async fn admin_accounts_list_should_not_decrypt_account_tokens() { @@ -55,6 +56,80 @@ async fn admin_accounts_list_should_not_decrypt_account_tokens() { ); } +#[tokio::test] +async fn admin_accounts_list_should_include_usage_quota_and_model_stats() { + let (app, _state, pool, _dir, _secret_box) = + admin_accounts_test_app("admin-accounts-stats.sqlite", 68).await; + seed_usage_account( + &pool, + "acct_stats", + "stats@example.com", + "stats", + "free", + 41, + 5, + 3_500_000, + 13_900, + 3_400_000, + "2026-06-23T08:50:13Z", + ) + .await; + sqlx::query("update accounts set quota_json = ?, quota_fetched_at = ? where id = ?") + .bind( + json!({ + "rate_limit": { + "used_percent": 87.8, + "reset_at": 1782737460, + "limit_window_seconds": 604800 + } + }) + .to_string(), + ) + .bind("2026-06-23T08:51:09Z") + .bind("acct_stats") + .execute(&pool) + .await + .unwrap(); + let store = SqliteEventLogStore::new(pool.clone()); + let mut log = EventLog::new("response", EventLevel::Info, "ok"); + log.account_id = Some("acct_stats".to_string()); + log.model = Some("gpt-5.5".to_string()); + log.created_at = "2026-06-23T08:50:13Z".parse().unwrap(); + log.metadata = json!({ + "usage": { + "inputTokens": 3_500_000u64, + "outputTokens": 13_900u64, + "cachedTokens": 3_400_000u64 + } + }); + store.append(&log).await.unwrap(); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/accounts?page=1&pageSize=10") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_accounts_stats") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + let item = &body["data"]["items"][0]; + assert_eq!(item["usage"]["requestCount"], 41); + assert_eq!(item["quota"]["usedPercentDisplay"], "87.8%"); + assert!(item["quota"]["windowUsedDisplay"] + .as_str() + .is_some_and(|value| value.contains(" / 7.0d"))); + assert_eq!(item["usage"]["createdTokens"], 100_000); + assert_eq!(item["usage"]["readTokens"], 3_400_000); + assert_eq!(item["usage"]["modelTop"]["model"], "gpt-5.5"); +} + #[tokio::test] async fn admin_accounts_list_should_return_numbered_page_with_search_total() { let (app, _state, pool, _dir, _secret_box) = diff --git a/web/src/api/modules/accounts.ts b/web/src/api/modules/accounts.ts index 5e41ebafd..bd1350173 100644 --- a/web/src/api/modules/accounts.ts +++ b/web/src/api/modules/accounts.ts @@ -15,6 +15,63 @@ export interface Account { addedAtDisplay: string updatedAt: string updatedAtDisplay: string + quota: AccountQuota + usage: AccountUsage +} + +export interface AccountQuota { + usedPercent?: number | null + usedPercentDisplay: string + resetAtDisplay: string + refreshedAtDisplay: string + windowUsedDisplay: string +} + +export interface AccountUsage { + requestCount: number + requestCountDisplay: string + emptyResponseCount: number + inputTokens: number + inputTokensDisplay: string + outputTokens: number + outputTokensDisplay: string + cachedTokens: number + cachedTokensDisplay: string + reasoningTokens: number + totalTokens: number + totalTokensDisplay: string + imageInputTokens: number + imageOutputTokens: number + imageTokensDisplay: string + imageRequestCount: number + imageRequestFailedCount: number + createdTokens: number + createdTokensDisplay: string + readTokens: number + readTokensDisplay: string + lastUsedAt?: string + lastUsedAtDisplay: string + modelTop?: AccountModelUsage | null +} + +export interface AccountModelUsage { + model: string + requestCount: number + requestCountDisplay: string + successRate: number + successRateDisplay: string + inputTokens: number + inputTokensDisplay: string + outputTokens: number + outputTokensDisplay: string + cachedTokens: number + cachedTokensDisplay: string + totalTokens: number + totalTokensDisplay: string + totalCostUsd: number + totalCostUsdDisplay: string + lastUsedAt?: string + lastUsedAtDisplay: string } export interface AccountsQuery { diff --git a/web/src/api/modules/usage.ts b/web/src/api/modules/usage.ts index b67c07023..76cbcc47c 100644 --- a/web/src/api/modules/usage.ts +++ b/web/src/api/modules/usage.ts @@ -39,6 +39,7 @@ export interface AccountUsageStats { imageRequestCount: number imageRequestFailedCount: number lastUsedAt?: string + lastUsedAtDisplay: string } export interface UsageStatsQuery { diff --git a/web/src/components/base/BaseTable.vue b/web/src/components/base/BaseTable.vue index 2d22bbe02..b91dd81fa 100644 --- a/web/src/components/base/BaseTable.vue +++ b/web/src/components/base/BaseTable.vue @@ -40,10 +40,12 @@ const props = withDefaults( emptyText?: string maxHeight?: string pagination?: BaseTablePagination + expandedRowKeys?: Array }>(), { rowKey: 'id', selectedRowKeys: () => [], + expandedRowKeys: () => [], stripe: true, loading: false, emptyText: '暂无数据', @@ -201,6 +203,10 @@ function isRowSelected(row: TableRow, index: number) { return props.selectedRowKeys.includes(getRowKey(row, index)) } +function isRowExpanded(row: TableRow, index: number) { + return props.expandedRowKeys.includes(getRowKey(row, index)) +} + function alignClass(column: BaseTableColumn) { if (column.align === 'center') { return 'text-center' @@ -342,36 +348,45 @@ function paginationPageClass(page: number) { - - + -
- - {{ cellValue(row, column.key) }} - -
- - - + +
+ + {{ cellValue(row, column.key) }} + +
+ + + + + + + + + diff --git a/web/src/views/accounts/AccountsView.vue b/web/src/views/accounts/AccountsView.vue index 3a5604cba..e585adffe 100644 --- a/web/src/views/accounts/AccountsView.vue +++ b/web/src/views/accounts/AccountsView.vue @@ -1,6 +1,6 @@