diff --git a/backend/src/admin/monitoring/dashboard.rs b/backend/src/admin/monitoring/dashboard.rs index 6190cd2e6..34b96e95c 100644 --- a/backend/src/admin/monitoring/dashboard.rs +++ b/backend/src/admin/monitoring/dashboard.rs @@ -15,7 +15,7 @@ use crate::{ admin::{ auth::session::AdminAuth, monitoring::{ - account_usage_service::AdminUsageTimeBucketRecord, + account_usage_service::{AdminUsageSummary, AdminUsageTimeBucketRecord}, diagnostics::{ fingerprint_diagnostics, AccountCapacityDiagnostics, AccountPoolDiagnostics, }, @@ -140,7 +140,7 @@ struct DashboardCacheCardData { yesterday_hit_rate_value: Option, total_hit_rate: String, total_cached_tokens: String, - first_token_latency_ms: String, + average_first_token_latency_ms: String, } #[derive(Debug, Serialize)] @@ -163,6 +163,7 @@ struct DashboardTrendPointData { output_tokens_value: u64, cached_tokens: String, cached_tokens_value: u64, + cache_hit_rate_value: f64, tokens_value: u64, errors: String, errors_value: u64, @@ -289,6 +290,12 @@ pub(crate) async fn dashboard_summary( let capacity = state.services.account_pool.capacity_summary_now().await; let now = Utc::now(); let today_filter = today_usage_record_filter(now); + let usage_summary = state + .services + .usage + .summary() + .await + .unwrap_or_else(|_| empty_usage_summary()); let account_usage_records = state .services .usage_records @@ -322,7 +329,7 @@ pub(crate) async fn dashboard_summary( Ok(AdminResponse::new( StatusCode::OK, AdminEnvelope::ok(DashboardSummaryData { - cards: dashboard_cards(&accounts, &time_buckets), + cards: dashboard_cards(&accounts, &time_buckets, &usage_summary), trend, health_timeline: dashboard_health_timeline_data(&time_buckets), account_usage: account_usage_data( @@ -368,13 +375,15 @@ pub(crate) async fn dashboard_trend( fn dashboard_cards( accounts: &[Account], buckets: &[AdminUsageTimeBucketRecord], + usage_summary: &AdminUsageSummary, ) -> DashboardCardsData { - dashboard_cards_at(accounts, buckets, Utc::now()) + dashboard_cards_at(accounts, buckets, usage_summary, Utc::now()) } fn dashboard_cards_at( accounts: &[Account], buckets: &[AdminUsageTimeBucketRecord], + usage_summary: &AdminUsageSummary, now: DateTime, ) -> DashboardCardsData { let today_start = china_day_start(now); @@ -382,6 +391,10 @@ fn dashboard_cards_at( let today = usage_window(buckets, today_start, now); let yesterday = usage_window(buckets, yesterday_start, today_start); let today_cost = cost_window(buckets, today_start, now).unwrap_or(0.0); + let total_requests = nonnegative_i64_to_u64(usage_summary.request_count); + let total_tokens = nonnegative_i64_to_u64(usage_summary.total_tokens); + let total_cached_tokens = nonnegative_i64_to_u64(usage_summary.cached_tokens); + let total_hit_rate = summary_cache_hit_rate(usage_summary); let total_accounts = accounts.len() as u64; let enabled_accounts = accounts @@ -414,26 +427,54 @@ fn dashboard_cards_at( today_requests: format_compact_number(today.requests), today_requests_value: today.requests, yesterday_requests_value: yesterday.requests, - total_requests: format_compact_number(today.requests), + total_requests: format_compact_number(total_requests), }, tokens: DashboardTokenCardData { today_tokens: format_tokens(today.tokens()), today_tokens_value: today.tokens(), yesterday_tokens_value: yesterday.tokens(), - total_tokens: format_tokens(today.tokens()), + total_tokens: format_tokens(total_tokens), today_cost_usd: format_cost(today_cost), }, cache: DashboardCacheCardData { today_hit_rate: format_rate(today.cache_hit_rate()), today_hit_rate_value: today.cache_hit_rate(), yesterday_hit_rate_value: yesterday.cache_hit_rate(), - total_hit_rate: format_rate(today.cache_hit_rate()), - total_cached_tokens: format_tokens(today.cached_tokens), - first_token_latency_ms: format_optional_duration_ms(today.avg_first_token_latency()), + total_hit_rate: format_rate(total_hit_rate), + total_cached_tokens: format_tokens(total_cached_tokens), + average_first_token_latency_ms: format_optional_duration_ms( + today.avg_first_token_latency(), + ), }, } } +fn empty_usage_summary() -> AdminUsageSummary { + AdminUsageSummary { + account_count: 0, + request_count: 0, + empty_response_count: 0, + input_tokens: 0, + output_tokens: 0, + cached_tokens: 0, + reasoning_tokens: 0, + total_tokens: 0, + image_input_tokens: 0, + image_output_tokens: 0, + image_request_count: 0, + image_request_failed_count: 0, + } +} + +fn summary_cache_hit_rate(summary: &AdminUsageSummary) -> Option { + let input_tokens = nonnegative_i64_to_u64(summary.input_tokens); + if input_tokens > 0 { + Some(nonnegative_i64_to_u64(summary.cached_tokens) as f64 / input_tokens as f64) + } else { + None + } +} + fn format_optional_duration_ms(value: Option) -> String { format_duration_ms(value.and_then(|value| i64::try_from(value).ok())) } @@ -495,6 +536,7 @@ fn dashboard_trend_data_at( let max_latency = bucket.max_first_token_bucket_latency().unwrap_or(0); let min_latency = bucket.min_first_token_bucket_latency().unwrap_or(0); let tokens = bucket.tokens(); + let cache_hit_rate = bucket.cache_hit_rate().unwrap_or(0.0); let success_rate = if bucket.requests > 0 { ((bucket.requests - bucket.errors) as f64 / bucket.requests as f64 * 1000.0).round() / 10.0 @@ -511,6 +553,7 @@ fn dashboard_trend_data_at( output_tokens_value: bucket.output_tokens, cached_tokens: format_tokens(bucket.cached_tokens), cached_tokens_value: bucket.cached_tokens, + cache_hit_rate_value: cache_hit_rate, tokens_value: tokens, errors: format_compact_number(bucket.errors), errors_value: bucket.errors, @@ -583,7 +626,7 @@ fn dashboard_health_timeline_data_at( }; DashboardHealthTimelineData { title: "请求健康时间线".to_string(), - description: "今日请求可靠性".to_string(), + description: "请求可靠性".to_string(), reliability_display: if requests > 0 { format!("{reliability:.1}%") } else { diff --git a/backend/src/upstream/protocol/responses.rs b/backend/src/upstream/protocol/responses.rs index b862a4d2b..72538d66a 100644 --- a/backend/src/upstream/protocol/responses.rs +++ b/backend/src/upstream/protocol/responses.rs @@ -640,18 +640,6 @@ impl CodexResponsesRequest { self.body.get("reasoning") } - /// 设置 reasoning 配置(模型后缀路由)。 - pub fn set_reasoning(&mut self, reasoning: Option) { - match reasoning { - Some(value) => { - self.body.insert("reasoning".to_string(), value); - } - None => { - self.body.remove("reasoning"); - } - } - } - /// 工具定义数组(非数组或空时 None)。 pub fn tools(&self) -> Option<&[Value]> { self.body diff --git a/backend/tests/admin/monitoring/dashboard.rs b/backend/tests/admin/monitoring/dashboard.rs index bd3a3c0f3..c3e99dc8a 100644 --- a/backend/tests/admin/monitoring/dashboard.rs +++ b/backend/tests/admin/monitoring/dashboard.rs @@ -66,8 +66,9 @@ async fn dashboard_summary_should_render_dash_when_fingerprint_updated_at_is_mis } #[tokio::test] -async fn dashboard_summary_should_calculate_today_traffic_and_latency_from_time_buckets() { - let (app, store, _pool, _dir) = dashboard_test_app( +async fn dashboard_summary_should_calculate_today_traffic_and_average_first_token_latency_from_time_buckets( +) { + let (app, store, pool, _dir) = dashboard_test_app( "dashboard-traffic-latency.sqlite", crate::support::fingerprint::test_fingerprint(), ) @@ -93,25 +94,32 @@ async fn dashboard_summary_should_calculate_today_traffic_and_latency_from_time_ .append(&usage_record_with_tokens(yesterday_record_at, 30)) .await .unwrap(); + seed_usage_summary_with_cached_tokens(&pool, 120, 30, 24).await; let body = dashboard_summary(app).await; assert_eq!(body["data"]["cards"]["traffic"]["todayRequests"], "2"); assert_eq!(body["data"]["cards"]["traffic"]["todayRequestsValue"], 2); - assert_eq!(body["data"]["cards"]["traffic"]["totalRequests"], "2"); + assert_eq!(body["data"]["cards"]["traffic"]["totalRequests"], "42"); assert_eq!( body["data"]["cards"]["traffic"]["yesterdayRequestsValue"], 1 ); assert_eq!(body["data"]["cards"]["tokens"]["todayTokens"], "30"); assert_eq!(body["data"]["cards"]["tokens"]["todayTokensValue"], 30); - assert_eq!(body["data"]["cards"]["tokens"]["totalTokens"], "30"); + assert_eq!(body["data"]["cards"]["tokens"]["totalTokens"], "150"); assert_eq!(body["data"]["cards"]["tokens"]["yesterdayTokensValue"], 30); - assert_eq!(body["data"]["cards"]["cache"]["totalCachedTokens"], "0"); + assert_eq!(body["data"]["cards"]["cache"]["totalHitRate"], "20.0%"); + assert_eq!(body["data"]["cards"]["cache"]["totalCachedTokens"], "24"); assert_eq!( - body["data"]["cards"]["cache"]["firstTokenLatencyMs"], + body["data"]["cards"]["cache"]["averageFirstTokenLatencyMs"], "300 ms" ); + assert!(body["data"]["cards"]["cache"] + .as_object() + .unwrap() + .get("averageLatencyMs") + .is_none()); } #[tokio::test] @@ -151,7 +159,12 @@ async fn dashboard_trend_should_bucket_usage_by_china_hour() { .await; let now = Utc::now(); store - .append(&usage_record_with_tokens(now - Duration::seconds(1), 10)) + .append(&usage_record_with_token_parts( + now - Duration::seconds(1), + 40, + 60, + 10, + )) .await .unwrap(); @@ -164,7 +177,9 @@ async fn dashboard_trend_should_bucket_usage_by_china_hour() { .expect("trend should include the inserted usage bucket"); assert_eq!(point["time"], format!("{:02}", china_hour(&now))); - assert_eq!(point["tokensValue"], 10); + assert_eq!(point["tokensValue"], 100); + assert_eq!(point["cachedTokensValue"], 10); + assert_f64_eq(point["cacheHitRateValue"].as_f64().unwrap(), 0.25); } #[tokio::test] @@ -596,10 +611,7 @@ async fn dashboard_summary_should_return_today_health_timeline() { let points = body["data"]["healthTimeline"]["points"].as_str().unwrap(); assert_eq!(body["data"]["healthTimeline"]["title"], "请求健康时间线"); - assert_eq!( - body["data"]["healthTimeline"]["description"], - "今日请求可靠性" - ); + assert_eq!(body["data"]["healthTimeline"]["description"], "请求可靠性"); assert_eq!(points.len(), 96); assert_eq!(points.matches('4').count(), 1); } @@ -707,6 +719,39 @@ async fn seed_usage_summary(pool: &SqlitePool, input_tokens: u64, output_tokens: .await; } +async fn seed_usage_summary_with_cached_tokens( + pool: &SqlitePool, + input_tokens: u64, + output_tokens: u64, + cached_tokens: u64, +) { + sqlx::query( + "insert into accounts (id, email, access_token, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?)", + ) + .bind("acct_usage_cached") + .bind("acct-usage-cached@example.com") + .bind("cipher") + .bind("active") + .bind("2026-06-18T00:00:00Z") + .bind("2026-06-18T00:00:00Z") + .execute(pool) + .await + .unwrap(); + sqlx::query( + "insert into account_usage (account_id, request_count, input_tokens, output_tokens, cached_tokens, total_tokens, last_used_at) values (?, ?, ?, ?, ?, ?, ?)", + ) + .bind("acct_usage_cached") + .bind(42_i64) + .bind(input_tokens as i64) + .bind(output_tokens as i64) + .bind(cached_tokens as i64) + .bind((input_tokens + output_tokens) as i64) + .bind("2026-06-18T00:00:00Z") + .execute(pool) + .await + .unwrap(); +} + async fn seed_usage_summary_with_last_used( pool: &SqlitePool, input_tokens: u64, @@ -777,14 +822,23 @@ fn service_status_value<'a>(body: &'a Value, label: &str) -> &'a str { } fn usage_record_with_tokens(created_at: chrono::DateTime, total_tokens: u64) -> UsageRecord { + usage_record_with_token_parts(created_at, total_tokens, 0, 0) +} + +fn usage_record_with_token_parts( + created_at: chrono::DateTime, + input_tokens: u64, + output_tokens: u64, + cached_tokens: u64, +) -> UsageRecord { usage_record( created_at, "gpt-5.5", json!({ "usage": { - "inputTokens": total_tokens, - "outputTokens": 0u64, - "cachedTokens": 0u64 + "inputTokens": input_tokens, + "outputTokens": output_tokens, + "cachedTokens": cached_tokens } }), ) diff --git a/backend/tests/upstream/protocol/codex_websocket.rs b/backend/tests/upstream/protocol/codex_websocket.rs index c17811204..02d893820 100644 --- a/backend/tests/upstream/protocol/codex_websocket.rs +++ b/backend/tests/upstream/protocol/codex_websocket.rs @@ -11,7 +11,6 @@ fn codex_websocket_payload_audit_snapshot_should_redact_user_content() { })], ); request.set_previous_response_id(Some("resp_secret".to_string())); - request.set_reasoning(Some(json!({"effort": "medium"}))); request.set_service_tier(Some("flex".to_string())); request.set_prompt_cache_key(Some("cache-secret".to_string())); request.set_client_metadata(Some(json!({ @@ -33,7 +32,6 @@ fn codex_websocket_payload_audit_snapshot_should_redact_user_content() { "stream", "store", "previous_response_id", - "reasoning", "service_tier", "prompt_cache_key", "client_metadata", diff --git a/frontend/src/views/dashboard/components/RequestTrendCard.vue b/frontend/src/views/dashboard/components/RequestTrendCard.vue index 51b760955..5300d2acd 100644 --- a/frontend/src/views/dashboard/components/RequestTrendCard.vue +++ b/frontend/src/views/dashboard/components/RequestTrendCard.vue @@ -186,7 +186,7 @@ function getSeries() { '成功率', props.points.map((p) => p.successRateValue), themeColor('--cp-success', '#10B981'), - false, + true, 1, ), lineSeries( @@ -197,7 +197,7 @@ function getSeries() { ] } -function lineSeries(name: string, data: number[], color: string, area = false, yAxisIndex = 0) { +function lineSeries(name: string, data: number[], color: string, area = true, yAxisIndex = 0) { return { name, type: 'line' as const, @@ -255,7 +255,7 @@ function handleTrendChange(value: string) { v-if="!hasSamples" compact title="暂无趋势数据" - description="今日暂无请求日志。" + description="当日暂无请求日志。" class="h-full place-content-center bg-transparent" /> diff --git a/frontend/src/views/dashboard/composables/useDashboard.ts b/frontend/src/views/dashboard/composables/useDashboard.ts index 62f75f7c2..4172fbf59 100644 --- a/frontend/src/views/dashboard/composables/useDashboard.ts +++ b/frontend/src/views/dashboard/composables/useDashboard.ts @@ -147,7 +147,7 @@ export function useDashboard(): any { yesterdayHitRateValue: null, totalHitRate: '—', totalCachedTokens: '0', - firstTokenLatencyMs: '—', + averageFirstTokenLatencyMs: '—', }, }, trend: { @@ -157,7 +157,7 @@ export function useDashboard(): any { }, healthTimeline: { title: '请求健康时间线', - description: '今日请求可靠性', + description: '请求可靠性', reliabilityDisplay: '-', points: '', }, @@ -208,7 +208,7 @@ export function useDashboard(): any { ], }, { - title: '今日请求', + title: '请求次数', value: traffic.todayRequests, valueRaw: traffic.todayRequestsValue, valueFormatter: formatDashboardCompactNumber, @@ -222,14 +222,14 @@ export function useDashboard(): any { details: [ { label: '总请求', value: traffic.totalRequests, tone: 'info' }, { - label: '今日首字', - value: cache.firstTokenLatencyMs, + label: '首字延迟', + value: cache.averageFirstTokenLatencyMs, tone: 'info', }, ], }, { - title: '今日 Token', + title: 'Token', value: tokens.todayTokens, valueRaw: tokens.todayTokensValue, valueFormatter: formatDashboardCompactNumber, @@ -243,21 +243,21 @@ export function useDashboard(): any { details: [ { label: '总 Token', value: tokens.totalTokens, tone: 'success' }, { - label: '今日计费', + label: '计费', value: tokens.todayCostUsd, tone: 'success', }, ], }, { - title: '今日缓存命中', + title: '缓存命中', value: cache.todayHitRate, valueRaw: cache.todayHitRateValue, valueFormatter: formatDashboardRate, icon: Timer, tone: cache.todayHitRateValue && cache.todayHitRateValue > 0 ? 'warning' : 'normal', sparkline: sparkline( - points.map((point) => point.cachedTokensValue), + points.map((point) => point.cacheHitRateValue), 'warning', ), trend: trendState( @@ -266,9 +266,9 @@ export function useDashboard(): any { 'warning', ), details: [ - { label: '总缓存命中', value: cache.totalHitRate, tone: 'warning' }, + { label: '平均缓存命中', value: cache.totalHitRate, tone: 'warning' }, { - label: '今日缓存', + label: '缓存', value: cache.totalCachedTokens, tone: 'warning', }, diff --git a/frontend/src/views/login/components/LoginPanel.vue b/frontend/src/views/login/components/LoginPanel.vue index 0f9bc4075..a6ba72879 100644 --- a/frontend/src/views/login/components/LoginPanel.vue +++ b/frontend/src/views/login/components/LoginPanel.vue @@ -49,7 +49,7 @@ function togglePasswordVisible(): void { as="form" :padded="false" variant="elevated" - class="login-form relative grid min-h-129.25 w-[min(450px,100%)] gap-3 rounded-lg px-7.5 pt-6.5 pb-6 max-[560px]:min-h-auto max-[560px]:gap-3 max-[560px]:p-5.5" + class="login-form relative grid min-h-129.25 w-[min(440px,100%)] gap-3 rounded-lg px-7.5 pt-6.5 pb-6 max-[560px]:min-h-auto max-[560px]:gap-3 max-[560px]:p-5.5" @submit.prevent="emit('submit')" >