mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 00:17:45 +08:00
feat(stats): add user group usage views
This commit is contained in:
@@ -596,6 +596,20 @@ pub(super) fn classify_admin_observability_family_route(
|
|||||||
"admin:stats",
|
"admin:stats",
|
||||||
false,
|
false,
|
||||||
))
|
))
|
||||||
|
} else if method == http::Method::GET
|
||||||
|
&& matches!(
|
||||||
|
normalized_path,
|
||||||
|
"/api/admin/stats/leaderboard/user-groups"
|
||||||
|
| "/api/admin/stats/leaderboard/user-groups/"
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Some(classified(
|
||||||
|
"admin_proxy",
|
||||||
|
"stats_manage",
|
||||||
|
"leaderboard_user_groups",
|
||||||
|
"admin:stats",
|
||||||
|
false,
|
||||||
|
))
|
||||||
} else if method == http::Method::GET
|
} else if method == http::Method::GET
|
||||||
&& matches!(
|
&& matches!(
|
||||||
normalized_path,
|
normalized_path,
|
||||||
|
|||||||
@@ -157,6 +157,28 @@ fn classifies_admin_stats_leaderboard_models_as_admin_proxy_route() {
|
|||||||
assert!(!decision.is_execution_runtime_candidate());
|
assert!(!decision.is_execution_runtime_candidate());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifies_admin_stats_leaderboard_user_groups_as_admin_proxy_route() {
|
||||||
|
let headers = headers(&[]);
|
||||||
|
let uri: Uri = "/api/admin/stats/leaderboard/user-groups"
|
||||||
|
.parse()
|
||||||
|
.expect("uri should parse");
|
||||||
|
let decision =
|
||||||
|
classify_control_route(&http::Method::GET, &uri, &headers).expect("route should classify");
|
||||||
|
|
||||||
|
assert_eq!(decision.route_class.as_deref(), Some("admin_proxy"));
|
||||||
|
assert_eq!(decision.route_family.as_deref(), Some("stats_manage"));
|
||||||
|
assert_eq!(
|
||||||
|
decision.route_kind.as_deref(),
|
||||||
|
Some("leaderboard_user_groups")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decision.auth_endpoint_signature.as_deref(),
|
||||||
|
Some("admin:stats")
|
||||||
|
);
|
||||||
|
assert!(!decision.is_execution_runtime_candidate());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classifies_admin_stats_leaderboard_users_as_admin_proxy_route() {
|
fn classifies_admin_stats_leaderboard_users_as_admin_proxy_route() {
|
||||||
let headers = headers(&[]);
|
let headers = headers(&[]);
|
||||||
|
|||||||
@@ -11,3 +11,31 @@ pub(crate) use self::stats::{
|
|||||||
};
|
};
|
||||||
pub(crate) use self::stats::{AdminStatsTimeRange, AdminStatsUsageFilter};
|
pub(crate) use self::stats::{AdminStatsTimeRange, AdminStatsUsageFilter};
|
||||||
pub(crate) use self::usage::maybe_build_local_admin_usage_response;
|
pub(crate) use self::usage::maybe_build_local_admin_usage_response;
|
||||||
|
|
||||||
|
pub(crate) async fn resolve_usage_user_group_scope(
|
||||||
|
state: &crate::handlers::admin::request::AdminAppState<'_>,
|
||||||
|
query: Option<&str>,
|
||||||
|
include_inactive: bool,
|
||||||
|
exclude_admin: bool,
|
||||||
|
) -> Result<Result<Option<Vec<String>>, String>, crate::GatewayError> {
|
||||||
|
let group_id = crate::handlers::admin::shared::query_param_value(query, "user_group_id");
|
||||||
|
let Some(group_id) = group_id else {
|
||||||
|
return Ok(Ok(None));
|
||||||
|
};
|
||||||
|
if crate::handlers::admin::shared::query_param_value(query, "user_id").is_some() {
|
||||||
|
return Ok(Err(
|
||||||
|
"user_id and user_group_id cannot be used together".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !state.has_user_data_reader() {
|
||||||
|
return Ok(Err("user group data is unavailable".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
match state
|
||||||
|
.resolve_usage_user_group_member_ids(&group_id, include_inactive, exclude_admin)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Some(user_ids) => Ok(Ok(Some(user_ids))),
|
||||||
|
None => Ok(Err("user_group_id does not exist".to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ pub(super) async fn build_admin_monitoring_system_status_response(
|
|||||||
created_from_unix_secs: today_start.timestamp().max(0) as u64,
|
created_from_unix_secs: today_start.timestamp().max(0) as u64,
|
||||||
created_until_unix_secs: now_unix_secs.saturating_add(1),
|
created_until_unix_secs: now_unix_secs.saturating_add(1),
|
||||||
user_id: None,
|
user_id: None,
|
||||||
|
user_ids: None,
|
||||||
provider_name: None,
|
provider_name: None,
|
||||||
model: None,
|
model: None,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use super::super::resolve_usage_user_group_scope;
|
||||||
use super::range::{build_comparison_range, parse_bounded_u32};
|
use super::range::{build_comparison_range, parse_bounded_u32};
|
||||||
use super::resolve_admin_usage_time_range;
|
use super::resolve_admin_usage_time_range;
|
||||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||||
@@ -294,6 +295,17 @@ pub(super) async fn maybe_build_local_admin_stats_analytics_response(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let filters = AdminStatsUsageFilter::from_query(request_context.query_string());
|
let filters = AdminStatsUsageFilter::from_query(request_context.query_string());
|
||||||
|
let user_ids = match resolve_usage_user_group_scope(
|
||||||
|
state,
|
||||||
|
request_context.query_string(),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
|
||||||
|
};
|
||||||
let query_granularity = match granularity {
|
let query_granularity = match granularity {
|
||||||
AdminStatsGranularity::Hour => UsageTimeSeriesGranularity::Hour,
|
AdminStatsGranularity::Hour => UsageTimeSeriesGranularity::Hour,
|
||||||
AdminStatsGranularity::Day
|
AdminStatsGranularity::Day
|
||||||
@@ -311,6 +323,7 @@ pub(super) async fn maybe_build_local_admin_stats_analytics_response(
|
|||||||
granularity: query_granularity,
|
granularity: query_granularity,
|
||||||
tz_offset_minutes: time_range.tz_offset_minutes,
|
tz_offset_minutes: time_range.tz_offset_minutes,
|
||||||
user_id: filters.user_id,
|
user_id: filters.user_id,
|
||||||
|
user_ids,
|
||||||
provider_name: filters.provider_name,
|
provider_name: filters.provider_name,
|
||||||
model: filters.model,
|
model: filters.model,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ pub(super) async fn maybe_build_local_admin_stats_cost_response(
|
|||||||
granularity: UsageTimeSeriesGranularity::Day,
|
granularity: UsageTimeSeriesGranularity::Day,
|
||||||
tz_offset_minutes: time_range.tz_offset_minutes,
|
tz_offset_minutes: time_range.tz_offset_minutes,
|
||||||
user_id: None,
|
user_id: None,
|
||||||
|
user_ids: None,
|
||||||
provider_name: None,
|
provider_name: None,
|
||||||
model: None,
|
model: None,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ use crate::GatewayError;
|
|||||||
use aether_data_contracts::repository::usage::StoredRequestUsageAudit;
|
use aether_data_contracts::repository::usage::StoredRequestUsageAudit;
|
||||||
|
|
||||||
pub(super) use aether_admin::observability::stats::{
|
pub(super) use aether_admin::observability::stats::{
|
||||||
build_admin_stats_leaderboard_response, build_api_key_leaderboard_items,
|
build_admin_stats_leaderboard_response, build_admin_stats_user_group_leaderboard_response,
|
||||||
build_api_key_leaderboard_items_from_summaries, build_model_leaderboard_items,
|
build_api_key_leaderboard_items, build_api_key_leaderboard_items_from_summaries,
|
||||||
build_model_leaderboard_items_from_summaries, build_user_leaderboard_items,
|
build_model_leaderboard_items, build_model_leaderboard_items_from_summaries,
|
||||||
build_user_leaderboard_items_from_summaries, compare_leaderboard_items, compute_dense_rank,
|
build_user_leaderboard_items, build_user_leaderboard_items_from_summaries,
|
||||||
AdminStatsLeaderboardItem, AdminStatsLeaderboardMetric, AdminStatsLeaderboardNameMode,
|
compare_leaderboard_items, compute_dense_rank, AdminStatsLeaderboardItem,
|
||||||
AdminStatsSortOrder, AdminStatsUserMetadata,
|
AdminStatsLeaderboardMetric, AdminStatsLeaderboardNameMode, AdminStatsSortOrder,
|
||||||
|
AdminStatsUserMetadata,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(super) async fn load_user_leaderboard_metadata(
|
pub(super) async fn load_user_leaderboard_metadata(
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
use super::super::resolve_usage_user_group_scope;
|
||||||
use super::leaderboard::{
|
use super::leaderboard::{
|
||||||
build_admin_stats_leaderboard_response, build_api_key_leaderboard_items_from_summaries,
|
build_admin_stats_leaderboard_response, build_admin_stats_user_group_leaderboard_response,
|
||||||
build_model_leaderboard_items_from_summaries, build_user_leaderboard_items_from_summaries,
|
build_api_key_leaderboard_items_from_summaries, build_model_leaderboard_items_from_summaries,
|
||||||
compare_leaderboard_items, load_user_leaderboard_metadata, AdminStatsLeaderboardNameMode,
|
build_user_leaderboard_items_from_summaries, compare_leaderboard_items,
|
||||||
|
load_user_leaderboard_metadata, AdminStatsLeaderboardItem, AdminStatsLeaderboardNameMode,
|
||||||
};
|
};
|
||||||
use super::range::{parse_bounded_u32, parse_nonnegative_usize};
|
use super::range::{parse_bounded_u32, parse_nonnegative_usize};
|
||||||
use super::resolve_admin_usage_time_range;
|
use super::resolve_admin_usage_time_range;
|
||||||
@@ -14,6 +16,7 @@ use aether_admin::observability::stats::{
|
|||||||
};
|
};
|
||||||
use aether_data_contracts::repository::usage::{UsageLeaderboardGroupBy, UsageLeaderboardQuery};
|
use aether_data_contracts::repository::usage::{UsageLeaderboardGroupBy, UsageLeaderboardQuery};
|
||||||
use axum::{body::Body, http, response::Response};
|
use axum::{body::Body, http, response::Response};
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
pub(super) async fn maybe_build_local_admin_stats_leaderboard_response(
|
pub(super) async fn maybe_build_local_admin_stats_leaderboard_response(
|
||||||
state: &AdminAppState<'_>,
|
state: &AdminAppState<'_>,
|
||||||
@@ -79,6 +82,7 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response(
|
|||||||
created_until_unix_secs,
|
created_until_unix_secs,
|
||||||
group_by: UsageLeaderboardGroupBy::Model,
|
group_by: UsageLeaderboardGroupBy::Model,
|
||||||
user_id: filters.user_id,
|
user_id: filters.user_id,
|
||||||
|
user_ids: None,
|
||||||
provider_name: filters.provider_name,
|
provider_name: filters.provider_name,
|
||||||
model: filters.model,
|
model: filters.model,
|
||||||
})
|
})
|
||||||
@@ -156,6 +160,7 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response(
|
|||||||
created_until_unix_secs,
|
created_until_unix_secs,
|
||||||
group_by: UsageLeaderboardGroupBy::ApiKey,
|
group_by: UsageLeaderboardGroupBy::ApiKey,
|
||||||
user_id: filters.user_id,
|
user_id: filters.user_id,
|
||||||
|
user_ids: None,
|
||||||
provider_name: filters.provider_name,
|
provider_name: filters.provider_name,
|
||||||
model: filters.model,
|
model: filters.model,
|
||||||
})
|
})
|
||||||
@@ -206,6 +211,154 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response(
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if request_context
|
||||||
|
.decision()
|
||||||
|
.and_then(|decision| decision.route_kind.as_deref())
|
||||||
|
== Some("leaderboard_user_groups")
|
||||||
|
&& request_context.method() == http::Method::GET
|
||||||
|
&& matches!(
|
||||||
|
request_context.path(),
|
||||||
|
"/api/admin/stats/leaderboard/user-groups"
|
||||||
|
| "/api/admin/stats/leaderboard/user-groups/"
|
||||||
|
)
|
||||||
|
{
|
||||||
|
let time_range = match resolve_admin_usage_time_range(query) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
|
||||||
|
};
|
||||||
|
let metric = match AdminStatsLeaderboardMetric::parse(query) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
|
||||||
|
};
|
||||||
|
let order = match AdminStatsSortOrder::parse(query) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
|
||||||
|
};
|
||||||
|
let limit = match query_param_value(query, "limit")
|
||||||
|
.map(|value| parse_bounded_u32("limit", &value, 1, 100))
|
||||||
|
.transpose()
|
||||||
|
{
|
||||||
|
Ok(Some(value)) => value as usize,
|
||||||
|
Ok(None) => 10,
|
||||||
|
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
|
||||||
|
};
|
||||||
|
let offset = match query_param_value(query, "offset")
|
||||||
|
.map(|value| parse_nonnegative_usize("offset", &value))
|
||||||
|
.transpose()
|
||||||
|
{
|
||||||
|
Ok(Some(value)) => value,
|
||||||
|
Ok(None) => 0,
|
||||||
|
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
|
||||||
|
};
|
||||||
|
let empty_counts = BTreeMap::new();
|
||||||
|
if !state.has_usage_data_reader() || !state.has_user_data_reader() {
|
||||||
|
return Ok(Some(build_admin_stats_user_group_leaderboard_response(
|
||||||
|
metric,
|
||||||
|
Some(&time_range),
|
||||||
|
&[],
|
||||||
|
&empty_counts,
|
||||||
|
&empty_counts,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let include_inactive = query_param_bool(query, "include_inactive", false);
|
||||||
|
let exclude_admin = query_param_bool(query, "exclude_admin", false);
|
||||||
|
let filters = AdminStatsUsageFilter::from_query(query);
|
||||||
|
if filters.user_id.is_some() {
|
||||||
|
return Ok(Some(admin_stats_bad_request_response(
|
||||||
|
"user_id is not supported for the user group leaderboard".to_string(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let Some((created_from_unix_secs, created_until_unix_secs)) = time_range.to_unix_bounds()
|
||||||
|
else {
|
||||||
|
return Ok(Some(build_admin_stats_user_group_leaderboard_response(
|
||||||
|
metric,
|
||||||
|
Some(&time_range),
|
||||||
|
&[],
|
||||||
|
&empty_counts,
|
||||||
|
&empty_counts,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
|
||||||
|
let summaries = state
|
||||||
|
.summarize_usage_leaderboard(&UsageLeaderboardQuery {
|
||||||
|
created_from_unix_secs,
|
||||||
|
created_until_unix_secs,
|
||||||
|
group_by: UsageLeaderboardGroupBy::User,
|
||||||
|
user_id: None,
|
||||||
|
user_ids: None,
|
||||||
|
provider_name: filters.provider_name,
|
||||||
|
model: filters.model,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let user_ids = summaries
|
||||||
|
.iter()
|
||||||
|
.map(|item| item.group_key.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let user_metadata = load_user_leaderboard_metadata(state, &user_ids).await?;
|
||||||
|
let user_usage = build_user_leaderboard_items_from_summaries(
|
||||||
|
&summaries,
|
||||||
|
&user_metadata,
|
||||||
|
state.has_auth_user_data_reader(),
|
||||||
|
state.has_user_data_reader(),
|
||||||
|
include_inactive,
|
||||||
|
exclude_admin,
|
||||||
|
)
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| (item.id.clone(), item))
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
|
||||||
|
let mut leaderboard = Vec::new();
|
||||||
|
let mut member_counts = BTreeMap::new();
|
||||||
|
let mut active_member_counts = BTreeMap::new();
|
||||||
|
for group in state.list_user_groups().await? {
|
||||||
|
let members = state.list_user_group_members(&group.id).await?;
|
||||||
|
let member_count = members.iter().filter(|member| !member.is_deleted).count();
|
||||||
|
let active_member_count = members
|
||||||
|
.iter()
|
||||||
|
.filter(|member| !member.is_deleted && member.is_active)
|
||||||
|
.count();
|
||||||
|
let scoped_user_ids = members
|
||||||
|
.iter()
|
||||||
|
.filter(|member| !member.is_deleted)
|
||||||
|
.filter(|member| include_inactive || member.is_active)
|
||||||
|
.filter(|member| !exclude_admin || !member.role.eq_ignore_ascii_case("admin"))
|
||||||
|
.map(|member| member.user_id.as_str())
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
let mut item = AdminStatsLeaderboardItem {
|
||||||
|
id: group.id.clone(),
|
||||||
|
name: group.name,
|
||||||
|
requests: 0,
|
||||||
|
tokens: 0,
|
||||||
|
cost: 0.0,
|
||||||
|
};
|
||||||
|
for user_id in scoped_user_ids {
|
||||||
|
if let Some(user) = user_usage.get(user_id) {
|
||||||
|
item.requests = item.requests.saturating_add(user.requests);
|
||||||
|
item.tokens = item.tokens.saturating_add(user.tokens);
|
||||||
|
item.cost += user.cost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
member_counts.insert(group.id.clone(), member_count);
|
||||||
|
active_member_counts.insert(group.id, active_member_count);
|
||||||
|
leaderboard.push(item);
|
||||||
|
}
|
||||||
|
leaderboard.sort_by(|left, right| compare_leaderboard_items(metric, order, left, right));
|
||||||
|
|
||||||
|
return Ok(Some(build_admin_stats_user_group_leaderboard_response(
|
||||||
|
metric,
|
||||||
|
Some(&time_range),
|
||||||
|
&leaderboard,
|
||||||
|
&member_counts,
|
||||||
|
&active_member_counts,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
if request_context
|
if request_context
|
||||||
.decision()
|
.decision()
|
||||||
.and_then(|decision| decision.route_kind.as_deref())
|
.and_then(|decision| decision.route_kind.as_deref())
|
||||||
@@ -253,6 +406,13 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response(
|
|||||||
let include_inactive = query_param_bool(query, "include_inactive", false);
|
let include_inactive = query_param_bool(query, "include_inactive", false);
|
||||||
let exclude_admin = query_param_bool(query, "exclude_admin", false);
|
let exclude_admin = query_param_bool(query, "exclude_admin", false);
|
||||||
let filters = AdminStatsUsageFilter::from_query(query);
|
let filters = AdminStatsUsageFilter::from_query(query);
|
||||||
|
let scoped_user_ids =
|
||||||
|
match resolve_usage_user_group_scope(state, query, include_inactive, exclude_admin)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
|
||||||
|
};
|
||||||
let Some((created_from_unix_secs, created_until_unix_secs)) = time_range.to_unix_bounds()
|
let Some((created_from_unix_secs, created_until_unix_secs)) = time_range.to_unix_bounds()
|
||||||
else {
|
else {
|
||||||
return Ok(Some(admin_stats_leaderboard_empty_response(
|
return Ok(Some(admin_stats_leaderboard_empty_response(
|
||||||
@@ -266,6 +426,7 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response(
|
|||||||
created_until_unix_secs,
|
created_until_unix_secs,
|
||||||
group_by: UsageLeaderboardGroupBy::User,
|
group_by: UsageLeaderboardGroupBy::User,
|
||||||
user_id: filters.user_id,
|
user_id: filters.user_id,
|
||||||
|
user_ids: scoped_user_ids,
|
||||||
provider_name: filters.provider_name,
|
provider_name: filters.provider_name,
|
||||||
model: filters.model,
|
model: filters.model,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use super::super::resolve_usage_user_group_scope;
|
||||||
use super::super::stats::resolve_admin_usage_time_range;
|
use super::super::stats::resolve_admin_usage_time_range;
|
||||||
use super::analytics::admin_usage_api_key_names;
|
use super::analytics::admin_usage_api_key_names;
|
||||||
use super::analytics::admin_usage_provider_key_names;
|
use super::analytics::admin_usage_provider_key_names;
|
||||||
@@ -710,11 +711,16 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response(
|
|||||||
&Default::default(),
|
&Default::default(),
|
||||||
)));
|
)));
|
||||||
};
|
};
|
||||||
|
let user_ids = match resolve_usage_user_group_scope(state, query, false, false).await? {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(detail) => return Ok(Some(admin_usage_bad_request_response(detail))),
|
||||||
|
};
|
||||||
let summary = state
|
let summary = state
|
||||||
.summarize_usage_audits(&UsageAuditSummaryQuery {
|
.summarize_usage_audits(&UsageAuditSummaryQuery {
|
||||||
created_from_unix_secs,
|
created_from_unix_secs,
|
||||||
created_until_unix_secs,
|
created_until_unix_secs,
|
||||||
user_id: query_param_value(query, "user_id"),
|
user_id: query_param_value(query, "user_id"),
|
||||||
|
user_ids,
|
||||||
provider_name: query_param_value(query, "provider"),
|
provider_name: query_param_value(query, "provider"),
|
||||||
model: query_param_value(query, "model"),
|
model: query_param_value(query, "model"),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -126,6 +126,30 @@ impl<'a> AdminAppState<'a> {
|
|||||||
self.app.list_user_group_members(group_id).await
|
self.app.list_user_group_members(group_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn resolve_usage_user_group_member_ids(
|
||||||
|
&self,
|
||||||
|
group_id: &str,
|
||||||
|
include_inactive: bool,
|
||||||
|
exclude_admin: bool,
|
||||||
|
) -> Result<Option<Vec<String>>, GatewayError> {
|
||||||
|
if self.find_user_group_by_id(group_id).await?.is_none() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut user_ids = self
|
||||||
|
.list_user_group_members(group_id)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|member| !member.is_deleted)
|
||||||
|
.filter(|member| include_inactive || member.is_active)
|
||||||
|
.filter(|member| !exclude_admin || !member.role.eq_ignore_ascii_case("admin"))
|
||||||
|
.map(|member| member.user_id)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
user_ids.sort();
|
||||||
|
user_ids.dedup();
|
||||||
|
Ok(Some(user_ids))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn replace_user_group_members(
|
pub(crate) async fn replace_user_group_members(
|
||||||
&self,
|
&self,
|
||||||
group_id: &str,
|
group_id: &str,
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ use aether_data::repository::auth::{
|
|||||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||||
use aether_data::repository::usage::InMemoryUsageReadRepository;
|
use aether_data::repository::usage::InMemoryUsageReadRepository;
|
||||||
use aether_data::repository::users::{
|
use aether_data::repository::users::{
|
||||||
InMemoryUserReadRepository, StoredUserAuthRecord, StoredUserSummary,
|
InMemoryUserReadRepository, StoredUserAuthRecord, StoredUserSummary, UpsertUserGroupRecord,
|
||||||
|
UserReadRepository,
|
||||||
};
|
};
|
||||||
use aether_data_contracts::repository::usage::StoredRequestUsageAudit;
|
use aether_data_contracts::repository::usage::StoredRequestUsageAudit;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -1937,6 +1938,135 @@ async fn gateway_handles_admin_stats_leaderboard_users_without_legacy_username_f
|
|||||||
upstream_handle.abort();
|
upstream_handle.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_aggregates_admin_stats_by_current_user_group_membership() {
|
||||||
|
let (_upstream_url, upstream_hits, upstream_handle) =
|
||||||
|
start_stats_upstream("/api/admin/stats/leaderboard/user-groups").await;
|
||||||
|
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
|
||||||
|
sample_usage_row(
|
||||||
|
"usage-group-a",
|
||||||
|
"req-group-a",
|
||||||
|
Some("user-1"),
|
||||||
|
Some("key-1"),
|
||||||
|
Some("key-1"),
|
||||||
|
"OpenAI",
|
||||||
|
"gpt-5",
|
||||||
|
60,
|
||||||
|
20,
|
||||||
|
0.4,
|
||||||
|
0.4,
|
||||||
|
DAY_1_UNIX_SECS,
|
||||||
|
),
|
||||||
|
sample_usage_row(
|
||||||
|
"usage-group-b",
|
||||||
|
"req-group-b",
|
||||||
|
Some("user-2"),
|
||||||
|
Some("key-2"),
|
||||||
|
Some("key-2"),
|
||||||
|
"OpenAI",
|
||||||
|
"gpt-5",
|
||||||
|
40,
|
||||||
|
10,
|
||||||
|
0.35,
|
||||||
|
0.35,
|
||||||
|
DAY_1_UNIX_SECS + 10,
|
||||||
|
),
|
||||||
|
sample_usage_row(
|
||||||
|
"usage-group-outside",
|
||||||
|
"req-group-outside",
|
||||||
|
Some("user-3"),
|
||||||
|
Some("key-3"),
|
||||||
|
Some("key-3"),
|
||||||
|
"OpenAI",
|
||||||
|
"gpt-5",
|
||||||
|
100,
|
||||||
|
50,
|
||||||
|
1.5,
|
||||||
|
1.5,
|
||||||
|
DAY_1_UNIX_SECS + 20,
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
let user_repository = InMemoryUserReadRepository::seed_auth_users([
|
||||||
|
sample_auth_user("user-1", "alice", "user", true),
|
||||||
|
sample_auth_user("user-2", "bob", "user", true),
|
||||||
|
sample_auth_user("user-3", "carol", "user", true),
|
||||||
|
]);
|
||||||
|
let group = user_repository
|
||||||
|
.create_user_group(UpsertUserGroupRecord {
|
||||||
|
name: "Engineering".to_string(),
|
||||||
|
description: None,
|
||||||
|
priority: 0,
|
||||||
|
allowed_providers: None,
|
||||||
|
allowed_providers_mode: "inherit".to_string(),
|
||||||
|
allowed_api_formats: None,
|
||||||
|
allowed_api_formats_mode: "inherit".to_string(),
|
||||||
|
allowed_models: None,
|
||||||
|
allowed_models_mode: "inherit".to_string(),
|
||||||
|
rate_limit: None,
|
||||||
|
rate_limit_mode: "inherit".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("group creation should succeed")
|
||||||
|
.expect("group should be created");
|
||||||
|
user_repository
|
||||||
|
.replace_user_group_members(&group.id, &["user-1".to_string(), "user-2".to_string()])
|
||||||
|
.await
|
||||||
|
.expect("group members should be replaced");
|
||||||
|
let data_state = GatewayDataState::with_usage_reader_for_tests(usage_repository)
|
||||||
|
.with_user_reader(Arc::new(user_repository));
|
||||||
|
let gateway = build_router_with_state(
|
||||||
|
AppState::new()
|
||||||
|
.expect("gateway should build")
|
||||||
|
.with_data_state_for_tests(data_state),
|
||||||
|
);
|
||||||
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let response = admin_request(client.get(format!(
|
||||||
|
"{gateway_url}/api/admin/stats/leaderboard/user-groups?start_date=2024-03-21&end_date=2024-03-21&metric=cost&tz_offset_minutes=0"
|
||||||
|
)))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("group leaderboard request should succeed");
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
|
assert_eq!(payload["attribution"], "current_membership");
|
||||||
|
assert_eq!(payload["total"], 1);
|
||||||
|
assert_eq!(payload["items"][0]["id"], group.id);
|
||||||
|
assert_eq!(payload["items"][0]["name"], "Engineering");
|
||||||
|
assert_eq!(payload["items"][0]["requests"], 2);
|
||||||
|
assert_eq!(payload["items"][0]["cost"], 0.75);
|
||||||
|
assert_eq!(payload["items"][0]["member_count"], 2);
|
||||||
|
assert_eq!(payload["items"][0]["active_member_count"], 2);
|
||||||
|
|
||||||
|
let response = admin_request(client.get(format!(
|
||||||
|
"{gateway_url}/api/admin/stats/leaderboard/users?start_date=2024-03-21&end_date=2024-03-21&metric=cost&tz_offset_minutes=0&user_group_id={}",
|
||||||
|
group.id
|
||||||
|
)))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("group member leaderboard request should succeed");
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
|
assert_eq!(payload["total"], 2);
|
||||||
|
assert!(payload["items"]
|
||||||
|
.as_array()
|
||||||
|
.is_some_and(|items| { items.iter().all(|item| item["id"] != "user-3") }));
|
||||||
|
|
||||||
|
let response = admin_request(client.get(format!(
|
||||||
|
"{gateway_url}/api/admin/stats/leaderboard/users?start_date=2024-03-21&end_date=2024-03-21&user_id=user-1&user_group_id={}",
|
||||||
|
group.id
|
||||||
|
)))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("conflicting scope request should complete");
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
upstream_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gateway_handles_admin_stats_leaderboard_users_locally_without_usage_reader() {
|
async fn gateway_handles_admin_stats_leaderboard_users_locally_without_usage_reader() {
|
||||||
let (upstream_url, upstream_hits, upstream_handle) =
|
let (upstream_url, upstream_hits, upstream_handle) =
|
||||||
|
|||||||
@@ -841,6 +841,53 @@ pub fn build_admin_stats_leaderboard_response(
|
|||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn build_admin_stats_user_group_leaderboard_response(
|
||||||
|
metric: AdminStatsLeaderboardMetric,
|
||||||
|
time_range: Option<&AdminStatsTimeRange>,
|
||||||
|
leaderboard: &[AdminStatsLeaderboardItem],
|
||||||
|
member_counts: &std::collections::BTreeMap<String, usize>,
|
||||||
|
active_member_counts: &std::collections::BTreeMap<String, usize>,
|
||||||
|
offset: usize,
|
||||||
|
limit: usize,
|
||||||
|
) -> Response<Body> {
|
||||||
|
let total = leaderboard.len();
|
||||||
|
let items: Vec<_> = leaderboard
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.skip(offset)
|
||||||
|
.take(limit)
|
||||||
|
.map(|(index, item)| {
|
||||||
|
let rank = compute_dense_rank(metric, leaderboard, index);
|
||||||
|
let value = match metric {
|
||||||
|
AdminStatsLeaderboardMetric::Requests => json!(item.requests),
|
||||||
|
AdminStatsLeaderboardMetric::Tokens => json!(item.tokens),
|
||||||
|
AdminStatsLeaderboardMetric::Cost => json!(round_to(item.cost, 6)),
|
||||||
|
};
|
||||||
|
json!({
|
||||||
|
"rank": rank,
|
||||||
|
"id": item.id,
|
||||||
|
"name": item.name,
|
||||||
|
"value": value,
|
||||||
|
"requests": item.requests,
|
||||||
|
"tokens": item.tokens,
|
||||||
|
"cost": round_to(item.cost, 6),
|
||||||
|
"member_count": member_counts.get(&item.id).copied().unwrap_or(0),
|
||||||
|
"active_member_count": active_member_counts.get(&item.id).copied().unwrap_or(0),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Json(json!({
|
||||||
|
"items": items,
|
||||||
|
"total": total,
|
||||||
|
"metric": metric.as_str(),
|
||||||
|
"start_date": time_range.map(|value| value.start_date.to_string()),
|
||||||
|
"end_date": time_range.map(|value| value.end_date.to_string()),
|
||||||
|
"attribution": "current_membership",
|
||||||
|
}))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn build_admin_stats_comparison_response(
|
pub fn build_admin_stats_comparison_response(
|
||||||
current_usage: &[StoredRequestUsageAudit],
|
current_usage: &[StoredRequestUsageAudit],
|
||||||
comparison_usage: &[StoredRequestUsageAudit],
|
comparison_usage: &[StoredRequestUsageAudit],
|
||||||
|
|||||||
@@ -1857,6 +1857,33 @@ fn usage_leaderboard_sql_fragments(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn push_usage_user_scope(
|
||||||
|
builder: &mut QueryBuilder<'_, Postgres>,
|
||||||
|
column: &str,
|
||||||
|
user_id: Option<&str>,
|
||||||
|
user_ids: Option<&[String]>,
|
||||||
|
) {
|
||||||
|
if let Some(user_id) = user_id {
|
||||||
|
builder
|
||||||
|
.push(" AND ")
|
||||||
|
.push(column)
|
||||||
|
.push(" = ")
|
||||||
|
.push_bind(user_id.to_string());
|
||||||
|
}
|
||||||
|
if let Some(user_ids) = user_ids {
|
||||||
|
if user_ids.is_empty() {
|
||||||
|
builder.push(" AND FALSE");
|
||||||
|
} else {
|
||||||
|
builder.push(" AND ").push(column).push(" IN (");
|
||||||
|
let mut separated = builder.separated(", ");
|
||||||
|
for user_id in user_ids {
|
||||||
|
separated.push_bind(user_id.clone());
|
||||||
|
}
|
||||||
|
separated.push_unseparated(")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const LIST_RECENT_USAGE_AUDITS_PREFIX: &str =
|
const LIST_RECENT_USAGE_AUDITS_PREFIX: &str =
|
||||||
include_str!("queries/list_recent_usage_audits_prefix.sql");
|
include_str!("queries/list_recent_usage_audits_prefix.sql");
|
||||||
|
|
||||||
@@ -3625,14 +3652,15 @@ OR (\"usage\".error_message IS NOT NULL AND BTRIM(\"usage\".error_message) <> ''
|
|||||||
start_day_utc: DateTime<Utc>,
|
start_day_utc: DateTime<Utc>,
|
||||||
end_day_utc: DateTime<Utc>,
|
end_day_utc: DateTime<Utc>,
|
||||||
user_id: Option<&str>,
|
user_id: Option<&str>,
|
||||||
|
user_ids: Option<&[String]>,
|
||||||
) -> Result<StoredUsageAuditSummary, DataLayerError> {
|
) -> Result<StoredUsageAuditSummary, DataLayerError> {
|
||||||
if start_day_utc >= end_day_utc {
|
if start_day_utc >= end_day_utc {
|
||||||
return Ok(StoredUsageAuditSummary::default());
|
return Ok(StoredUsageAuditSummary::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
let row = if let Some(user_id) = user_id {
|
let scoped_to_users = user_id.is_some() || user_ids.is_some();
|
||||||
sqlx::query(
|
let mut builder = QueryBuilder::<Postgres>::new(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(SUM(total_requests), 0)::BIGINT AS total_requests,
|
COALESCE(SUM(total_requests), 0)::BIGINT AS total_requests,
|
||||||
COALESCE(SUM(input_tokens), 0)::BIGINT AS input_tokens,
|
COALESCE(SUM(input_tokens), 0)::BIGINT AS input_tokens,
|
||||||
@@ -3657,57 +3685,27 @@ SELECT
|
|||||||
COALESCE(SUM(cache_read_cost), 0)::DOUBLE PRECISION AS cache_read_cost_usd,
|
COALESCE(SUM(cache_read_cost), 0)::DOUBLE PRECISION AS cache_read_cost_usd,
|
||||||
COALESCE(SUM(response_time_sum_ms), 0)::DOUBLE PRECISION AS total_response_time_ms,
|
COALESCE(SUM(response_time_sum_ms), 0)::DOUBLE PRECISION AS total_response_time_ms,
|
||||||
COALESCE(SUM(error_requests), 0)::BIGINT AS error_requests
|
COALESCE(SUM(error_requests), 0)::BIGINT AS error_requests
|
||||||
FROM stats_user_daily
|
FROM "#,
|
||||||
WHERE user_id = $1
|
);
|
||||||
AND date >= $2
|
builder.push(if scoped_to_users {
|
||||||
AND date < $3
|
"stats_user_daily"
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(start_day_utc)
|
|
||||||
.bind(end_day_utc)
|
|
||||||
.fetch_one(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_postgres_err()?
|
|
||||||
} else {
|
} else {
|
||||||
sqlx::query(
|
"stats_daily"
|
||||||
r#"
|
});
|
||||||
SELECT
|
builder
|
||||||
COALESCE(SUM(total_requests), 0)::BIGINT AS total_requests,
|
.push(" WHERE date >= ")
|
||||||
COALESCE(SUM(input_tokens), 0)::BIGINT AS input_tokens,
|
.push_bind(start_day_utc)
|
||||||
COALESCE(SUM(output_tokens), 0)::BIGINT AS output_tokens,
|
.push(" AND date < ")
|
||||||
COALESCE(SUM(
|
.push_bind(end_day_utc);
|
||||||
CASE
|
if scoped_to_users {
|
||||||
WHEN effective_input_tokens = 0 AND total_input_context = 0 AND input_tokens > 0
|
push_usage_user_scope(&mut builder, "user_id", user_id, user_ids);
|
||||||
THEN input_tokens
|
}
|
||||||
ELSE effective_input_tokens
|
|
||||||
END
|
let row = builder
|
||||||
+ output_tokens + cache_creation_tokens + cache_read_tokens
|
.build()
|
||||||
), 0)::BIGINT AS recorded_total_tokens,
|
|
||||||
COALESCE(SUM(cache_creation_tokens), 0)::BIGINT AS cache_creation_tokens,
|
|
||||||
COALESCE(SUM(cache_creation_ephemeral_5m_tokens), 0)::BIGINT
|
|
||||||
AS cache_creation_ephemeral_5m_tokens,
|
|
||||||
COALESCE(SUM(cache_creation_ephemeral_1h_tokens), 0)::BIGINT
|
|
||||||
AS cache_creation_ephemeral_1h_tokens,
|
|
||||||
COALESCE(SUM(cache_read_tokens), 0)::BIGINT AS cache_read_tokens,
|
|
||||||
COALESCE(SUM(total_cost), 0)::DOUBLE PRECISION AS total_cost_usd,
|
|
||||||
COALESCE(SUM(actual_total_cost), 0)::DOUBLE PRECISION AS actual_total_cost_usd,
|
|
||||||
COALESCE(SUM(cache_creation_cost), 0)::DOUBLE PRECISION AS cache_creation_cost_usd,
|
|
||||||
COALESCE(SUM(cache_read_cost), 0)::DOUBLE PRECISION AS cache_read_cost_usd,
|
|
||||||
COALESCE(SUM(response_time_sum_ms), 0)::DOUBLE PRECISION AS total_response_time_ms,
|
|
||||||
COALESCE(SUM(error_requests), 0)::BIGINT AS error_requests
|
|
||||||
FROM stats_daily
|
|
||||||
WHERE date >= $1
|
|
||||||
AND date < $2
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(start_day_utc)
|
|
||||||
.bind(end_day_utc)
|
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_postgres_err()?
|
.map_postgres_err()?;
|
||||||
};
|
|
||||||
|
|
||||||
decode_usage_audit_summary_row(&row)
|
decode_usage_audit_summary_row(&row)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3716,6 +3714,7 @@ WHERE date >= $1
|
|||||||
created_from_unix_secs: u64,
|
created_from_unix_secs: u64,
|
||||||
created_until_unix_secs: u64,
|
created_until_unix_secs: u64,
|
||||||
user_id: Option<&str>,
|
user_id: Option<&str>,
|
||||||
|
user_ids: Option<&[String]>,
|
||||||
provider_name: Option<&str>,
|
provider_name: Option<&str>,
|
||||||
model: Option<&str>,
|
model: Option<&str>,
|
||||||
) -> Result<StoredUsageAuditSummary, DataLayerError> {
|
) -> Result<StoredUsageAuditSummary, DataLayerError> {
|
||||||
@@ -3770,12 +3769,7 @@ FROM usage_billing_facts AS "usage"
|
|||||||
.push("\"usage\".created_at < TO_TIMESTAMP(")
|
.push("\"usage\".created_at < TO_TIMESTAMP(")
|
||||||
.push_bind(created_until_unix_secs as f64)
|
.push_bind(created_until_unix_secs as f64)
|
||||||
.push("::double precision)");
|
.push("::double precision)");
|
||||||
if let Some(user_id) = user_id {
|
push_usage_user_scope(&mut builder, "\"usage\".user_id", user_id, user_ids);
|
||||||
builder.push(if has_where { " AND " } else { " WHERE " });
|
|
||||||
builder
|
|
||||||
.push("\"usage\".user_id = ")
|
|
||||||
.push_bind(user_id.to_string());
|
|
||||||
}
|
|
||||||
if let Some(provider_name) = provider_name {
|
if let Some(provider_name) = provider_name {
|
||||||
builder.push(if has_where { " AND " } else { " WHERE " });
|
builder.push(if has_where { " AND " } else { " WHERE " });
|
||||||
has_where = true;
|
has_where = true;
|
||||||
@@ -3808,6 +3802,7 @@ FROM usage_billing_facts AS "usage"
|
|||||||
query.created_from_unix_secs,
|
query.created_from_unix_secs,
|
||||||
query.created_until_unix_secs,
|
query.created_until_unix_secs,
|
||||||
query.user_id.as_deref(),
|
query.user_id.as_deref(),
|
||||||
|
query.user_ids.as_deref(),
|
||||||
query.provider_name.as_deref(),
|
query.provider_name.as_deref(),
|
||||||
query.model.as_deref(),
|
query.model.as_deref(),
|
||||||
)
|
)
|
||||||
@@ -3819,6 +3814,7 @@ FROM usage_billing_facts AS "usage"
|
|||||||
query.created_from_unix_secs,
|
query.created_from_unix_secs,
|
||||||
query.created_until_unix_secs,
|
query.created_until_unix_secs,
|
||||||
query.user_id.as_deref(),
|
query.user_id.as_deref(),
|
||||||
|
query.user_ids.as_deref(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -3834,6 +3830,7 @@ FROM usage_billing_facts AS "usage"
|
|||||||
query.created_from_unix_secs,
|
query.created_from_unix_secs,
|
||||||
query.created_until_unix_secs,
|
query.created_until_unix_secs,
|
||||||
query.user_id.as_deref(),
|
query.user_id.as_deref(),
|
||||||
|
query.user_ids.as_deref(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -3848,6 +3845,7 @@ FROM usage_billing_facts AS "usage"
|
|||||||
dashboard_utc_to_unix_secs(raw_start),
|
dashboard_utc_to_unix_secs(raw_start),
|
||||||
dashboard_utc_to_unix_secs(raw_end),
|
dashboard_utc_to_unix_secs(raw_end),
|
||||||
query.user_id.as_deref(),
|
query.user_id.as_deref(),
|
||||||
|
query.user_ids.as_deref(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -3861,6 +3859,7 @@ FROM usage_billing_facts AS "usage"
|
|||||||
aggregate_start,
|
aggregate_start,
|
||||||
aggregate_end,
|
aggregate_end,
|
||||||
query.user_id.as_deref(),
|
query.user_id.as_deref(),
|
||||||
|
query.user_ids.as_deref(),
|
||||||
)
|
)
|
||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
@@ -3872,6 +3871,7 @@ FROM usage_billing_facts AS "usage"
|
|||||||
dashboard_utc_to_unix_secs(raw_start),
|
dashboard_utc_to_unix_secs(raw_start),
|
||||||
dashboard_utc_to_unix_secs(raw_end),
|
dashboard_utc_to_unix_secs(raw_end),
|
||||||
query.user_id.as_deref(),
|
query.user_id.as_deref(),
|
||||||
|
query.user_ids.as_deref(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -6815,13 +6815,12 @@ FROM usage_billing_facts AS "usage"
|
|||||||
.push("\"usage\".created_at < TO_TIMESTAMP(")
|
.push("\"usage\".created_at < TO_TIMESTAMP(")
|
||||||
.push_bind(query.created_until_unix_secs as f64)
|
.push_bind(query.created_until_unix_secs as f64)
|
||||||
.push("::double precision)");
|
.push("::double precision)");
|
||||||
if let Some(user_id) = query.user_id.as_deref() {
|
push_usage_user_scope(
|
||||||
builder.push(if has_where { " AND " } else { " WHERE " });
|
&mut builder,
|
||||||
has_where = true;
|
"\"usage\".user_id",
|
||||||
builder
|
query.user_id.as_deref(),
|
||||||
.push("\"usage\".user_id = ")
|
query.user_ids.as_deref(),
|
||||||
.push_bind(user_id.to_string());
|
);
|
||||||
}
|
|
||||||
if let Some(provider_name) = query.provider_name.as_deref() {
|
if let Some(provider_name) = query.provider_name.as_deref() {
|
||||||
builder.push(if has_where { " AND " } else { " WHERE " });
|
builder.push(if has_where { " AND " } else { " WHERE " });
|
||||||
has_where = true;
|
has_where = true;
|
||||||
@@ -6861,62 +6860,45 @@ FROM usage_billing_facts AS "usage"
|
|||||||
start_day_utc: DateTime<Utc>,
|
start_day_utc: DateTime<Utc>,
|
||||||
end_day_utc: DateTime<Utc>,
|
end_day_utc: DateTime<Utc>,
|
||||||
user_id: Option<&str>,
|
user_id: Option<&str>,
|
||||||
|
user_ids: Option<&[String]>,
|
||||||
) -> Result<Vec<StoredUsageTimeSeriesBucket>, DataLayerError> {
|
) -> Result<Vec<StoredUsageTimeSeriesBucket>, DataLayerError> {
|
||||||
if start_day_utc >= end_day_utc {
|
if start_day_utc >= end_day_utc {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
let rows = if let Some(user_id) = user_id {
|
let scoped_to_users = user_id.is_some() || user_ids.is_some();
|
||||||
sqlx::query(
|
let mut builder = QueryBuilder::<Postgres>::new(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
TO_CHAR(date, 'YYYY-MM-DD') AS bucket_key,
|
TO_CHAR(date, 'YYYY-MM-DD') AS bucket_key,
|
||||||
total_requests::BIGINT AS total_requests,
|
COALESCE(SUM(total_requests), 0)::BIGINT AS total_requests,
|
||||||
input_tokens::BIGINT AS input_tokens,
|
COALESCE(SUM(input_tokens), 0)::BIGINT AS input_tokens,
|
||||||
output_tokens::BIGINT AS output_tokens,
|
COALESCE(SUM(output_tokens), 0)::BIGINT AS output_tokens,
|
||||||
cache_creation_tokens::BIGINT AS cache_creation_tokens,
|
COALESCE(SUM(cache_creation_tokens), 0)::BIGINT AS cache_creation_tokens,
|
||||||
cache_read_tokens::BIGINT AS cache_read_tokens,
|
COALESCE(SUM(cache_read_tokens), 0)::BIGINT AS cache_read_tokens,
|
||||||
CAST(total_cost AS DOUBLE PRECISION) AS total_cost_usd,
|
COALESCE(SUM(CAST(total_cost AS DOUBLE PRECISION)), 0) AS total_cost_usd,
|
||||||
CAST(response_time_sum_ms AS DOUBLE PRECISION) AS total_response_time_ms
|
COALESCE(SUM(CAST(response_time_sum_ms AS DOUBLE PRECISION)), 0)
|
||||||
FROM stats_user_daily
|
AS total_response_time_ms
|
||||||
WHERE user_id = $1
|
FROM "#,
|
||||||
AND date >= $2
|
);
|
||||||
AND date < $3
|
builder.push(if scoped_to_users {
|
||||||
ORDER BY date ASC
|
"stats_user_daily"
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(start_day_utc)
|
|
||||||
.bind(end_day_utc)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_postgres_err()?
|
|
||||||
} else {
|
} else {
|
||||||
sqlx::query(
|
"stats_daily"
|
||||||
r#"
|
});
|
||||||
SELECT
|
builder
|
||||||
TO_CHAR(date, 'YYYY-MM-DD') AS bucket_key,
|
.push(" WHERE date >= ")
|
||||||
total_requests::BIGINT AS total_requests,
|
.push_bind(start_day_utc)
|
||||||
input_tokens::BIGINT AS input_tokens,
|
.push(" AND date < ")
|
||||||
output_tokens::BIGINT AS output_tokens,
|
.push_bind(end_day_utc);
|
||||||
cache_creation_tokens::BIGINT AS cache_creation_tokens,
|
if scoped_to_users {
|
||||||
cache_read_tokens::BIGINT AS cache_read_tokens,
|
push_usage_user_scope(&mut builder, "user_id", user_id, user_ids);
|
||||||
CAST(total_cost AS DOUBLE PRECISION) AS total_cost_usd,
|
}
|
||||||
CAST(response_time_sum_ms AS DOUBLE PRECISION) AS total_response_time_ms
|
builder.push(" GROUP BY date ORDER BY date ASC");
|
||||||
FROM stats_daily
|
|
||||||
WHERE date >= $1
|
let mut rows = builder.build().fetch(&self.pool);
|
||||||
AND date < $2
|
|
||||||
ORDER BY date ASC
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(start_day_utc)
|
|
||||||
.bind(end_day_utc)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_postgres_err()?
|
|
||||||
};
|
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
for row in rows {
|
while let Some(row) = rows.try_next().await.map_postgres_err()? {
|
||||||
items.push(decode_usage_time_series_bucket_row(&row)?);
|
items.push(decode_usage_time_series_bucket_row(&row)?);
|
||||||
}
|
}
|
||||||
Ok(items)
|
Ok(items)
|
||||||
@@ -7002,6 +6984,7 @@ WHERE is_complete IS TRUE
|
|||||||
granularity: UsageTimeSeriesGranularity::Day,
|
granularity: UsageTimeSeriesGranularity::Day,
|
||||||
tz_offset_minutes: 0,
|
tz_offset_minutes: 0,
|
||||||
user_id: query.user_id.clone(),
|
user_id: query.user_id.clone(),
|
||||||
|
user_ids: query.user_ids.clone(),
|
||||||
provider_name: None,
|
provider_name: None,
|
||||||
model: None,
|
model: None,
|
||||||
},
|
},
|
||||||
@@ -7016,6 +6999,7 @@ WHERE is_complete IS TRUE
|
|||||||
aggregate_start,
|
aggregate_start,
|
||||||
aggregate_end,
|
aggregate_end,
|
||||||
query.user_id.as_deref(),
|
query.user_id.as_deref(),
|
||||||
|
query.user_ids.as_deref(),
|
||||||
)
|
)
|
||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
@@ -7028,6 +7012,7 @@ WHERE is_complete IS TRUE
|
|||||||
granularity: UsageTimeSeriesGranularity::Day,
|
granularity: UsageTimeSeriesGranularity::Day,
|
||||||
tz_offset_minutes: 0,
|
tz_offset_minutes: 0,
|
||||||
user_id: query.user_id.clone(),
|
user_id: query.user_id.clone(),
|
||||||
|
user_ids: query.user_ids.clone(),
|
||||||
provider_name: None,
|
provider_name: None,
|
||||||
model: None,
|
model: None,
|
||||||
},
|
},
|
||||||
@@ -7045,6 +7030,7 @@ WHERE is_complete IS TRUE
|
|||||||
granularity: UsageTimeSeriesGranularity::Day,
|
granularity: UsageTimeSeriesGranularity::Day,
|
||||||
tz_offset_minutes: 0,
|
tz_offset_minutes: 0,
|
||||||
user_id: query.user_id.clone(),
|
user_id: query.user_id.clone(),
|
||||||
|
user_ids: query.user_ids.clone(),
|
||||||
provider_name: None,
|
provider_name: None,
|
||||||
model: None,
|
model: None,
|
||||||
},
|
},
|
||||||
@@ -7058,7 +7044,8 @@ WHERE is_complete IS TRUE
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if query.user_id.is_none() && query.tz_offset_minutes % 60 == 0 {
|
if query.user_id.is_none() && query.user_ids.is_none() && query.tz_offset_minutes % 60 == 0
|
||||||
|
{
|
||||||
if let Some(cutoff_utc) = self.read_stats_hourly_cutoff().await? {
|
if let Some(cutoff_utc) = self.read_stats_hourly_cutoff().await? {
|
||||||
let start_utc = dashboard_unix_secs_to_utc(query.created_from_unix_secs);
|
let start_utc = dashboard_unix_secs_to_utc(query.created_from_unix_secs);
|
||||||
let end_utc = dashboard_unix_secs_to_utc(query.created_until_unix_secs);
|
let end_utc = dashboard_unix_secs_to_utc(query.created_until_unix_secs);
|
||||||
@@ -7075,6 +7062,7 @@ WHERE is_complete IS TRUE
|
|||||||
granularity: query.granularity,
|
granularity: query.granularity,
|
||||||
tz_offset_minutes: query.tz_offset_minutes,
|
tz_offset_minutes: query.tz_offset_minutes,
|
||||||
user_id: None,
|
user_id: None,
|
||||||
|
user_ids: None,
|
||||||
provider_name: None,
|
provider_name: None,
|
||||||
model: None,
|
model: None,
|
||||||
},
|
},
|
||||||
@@ -7102,6 +7090,7 @@ WHERE is_complete IS TRUE
|
|||||||
granularity: query.granularity,
|
granularity: query.granularity,
|
||||||
tz_offset_minutes: query.tz_offset_minutes,
|
tz_offset_minutes: query.tz_offset_minutes,
|
||||||
user_id: None,
|
user_id: None,
|
||||||
|
user_ids: None,
|
||||||
provider_name: None,
|
provider_name: None,
|
||||||
model: None,
|
model: None,
|
||||||
},
|
},
|
||||||
@@ -7119,6 +7108,7 @@ WHERE is_complete IS TRUE
|
|||||||
granularity: query.granularity,
|
granularity: query.granularity,
|
||||||
tz_offset_minutes: query.tz_offset_minutes,
|
tz_offset_minutes: query.tz_offset_minutes,
|
||||||
user_id: None,
|
user_id: None,
|
||||||
|
user_ids: None,
|
||||||
provider_name: None,
|
provider_name: None,
|
||||||
model: None,
|
model: None,
|
||||||
},
|
},
|
||||||
@@ -7158,6 +7148,7 @@ WHERE "usage".created_at >= TO_TIMESTAMP($1::double precision)
|
|||||||
AND ($3::varchar IS NULL OR "usage".user_id = $3)
|
AND ($3::varchar IS NULL OR "usage".user_id = $3)
|
||||||
AND ($4::varchar IS NULL OR "usage".provider_name = $4)
|
AND ($4::varchar IS NULL OR "usage".provider_name = $4)
|
||||||
AND ($5::varchar IS NULL OR "usage".model = $5)
|
AND ($5::varchar IS NULL OR "usage".model = $5)
|
||||||
|
AND ($6::text[] IS NULL OR "usage".user_id::text = ANY($6))
|
||||||
GROUP BY group_key
|
GROUP BY group_key
|
||||||
ORDER BY group_key ASC
|
ORDER BY group_key ASC
|
||||||
"#,
|
"#,
|
||||||
@@ -7171,6 +7162,7 @@ ORDER BY group_key ASC
|
|||||||
.bind(query.user_id.as_deref())
|
.bind(query.user_id.as_deref())
|
||||||
.bind(query.provider_name.as_deref())
|
.bind(query.provider_name.as_deref())
|
||||||
.bind(query.model.as_deref())
|
.bind(query.model.as_deref())
|
||||||
|
.bind(query.user_ids.clone())
|
||||||
.fetch(&self.pool);
|
.fetch(&self.pool);
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
while let Some(row) = rows.try_next().await.map_postgres_err()? {
|
while let Some(row) = rows.try_next().await.map_postgres_err()? {
|
||||||
@@ -7314,11 +7306,12 @@ WHERE date >=
|
|||||||
.push_bind(end_day_utc)
|
.push_bind(end_day_utc)
|
||||||
.push(" AND provider_name = ")
|
.push(" AND provider_name = ")
|
||||||
.push_bind(provider_name.to_string());
|
.push_bind(provider_name.to_string());
|
||||||
if let Some(user_id) = query.user_id.as_deref() {
|
push_usage_user_scope(
|
||||||
builder
|
&mut builder,
|
||||||
.push(" AND user_id = ")
|
"user_id",
|
||||||
.push_bind(user_id.to_string());
|
query.user_id.as_deref(),
|
||||||
}
|
query.user_ids.as_deref(),
|
||||||
|
);
|
||||||
builder.push(" GROUP BY user_id ORDER BY user_id ASC");
|
builder.push(" GROUP BY user_id ORDER BY user_id ASC");
|
||||||
builder
|
builder
|
||||||
} else if let Some(model) = query.model.as_deref() {
|
} else if let Some(model) = query.model.as_deref() {
|
||||||
@@ -7340,11 +7333,12 @@ WHERE date >=
|
|||||||
.push_bind(end_day_utc)
|
.push_bind(end_day_utc)
|
||||||
.push(" AND model = ")
|
.push(" AND model = ")
|
||||||
.push_bind(model.to_string());
|
.push_bind(model.to_string());
|
||||||
if let Some(user_id) = query.user_id.as_deref() {
|
push_usage_user_scope(
|
||||||
builder
|
&mut builder,
|
||||||
.push(" AND user_id = ")
|
"user_id",
|
||||||
.push_bind(user_id.to_string());
|
query.user_id.as_deref(),
|
||||||
}
|
query.user_ids.as_deref(),
|
||||||
|
);
|
||||||
builder.push(" GROUP BY user_id ORDER BY user_id ASC");
|
builder.push(" GROUP BY user_id ORDER BY user_id ASC");
|
||||||
builder
|
builder
|
||||||
} else {
|
} else {
|
||||||
@@ -7368,11 +7362,12 @@ WHERE date >=
|
|||||||
.push(" AND date < ")
|
.push(" AND date < ")
|
||||||
.push_bind(end_day_utc)
|
.push_bind(end_day_utc)
|
||||||
.push(" AND user_id IS NOT NULL");
|
.push(" AND user_id IS NOT NULL");
|
||||||
if let Some(user_id) = query.user_id.as_deref() {
|
push_usage_user_scope(
|
||||||
builder
|
&mut builder,
|
||||||
.push(" AND user_id = ")
|
"user_id",
|
||||||
.push_bind(user_id.to_string());
|
query.user_id.as_deref(),
|
||||||
}
|
query.user_ids.as_deref(),
|
||||||
|
);
|
||||||
builder.push(" GROUP BY user_id ORDER BY user_id ASC");
|
builder.push(" GROUP BY user_id ORDER BY user_id ASC");
|
||||||
builder
|
builder
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3251,13 +3251,15 @@ fn usage_sql_canonical_openai_cache_case_preserves_effective_and_total_tokens()
|
|||||||
aggregate_audit_summary
|
aggregate_audit_summary
|
||||||
.matches("WHEN effective_input_tokens = 0 AND total_input_context = 0")
|
.matches("WHEN effective_input_tokens = 0 AND total_input_context = 0")
|
||||||
.count(),
|
.count(),
|
||||||
2
|
1,
|
||||||
|
"the shared daily aggregate query should define the legacy token fallback once"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
aggregate_audit_summary
|
aggregate_audit_summary
|
||||||
.matches("+ output_tokens + cache_creation_tokens + cache_read_tokens")
|
.matches("+ output_tokens + cache_creation_tokens + cache_read_tokens")
|
||||||
.count(),
|
.count(),
|
||||||
2
|
1,
|
||||||
|
"the shared daily aggregate query should define canonical total tokens once"
|
||||||
);
|
);
|
||||||
assert!(!aggregate_audit_summary.contains("SUM(input_tokens + output_tokens)"));
|
assert!(!aggregate_audit_summary.contains("SUM(input_tokens + output_tokens)"));
|
||||||
|
|
||||||
|
|||||||
@@ -1063,6 +1063,9 @@ pub struct UsageAuditSummaryQuery {
|
|||||||
pub created_from_unix_secs: u64,
|
pub created_from_unix_secs: u64,
|
||||||
pub created_until_unix_secs: u64,
|
pub created_until_unix_secs: u64,
|
||||||
pub user_id: Option<String>,
|
pub user_id: Option<String>,
|
||||||
|
/// Optional bulk user scope used by current user-group reporting.
|
||||||
|
/// An empty list intentionally matches no usage rows.
|
||||||
|
pub user_ids: Option<Vec<String>>,
|
||||||
pub provider_name: Option<String>,
|
pub provider_name: Option<String>,
|
||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -1455,6 +1458,9 @@ pub struct UsageTimeSeriesQuery {
|
|||||||
pub granularity: UsageTimeSeriesGranularity,
|
pub granularity: UsageTimeSeriesGranularity,
|
||||||
pub tz_offset_minutes: i32,
|
pub tz_offset_minutes: i32,
|
||||||
pub user_id: Option<String>,
|
pub user_id: Option<String>,
|
||||||
|
/// Optional bulk user scope used by current user-group reporting.
|
||||||
|
/// An empty list intentionally matches no usage rows.
|
||||||
|
pub user_ids: Option<Vec<String>>,
|
||||||
pub provider_name: Option<String>,
|
pub provider_name: Option<String>,
|
||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -1485,6 +1491,9 @@ pub struct UsageLeaderboardQuery {
|
|||||||
pub created_until_unix_secs: u64,
|
pub created_until_unix_secs: u64,
|
||||||
pub group_by: UsageLeaderboardGroupBy,
|
pub group_by: UsageLeaderboardGroupBy,
|
||||||
pub user_id: Option<String>,
|
pub user_id: Option<String>,
|
||||||
|
/// Optional bulk user scope used by current user-group reporting.
|
||||||
|
/// An empty list intentionally matches no usage rows.
|
||||||
|
pub user_ids: Option<Vec<String>>,
|
||||||
pub provider_name: Option<String>,
|
pub provider_name: Option<String>,
|
||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2388,6 +2388,7 @@ INSERT INTO public.stats_daily_api_key (
|
|||||||
.expect("historical stats end should be nonnegative"),
|
.expect("historical stats end should be nonnegative"),
|
||||||
group_by: UsageLeaderboardGroupBy::ApiKey,
|
group_by: UsageLeaderboardGroupBy::ApiKey,
|
||||||
user_id: Some("leaderboard-owner".to_string()),
|
user_id: Some("leaderboard-owner".to_string()),
|
||||||
|
user_ids: None,
|
||||||
provider_name: None,
|
provider_name: None,
|
||||||
model: None,
|
model: None,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -493,6 +493,15 @@ fn usage_matches_summary_query(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(user_ids) = query.user_ids.as_deref() {
|
||||||
|
if !item
|
||||||
|
.user_id
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|user_id| user_ids.contains(user_id))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(provider_name) = query.provider_name.as_deref() {
|
if let Some(provider_name) = query.provider_name.as_deref() {
|
||||||
if item.provider_name != provider_name {
|
if item.provider_name != provider_name {
|
||||||
return false;
|
return false;
|
||||||
@@ -520,6 +529,15 @@ fn usage_matches_time_series_query(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(user_ids) = query.user_ids.as_deref() {
|
||||||
|
if !item
|
||||||
|
.user_id
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|user_id| user_ids.contains(user_id))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(provider_name) = query.provider_name.as_deref() {
|
if let Some(provider_name) = query.provider_name.as_deref() {
|
||||||
if item.provider_name != provider_name {
|
if item.provider_name != provider_name {
|
||||||
return false;
|
return false;
|
||||||
@@ -891,6 +909,15 @@ fn usage_matches_leaderboard_query(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(user_ids) = query.user_ids.as_deref() {
|
||||||
|
if !item
|
||||||
|
.user_id
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|user_id| user_ids.contains(user_id))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(provider_name) = query.provider_name.as_deref() {
|
if let Some(provider_name) = query.provider_name.as_deref() {
|
||||||
if item.provider_name != provider_name {
|
if item.provider_name != provider_name {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use aether_data_contracts::repository::usage::{
|
|||||||
UsageAuditAggregationQuery, UsageAuditKeywordSearchQuery, UsageAuditListQuery,
|
UsageAuditAggregationQuery, UsageAuditKeywordSearchQuery, UsageAuditListQuery,
|
||||||
UsageAuditSummaryQuery, UsageBodyCaptureState, UsageBodyField, UsageDashboardSummaryQuery,
|
UsageAuditSummaryQuery, UsageBodyCaptureState, UsageBodyField, UsageDashboardSummaryQuery,
|
||||||
UsageLeaderboardGroupBy, UsageLeaderboardQuery, UsageProviderPerformanceQuery,
|
UsageLeaderboardGroupBy, UsageLeaderboardQuery, UsageProviderPerformanceQuery,
|
||||||
UsageTimeSeriesGranularity,
|
UsageTimeSeriesGranularity, UsageTimeSeriesQuery,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
@@ -2217,6 +2217,7 @@ async fn dashboard_and_leaderboard_total_tokens_use_effective_cache_aware_tokens
|
|||||||
created_until_unix_secs: 1_711_000_001,
|
created_until_unix_secs: 1_711_000_001,
|
||||||
group_by: UsageLeaderboardGroupBy::User,
|
group_by: UsageLeaderboardGroupBy::User,
|
||||||
user_id: None,
|
user_id: None,
|
||||||
|
user_ids: None,
|
||||||
provider_name: None,
|
provider_name: None,
|
||||||
model: None,
|
model: None,
|
||||||
})
|
})
|
||||||
@@ -2226,6 +2227,64 @@ async fn dashboard_and_leaderboard_total_tokens_use_effective_cache_aware_tokens
|
|||||||
assert_eq!(leaderboard[0].total_tokens, 120);
|
assert_eq!(leaderboard[0].total_tokens, 120);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn usage_analytics_filters_by_multiple_user_ids() {
|
||||||
|
let user_one = sample_usage("req-user-1", 1_711_000_000);
|
||||||
|
let mut user_two = sample_usage("req-user-2", 1_711_000_000);
|
||||||
|
user_two.user_id = Some("user-2".to_string());
|
||||||
|
let mut user_three = sample_usage("req-user-3", 1_711_000_000);
|
||||||
|
user_three.user_id = Some("user-3".to_string());
|
||||||
|
let repository = InMemoryUsageReadRepository::seed(vec![user_one, user_two, user_three]);
|
||||||
|
let scoped_user_ids = vec!["user-1".to_string(), "user-2".to_string()];
|
||||||
|
|
||||||
|
let summary = repository
|
||||||
|
.summarize_usage_audits(&UsageAuditSummaryQuery {
|
||||||
|
created_from_unix_secs: 1_711_000_000,
|
||||||
|
created_until_unix_secs: 1_711_000_001,
|
||||||
|
user_ids: Some(scoped_user_ids.clone()),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("summary should filter by multiple users");
|
||||||
|
assert_eq!(summary.total_requests, 2);
|
||||||
|
|
||||||
|
let buckets = repository
|
||||||
|
.summarize_usage_time_series(&UsageTimeSeriesQuery {
|
||||||
|
created_from_unix_secs: 1_711_000_000,
|
||||||
|
created_until_unix_secs: 1_711_000_001,
|
||||||
|
granularity: UsageTimeSeriesGranularity::Day,
|
||||||
|
tz_offset_minutes: 0,
|
||||||
|
user_id: None,
|
||||||
|
user_ids: Some(scoped_user_ids.clone()),
|
||||||
|
provider_name: None,
|
||||||
|
model: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("time series should filter by multiple users");
|
||||||
|
assert_eq!(
|
||||||
|
buckets
|
||||||
|
.iter()
|
||||||
|
.map(|bucket| bucket.total_requests)
|
||||||
|
.sum::<u64>(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
|
||||||
|
let leaderboard = repository
|
||||||
|
.summarize_usage_leaderboard(&UsageLeaderboardQuery {
|
||||||
|
created_from_unix_secs: 1_711_000_000,
|
||||||
|
created_until_unix_secs: 1_711_000_001,
|
||||||
|
group_by: UsageLeaderboardGroupBy::User,
|
||||||
|
user_id: None,
|
||||||
|
user_ids: Some(scoped_user_ids),
|
||||||
|
provider_name: None,
|
||||||
|
model: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("leaderboard should filter by multiple users");
|
||||||
|
assert_eq!(leaderboard.len(), 2);
|
||||||
|
assert!(leaderboard.iter().all(|item| item.group_key != "user-3"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn summarizes_provider_api_key_last_used_at_in_seconds() {
|
async fn summarizes_provider_api_key_last_used_at_in_seconds() {
|
||||||
let repository = InMemoryUsageReadRepository::seed(vec![
|
let repository = InMemoryUsageReadRepository::seed(vec![
|
||||||
|
|||||||
@@ -82,4 +82,35 @@ describe('adminApi analytics cache options', () => {
|
|||||||
})
|
})
|
||||||
expect(getMock).toHaveBeenNthCalledWith(4, '/api/admin/stats/errors/distribution', { params })
|
expect(getMock).toHaveBeenNthCalledWith(4, '/api/admin/stats/errors/distribution', { params })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('requests the user group leaderboard with scoped cache parameters', async () => {
|
||||||
|
const groupParams = {
|
||||||
|
...params,
|
||||||
|
metric: 'cost' as const,
|
||||||
|
offset: 10,
|
||||||
|
limit: 10,
|
||||||
|
include_inactive: true,
|
||||||
|
}
|
||||||
|
getMock.mockResolvedValueOnce({
|
||||||
|
data: { items: [], total: 0, metric: 'cost', attribution: 'current_membership' },
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(adminApi.getLeaderboardUserGroups(groupParams)).resolves.toMatchObject({
|
||||||
|
attribution: 'current_membership',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(buildCacheKeyMock).toHaveBeenCalledWith(
|
||||||
|
'admin:stats:leaderboard:user-groups',
|
||||||
|
groupParams
|
||||||
|
)
|
||||||
|
expect(cachedRequestMock).toHaveBeenCalledWith(
|
||||||
|
'admin:stats:leaderboard:user-groups',
|
||||||
|
expect.any(Function),
|
||||||
|
20 * 1000
|
||||||
|
)
|
||||||
|
expect(getMock).toHaveBeenCalledWith('/api/admin/stats/leaderboard/user-groups', {
|
||||||
|
params: groupParams,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -709,6 +709,8 @@ export interface LeaderboardItem {
|
|||||||
requests: number
|
requests: number
|
||||||
tokens: number
|
tokens: number
|
||||||
cost: number
|
cost: number
|
||||||
|
member_count?: number
|
||||||
|
active_member_count?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LeaderboardResponse {
|
export interface LeaderboardResponse {
|
||||||
@@ -717,6 +719,7 @@ export interface LeaderboardResponse {
|
|||||||
metric: string
|
metric: string
|
||||||
start_date?: string | null
|
start_date?: string | null
|
||||||
end_date?: string | null
|
end_date?: string | null
|
||||||
|
attribution?: 'current_membership'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CostForecastResponse {
|
export interface CostForecastResponse {
|
||||||
@@ -1298,6 +1301,7 @@ export const adminApi = {
|
|||||||
model?: string
|
model?: string
|
||||||
include_inactive?: boolean
|
include_inactive?: boolean
|
||||||
exclude_admin?: boolean
|
exclude_admin?: boolean
|
||||||
|
user_group_id?: string
|
||||||
}): Promise<LeaderboardResponse> {
|
}): Promise<LeaderboardResponse> {
|
||||||
const cacheKey = buildCacheKey('admin:stats:leaderboard:users', params)
|
const cacheKey = buildCacheKey('admin:stats:leaderboard:users', params)
|
||||||
return cachedRequest(
|
return cachedRequest(
|
||||||
@@ -1312,6 +1316,35 @@ export const adminApi = {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getLeaderboardUserGroups(params?: {
|
||||||
|
start_date?: string
|
||||||
|
end_date?: string
|
||||||
|
preset?: string
|
||||||
|
timezone?: string
|
||||||
|
tz_offset_minutes?: number
|
||||||
|
metric?: 'requests' | 'tokens' | 'cost'
|
||||||
|
order?: 'asc' | 'desc'
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
provider_name?: string
|
||||||
|
model?: string
|
||||||
|
include_inactive?: boolean
|
||||||
|
exclude_admin?: boolean
|
||||||
|
}): Promise<LeaderboardResponse> {
|
||||||
|
const cacheKey = buildCacheKey('admin:stats:leaderboard:user-groups', params)
|
||||||
|
return cachedRequest(
|
||||||
|
cacheKey,
|
||||||
|
async () => {
|
||||||
|
const response = await apiClient.get<LeaderboardResponse>(
|
||||||
|
'/api/admin/stats/leaderboard/user-groups',
|
||||||
|
{ params }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
20 * 1000
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
async getLeaderboardApiKeys(params?: {
|
async getLeaderboardApiKeys(params?: {
|
||||||
start_date?: string
|
start_date?: string
|
||||||
end_date?: string
|
end_date?: string
|
||||||
@@ -1595,6 +1628,7 @@ export const adminApi = {
|
|||||||
timezone?: string
|
timezone?: string
|
||||||
tz_offset_minutes?: number
|
tz_offset_minutes?: number
|
||||||
user_id?: string
|
user_id?: string
|
||||||
|
user_group_id?: string
|
||||||
model?: string
|
model?: string
|
||||||
provider_name?: string
|
provider_name?: string
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export interface UsageByApiFormat {
|
|||||||
|
|
||||||
export interface UsageFilters {
|
export interface UsageFilters {
|
||||||
user_id?: string // UUID
|
user_id?: string // UUID
|
||||||
|
user_group_id?: string // UUID
|
||||||
provider_id?: string // UUID
|
provider_id?: string // UUID
|
||||||
model?: string
|
model?: string
|
||||||
search?: string
|
search?: string
|
||||||
|
|||||||
@@ -8,17 +8,17 @@
|
|||||||
@update:model-value="emitMetric"
|
@update:model-value="emitMetric"
|
||||||
>
|
>
|
||||||
<SelectTrigger class="h-8 text-xs w-28">
|
<SelectTrigger class="h-8 text-xs w-28">
|
||||||
<SelectValue placeholder="指标" />
|
<SelectValue :placeholder="t('stats.metric.placeholder')" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="requests">
|
<SelectItem value="requests">
|
||||||
请求数
|
{{ t('stats.metric.requests') }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="tokens">
|
<SelectItem value="tokens">
|
||||||
Tokens
|
{{ t('stats.metric.tokens') }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="cost">
|
<SelectItem value="cost">
|
||||||
成本
|
{{ t('stats.metric.cost') }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -36,25 +36,31 @@
|
|||||||
class="p-6"
|
class="p-6"
|
||||||
>
|
>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="暂无数据"
|
:title="t('stats.empty.title')"
|
||||||
description="当前时间范围内没有统计结果"
|
:description="t('stats.empty.description')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Table v-else>
|
<Table v-else>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead class="w-16">
|
<TableHead class="w-16">
|
||||||
排名
|
{{ t('stats.column.rank') }}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>名称</TableHead>
|
<TableHead>{{ t('stats.column.name') }}</TableHead>
|
||||||
<TableHead class="text-right">
|
<TableHead
|
||||||
请求数
|
v-if="showMemberCount"
|
||||||
|
class="text-right"
|
||||||
|
>
|
||||||
|
{{ t('stats.column.members') }}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead class="text-right">
|
<TableHead class="text-right">
|
||||||
Tokens
|
{{ t('stats.metric.requests') }}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead class="text-right">
|
<TableHead class="text-right">
|
||||||
成本
|
{{ t('stats.metric.tokens') }}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead class="text-right">
|
||||||
|
{{ t('stats.metric.cost') }}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -62,11 +68,22 @@
|
|||||||
<TableRow
|
<TableRow
|
||||||
v-for="item in items"
|
v-for="item in items"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
|
:class="selectable ? 'cursor-pointer hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' : undefined"
|
||||||
|
:tabindex="selectable ? 0 : undefined"
|
||||||
|
@click="selectable && emit('select', item)"
|
||||||
|
@keydown.enter.prevent="selectable && emit('select', item)"
|
||||||
|
@keydown.space.prevent="selectable && emit('select', item)"
|
||||||
>
|
>
|
||||||
<TableCell class="font-medium">
|
<TableCell class="font-medium">
|
||||||
{{ item.rank }}
|
{{ item.rank }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{{ item.name }}</TableCell>
|
<TableCell>{{ item.name }}</TableCell>
|
||||||
|
<TableCell
|
||||||
|
v-if="showMemberCount"
|
||||||
|
class="text-right"
|
||||||
|
>
|
||||||
|
{{ item.active_member_count ?? 0 }} / {{ item.member_count ?? 0 }}
|
||||||
|
</TableCell>
|
||||||
<TableCell class="text-right">
|
<TableCell class="text-right">
|
||||||
{{ item.requests }}
|
{{ item.requests }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -102,6 +119,7 @@ import {
|
|||||||
TableRow
|
TableRow
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import { formatCurrency, formatTokens } from '@/utils/format'
|
import { formatCurrency, formatTokens } from '@/utils/format'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import type { LeaderboardItem } from '@/api/admin'
|
import type { LeaderboardItem } from '@/api/admin'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -110,17 +128,24 @@ interface Props {
|
|||||||
metric: 'requests' | 'tokens' | 'cost'
|
metric: 'requests' | 'tokens' | 'cost'
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
showMetricSelect?: boolean
|
showMetricSelect?: boolean
|
||||||
|
showMemberCount?: boolean
|
||||||
|
selectable?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
loading: false,
|
loading: false,
|
||||||
showMetricSelect: true
|
showMetricSelect: true,
|
||||||
|
showMemberCount: false,
|
||||||
|
selectable: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:metric', value: 'requests' | 'tokens' | 'cost'): void
|
(e: 'update:metric', value: 'requests' | 'tokens' | 'cost'): void
|
||||||
|
(e: 'select', value: LeaderboardItem): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const metric = computed(() => props.metric)
|
const metric = computed(() => props.metric)
|
||||||
|
|
||||||
function emitMetric(value: string) {
|
function emitMetric(value: string) {
|
||||||
|
|||||||
@@ -346,6 +346,44 @@ export const messages = {
|
|||||||
'nav.usageStats': '使用统计',
|
'nav.usageStats': '使用统计',
|
||||||
'nav.operations': '运维总览',
|
'nav.operations': '运维总览',
|
||||||
'nav.userStats': '用户统计',
|
'nav.userStats': '用户统计',
|
||||||
|
'userStats.title': '用户统计',
|
||||||
|
'userStats.description': '按用户或当前用户组成员查看排行榜与使用趋势',
|
||||||
|
'userStats.scope.placeholder': '统计维度',
|
||||||
|
'userStats.scope.user': '按用户',
|
||||||
|
'userStats.scope.userGroup': '按用户组',
|
||||||
|
'userStats.search.user': '搜索用户',
|
||||||
|
'userStats.search.userGroup': '搜索用户组',
|
||||||
|
'userStats.select.user': '选择用户',
|
||||||
|
'userStats.select.userGroup': '选择用户组',
|
||||||
|
'userStats.compare.placeholder': '对比对象(可选)',
|
||||||
|
'userStats.compare.none': '不对比',
|
||||||
|
'userStats.leaderboard.user': '用户排行榜',
|
||||||
|
'userStats.leaderboard.userGroup': '用户组排行榜',
|
||||||
|
'userStats.pagination.summary': '共 {total} 项 · 第 {page} 页',
|
||||||
|
'userStats.pagination.previous': '上一页',
|
||||||
|
'userStats.pagination.next': '下一页',
|
||||||
|
'userStats.summary.user': '用户摘要',
|
||||||
|
'userStats.summary.userGroup': '用户组摘要',
|
||||||
|
'userStats.selectPrompt': '请选择统计对象',
|
||||||
|
'userStats.members.current': '当前成员',
|
||||||
|
'userStats.members.active': '活跃成员',
|
||||||
|
'userStats.memberLeaderboard': '组内成员排行',
|
||||||
|
'userStats.trend.user': '用户使用趋势',
|
||||||
|
'userStats.trend.userGroup': '用户组使用趋势',
|
||||||
|
'userStats.comparisonTrend.user': '用户对比趋势',
|
||||||
|
'userStats.comparisonTrend.userGroup': '用户组对比趋势',
|
||||||
|
'userStats.chart.current': '当前对象',
|
||||||
|
'userStats.chart.comparison': '对比对象',
|
||||||
|
'stats.metric.placeholder': '指标',
|
||||||
|
'stats.metric.requests': '请求数',
|
||||||
|
'stats.metric.tokens': 'Tokens',
|
||||||
|
'stats.metric.cost': '成本',
|
||||||
|
'stats.metric.errorRate': '错误率',
|
||||||
|
'stats.empty.title': '暂无数据',
|
||||||
|
'stats.empty.description': '当前时间范围内没有统计结果',
|
||||||
|
'stats.column.rank': '排名',
|
||||||
|
'stats.column.name': '名称',
|
||||||
|
'stats.column.members': '成员',
|
||||||
'nav.costAnalysis': '成本分析',
|
'nav.costAnalysis': '成本分析',
|
||||||
'nav.performanceAnalysis': '性能分析',
|
'nav.performanceAnalysis': '性能分析',
|
||||||
'nav.userManagement': '用户管理',
|
'nav.userManagement': '用户管理',
|
||||||
@@ -792,6 +830,44 @@ export const messages = {
|
|||||||
'nav.usageStats': 'Usage stats',
|
'nav.usageStats': 'Usage stats',
|
||||||
'nav.operations': 'Operations',
|
'nav.operations': 'Operations',
|
||||||
'nav.userStats': 'User stats',
|
'nav.userStats': 'User stats',
|
||||||
|
'userStats.title': 'User statistics',
|
||||||
|
'userStats.description': 'View leaderboards and usage trends by user or current user-group membership',
|
||||||
|
'userStats.scope.placeholder': 'Statistics scope',
|
||||||
|
'userStats.scope.user': 'By user',
|
||||||
|
'userStats.scope.userGroup': 'By user group',
|
||||||
|
'userStats.search.user': 'Search users',
|
||||||
|
'userStats.search.userGroup': 'Search user groups',
|
||||||
|
'userStats.select.user': 'Select a user',
|
||||||
|
'userStats.select.userGroup': 'Select a user group',
|
||||||
|
'userStats.compare.placeholder': 'Comparison (optional)',
|
||||||
|
'userStats.compare.none': 'No comparison',
|
||||||
|
'userStats.leaderboard.user': 'User leaderboard',
|
||||||
|
'userStats.leaderboard.userGroup': 'User-group leaderboard',
|
||||||
|
'userStats.pagination.summary': '{total} items · Page {page}',
|
||||||
|
'userStats.pagination.previous': 'Previous',
|
||||||
|
'userStats.pagination.next': 'Next',
|
||||||
|
'userStats.summary.user': 'User summary',
|
||||||
|
'userStats.summary.userGroup': 'User-group summary',
|
||||||
|
'userStats.selectPrompt': 'Select a statistics entity',
|
||||||
|
'userStats.members.current': 'Current members',
|
||||||
|
'userStats.members.active': 'Active members',
|
||||||
|
'userStats.memberLeaderboard': 'Group member leaderboard',
|
||||||
|
'userStats.trend.user': 'User usage trend',
|
||||||
|
'userStats.trend.userGroup': 'User-group usage trend',
|
||||||
|
'userStats.comparisonTrend.user': 'User comparison trend',
|
||||||
|
'userStats.comparisonTrend.userGroup': 'User-group comparison trend',
|
||||||
|
'userStats.chart.current': 'Current entity',
|
||||||
|
'userStats.chart.comparison': 'Comparison entity',
|
||||||
|
'stats.metric.placeholder': 'Metric',
|
||||||
|
'stats.metric.requests': 'Requests',
|
||||||
|
'stats.metric.tokens': 'Tokens',
|
||||||
|
'stats.metric.cost': 'Cost',
|
||||||
|
'stats.metric.errorRate': 'Error rate',
|
||||||
|
'stats.empty.title': 'No data',
|
||||||
|
'stats.empty.description': 'No statistics are available for the current time range',
|
||||||
|
'stats.column.rank': 'Rank',
|
||||||
|
'stats.column.name': 'Name',
|
||||||
|
'stats.column.members': 'Members',
|
||||||
'nav.costAnalysis': 'Cost analysis',
|
'nav.costAnalysis': 'Cost analysis',
|
||||||
'nav.performanceAnalysis': 'Performance analysis',
|
'nav.performanceAnalysis': 'Performance analysis',
|
||||||
'nav.userManagement': 'Users',
|
'nav.userManagement': 'Users',
|
||||||
|
|||||||
@@ -1,70 +1,117 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
|
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-lg font-semibold">
|
<h1 class="text-lg font-semibold">
|
||||||
用户统计
|
{{ t('userStats.title') }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
查看用户排行榜与使用趋势
|
{{ t('userStats.description') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Select v-model="scope">
|
||||||
|
<SelectTrigger class="h-8 w-32 text-xs">
|
||||||
|
<SelectValue :placeholder="t('userStats.scope.placeholder')" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="user">
|
||||||
|
{{ t('userStats.scope.user') }}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="user_group">
|
||||||
|
{{ t('userStats.scope.userGroup') }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select v-model="selectedEntityId">
|
||||||
|
<SelectTrigger class="h-8 w-52 text-xs">
|
||||||
|
<SelectValue :placeholder="scope === 'user' ? t('userStats.select.user') : t('userStats.select.userGroup')" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent
|
||||||
|
:search-threshold="0"
|
||||||
|
:search-placeholder="scope === 'user' ? t('userStats.search.user') : t('userStats.search.userGroup')"
|
||||||
|
>
|
||||||
|
<SelectItem
|
||||||
|
v-for="entity in allEntities"
|
||||||
|
:key="entity.id"
|
||||||
|
:value="entity.id"
|
||||||
|
>
|
||||||
|
{{ entity.name }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select v-model="compareEntityId">
|
||||||
|
<SelectTrigger class="h-8 w-52 text-xs">
|
||||||
|
<SelectValue :placeholder="t('userStats.compare.placeholder')" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent
|
||||||
|
:search-threshold="0"
|
||||||
|
:search-placeholder="scope === 'user' ? t('userStats.search.user') : t('userStats.search.userGroup')"
|
||||||
|
>
|
||||||
|
<SelectItem value="__none__">
|
||||||
|
{{ t('userStats.compare.none') }}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
v-for="entity in comparisonEntities"
|
||||||
|
:key="`compare-${entity.id}`"
|
||||||
|
:value="entity.id"
|
||||||
|
>
|
||||||
|
{{ entity.name }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<TimeRangePicker
|
<TimeRangePicker
|
||||||
v-model="timeRange"
|
v-model="timeRange"
|
||||||
:allow-hourly="true"
|
:allow-hourly="true"
|
||||||
/>
|
/>
|
||||||
<Select
|
|
||||||
v-model="selectedUserId"
|
|
||||||
>
|
|
||||||
<SelectTrigger class="h-8 text-xs w-52">
|
|
||||||
<SelectValue placeholder="选择用户" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem
|
|
||||||
v-for="user in users"
|
|
||||||
:key="user.id"
|
|
||||||
:value="user.id"
|
|
||||||
>
|
|
||||||
{{ user.username || user.email }}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Select
|
|
||||||
v-model="compareUserId"
|
|
||||||
>
|
|
||||||
<SelectTrigger class="h-8 text-xs w-52">
|
|
||||||
<SelectValue placeholder="对比用户(可选)" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="__none__">
|
|
||||||
不对比
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem
|
|
||||||
v-for="user in users"
|
|
||||||
:key="`compare-${user.id}`"
|
|
||||||
:value="user.id"
|
|
||||||
>
|
|
||||||
{{ user.username || user.email }}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<LeaderboardTable
|
<LeaderboardTable
|
||||||
title="用户排行榜"
|
:title="scope === 'user' ? t('userStats.leaderboard.user') : t('userStats.leaderboard.userGroup')"
|
||||||
:items="leaderboard"
|
:items="leaderboard"
|
||||||
:metric="metric"
|
:metric="metric"
|
||||||
:loading="leaderboardLoading"
|
:loading="leaderboardLoading"
|
||||||
|
:show-member-count="scope === 'user_group'"
|
||||||
|
selectable
|
||||||
@update:metric="metric = $event"
|
@update:metric="metric = $event"
|
||||||
/>
|
@select="selectLeaderboardItem"
|
||||||
|
>
|
||||||
|
<template #pagination>
|
||||||
|
<div class="flex items-center justify-between border-t px-4 py-3 text-xs text-muted-foreground">
|
||||||
|
<span>{{ t('userStats.pagination.summary', { total: leaderboardTotal, page: currentPage }) }}</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:disabled="leaderboardOffset === 0 || leaderboardLoading"
|
||||||
|
@click="changeLeaderboardPage(-1)"
|
||||||
|
>
|
||||||
|
{{ t('userStats.pagination.previous') }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:disabled="!hasNextLeaderboardPage || leaderboardLoading"
|
||||||
|
@click="changeLeaderboardPage(1)"
|
||||||
|
>
|
||||||
|
{{ t('userStats.pagination.next') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</LeaderboardTable>
|
||||||
|
|
||||||
<Card class="p-4 space-y-3">
|
<Card class="space-y-3 p-4">
|
||||||
<h3 class="text-sm font-semibold">
|
<div>
|
||||||
用户摘要
|
<h3 class="text-sm font-semibold">
|
||||||
</h3>
|
{{ scope === 'user' ? t('userStats.summary.user') : t('userStats.summary.userGroup') }}
|
||||||
|
</h3>
|
||||||
|
<p class="mt-0.5 truncate text-xs text-muted-foreground">
|
||||||
|
{{ selectedEntityName || t('userStats.selectPrompt') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="summaryLoading"
|
v-if="summaryLoading"
|
||||||
class="p-6"
|
class="p-6"
|
||||||
@@ -77,44 +124,78 @@
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-muted-foreground">
|
<div class="text-xs text-muted-foreground">
|
||||||
请求数
|
{{ t('stats.metric.requests') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="font-semibold">
|
<div class="font-semibold">
|
||||||
{{ userSummary?.total_requests ?? 0 }}
|
{{ usageSummary?.total_requests ?? 0 }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-muted-foreground">
|
<div class="text-xs text-muted-foreground">
|
||||||
Tokens
|
{{ t('stats.metric.tokens') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="font-semibold">
|
<div class="font-semibold">
|
||||||
{{ formatTokens(userSummary?.total_tokens ?? 0) }}
|
{{ formatTokens(usageSummary?.total_tokens ?? 0) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-muted-foreground">
|
<div class="text-xs text-muted-foreground">
|
||||||
成本
|
{{ t('stats.metric.cost') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="font-semibold">
|
<div class="font-semibold">
|
||||||
{{ formatCurrency(userSummary?.total_cost ?? 0) }}
|
{{ formatCurrency(usageSummary?.total_cost ?? 0) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-muted-foreground">
|
<div class="text-xs text-muted-foreground">
|
||||||
错误率
|
{{ t('stats.metric.errorRate') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="font-semibold">
|
<div class="font-semibold">
|
||||||
{{ userSummary?.error_rate ?? 0 }}%
|
{{ usageSummary?.error_rate ?? 0 }}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<template v-if="scope === 'user_group'">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
{{ t('userStats.members.current') }}
|
||||||
|
</div>
|
||||||
|
<div class="font-semibold">
|
||||||
|
{{ groupMemberCount }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
{{ t('userStats.members.active') }}
|
||||||
|
</div>
|
||||||
|
<div class="font-semibold">
|
||||||
|
{{ activeGroupMemberCount }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card class="p-4 space-y-4">
|
<LeaderboardTable
|
||||||
<h3 class="text-sm font-semibold">
|
v-if="scope === 'user_group'"
|
||||||
用户使用趋势
|
:title="t('userStats.memberLeaderboard')"
|
||||||
</h3>
|
:items="memberLeaderboard"
|
||||||
|
:metric="metric"
|
||||||
|
:loading="memberLeaderboardLoading"
|
||||||
|
:show-metric-select="false"
|
||||||
|
selectable
|
||||||
|
@select="selectMember"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card class="space-y-4 p-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold">
|
||||||
|
{{ scope === 'user' ? t('userStats.trend.user') : t('userStats.trend.userGroup') }}
|
||||||
|
</h3>
|
||||||
|
<p class="mt-0.5 truncate text-xs text-muted-foreground">
|
||||||
|
{{ selectedEntityName || t('userStats.selectPrompt') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="seriesLoading"
|
v-if="seriesLoading"
|
||||||
class="p-6"
|
class="p-6"
|
||||||
@@ -131,10 +212,10 @@
|
|||||||
|
|
||||||
<Card
|
<Card
|
||||||
v-if="comparisonSeries.length > 0"
|
v-if="comparisonSeries.length > 0"
|
||||||
class="p-4 space-y-4"
|
class="space-y-4 p-4"
|
||||||
>
|
>
|
||||||
<h3 class="text-sm font-semibold">
|
<h3 class="text-sm font-semibold">
|
||||||
用户对比趋势
|
{{ scope === 'user' ? t('userStats.comparisonTrend.user') : t('userStats.comparisonTrend.userGroup') }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="h-[280px]">
|
<div class="h-[280px]">
|
||||||
<LineChart :data="comparisonChartData" />
|
<LineChart :data="comparisonChartData" />
|
||||||
@@ -144,27 +225,29 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { Card, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from '@/components/ui'
|
||||||
import LineChart from '@/components/charts/LineChart.vue'
|
import LineChart from '@/components/charts/LineChart.vue'
|
||||||
import { LoadingState, TimeRangePicker } from '@/components/common'
|
import { LoadingState, TimeRangePicker } from '@/components/common'
|
||||||
import { LeaderboardTable } from '@/components/stats'
|
import { LeaderboardTable } from '@/components/stats'
|
||||||
import { adminApi, type LeaderboardItem } from '@/api/admin'
|
import { adminApi, type LeaderboardItem } from '@/api/admin'
|
||||||
import { usersApi, type User } from '@/api/users'
|
import { usersApi, type User, type UserGroup, type UserGroupMember } from '@/api/users'
|
||||||
import { usageApi } from '@/api/usage'
|
import { usageApi } from '@/api/usage'
|
||||||
import { formatCurrency, formatTokens } from '@/utils/format'
|
import { formatCurrency, formatTokens } from '@/utils/format'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { getDateRangeFromPeriod } from '@/features/usage/composables'
|
import { getDateRangeFromPeriod } from '@/features/usage/composables'
|
||||||
import type { DateRangeParams } from '@/features/usage/types'
|
import type { DateRangeParams } from '@/features/usage/types'
|
||||||
|
|
||||||
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last7days'))
|
type StatsScope = 'user' | 'user_group'
|
||||||
const metric = ref<'requests' | 'tokens' | 'cost'>('requests')
|
type SelectableEntity = { id: string; name: string }
|
||||||
|
|
||||||
const users = ref<User[]>([])
|
|
||||||
const selectedUserId = ref<string | null>(null)
|
|
||||||
const compareUserId = ref<string>('__none__')
|
|
||||||
|
|
||||||
const leaderboard = ref<LeaderboardItem[]>([])
|
|
||||||
const leaderboardLoading = ref(false)
|
|
||||||
|
|
||||||
interface UsageSummary {
|
interface UsageSummary {
|
||||||
total_requests: number
|
total_requests: number
|
||||||
@@ -178,21 +261,73 @@ interface TimeSeriesItem {
|
|||||||
total_cost: number
|
total_cost: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const userSummary = ref<UsageSummary | null>(null)
|
const { t } = useI18n()
|
||||||
const summaryLoading = ref(false)
|
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10
|
||||||
|
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last7days'))
|
||||||
|
const metric = ref<'requests' | 'tokens' | 'cost'>('requests')
|
||||||
|
const scope = ref<StatsScope>('user')
|
||||||
|
|
||||||
|
const users = ref<User[]>([])
|
||||||
|
const userGroups = ref<UserGroup[]>([])
|
||||||
|
const selectedUserId = ref('')
|
||||||
|
const selectedUserGroupId = ref('')
|
||||||
|
const compareUserId = ref('__none__')
|
||||||
|
const compareUserGroupId = ref('__none__')
|
||||||
|
|
||||||
|
const leaderboard = ref<LeaderboardItem[]>([])
|
||||||
|
const leaderboardTotal = ref(0)
|
||||||
|
const leaderboardOffset = ref(0)
|
||||||
|
const leaderboardLoading = ref(false)
|
||||||
|
const memberLeaderboard = ref<LeaderboardItem[]>([])
|
||||||
|
const memberLeaderboardLoading = ref(false)
|
||||||
|
const groupMemberCount = ref(0)
|
||||||
|
const activeGroupMemberCount = ref(0)
|
||||||
|
const usageSummary = ref<UsageSummary | null>(null)
|
||||||
|
const summaryLoading = ref(false)
|
||||||
const series = ref<TimeSeriesItem[]>([])
|
const series = ref<TimeSeriesItem[]>([])
|
||||||
const comparisonSeries = ref<TimeSeriesItem[]>([])
|
const comparisonSeries = ref<TimeSeriesItem[]>([])
|
||||||
const seriesLoading = ref(false)
|
const seriesLoading = ref(false)
|
||||||
|
|
||||||
let leaderboardRequestId = 0
|
let leaderboardRequestId = 0
|
||||||
let summaryRequestId = 0
|
let panelRequestId = 0
|
||||||
let seriesRequestId = 0
|
|
||||||
let leaderboardLoadPromise: Promise<void> | null = null
|
|
||||||
let hasPendingLeaderboardLoad = false
|
|
||||||
let leaderboardDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
let leaderboardDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let userPanelsLoadPromise: Promise<void> | null = null
|
let panelDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let hasPendingUserPanelsLoad = false
|
let ready = false
|
||||||
let userPanelsDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
const allEntities = computed<SelectableEntity[]>(() => scope.value === 'user'
|
||||||
|
? users.value.map(user => ({ id: user.id, name: user.username || user.email || user.id }))
|
||||||
|
: userGroups.value.map(group => ({ id: group.id, name: group.name })))
|
||||||
|
|
||||||
|
const selectedEntityId = computed({
|
||||||
|
get: () => scope.value === 'user' ? selectedUserId.value : selectedUserGroupId.value,
|
||||||
|
set: (value: string) => {
|
||||||
|
if (scope.value === 'user') selectedUserId.value = value
|
||||||
|
else selectedUserGroupId.value = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const compareEntityId = computed({
|
||||||
|
get: () => scope.value === 'user' ? compareUserId.value : compareUserGroupId.value,
|
||||||
|
set: (value: string) => {
|
||||||
|
if (scope.value === 'user') compareUserId.value = value
|
||||||
|
else compareUserGroupId.value = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const comparisonEntities = computed(() => allEntities.value.filter(
|
||||||
|
entity => entity.id !== selectedEntityId.value
|
||||||
|
))
|
||||||
|
const selectedEntityName = computed(() => allEntities.value.find(
|
||||||
|
entity => entity.id === selectedEntityId.value
|
||||||
|
)?.name ?? '')
|
||||||
|
const comparedEntityName = computed(() => allEntities.value.find(
|
||||||
|
entity => entity.id === compareEntityId.value
|
||||||
|
)?.name ?? '')
|
||||||
|
const currentPage = computed(() => Math.floor(leaderboardOffset.value / PAGE_SIZE) + 1)
|
||||||
|
const hasNextLeaderboardPage = computed(
|
||||||
|
() => leaderboardOffset.value + leaderboard.value.length < leaderboardTotal.value
|
||||||
|
)
|
||||||
|
|
||||||
function buildTimeRangeParams() {
|
function buildTimeRangeParams() {
|
||||||
return {
|
return {
|
||||||
@@ -205,136 +340,149 @@ function buildTimeRangeParams() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadUsers() {
|
function scopeParams(id: string) {
|
||||||
users.value = await usersApi.getAllUsers()
|
return scope.value === 'user' ? { user_id: id } : { user_group_id: id }
|
||||||
if (!selectedUserId.value && users.value.length > 0) {
|
}
|
||||||
selectedUserId.value = users.value[0].id
|
|
||||||
|
function ensureSelectedEntity() {
|
||||||
|
const entities = allEntities.value
|
||||||
|
if (!entities.some(entity => entity.id === selectedEntityId.value)) {
|
||||||
|
selectedEntityId.value = entities[0]?.id ?? ''
|
||||||
}
|
}
|
||||||
|
if (compareEntityId.value !== '__none__' && !entities.some(entity => entity.id === compareEntityId.value)) {
|
||||||
|
compareEntityId.value = '__none__'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEntities() {
|
||||||
|
const [loadedUsers, groupsResponse] = await Promise.all([
|
||||||
|
usersApi.getAllUsers(),
|
||||||
|
usersApi.listUserGroups()
|
||||||
|
])
|
||||||
|
users.value = loadedUsers
|
||||||
|
userGroups.value = groupsResponse.items
|
||||||
|
ensureSelectedEntity()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLeaderboard() {
|
async function loadLeaderboard() {
|
||||||
if (leaderboardLoadPromise) {
|
|
||||||
hasPendingLeaderboardLoad = true
|
|
||||||
return leaderboardLoadPromise
|
|
||||||
}
|
|
||||||
leaderboardLoadPromise = (async () => {
|
|
||||||
const requestId = ++leaderboardRequestId
|
const requestId = ++leaderboardRequestId
|
||||||
leaderboardLoading.value = true
|
leaderboardLoading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await adminApi.getLeaderboardUsers({
|
const params = {
|
||||||
...buildTimeRangeParams(),
|
...buildTimeRangeParams(),
|
||||||
metric: metric.value,
|
metric: metric.value,
|
||||||
limit: 10
|
limit: PAGE_SIZE,
|
||||||
})
|
offset: leaderboardOffset.value
|
||||||
|
}
|
||||||
|
const response = scope.value === 'user'
|
||||||
|
? await adminApi.getLeaderboardUsers(params)
|
||||||
|
: await adminApi.getLeaderboardUserGroups(params)
|
||||||
if (requestId !== leaderboardRequestId) return
|
if (requestId !== leaderboardRequestId) return
|
||||||
leaderboard.value = response.items
|
leaderboard.value = response.items
|
||||||
|
leaderboardTotal.value = response.total
|
||||||
} finally {
|
} finally {
|
||||||
if (requestId === leaderboardRequestId) {
|
if (requestId === leaderboardRequestId) leaderboardLoading.value = false
|
||||||
leaderboardLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})().finally(() => {
|
|
||||||
leaderboardLoadPromise = null
|
|
||||||
if (hasPendingLeaderboardLoad) {
|
|
||||||
hasPendingLeaderboardLoad = false
|
|
||||||
void loadLeaderboard()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return leaderboardLoadPromise
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSummary() {
|
async function loadPanels() {
|
||||||
if (!selectedUserId.value) return
|
const selectedId = selectedEntityId.value
|
||||||
const requestId = ++summaryRequestId
|
const requestId = ++panelRequestId
|
||||||
|
if (!selectedId) {
|
||||||
|
usageSummary.value = null
|
||||||
|
series.value = []
|
||||||
|
comparisonSeries.value = []
|
||||||
|
memberLeaderboard.value = []
|
||||||
|
groupMemberCount.value = 0
|
||||||
|
activeGroupMemberCount.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
summaryLoading.value = true
|
summaryLoading.value = true
|
||||||
try {
|
|
||||||
const summary = await usageApi.getUsageStats({
|
|
||||||
...buildTimeRangeParams(),
|
|
||||||
user_id: selectedUserId.value
|
|
||||||
})
|
|
||||||
if (requestId !== summaryRequestId) return
|
|
||||||
userSummary.value = { ...summary, error_rate: summary.error_rate ?? 0 }
|
|
||||||
} finally {
|
|
||||||
if (requestId === summaryRequestId) {
|
|
||||||
summaryLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSeries() {
|
|
||||||
if (!selectedUserId.value) return
|
|
||||||
const requestId = ++seriesRequestId
|
|
||||||
seriesLoading.value = true
|
seriesLoading.value = true
|
||||||
|
memberLeaderboardLoading.value = scope.value === 'user_group'
|
||||||
try {
|
try {
|
||||||
const baseParams = {
|
const primaryParams = { ...buildTimeRangeParams(), ...scopeParams(selectedId) }
|
||||||
...buildTimeRangeParams(),
|
const shouldCompare = compareEntityId.value !== '__none__'
|
||||||
user_id: selectedUserId.value
|
const comparisonPromise: Promise<TimeSeriesItem[]> = shouldCompare
|
||||||
}
|
|
||||||
const shouldCompare = Boolean(compareUserId.value && compareUserId.value !== '__none__')
|
|
||||||
const comparePromise: Promise<TimeSeriesItem[]> = shouldCompare
|
|
||||||
? adminApi.getTimeSeries({
|
? adminApi.getTimeSeries({
|
||||||
...buildTimeRangeParams(),
|
...buildTimeRangeParams(),
|
||||||
user_id: compareUserId.value
|
...scopeParams(compareEntityId.value)
|
||||||
})
|
})
|
||||||
: Promise.resolve([])
|
: Promise.resolve([])
|
||||||
|
const memberPromise: Promise<{ items: LeaderboardItem[] }> = scope.value === 'user_group'
|
||||||
|
? adminApi.getLeaderboardUsers({
|
||||||
|
...buildTimeRangeParams(),
|
||||||
|
metric: metric.value,
|
||||||
|
user_group_id: selectedId,
|
||||||
|
limit: PAGE_SIZE
|
||||||
|
})
|
||||||
|
: Promise.resolve({ items: [] })
|
||||||
|
const groupMembersPromise: Promise<UserGroupMember[]> = scope.value === 'user_group'
|
||||||
|
? usersApi.listUserGroupMembers(selectedId)
|
||||||
|
: Promise.resolve([])
|
||||||
|
|
||||||
const [primarySeries, compareSeries] = await Promise.all([
|
const [summary, primarySeries, compareSeries, members, groupMembers] = await Promise.all([
|
||||||
adminApi.getTimeSeries(baseParams),
|
usageApi.getUsageStats(primaryParams),
|
||||||
comparePromise
|
adminApi.getTimeSeries(primaryParams),
|
||||||
|
comparisonPromise,
|
||||||
|
memberPromise,
|
||||||
|
groupMembersPromise
|
||||||
])
|
])
|
||||||
|
if (requestId !== panelRequestId) return
|
||||||
if (requestId !== seriesRequestId) return
|
usageSummary.value = { ...summary, error_rate: summary.error_rate ?? 0 }
|
||||||
series.value = primarySeries
|
series.value = primarySeries
|
||||||
comparisonSeries.value = compareSeries
|
comparisonSeries.value = compareSeries
|
||||||
|
memberLeaderboard.value = members.items
|
||||||
|
groupMemberCount.value = groupMembers.filter(member => !member.is_deleted).length
|
||||||
|
activeGroupMemberCount.value = groupMembers.filter(
|
||||||
|
member => !member.is_deleted && member.is_active
|
||||||
|
).length
|
||||||
} finally {
|
} finally {
|
||||||
if (requestId === seriesRequestId) {
|
if (requestId === panelRequestId) {
|
||||||
|
summaryLoading.value = false
|
||||||
seriesLoading.value = false
|
seriesLoading.value = false
|
||||||
|
memberLeaderboardLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadUserPanels() {
|
function selectLeaderboardItem(item: LeaderboardItem) {
|
||||||
if (userPanelsLoadPromise) {
|
selectedEntityId.value = item.id
|
||||||
hasPendingUserPanelsLoad = true
|
}
|
||||||
return userPanelsLoadPromise
|
|
||||||
}
|
function selectMember(item: LeaderboardItem) {
|
||||||
userPanelsLoadPromise = Promise.all([loadSummary(), loadSeries()])
|
scope.value = 'user'
|
||||||
.then(() => undefined)
|
selectedUserId.value = item.id
|
||||||
.finally(() => {
|
}
|
||||||
userPanelsLoadPromise = null
|
|
||||||
if (hasPendingUserPanelsLoad) {
|
function changeLeaderboardPage(direction: -1 | 1) {
|
||||||
hasPendingUserPanelsLoad = false
|
leaderboardOffset.value = Math.max(0, leaderboardOffset.value + direction * PAGE_SIZE)
|
||||||
void loadUserPanels()
|
void loadLeaderboard()
|
||||||
}
|
|
||||||
})
|
|
||||||
return userPanelsLoadPromise
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const seriesChartData = computed(() => ({
|
const seriesChartData = computed(() => ({
|
||||||
labels: series.value.map(item => item.date),
|
labels: series.value.map(item => item.date),
|
||||||
datasets: [
|
datasets: [{
|
||||||
{
|
label: t('stats.metric.cost'),
|
||||||
label: '成本',
|
data: series.value.map(item => item.total_cost),
|
||||||
data: series.value.map(item => item.total_cost),
|
borderColor: 'rgb(59, 130, 246)',
|
||||||
borderColor: 'rgb(59, 130, 246)',
|
tension: 0.25,
|
||||||
tension: 0.25,
|
pointRadius: 2
|
||||||
pointRadius: 2
|
}]
|
||||||
}
|
|
||||||
]
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const comparisonChartData = computed(() => ({
|
const comparisonChartData = computed(() => ({
|
||||||
labels: series.value.map(item => item.date),
|
labels: series.value.map(item => item.date),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: '当前用户',
|
label: selectedEntityName.value || t('userStats.chart.current'),
|
||||||
data: series.value.map(item => item.total_cost),
|
data: series.value.map(item => item.total_cost),
|
||||||
borderColor: 'rgb(59, 130, 246)',
|
borderColor: 'rgb(59, 130, 246)',
|
||||||
tension: 0.25,
|
tension: 0.25,
|
||||||
pointRadius: 2
|
pointRadius: 2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '对比用户',
|
label: comparedEntityName.value || t('userStats.chart.comparison'),
|
||||||
data: comparisonSeries.value.map(item => item.total_cost),
|
data: comparisonSeries.value.map(item => item.total_cost),
|
||||||
borderColor: 'rgb(234, 179, 8)',
|
borderColor: 'rgb(234, 179, 8)',
|
||||||
tension: 0.25,
|
tension: 0.25,
|
||||||
@@ -344,50 +492,46 @@ const comparisonChartData = computed(() => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
function scheduleLeaderboardLoad() {
|
function scheduleLeaderboardLoad() {
|
||||||
if (leaderboardDebounceTimer) {
|
if (!ready) return
|
||||||
clearTimeout(leaderboardDebounceTimer)
|
if (leaderboardDebounceTimer) clearTimeout(leaderboardDebounceTimer)
|
||||||
}
|
|
||||||
leaderboardDebounceTimer = setTimeout(() => {
|
leaderboardDebounceTimer = setTimeout(() => {
|
||||||
leaderboardDebounceTimer = null
|
leaderboardDebounceTimer = null
|
||||||
void loadLeaderboard()
|
void loadLeaderboard()
|
||||||
}, 120)
|
}, 120)
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleUserPanelsLoad() {
|
function schedulePanelLoad() {
|
||||||
if (userPanelsDebounceTimer) {
|
if (!ready) return
|
||||||
clearTimeout(userPanelsDebounceTimer)
|
if (panelDebounceTimer) clearTimeout(panelDebounceTimer)
|
||||||
}
|
panelDebounceTimer = setTimeout(() => {
|
||||||
userPanelsDebounceTimer = setTimeout(() => {
|
panelDebounceTimer = null
|
||||||
userPanelsDebounceTimer = null
|
void loadPanels()
|
||||||
void loadUserPanels()
|
|
||||||
}, 120)
|
}, 120)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch([timeRange, metric], scheduleLeaderboardLoad, { deep: true })
|
watch(scope, () => {
|
||||||
watch([timeRange, selectedUserId, compareUserId], scheduleUserPanelsLoad, { deep: true })
|
leaderboardOffset.value = 0
|
||||||
|
ensureSelectedEntity()
|
||||||
|
scheduleLeaderboardLoad()
|
||||||
|
schedulePanelLoad()
|
||||||
|
})
|
||||||
|
watch([timeRange, metric], () => {
|
||||||
|
leaderboardOffset.value = 0
|
||||||
|
scheduleLeaderboardLoad()
|
||||||
|
schedulePanelLoad()
|
||||||
|
}, { deep: true })
|
||||||
|
watch([selectedEntityId, compareEntityId], schedulePanelLoad)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([
|
await loadEntities()
|
||||||
loadLeaderboard(),
|
ready = true
|
||||||
loadUsers()
|
await Promise.all([loadLeaderboard(), loadPanels()])
|
||||||
])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (leaderboardDebounceTimer) {
|
if (leaderboardDebounceTimer) clearTimeout(leaderboardDebounceTimer)
|
||||||
clearTimeout(leaderboardDebounceTimer)
|
if (panelDebounceTimer) clearTimeout(panelDebounceTimer)
|
||||||
leaderboardDebounceTimer = null
|
|
||||||
}
|
|
||||||
if (userPanelsDebounceTimer) {
|
|
||||||
clearTimeout(userPanelsDebounceTimer)
|
|
||||||
userPanelsDebounceTimer = null
|
|
||||||
}
|
|
||||||
hasPendingLeaderboardLoad = false
|
|
||||||
hasPendingUserPanelsLoad = false
|
|
||||||
leaderboardLoadPromise = null
|
|
||||||
userPanelsLoadPromise = null
|
|
||||||
leaderboardRequestId += 1
|
leaderboardRequestId += 1
|
||||||
summaryRequestId += 1
|
panelRequestId += 1
|
||||||
seriesRequestId += 1
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user