fix: refine dashboard metrics

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-07-06 16:53:31 +08:00
co-authored by Codex
parent 9c37d262d1
commit 875d610812
8 changed files with 138 additions and 55 deletions
+53 -10
View File
@@ -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<f64>,
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<Utc>,
) -> 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<f64> {
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<u64>) -> 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 {
@@ -640,18 +640,6 @@ impl CodexResponsesRequest {
self.body.get("reasoning")
}
/// 设置 reasoning 配置(模型后缀路由)。
pub fn set_reasoning(&mut self, reasoning: Option<Value>) {
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
+69 -15
View File
@@ -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<Utc>, 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<Utc>,
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
}
}),
)
@@ -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",
@@ -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"
/>
</div>
@@ -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',
},
@@ -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')"
>
<div class="login-form-line" aria-hidden="true" />
+1 -1
View File
@@ -74,7 +74,7 @@ export const usageRecordColumns = [
{
key: 'clientIp',
label: 'IP',
width: '160px',
width: '200px',
ellipsis: false,
},
{