From bd8a281baf2404097dfd2b9bf603ae4c40392e46 Mon Sep 17 00:00:00 2001 From: zyycn Date: Wed, 1 Jul 2026 21:27:25 +0800 Subject: [PATCH] feat: refine dashboard usage reporting Track account usage windows from the first use or reset cycle. Render today's request health timeline with availability levels and polish dashboard card layout. Co-authored-by: Codex --- backend/src/admin/accounts/routes.rs | 13 +- backend/src/admin/monitoring/dashboard.rs | 110 +++++++++++--- backend/src/upstream/accounts/pool.rs | 2 +- backend/src/upstream/accounts/store.rs | 13 +- backend/tests/admin/accounts/list.rs | 134 ++++++++++++++++++ backend/tests/admin/monitoring/dashboard.rs | 10 +- .../accounts/account_pool/usage_window.rs | 20 +++ .../upstream/accounts/account_repository.rs | 16 ++- .../components/AccountOverviewCard.vue | 57 ++++---- .../components/RequestHealthTimelineCard.vue | 82 +++++++---- .../dashboard/components/RequestTrendCard.vue | 8 +- .../dashboard/composables/useDashboard.ts | 2 +- frontend/src/views/dashboard/index.vue | 2 +- 13 files changed, 361 insertions(+), 108 deletions(-) diff --git a/backend/src/admin/accounts/routes.rs b/backend/src/admin/accounts/routes.rs index 60a9cbaf2..d6e80de85 100644 --- a/backend/src/admin/accounts/routes.rs +++ b/backend/src/admin/accounts/routes.rs @@ -12,7 +12,7 @@ use axum::{ }; use std::collections::HashMap; -use chrono::{DateTime, Duration, Utc}; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value; use sqlx::{QueryBuilder, Row, Sqlite}; @@ -37,7 +37,7 @@ use crate::{ format_cost, format_percent, format_plain_number, format_tokens, nonnegative_i64_to_u64, }, json::{clamp_limit, clamp_page, total_pages, Page}, - time::{china_datetime, china_relative_time, china_rfc3339}, + time::{china_datetime, china_quarter_hour_start, china_relative_time, china_rfc3339}, }, runtime::state::AppState, upstream::accounts::model::AccountStatus, @@ -1029,10 +1029,11 @@ async fn list_current_window_model_usage( if index > 0 { builder.push(" or "); } + let bucket_start = china_quarter_hour_start(*start); builder.push("(account_id = "); builder.push_bind(account_id); builder.push(" and bucket_start >= "); - builder.push_bind(start.to_rfc3339()); + builder.push_bind(bucket_start.to_rfc3339()); builder.push(" and bucket_start <= "); builder.push_bind(end.to_rfc3339()); builder.push(")"); @@ -1095,11 +1096,7 @@ fn current_usage_window( usage: &AdminUsageRecord, now: DateTime, ) -> Option<(DateTime, DateTime)> { - let start = usage.window_started_at.or_else(|| { - let reset_at = usage.window_reset_at?; - let seconds = i64::try_from(usage.limit_window_seconds?).ok()?; - Some(reset_at - Duration::seconds(seconds)) - })?; + let start = usage.window_started_at?; let end = usage.window_reset_at.unwrap_or(now); (start <= end).then_some((start, end)) } diff --git a/backend/src/admin/monitoring/dashboard.rs b/backend/src/admin/monitoring/dashboard.rs index 2ebe1436a..c8ea01a03 100644 --- a/backend/src/admin/monitoring/dashboard.rs +++ b/backend/src/admin/monitoring/dashboard.rs @@ -41,7 +41,9 @@ use crate::{ const DASHBOARD_USAGE_RECORD_LIMIT: u32 = 1000; const HEALTH_TIMELINE_SLOT_MINUTES: i64 = 15; -const HEALTH_TIMELINE_SLOTS: i64 = 7 * 24 * 4; +const DASHBOARD_TIME_BUCKET_SLOTS: i64 = 7 * 24 * 4; +const HEALTH_TIMELINE_SLOTS: i64 = 24 * 4; +const HEALTH_TIMELINE_STABLE_SUCCESS_THRESHOLD: u64 = 3; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -432,7 +434,7 @@ async fn dashboard_time_buckets( ) -> Vec { let current_slot = china_quarter_hour_start(now); let start = current_slot - - Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * (HEALTH_TIMELINE_SLOTS - 1)); + - Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * (DASHBOARD_TIME_BUCKET_SLOTS - 1)); state .services .usage @@ -530,8 +532,7 @@ fn dashboard_health_timeline_data_at( now: DateTime, ) -> DashboardHealthTimelineData { let current_slot = china_quarter_hour_start(now); - let start = current_slot - - Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * (HEALTH_TIMELINE_SLOTS - 1)); + let start = china_day_start(now); let mut buckets = (0..HEALTH_TIMELINE_SLOTS) .map(|index| { let bucket_start = start + Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * index); @@ -554,52 +555,121 @@ fn dashboard_health_timeline_data_at( let requests = buckets .iter() + .filter(|(bucket_start, _)| *bucket_start <= current_slot) .map(|(_, bucket)| bucket.requests) .sum::(); - let errors = buckets.iter().map(|(_, bucket)| bucket.errors).sum::(); + let errors = buckets + .iter() + .filter(|(bucket_start, _)| *bucket_start <= current_slot) + .map(|(_, bucket)| bucket.errors) + .sum::(); + let successes = requests.saturating_sub(errors); let reliability = if requests > 0 { - ((requests - errors) as f64 / requests as f64 * 1000.0).round() / 10.0 + (successes as f64 / requests as f64 * 1000.0).round() / 10.0 } else { 0.0 }; - let end = current_slot + Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES); - DashboardHealthTimelineData { title: "请求健康时间线".to_string(), - description: "最近 7 天请求可靠性".to_string(), - range_display: format!("{} - {}", china_datetime(&start), china_datetime(&end)), + description: "今日请求可靠性".to_string(), + range_display: format!("{} - {}", china_datetime(&start), china_datetime(&now)), reliability_display: if requests > 0 { format!("{reliability:.1}%") } else { "-".to_string() }, - oldest_label: "最早".to_string(), - newest_label: "最新".to_string(), + oldest_label: "00:00".to_string(), + newest_label: "24:00".to_string(), points: buckets .into_iter() - .map(|(_, bucket)| { + .map(|(bucket_start, bucket)| { let success_rate = if bucket.requests > 0 { - ((bucket.requests - bucket.errors) as f64 / bucket.requests as f64 * 1000.0) + (bucket.requests.saturating_sub(bucket.errors) as f64 / bucket.requests as f64 + * 1000.0) .round() / 10.0 } else { 0.0 }; - health_tone(bucket, success_rate) + health_tone(bucket, success_rate, bucket_start > current_slot) }) .collect(), } } -fn health_tone(bucket: UsageWindow, success_rate: f64) -> char { - if bucket.requests == 0 { +fn health_tone(bucket: UsageWindow, success_rate: f64, is_future: bool) -> char { + let successes = bucket.requests.saturating_sub(bucket.errors); + if is_future { '0' - } else if bucket.errors == 0 { + } else if bucket.requests == 0 { '1' - } else if success_rate >= 90.0 { + } else if successes == 0 { '2' - } else { + } else if success_rate < 90.0 { '3' + } else if successes < HEALTH_TIMELINE_STABLE_SUCCESS_THRESHOLD { + '4' + } else { + '5' + } +} + +#[cfg(test)] +mod tests { + use super::{health_tone, UsageWindow}; + + #[test] + fn health_tone_should_classify_availability_levels() { + assert_eq!(health_tone(UsageWindow::default(), 0.0, true), '0'); + assert_eq!(health_tone(UsageWindow::default(), 0.0, false), '1'); + assert_eq!( + health_tone( + UsageWindow { + requests: 2, + errors: 2, + ..UsageWindow::default() + }, + 0.0, + false, + ), + '2' + ); + assert_eq!( + health_tone( + UsageWindow { + requests: 10, + errors: 2, + ..UsageWindow::default() + }, + 80.0, + false, + ), + '3' + ); + assert_eq!( + health_tone( + UsageWindow { + requests: 2, + errors: 0, + ..UsageWindow::default() + }, + 100.0, + false, + ), + '4' + ); + assert_eq!( + health_tone( + UsageWindow { + requests: 3, + errors: 0, + ..UsageWindow::default() + }, + 100.0, + false, + ), + '5' + ); } } diff --git a/backend/src/upstream/accounts/pool.rs b/backend/src/upstream/accounts/pool.rs index 3daddcfa1..9e3e466b2 100644 --- a/backend/src/upstream/accounts/pool.rs +++ b/backend/src/upstream/accounts/pool.rs @@ -718,8 +718,8 @@ impl AccountPool { account.request_count = account.request_count.saturating_add(1); account.window_request_count = account.window_request_count.saturating_add(1); if account.window_started_at.is_none() { + account.window_started_at = Some(now); if let Some(seconds) = account.limit_window_seconds { - account.window_started_at = Some(now); let seconds = i64::try_from(seconds).unwrap_or(i64::MAX); account.window_reset_at = Some(now + Duration::seconds(seconds)); } diff --git a/backend/src/upstream/accounts/store.rs b/backend/src/upstream/accounts/store.rs index ec00c0a8e..172cc6461 100644 --- a/backend/src/upstream/accounts/store.rs +++ b/backend/src/upstream/accounts/store.rs @@ -293,8 +293,9 @@ insert into account_usage ( window_image_output_tokens, window_image_request_count, window_image_request_failed_count, + window_started_at, last_used_at -) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) on conflict(account_id) do update set request_count = request_count + excluded.request_count, empty_response_count = empty_response_count + excluded.empty_response_count, @@ -315,13 +316,8 @@ on conflict(account_id) do update set window_image_output_tokens = window_image_output_tokens + excluded.window_image_output_tokens, window_image_request_count = window_image_request_count + excluded.window_image_request_count, window_image_request_failed_count = window_image_request_failed_count + excluded.window_image_request_failed_count, - window_started_at = case - when account_usage.window_started_at is null - and (account_usage.window_reset_at is not null or account_usage.limit_window_seconds is not null) - then excluded.last_used_at - else account_usage.window_started_at - end, - last_used_at = excluded.last_used_at"; + window_started_at = coalesce(account_usage.window_started_at, excluded.window_started_at), + last_used_at = excluded.last_used_at"; const RECORD_MODEL_USAGE_SQL: &str = r" insert into account_model_usage ( @@ -1293,6 +1289,7 @@ impl SqliteAccountStore { .bind(delta.window_image_request_count) .bind(delta.window_image_request_failed_count) .bind(&last_used_at) + .bind(&last_used_at) .execute(&self.pool) .await?; Ok(()) diff --git a/backend/tests/admin/accounts/list.rs b/backend/tests/admin/accounts/list.rs index c31279c2c..94d52a732 100644 --- a/backend/tests/admin/accounts/list.rs +++ b/backend/tests/admin/accounts/list.rs @@ -252,6 +252,140 @@ async fn admin_accounts_list_should_include_usage_quota_and_model_stats() { assert_eq!(item["usage"]["models"][1]["model"], "gpt-5"); } +#[tokio::test] +async fn admin_accounts_list_should_require_current_window_start_for_model_stats() { + let (app, _state, pool, _dir) = + admin_accounts_test_app("admin-accounts-model-window-start.sqlite", 69).await; + sqlx::query("insert into accounts (id, email, access_token, status, added_at, updated_at) values (?, ?, ?, 'active', ?, ?)") + .bind("acct_no_window_start") + .bind("stats@example.com") + .bind("access-token") + .bind("2026-06-11T00:00:00Z") + .bind("2026-06-11T00:00:00Z") + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "insert into account_usage ( + account_id, + request_count, + window_request_count, + window_input_tokens, + window_output_tokens, + window_cached_tokens, + window_reset_at, + limit_window_seconds, + last_used_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind("acct_no_window_start") + .bind(3) + .bind(3) + .bind(120) + .bind(40) + .bind(20) + .bind("2026-06-27T00:00:00Z") + .bind(604_800) + .bind("2026-06-23T08:50:13Z") + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "insert into usage_time_buckets (bucket_start, account_id, model, request_count, input_tokens, output_tokens, cached_tokens, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind("2026-06-23T08:45:00+00:00") + .bind("acct_no_window_start") + .bind("gpt-5.5") + .bind(3) + .bind(120) + .bind(40) + .bind(20) + .bind("2026-06-23T08:45:00Z") + .execute(&pool) + .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_model_window_start") + .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"]["models"].as_array().unwrap().len(), 0); +} + +#[tokio::test] +async fn admin_accounts_list_should_include_bucket_containing_window_start_for_model_stats() { + let (app, _state, pool, _dir) = + admin_accounts_test_app("admin-accounts-model-window-bucket-start.sqlite", 70).await; + seed_usage_account( + &pool, + UsageAccountSeed { + id: "acct_bucket_start", + email: "bucket@example.com", + label: "bucket", + plan_type: "free", + request_count: 2, + empty_response_count: 0, + input_tokens: 120, + output_tokens: 40, + cached_tokens: 20, + window_request_count: 2, + window_input_tokens: 120, + window_output_tokens: 40, + window_cached_tokens: 20, + window_started_at: "2026-06-29T12:56:15.098980168+00:00", + window_reset_at: "2026-07-29T12:56:15.098980168+00:00", + limit_window_seconds: 2_592_000, + last_used_at: "2026-06-29T12:59:42.044220420+00:00", + }, + ) + .await; + sqlx::query( + "insert into usage_time_buckets (bucket_start, account_id, model, request_count, input_tokens, output_tokens, cached_tokens, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind("2026-06-29T12:45:00+00:00") + .bind("acct_bucket_start") + .bind("gpt-5.5") + .bind(2) + .bind(120) + .bind(40) + .bind(20) + .bind("2026-06-29T12:59:42.044220420+00:00") + .execute(&pool) + .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_model_window_bucket_start") + .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"]["models"][0]["model"], "gpt-5.5"); + assert_eq!(item["usage"]["models"][0]["requestCount"], 2); +} + #[tokio::test] async fn admin_accounts_list_should_return_numbered_page_with_search_total() { let (app, _state, pool, _dir) = diff --git a/backend/tests/admin/monitoring/dashboard.rs b/backend/tests/admin/monitoring/dashboard.rs index 3375c5753..259b1b277 100644 --- a/backend/tests/admin/monitoring/dashboard.rs +++ b/backend/tests/admin/monitoring/dashboard.rs @@ -322,7 +322,7 @@ async fn dashboard_summary_should_order_account_usage_by_recent_usage() { } #[tokio::test] -async fn dashboard_summary_should_return_seven_day_health_timeline() { +async fn dashboard_summary_should_return_today_health_timeline() { let (app, store, _pool, _dir) = dashboard_test_app( "dashboard-health-timeline.sqlite", crate::support::fingerprint::test_fingerprint(), @@ -344,8 +344,12 @@ async fn dashboard_summary_should_return_seven_day_health_timeline() { let points = body["data"]["healthTimeline"]["points"].as_str().unwrap(); assert_eq!(body["data"]["healthTimeline"]["title"], "请求健康时间线"); - assert_eq!(points.len(), 672); - assert_eq!(points.matches('1').count(), 1); + assert_eq!( + body["data"]["healthTimeline"]["description"], + "今日请求可靠性" + ); + assert_eq!(points.len(), 96); + assert_eq!(points.matches('4').count(), 1); } async fn dashboard_summary_total_cost_for_usage_record( diff --git a/backend/tests/upstream/accounts/account_pool/usage_window.rs b/backend/tests/upstream/accounts/account_pool/usage_window.rs index 0af4eba10..c4a97513b 100644 --- a/backend/tests/upstream/accounts/account_pool/usage_window.rs +++ b/backend/tests/upstream/accounts/account_pool/usage_window.rs @@ -116,6 +116,26 @@ fn release_should_mark_runtime_usage_window() { assert!(released.last_used_at.is_some()); } +#[test] +fn release_should_start_usage_window_without_known_reset() { + let now = fixed_time(); + let mut pool = AccountPool::default(); + pool.insert(crate::support::accounts::test_account( + "acct_a", + AccountStatus::Active, + )); + + let acquired = pool + .acquire_with(&AccountAcquireRequest::new("gpt-5.5", now)) + .unwrap(); + + pool.release(&acquired.account.id); + let released = pool.get(&acquired.account.id).unwrap(); + + assert!(released.window_started_at.is_some()); + assert!(released.window_reset_at.is_none()); +} + #[test] fn release_should_mark_usage_after_stale_slot_cleanup() { let now = fixed_time(); diff --git a/backend/tests/upstream/accounts/account_repository.rs b/backend/tests/upstream/accounts/account_repository.rs index f388f0cc6..c4e1e51c3 100644 --- a/backend/tests/upstream/accounts/account_repository.rs +++ b/backend/tests/upstream/accounts/account_repository.rs @@ -252,10 +252,21 @@ async fn account_repository_should_accumulate_usage_counters() { .await .unwrap(); - let usage: (i64, i64, i64, i64, i64, i64, i64, Option) = sqlx::query_as( + let usage: ( + i64, + i64, + i64, + i64, + i64, + i64, + i64, + Option, + Option, + ) = sqlx::query_as( r" select request_count, input_tokens, output_tokens, cached_tokens, - reasoning_tokens, total_tokens, empty_response_count, last_used_at + reasoning_tokens, total_tokens, empty_response_count, + window_started_at, last_used_at from account_usage where account_id = ? ", @@ -272,6 +283,7 @@ async fn account_repository_should_accumulate_usage_counters() { assert_eq!(usage.5, 24); assert_eq!(usage.6, 1); assert!(usage.7.is_some()); + assert!(usage.8.is_some()); } #[tokio::test] diff --git a/frontend/src/views/dashboard/components/AccountOverviewCard.vue b/frontend/src/views/dashboard/components/AccountOverviewCard.vue index 2d883fb0d..0bb9b1d19 100644 --- a/frontend/src/views/dashboard/components/AccountOverviewCard.vue +++ b/frontend/src/views/dashboard/components/AccountOverviewCard.vue @@ -138,61 +138,56 @@ const quotaToneClasses: Record = {
-
+

账号调度

容量、并发与分配策略

-
+
槽位占用 -
- {{ usedRatio }} - {{ usedPercent }}% 已占用 -
-
- +
+
+ {{ usedRatio }} + {{ usedPercent }}% 已占用 +
+
+ +
-
-
+
+
{{ stat.label }} {{ stat.value }}
-
+
分配策略 - {{ strategyLabel }} + {{ + strategyLabel + }}
-
+

活跃账号用量

最近使用排序 diff --git a/frontend/src/views/dashboard/components/RequestHealthTimelineCard.vue b/frontend/src/views/dashboard/components/RequestHealthTimelineCard.vue index 35ec0754d..c69bc99e8 100644 --- a/frontend/src/views/dashboard/components/RequestHealthTimelineCard.vue +++ b/frontend/src/views/dashboard/components/RequestHealthTimelineCard.vue @@ -7,12 +7,31 @@ const props = defineProps<{ timeline: any }>() -const points = computed(() => props.timeline.points.split('')) +const healthLegend = [ + { code: '0', label: '未来' }, + { code: '1', label: '无请求' }, + { code: '2', label: '不可达' }, + { code: '3', label: '不稳定' }, + { code: '4', label: '低样本' }, + { code: '5', label: '稳定' }, +] + +const points = computed(() => String(props.timeline.points ?? '').split('')) +const pointGroups = computed(() => { + const groups: string[][] = [] + for (let index = 0; index < points.value.length; index += 4) { + groups.push(points.value.slice(index, index + 4)) + } + return groups +}) function cellClass(point: string) { - if (point === '1') return 'bg-(--cp-success)' - if (point === '2') return 'bg-(--cp-warning)' - if (point === '3') return 'bg-(--cp-danger)' + if (point === '0') return 'bg-(--cp-disabled-bg) opacity-60' + if (point === '1') return 'bg-(--cp-default-border-hover)' + if (point === '2') return 'bg-(--cp-danger)' + if (point === '3') return 'bg-(--cp-warning)' + if (point === '4') return 'bg-(--cp-normal)' + if (point === '5') return 'bg-(--cp-success)' return 'bg-(--cp-default-border-hover)' } @@ -27,35 +46,46 @@ function cellClass(point: string) { class="w-full" > diff --git a/frontend/src/views/dashboard/components/RequestTrendCard.vue b/frontend/src/views/dashboard/components/RequestTrendCard.vue index 9b49a1992..13104bed1 100644 --- a/frontend/src/views/dashboard/components/RequestTrendCard.vue +++ b/frontend/src/views/dashboard/components/RequestTrendCard.vue @@ -229,13 +229,7 @@ function summaryMarkerStyle(item: any) {