Merge pull request #615 from AAEE86/main

feat: 健康监控仪表盘与关联下钻优化,完善使用记录展示
This commit is contained in:
fawney19
2026-06-21 12:48:13 +08:00
committed by GitHub
41 changed files with 4445 additions and 1602 deletions
File diff suppressed because it is too large Load Diff
@@ -10,10 +10,11 @@ pub(crate) use self::catalog_helpers::{
admin_requested_force_stream, api_format_display_name, build_api_format_health_monitor_payload,
build_model_health_monitor_payload, build_provider_health_monitor_payload,
build_public_catalog_models_payload, build_public_catalog_search_models_payload,
build_public_health_timeline, build_public_providers_payload, normalize_admin_base_url,
build_public_health_timeline, build_public_health_timeline_details,
build_public_providers_payload, build_related_health_monitor_payload, normalize_admin_base_url,
provider_key_api_formats, request_candidate_event_unix_ms, request_candidate_status_label,
sanitize_public_model_config_for_user, ApiFormatHealthMonitorOptions,
ModelHealthMonitorOptions,
HealthMonitorRelationDimension, ModelHealthMonitorOptions,
};
pub(crate) use self::system_modules_helpers::{
build_admin_keys_grouped_by_format_payload, build_public_auth_modules_status_payload,
@@ -2,9 +2,10 @@ use super::{
build_api_format_health_monitor_payload, build_model_health_monitor_payload,
build_public_auth_modules_status_payload, build_public_catalog_models_payload,
build_public_catalog_search_models_payload, build_public_providers_payload,
capability_detail_by_name, ldap_module_config_is_valid, sanitize_public_model_config_for_user,
serialize_public_capability, supported_capability_names, ApiFormatHealthMonitorOptions,
ModelHealthMonitorOptions, PUBLIC_CAPABILITY_DEFINITIONS,
build_related_health_monitor_payload, capability_detail_by_name, ldap_module_config_is_valid,
sanitize_public_model_config_for_user, serialize_public_capability, supported_capability_names,
ApiFormatHealthMonitorOptions, HealthMonitorRelationDimension, ModelHealthMonitorOptions,
PUBLIC_CAPABILITY_DEFINITIONS,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::shared::{
@@ -477,6 +478,77 @@ pub(crate) async fn maybe_build_local_public_support_response(
.await?;
return Some(Json(payload).into_response());
}
if decision.route_kind.as_deref() == Some("health_related")
&& request_context.request_path == "/api/public/health/related"
{
let Some(dimension) =
query_param_value(request_context.request_query_string.as_deref(), "dimension")
.and_then(|value| HealthMonitorRelationDimension::parse(&value))
else {
return Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "dimension 必须是 endpoint 或 model" })),
)
.into_response(),
);
};
if matches!(dimension, HealthMonitorRelationDimension::Provider) {
return Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "公开健康监控不支持 provider 维度" })),
)
.into_response(),
);
}
let Some(value) =
query_param_value(request_context.request_query_string.as_deref(), "value")
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
else {
return Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "value 不能为空" })),
)
.into_response(),
);
};
let lookback_hours = query_param_value(
request_context.request_query_string.as_deref(),
"lookback_hours",
)
.and_then(|value| value.parse::<u64>().ok())
.filter(|value| (1..=168).contains(value))
.unwrap_or(6);
let related_limit = query_param_value(
request_context.request_query_string.as_deref(),
"related_limit",
)
.and_then(|value| value.parse::<usize>().ok())
.filter(|value| (1..=50).contains(value))
.unwrap_or(8);
let per_item_limit = query_param_value(
request_context.request_query_string.as_deref(),
"per_item_limit",
)
.and_then(|value| value.parse::<usize>().ok())
.filter(|value| (10..=500).contains(value))
.unwrap_or(100);
let payload = build_related_health_monitor_payload(
state,
lookback_hours,
dimension,
&value,
related_limit,
per_item_limit,
false,
)
.await?;
return Some(Json(payload).into_response());
}
}
if decision.route_family.as_deref() == Some("capabilities") {
@@ -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,
});
@@ -965,6 +965,9 @@ pub(super) async fn handle_users_me_usage_get(
created_until_unix_secs,
user_id: Some(auth.user.id.clone()),
provider_name: None,
model: None,
api_format: None,
exclude_status_codes: Vec::new(),
group_by: UsageBreakdownGroupBy::Model,
})
.await
@@ -985,6 +988,9 @@ pub(super) async fn handle_users_me_usage_get(
created_until_unix_secs,
user_id: Some(auth.user.id.clone()),
provider_name: None,
model: None,
api_format: None,
exclude_status_codes: Vec::new(),
group_by: UsageBreakdownGroupBy::Provider,
})
.await
@@ -1005,6 +1011,9 @@ pub(super) async fn handle_users_me_usage_get(
created_until_unix_secs,
user_id: Some(auth.user.id.clone()),
provider_name: None,
model: None,
api_format: None,
exclude_status_codes: Vec::new(),
group_by: UsageBreakdownGroupBy::ApiFormat,
})
.await
@@ -1045,6 +1054,7 @@ pub(super) async fn handle_users_me_usage_get(
client_family: None,
exclude_unknown_model_or_provider: false,
statuses: None,
exclude_status_codes: Vec::new(),
is_stream: None,
error_only: false,
keywords,
@@ -1100,6 +1110,7 @@ pub(super) async fn handle_users_me_usage_get(
client_family: None,
exclude_unknown_model_or_provider: false,
statuses: None,
exclude_status_codes: Vec::new(),
is_stream: None,
error_only: false,
limit: None,
@@ -1128,6 +1139,7 @@ pub(super) async fn handle_users_me_usage_get(
client_family: None,
exclude_unknown_model_or_provider: false,
statuses: None,
exclude_status_codes: Vec::new(),
is_stream: None,
error_only: false,
limit: Some(limit),
@@ -1266,6 +1278,7 @@ pub(super) async fn handle_users_me_usage_active_get(
client_family: None,
exclude_unknown_model_or_provider: false,
statuses: Some(vec!["pending".to_string(), "streaming".to_string()]),
exclude_status_codes: Vec::new(),
is_stream: None,
error_only: false,
limit: Some(50),