feat(dashboard): 增加全站实时指标和自动刷新

- 管理员仪表盘新增全站 RPM/TPM 与在线/启用用户指标
- 合并今日请求/费用、全站 RPM/TPM、在线/启用用户卡片展示
- 在线用户按最近 5 分钟活跃请求去重统计
- 全站 RPM/TPM 按最近 60 秒请求与 Token 统计
- 新增仪表盘自动刷新按钮,开启后每 10 秒静默刷新数据
- 同步前端类型、空态占位和仪表盘测试
This commit is contained in:
AAEE86
2026-06-02 18:16:24 +08:00
parent 86f72da3d9
commit 7835840ebd
5 changed files with 1018 additions and 689 deletions
@@ -19,6 +19,10 @@ use chrono::Datelike;
use serde_json::json;
use std::collections::{BTreeMap, BTreeSet};
const DASHBOARD_SITE_RATE_WINDOW_SECS: u64 = 60;
const DASHBOARD_ONLINE_USER_WINDOW_SECS: u64 = 300;
const DASHBOARD_ONLINE_USER_AGGREGATION_LIMIT: usize = 100_000;
#[derive(Debug, Clone, Copy)]
struct DashboardDateRange {
start_date: chrono::NaiveDate,
@@ -822,6 +826,22 @@ async fn dashboard_load_user_counts(
Ok((count, count))
}
async fn dashboard_load_online_user_count(
state: &AppState,
now_unix_secs: u64,
) -> Result<u64, GatewayError> {
let rows = state
.aggregate_usage_audits(&UsageAuditAggregationQuery {
created_from_unix_secs: now_unix_secs.saturating_sub(DASHBOARD_ONLINE_USER_WINDOW_SECS),
created_until_unix_secs: now_unix_secs.saturating_add(1),
group_by: UsageAuditAggregationGroupBy::User,
limit: DASHBOARD_ONLINE_USER_AGGREGATION_LIMIT,
exclude_reserved_provider_labels: false,
})
.await?;
Ok(rows.len() as u64)
}
fn dashboard_cache_savings_usd(summary: &StoredUsageCostSavingsSummary) -> f64 {
let estimated_full_cost =
if summary.estimated_full_cost_usd <= 0.0 && summary.cache_read_cost_usd > 0.0 {
@@ -966,6 +986,30 @@ pub(super) async fn handle_dashboard_stats_get(
});
if is_admin {
let now_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
let site_rate_summary = match dashboard_summary_for_unix_range_raw(
state,
now_unix_secs.saturating_sub(DASHBOARD_SITE_RATE_WINDOW_SECS),
now_unix_secs.saturating_add(1),
None,
"dashboard realtime site stats lookup failed",
)
.await
{
Ok(value) => value,
Err(response) => return response,
};
let site_rate_totals = dashboard_usage_totals_from_summary(&site_rate_summary);
let online_users = match dashboard_load_online_user_count(state, now_unix_secs).await {
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("dashboard online user stats lookup failed: {err:?}"),
false,
);
}
};
let (total_users, active_users) =
match dashboard_load_user_counts(state, summary_range).await {
Ok(value) => value,
@@ -1010,9 +1054,17 @@ pub(super) async fn handle_dashboard_stats_get(
};
let stats = json!([
{
"name": "今日请求",
"value": dashboard_format_integer(today_totals.requests),
"subValue": format!("成功率 {}", dashboard_format_percentage(success_rate)),
"name": "今日请求 / 费用",
"value": format!(
"{} / {}",
dashboard_format_integer(today_totals.requests),
dashboard_format_usd(today_totals.total_cost_usd)
),
"subValue": format!(
"成功率 {} / 节省 {}",
dashboard_format_percentage(success_rate),
dashboard_format_usd(today_cost_savings.max(0.0))
),
"icon": "Activity",
},
{
@@ -1022,15 +1074,26 @@ pub(super) async fn handle_dashboard_stats_get(
"icon": "Zap",
},
{
"name": "今日费用",
"value": dashboard_format_usd(today_totals.total_cost_usd),
"subValue": format!("节省 {}", dashboard_format_usd(today_cost_savings.max(0.0))),
"icon": "DollarSign",
"name": "全站 RPM / TPM",
"value": format!(
"{} / {}",
dashboard_format_integer(site_rate_totals.requests),
dashboard_format_token_compact(site_rate_totals.total_tokens)
),
"subValue": "最近 60 秒",
"icon": "Activity",
},
{
"name": "活跃用户",
"value": dashboard_format_integer(active_users),
"subValue": format!("总用户 {}", dashboard_format_integer(total_users)),
"name": "在线 / 启用用户",
"value": format!(
"{} / {}",
dashboard_format_integer(online_users),
dashboard_format_integer(active_users)
),
"subValue": format!(
"最近 5 分钟 / 总用户 {}",
dashboard_format_integer(total_users)
),
"icon": "Users",
}
]);
@@ -1060,6 +1123,7 @@ pub(super) async fn handle_dashboard_stats_get(
"users": {
"total": total_users,
"active": active_users,
"online": online_users,
},
"token_breakdown": token_breakdown,
});
@@ -649,11 +649,24 @@ async fn gateway_handles_admin_dashboard_stats_locally_without_proxying_upstream
assert_eq!(payload["today"]["tokens"], 17_450);
assert_eq!(payload["today"]["cost"], json!(2.5));
assert_eq!(payload["cost_stats"]["cost_savings"], json!(0.025));
assert_eq!(payload["stats"][2]["subValue"], json!("节省 $0.01"));
assert_eq!(payload["stats"][0]["value"], json!("2"));
assert_eq!(payload["stats"][1]["value"], json!("17.4K"));
let stats = payload["stats"].as_array().expect("stats should be array");
assert_eq!(stats.len(), 4);
let today_request_stats = stats
.iter()
.find(|item| item["name"] == json!("今日请求 / 费用"))
.expect("today request stats card should exist");
assert_eq!(today_request_stats["value"], json!("2 / $2.50"));
assert_eq!(
payload["stats"][1]["subValue"],
today_request_stats["subValue"],
json!("成功率 100.0% / 节省 $0.01")
);
let today_token_stats = stats
.iter()
.find(|item| item["name"] == json!("今日 Token"))
.expect("today token stats card should exist");
assert_eq!(today_token_stats["value"], json!("17.4K"));
assert_eq!(
today_token_stats["subValue"],
json!("输入 12.1K / 输出 3.1K · 写缓存 1.25K / 读缓存 1K")
);
assert_eq!(payload["users"]["total"], 2);