mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix: refine dashboard metrics
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -74,7 +74,7 @@ export const usageRecordColumns = [
|
||||
{
|
||||
key: 'clientIp',
|
||||
label: 'IP',
|
||||
width: '160px',
|
||||
width: '200px',
|
||||
ellipsis: false,
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user