feat: unify user analytics and optimize overview aggregation

Merge user accounts and usage reporting into one page with a combined ranking and account table, shared precise time ranges, and simpler range labels.

Parse overview metadata once through a schema-only view migration and disable JIT locally for bucket rebuilds. Preserve automatic backfills.

Add redacted OAuth refresh diagnostics, bucket failure context, and regression coverage. Resolve strict Clippy warnings.
This commit is contained in:
elky
2026-10-05 00:28:31 +08:00
parent 2075cd95de
commit cb7b9c9ecd
27 changed files with 1255 additions and 587 deletions
@@ -112,6 +112,22 @@ pub(crate) fn build_cross_format_openai_chat_request_body(
Some(provider_request_body)
}
pub(crate) fn build_cross_format_openai_chat_upstream_url(
parts: &http::request::Parts,
transport: &GatewayProviderTransportSnapshot,
mapped_model: &str,
provider_api_format: &str,
upstream_is_stream: bool,
) -> Option<String> {
crate::ai_serving::transport::build_cross_format_openai_chat_upstream_url(
transport,
mapped_model,
provider_api_format,
upstream_is_stream,
parts.uri.query(),
)
}
#[cfg(test)]
mod antigravity_schema_tests {
use super::*;
@@ -147,19 +163,3 @@ mod antigravity_schema_tests {
}
}
}
pub(crate) fn build_cross_format_openai_chat_upstream_url(
parts: &http::request::Parts,
transport: &GatewayProviderTransportSnapshot,
mapped_model: &str,
provider_api_format: &str,
upstream_is_stream: bool,
) -> Option<String> {
crate::ai_serving::transport::build_cross_format_openai_chat_upstream_url(
transport,
mapped_model,
provider_api_format,
upstream_is_stream,
parts.uri.query(),
)
}
@@ -275,6 +275,24 @@ pub(crate) fn build_local_openai_responses_upstream_url(
)
}
pub(crate) fn build_cross_format_openai_responses_upstream_url(
parts: &http::request::Parts,
transport: &GatewayProviderTransportSnapshot,
mapped_model: &str,
client_api_format: &str,
provider_api_format: &str,
upstream_is_stream: bool,
) -> Option<String> {
crate::ai_serving::transport::build_cross_format_openai_responses_upstream_url(
transport,
mapped_model,
client_api_format,
provider_api_format,
upstream_is_stream,
parts.uri.query(),
)
}
#[cfg(test)]
mod antigravity_schema_tests {
use super::*;
@@ -309,21 +327,3 @@ mod antigravity_schema_tests {
}
}
}
pub(crate) fn build_cross_format_openai_responses_upstream_url(
parts: &http::request::Parts,
transport: &GatewayProviderTransportSnapshot,
mapped_model: &str,
client_api_format: &str,
provider_api_format: &str,
upstream_is_stream: bool,
) -> Option<String> {
crate::ai_serving::transport::build_cross_format_openai_responses_upstream_url(
transport,
mapped_model,
client_api_format,
provider_api_format,
upstream_is_stream,
parts.uri.query(),
)
}
+9 -12
View File
@@ -305,13 +305,11 @@ pub(crate) fn spawn_worker(app: AppState) -> tokio::task::JoinHandle<()> {
#[cfg(test)]
mod tests {
use std::sync::{
atomic::{AtomicBool, Ordering},
Mutex, OnceLock,
};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use aether_runtime_state::{MemoryRuntimeStateConfig, RuntimeState};
use tokio::sync::{Mutex, MutexGuard};
use super::{
cached_version_to_restore, fixed_version_from, parse_cli_release, refresh_enabled_from,
@@ -322,7 +320,7 @@ mod tests {
set_codex_client_profile, CodexClientProfile,
};
static PROFILE_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
static PROFILE_TEST_LOCK: Mutex<()> = Mutex::const_new(());
struct ProfileRestore(CodexClientProfile);
@@ -332,9 +330,8 @@ mod tests {
}
}
fn profile_restore_guard() -> (std::sync::MutexGuard<'static, ()>, ProfileRestore) {
let lock = PROFILE_TEST_LOCK.get_or_init(|| Mutex::new(()));
let guard = lock.lock().expect("profile test lock");
async fn profile_restore_guard() -> (MutexGuard<'static, ()>, ProfileRestore) {
let guard = PROFILE_TEST_LOCK.lock().await;
let restore = ProfileRestore(codex_client_profile());
(guard, restore)
}
@@ -397,7 +394,7 @@ mod tests {
#[tokio::test]
async fn cache_hit_is_restored_without_network_when_refresh_is_disabled() {
let (_lock, _restore) = profile_restore_guard();
let (_lock, _restore) = profile_restore_guard().await;
let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default());
runtime
.kv_set(
@@ -424,7 +421,7 @@ mod tests {
#[tokio::test]
async fn refresh_failure_keeps_previous_profile() {
let (_lock, _restore) = profile_restore_guard();
let (_lock, _restore) = profile_restore_guard().await;
let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default());
let before = codex_client_profile();
let result = refresh_once_with_fetch(&runtime, None, true, || async {
@@ -438,7 +435,7 @@ mod tests {
#[tokio::test]
async fn fixed_version_override_skips_network_and_publishes_profile() {
let (_lock, _restore) = profile_restore_guard();
let (_lock, _restore) = profile_restore_guard().await;
let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default());
let fetch_called = AtomicBool::new(false);
let result = refresh_once_with_fetch(&runtime, Some("0.220.0"), true, || async {
@@ -455,7 +452,7 @@ mod tests {
#[tokio::test]
async fn rollback_is_rejected_without_replacing_profile() {
let (_lock, _restore) = profile_restore_guard();
let (_lock, _restore) = profile_restore_guard().await;
set_codex_cli_version("0.220.0").unwrap();
let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default());
let result =
@@ -1,5 +1,8 @@
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, precise_admin_stats_time_range,
resolve_precise_time_bounds,
};
use super::resolve_admin_usage_time_range;
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::{
@@ -285,12 +288,36 @@ pub(super) async fn maybe_build_local_admin_stats_analytics_response(
Ok(value) => value,
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
};
let time_range = match resolve_admin_usage_time_range(request_context.query_string()) {
let legacy_time_range = match resolve_admin_usage_time_range(request_context.query_string())
{
Ok(value) => value,
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
};
if let Err(detail) = time_range.validate_for_time_series(granularity) {
return Ok(Some(admin_stats_bad_request_response(detail)));
let precise_bounds = match resolve_precise_time_bounds(request_context.query_string()) {
Ok(value) => value,
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
};
let precise_time_range = match precise_bounds {
Some((from, to)) => {
match precise_admin_stats_time_range(request_context.query_string(), from, to) {
Ok(value) => Some(value),
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
}
}
None => None,
};
let time_range = precise_time_range.as_ref().unwrap_or(&legacy_time_range);
if precise_bounds.is_none() {
if let Err(detail) = time_range.validate_for_time_series(granularity) {
return Ok(Some(admin_stats_bad_request_response(detail)));
}
} else if precise_bounds
.and_then(|(from, to)| to.checked_sub(from))
.is_some_and(|seconds| seconds > 90 * 86_400)
{
return Ok(Some(admin_stats_bad_request_response(
"Query range cannot exceed 90 days".to_string(),
)));
}
if !state.has_usage_data_reader() {
return Ok(Some(admin_stats_time_series_empty_response()));
@@ -314,7 +341,8 @@ pub(super) async fn maybe_build_local_admin_stats_analytics_response(
| AdminStatsGranularity::Week
| AdminStatsGranularity::Month => UsageTimeSeriesGranularity::Day,
};
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)) =
precise_bounds.or_else(|| time_range.to_unix_bounds())
else {
return Ok(Some(admin_stats_time_series_empty_response()));
};
@@ -336,7 +364,7 @@ pub(super) async fn maybe_build_local_admin_stats_analytics_response(
})
.await?;
return Ok(Some(build_admin_stats_time_series_response_from_summaries(
&time_range,
time_range,
granularity,
&buckets,
)));
@@ -5,7 +5,7 @@ use super::leaderboard::{
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, resolve_precise_time_bounds};
use super::resolve_admin_usage_time_range;
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::{query_param_bool, query_param_value};
@@ -228,6 +228,10 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response(
Ok(value) => value,
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
};
let precise_bounds = match resolve_precise_time_bounds(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))),
@@ -272,7 +276,8 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_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()
let Some((created_from_unix_secs, created_until_unix_secs)) =
precise_bounds.or_else(|| time_range.to_unix_bounds())
else {
return Ok(Some(build_admin_stats_user_group_leaderboard_response(
metric,
@@ -402,6 +407,10 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response(
Ok(value) => value,
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
};
let precise_bounds = match resolve_precise_time_bounds(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))),
@@ -442,7 +451,8 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response(
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)) =
precise_bounds.or_else(|| time_range.to_unix_bounds())
else {
return Ok(Some(admin_stats_leaderboard_empty_response(
metric,
@@ -8,7 +8,10 @@ mod leaderboard;
mod leaderboard_routes;
mod provider_quota_routes;
mod range;
pub(crate) use self::range::{parse_bounded_u32, resolve_admin_usage_time_range};
pub(crate) use self::range::{
parse_bounded_u32, precise_admin_stats_time_range, resolve_admin_usage_time_range,
resolve_precise_time_bounds, resolve_usage_time_bounds,
};
pub(crate) use aether_admin::observability::stats::{
admin_stats_bad_request_response, aggregate_usage_stats, round_to, AdminStatsTimeRange,
AdminStatsUsageFilter,
@@ -4,10 +4,14 @@ pub(super) use aether_admin::observability::stats::{
admin_usage_default_days, build_comparison_range, build_time_range_from_days, parse_naive_date,
parse_nonnegative_usize, parse_tz_offset_minutes, resolve_preset_dates, user_today,
};
use chrono::{DateTime, Offset, TimeZone, Utc};
pub(crate) fn resolve_admin_usage_time_range(
query: Option<&str>,
) -> Result<AdminStatsTimeRange, String> {
if let Some((from, to)) = resolve_precise_time_bounds(query)? {
return precise_admin_stats_time_range(query, from, to);
}
match AdminStatsTimeRange::resolve_optional(query)? {
Some(time_range) => Ok(time_range),
None => {
@@ -20,3 +24,153 @@ pub(crate) fn resolve_admin_usage_time_range(
}
}
}
/// Resolve an exact UTC range supplied by the shared admin range picker.
///
/// The older stats handlers use `start_date`/`end_date` and fixed offsets. Keep
/// that parser intact and only opt into this path when both RFC 3339 endpoints
/// are present, so existing callers retain their behavior.
pub(crate) fn resolve_precise_time_bounds(
query: Option<&str>,
) -> Result<Option<(u64, u64)>, String> {
let entries =
url::form_urlencoded::parse(query.unwrap_or_default().as_bytes()).collect::<Vec<_>>();
let from = entries
.iter()
.filter(|(key, _)| key == "from")
.collect::<Vec<_>>();
let to = entries
.iter()
.filter(|(key, _)| key == "to")
.collect::<Vec<_>>();
if from.is_empty() && to.is_empty() {
return Ok(None);
}
if from.len() != 1 || to.len() != 1 {
return Err("from and to must each be provided once".into());
}
if entries
.iter()
.any(|(key, _)| matches!(key.as_ref(), "start_date" | "end_date" | "preset" | "days"))
{
return Err("precise from/to cannot be combined with date presets".into());
}
if let Some(zone) = query_param_value(query, "timezone") {
zone.parse::<chrono_tz::Tz>()
.map_err(|_| "invalid timezone".to_string())?;
}
let parse = |value: &str| -> Result<u64, String> {
let value = DateTime::parse_from_rfc3339(value)
.map_err(|_| "from/to must be RFC 3339 timestamps".to_string())?;
if value.timestamp_subsec_nanos() != 0 {
return Err("request records support second-aligned ranges".into());
}
u64::try_from(value.timestamp()).map_err(|_| "from/to must not precede Unix epoch".into())
};
let bounds = (parse(&from[0].1)?, parse(&to[0].1)?);
if bounds.0 >= bounds.1 || bounds.1 - bounds.0 > 366 * 86_400 {
return Err("from/to must define a nonempty range of at most 366 days".into());
}
Ok(Some(bounds))
}
/// Return the exact range when present, otherwise preserve the legacy stats
/// date/preset behavior.
pub(crate) fn resolve_usage_time_bounds(query: Option<&str>) -> Result<Option<(u64, u64)>, String> {
if let Some(bounds) = resolve_precise_time_bounds(query)? {
return Ok(Some(bounds));
}
Ok(resolve_admin_usage_time_range(query)?.to_unix_bounds())
}
/// Build the date metadata used by the existing stats response builders for an
/// exact range. The data query still uses the exact UTC bounds; this metadata
/// only supplies the local date labels and offset expected by old clients.
pub(crate) fn precise_admin_stats_time_range(
query: Option<&str>,
from: u64,
to: u64,
) -> Result<AdminStatsTimeRange, String> {
let timezone_name = query_param_value(query, "timezone");
let (start_date, end_date, tz_offset_minutes) = if let Some(name) = timezone_name {
let timezone = name
.parse::<chrono_tz::Tz>()
.map_err(|_| "invalid timezone".to_string())?;
let start = Utc
.timestamp_opt(
i64::try_from(from).map_err(|_| "invalid from timestamp")?,
0,
)
.single()
.ok_or_else(|| "invalid from timestamp".to_string())?
.with_timezone(&timezone);
let end = Utc
.timestamp_opt(
i64::try_from(to.saturating_sub(1)).map_err(|_| "invalid to timestamp")?,
0,
)
.single()
.ok_or_else(|| "invalid to timestamp".to_string())?
.with_timezone(&timezone);
(
start.date_naive(),
end.date_naive(),
start.offset().fix().local_minus_utc() / 60,
)
} else {
let offset = parse_tz_offset_minutes(query)?;
let fixed = chrono::FixedOffset::east_opt(offset * 60)
.ok_or_else(|| "invalid timezone offset".to_string())?;
let start = Utc
.timestamp_opt(
i64::try_from(from).map_err(|_| "invalid from timestamp")?,
0,
)
.single()
.ok_or_else(|| "invalid from timestamp".to_string())?
.with_timezone(&fixed);
let end = Utc
.timestamp_opt(
i64::try_from(to.saturating_sub(1)).map_err(|_| "invalid to timestamp")?,
0,
)
.single()
.ok_or_else(|| "invalid to timestamp".to_string())?
.with_timezone(&fixed);
(start.date_naive(), end.date_naive(), offset)
};
Ok(AdminStatsTimeRange {
start_date,
end_date,
tz_offset_minutes,
})
}
fn query_param_value(query: Option<&str>, key: &str) -> Option<String> {
url::form_urlencoded::parse(query.unwrap_or_default().as_bytes())
.find(|(name, _)| name == key)
.map(|(_, value)| value.into_owned())
}
#[cfg(test)]
mod tests {
use super::{precise_admin_stats_time_range, resolve_precise_time_bounds};
#[test]
fn precise_stats_range_preserves_subday_bounds_and_timezone_labels() {
let query = "from=2026-09-01T23:45:00Z&to=2026-09-02T00:15:00Z&timezone=Asia%2FShanghai";
let (from, to) = resolve_precise_time_bounds(Some(query)).unwrap().unwrap();
assert_eq!(to - from, 30 * 60);
let range = precise_admin_stats_time_range(Some(query), from, to).unwrap();
assert_eq!(range.start_date.to_string(), "2026-09-02");
assert_eq!(range.end_date.to_string(), "2026-09-02");
assert_eq!(range.tz_offset_minutes, 480);
}
#[test]
fn precise_stats_range_rejects_mixed_legacy_presets() {
let query = "from=2026-09-01T00:00:00Z&to=2026-09-02T00:00:00Z&preset=today";
assert!(resolve_precise_time_bounds(Some(query)).is_err());
}
}
@@ -1,5 +1,5 @@
use super::super::resolve_usage_user_group_scope;
use super::super::stats::resolve_admin_usage_time_range;
use super::super::stats::resolve_usage_time_bounds;
use super::analytics::admin_usage_api_key_names;
use super::analytics::admin_usage_provider_key_names;
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
@@ -37,45 +37,7 @@ const ADMIN_USAGE_ACTIVE_LIMIT: usize = 50;
pub(super) fn resolve_record_time_bounds(
query: Option<&str>,
) -> Result<Option<(u64, u64)>, String> {
let entries =
url::form_urlencoded::parse(query.unwrap_or_default().as_bytes()).collect::<Vec<_>>();
let from = entries
.iter()
.filter(|(key, _)| key == "from")
.collect::<Vec<_>>();
let to = entries
.iter()
.filter(|(key, _)| key == "to")
.collect::<Vec<_>>();
if from.is_empty() && to.is_empty() {
return resolve_admin_usage_time_range(query).map(|range| range.to_unix_bounds());
}
if from.len() != 1 || to.len() != 1 {
return Err("from and to must each be provided once".into());
}
if entries
.iter()
.any(|(key, _)| matches!(key.as_ref(), "start_date" | "end_date" | "preset" | "days"))
{
return Err("precise from/to cannot be combined with date presets".into());
}
if let Some(zone) = query_param_value(query, "timezone") {
zone.parse::<chrono_tz::Tz>()
.map_err(|_| "invalid timezone".to_string())?;
}
let parse = |value: &str| -> Result<u64, String> {
let value = chrono::DateTime::parse_from_rfc3339(value)
.map_err(|_| "from/to must be RFC 3339 timestamps".to_string())?;
if value.timestamp_subsec_nanos() != 0 {
return Err("request records support second-aligned ranges".into());
}
u64::try_from(value.timestamp()).map_err(|_| "from/to must not precede Unix epoch".into())
};
let bounds = (parse(&from[0].1)?, parse(&to[0].1)?);
if bounds.0 >= bounds.1 || bounds.1 - bounds.0 > 366 * 86_400 {
return Err("from/to must define a nonempty range of at most 366 days".into());
}
Ok(Some(bounds))
resolve_usage_time_bounds(query)
}
async fn load_admin_usage_by_ids(
@@ -155,14 +155,17 @@ pub(crate) async fn perform_oauth_token_refresh_once(
Ok(None) => {
summary.skipped = summary.skipped.saturating_add(1);
}
Err(_) => {
Err(err) => {
summary.failed = summary.failed.saturating_add(1);
warn!(
event_name = "oauth_token_refresh_failed",
log_type = "ops",
worker = "oauth_token_refresh",
provider_id = %provider.id,
provider_type = %provider.provider_type,
endpoint_id = %endpoint.id,
key_id = %key.id,
error = %crate::error::redact_error_debug(&err),
"gateway oauth token auto refresh failed"
);
}
@@ -440,6 +443,7 @@ mod tests {
agent_identity_needs_task_recovery, auth_config_has_refresh_token,
is_nonfatal_legacy_catalog_credential_error, oauth_refresh_candidate,
};
use crate::error::redact_error_debug;
use crate::GatewayError;
#[test]
@@ -543,4 +547,18 @@ mod tests {
)
));
}
#[test]
fn oauth_refresh_failure_detail_preserves_context_without_credentials() {
let error = GatewayError::Internal(
r#"oauth request failed: status=503 token="refresh-secret" retry=2"#.to_string(),
);
let detail = redact_error_debug(&error);
assert!(detail.contains("oauth request failed"));
assert!(detail.contains("status=503"));
assert!(detail.contains("[REDACTED]"));
assert!(!detail.contains("refresh-secret"));
}
}
+90 -8
View File
@@ -412,6 +412,17 @@ fn normalize_local_oauth_refresh_error_message(
.unwrap_or_else(|| "Token 刷新失败".to_string())
}
fn local_oauth_refresh_gateway_error(
error: &provider_transport::LocalOAuthRefreshError,
) -> GatewayError {
// Keep a bounded, credential-redacted reason for internal diagnostics.
// GatewayError::Internal still returns the generic error response to clients.
GatewayError::Internal(format!(
"local oauth refresh failed: {}",
crate::error::redact_error_detail(error)
))
}
fn merge_local_oauth_refresh_failure_reason(
current_reason: Option<&str>,
refresh_reason: &str,
@@ -1556,10 +1567,8 @@ impl AppState {
}
return Ok(None);
}
Err(_) => {
return Err(GatewayError::Internal(
"local oauth refresh failed".to_string(),
));
Err(err) => {
return Err(local_oauth_refresh_gateway_error(&err));
}
};
@@ -3484,13 +3493,86 @@ mod tests {
use tokio::sync::Notify;
use super::{
AgentIdentityAuthConfigFence, AppState, CodexRuntimeOAuthObservation,
ProviderTransportSnapshotCacheKey, ProviderTransportSnapshotFlight,
ProviderTransportSnapshotFlightResult, ProviderTransportSnapshotInflightRegistration,
PROVIDER_TRANSPORT_SNAPSHOT_CACHE_STALE_TTL, PROVIDER_TRANSPORT_SNAPSHOT_CACHE_TTL,
local_oauth_refresh_gateway_error, AgentIdentityAuthConfigFence, AppState,
CodexRuntimeOAuthObservation, ProviderTransportSnapshotCacheKey,
ProviderTransportSnapshotFlight, ProviderTransportSnapshotFlightResult,
ProviderTransportSnapshotInflightRegistration, PROVIDER_TRANSPORT_SNAPSHOT_CACHE_STALE_TTL,
PROVIDER_TRANSPORT_SNAPSHOT_CACHE_TTL,
};
use crate::data::GatewayDataState;
#[test]
fn oauth_refresh_diagnostic_preserves_failure_reason_and_redacts_credentials() {
for (message, secret) in [
(
"connection refused refresh_token=refresh-secret",
"refresh-secret",
),
(
"connection refused accessToken=access-secret",
"access-secret",
),
(
"connection refused client_secret=client-secret",
"client-secret",
),
(
"connection refused Authorization: Bearer bearer-secret",
"bearer-secret",
),
(
"connection refused https://proxy-user:[email protected]",
"proxy-secret",
),
] {
let error = crate::provider_transport::LocalOAuthRefreshError::TransportMessage {
provider_type: "codex",
message: message.to_string(),
};
let diagnostic = local_oauth_refresh_gateway_error(&error).into_message();
assert!(diagnostic.contains("codex oauth refresh transport failed"));
assert!(diagnostic.contains("connection refused"));
assert!(!diagnostic.contains(secret), "diagnostic: {diagnostic}");
}
}
#[test]
fn oauth_refresh_diagnostic_keeps_http_status_without_provider_body() {
let error = crate::provider_transport::LocalOAuthRefreshError::HttpStatus {
provider_type: "codex",
status_code: 503,
body_excerpt: "unstructured-provider-secret".to_string(),
};
let diagnostic = local_oauth_refresh_gateway_error(&error).into_message();
assert!(diagnostic.contains("codex oauth refresh returned HTTP 503"));
assert!(!diagnostic.contains("unstructured-provider-secret"));
}
#[tokio::test]
async fn oauth_refresh_diagnostic_is_hidden_from_client_response() {
use axum::body::to_bytes;
use axum::response::IntoResponse;
let error = crate::provider_transport::LocalOAuthRefreshError::TransportMessage {
provider_type: "codex",
message: "connection refused".to_string(),
};
let response = local_oauth_refresh_gateway_error(&error).into_response();
assert_eq!(
response.status(),
axum::http::StatusCode::INTERNAL_SERVER_ERROR
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("error response body should read");
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&body).expect("error should be JSON"),
json!({"error": {"message": "internal server error"}}),
);
}
fn sample_provider() -> StoredProviderCatalogProvider {
StoredProviderCatalogProvider::new(
"provider-1".to_string(),
@@ -0,0 +1,78 @@
-- Replace only the read model; existing facts, queues and projections are untouched.
-- Preserve billing/attribution semantics while avoiding repeated JSON parsing.
CREATE OR REPLACE VIEW public.usage_analytics_facts_v1 AS
SELECT u.request_id, COALESCE(u.id, u.request_id) AS id, u.created_at,
CASE WHEN identity.owner_id IS NOT NULL AND identity.is_standalone=false THEN identity.owner_id END AS actor_user_id,
identity.owner_id AS credential_owner_id,
CASE WHEN identity.owner_id IS NULL THEN 'unknown' WHEN identity.is_standalone THEN 'standalone'
WHEN NOT identity.is_standalone THEN 'employee' ELSE 'unknown' END AS attribution_kind,
CASE WHEN identity.owner_id IS NULL THEN 'unknown' WHEN identity.is_standalone THEN 'standalone_key'
WHEN NOT identity.is_standalone THEN 'user_account' ELSE 'unknown' END AS attribution_source,
COALESCE(a.record_kind, 'request') AS record_kind, a.parent_request_id,
u.api_key_id, u.model, u.target_model, u.provider_id, u.provider_name,
u.api_format, u.endpoint_kind, u.request_type, u.is_stream, u.has_format_conversion,
u.status, u.status_code, u.error_category, u.failure_origin, u.failure_stage, u.failure_reason,
u.failure_schema_version, u.response_time_ms, u.first_byte_time_ms,
COALESCE(s.billing_status, u.billing_status) AS settlement_status,
COALESCE(metadata.value->'usage_available', 'true'::jsonb) <> 'false'::jsonb AS usage_available,
COALESCE(metadata.value->'usage_pricing_available', 'true'::jsonb) <> 'false'::jsonb
AND (s.billing_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status, u.billing_status) = 'settled') AS pricing_available,
CASE WHEN COALESCE(metadata.value->'usage_available', 'true'::jsonb) <> 'false'::jsonb
THEN b.input_tokens END AS input_tokens,
CASE WHEN COALESCE(metadata.value->'usage_available', 'true'::jsonb) <> 'false'::jsonb
THEN b.output_tokens END AS output_tokens,
CASE WHEN COALESCE(metadata.value->'usage_available', 'true'::jsonb) <> 'false'::jsonb
THEN b.total_tokens END AS total_tokens,
CASE WHEN COALESCE(metadata.value->'usage_available', 'true'::jsonb) <> 'false'::jsonb
THEN b.cache_read_input_tokens END AS cache_read_input_tokens,
CASE WHEN COALESCE(metadata.value->'usage_available', 'true'::jsonb) <> 'false'::jsonb
THEN b.cache_creation_input_tokens END AS cache_creation_input_tokens,
CASE WHEN COALESCE(metadata.value->'usage_pricing_available', 'true'::jsonb) <> 'false'::jsonb
AND (s.billing_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status, u.billing_status) = 'settled')
THEN round(COALESCE(s.billing_total_cost_usd::numeric, u.total_cost_usd::numeric), 8) END AS rated_amount,
CASE WHEN COALESCE(metadata.value->'usage_pricing_available', 'true'::jsonb) <> 'false'::jsonb
AND (s.billing_actual_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status, u.billing_status) = 'settled')
THEN round(COALESCE(s.billing_actual_total_cost_usd::numeric, u.actual_total_cost_usd::numeric), 8) END AS billable_amount,
s.quota_covered_amount_usd AS quota_covered_amount,
s.wallet_consumed_amount_usd AS wallet_consumed_amount,
s.wallet_debit_amount_usd AS wallet_debit_amount,
s.wallet_recharge_debit_usd AS wallet_recharge_debit_amount,
s.wallet_gift_debit_usd AS wallet_gift_debit_amount,
s.wallet_overdraft_usd AS wallet_overdraft_amount,
s.allocation_status, s.finalized_at AS settled_at,
CASE WHEN s.billing_total_cost_usd IS NOT NULL THEN 'settlement_snapshot' ELSE 'legacy_float' END AS amount_source,
b.upstream_is_stream,
CASE WHEN metadata.value #>> '{analytics_measurement,source}' IN ('reported','estimated','mixed')
THEN metadata.value #>> '{analytics_measurement,source}' ELSE 'unknown' END AS token_source,
CASE WHEN COALESCE(metadata.value->'usage_available','true'::jsonb) <> 'false'::jsonb
AND COALESCE(metadata.value->'usage_pricing_available','true'::jsonb) <> 'false'::jsonb
AND s.input_price_per_1m IS NOT NULL AND s.billing_cache_read_cost_usd IS NOT NULL
THEN round(s.input_price_per_1m::numeric * b.cache_read_input_tokens::numeric / 1000000,8) END AS cache_estimated_full_cost_amount,
CASE WHEN COALESCE(metadata.value->'usage_available','true'::jsonb) <> 'false'::jsonb
AND COALESCE(metadata.value->'usage_pricing_available','true'::jsonb) <> 'false'::jsonb
AND s.input_price_per_1m IS NOT NULL AND s.billing_cache_read_cost_usd IS NOT NULL
THEN round(s.billing_cache_read_cost_usd::numeric,8) END AS cache_read_cost_amount,
CASE WHEN COALESCE(metadata.value->'usage_available','true'::jsonb) <> 'false'::jsonb
AND COALESCE(metadata.value->'usage_pricing_available','true'::jsonb) <> 'false'::jsonb
AND s.input_price_per_1m IS NOT NULL AND s.billing_cache_creation_cost_usd IS NOT NULL
THEN round(s.billing_cache_creation_cost_usd::numeric,8) END AS cache_creation_cost_amount
FROM public.usage u
-- OFFSET 0 keeps this projection from being flattened: large metadata is
-- detoasted and parsed once per request, rather than once per metric expression.
CROSS JOIN LATERAL (SELECT u.request_metadata::jsonb AS value OFFSET 0) metadata
LEFT JOIN public.usage_settlement_snapshots s USING (request_id)
LEFT JOIN public.usage_attribution_snapshots a USING (request_id)
JOIN public.usage_billing_facts b USING (request_id)
LEFT JOIN public.api_keys k ON k.id=u.api_key_id
CROSS JOIN LATERAL (
SELECT CASE WHEN a.request_id IS NOT NULL THEN a.credential_owner_id
WHEN EXISTS (SELECT 1 FROM public.users WHERE id=u.user_id AND NOT is_deleted) THEN u.user_id END AS owner_id,
COALESCE(k.is_standalone,
CASE WHEN jsonb_typeof(metadata.value #> '{analytics_attribution,is_standalone}')='boolean'
THEN (metadata.value #>> '{analytics_attribution,is_standalone}')::boolean END,
CASE WHEN jsonb_typeof(metadata.value->'api_key_is_standalone')='boolean'
THEN (metadata.value->>'api_key_is_standalone')::boolean END,
CASE WHEN a.attribution_source='user_account' THEN false
WHEN a.attribution_source='standalone_key' THEN true END,
CASE WHEN u.api_key_id IS NULL THEN false END) AS is_standalone
) identity;
@@ -88,6 +88,7 @@ impl SqlxUsageReadRepository {
for row in rows {
let granularity: String = row.try_get("granularity").map_postgres_err()?;
let bucket: DateTime<Utc> = row.try_get("bucket_start").map_postgres_err()?;
let started = std::time::Instant::now();
match self
.rebuild_merged_overview_bucket(&granularity, bucket)
.await
@@ -95,8 +96,20 @@ impl SqlxUsageReadRepository {
Ok(true) => published += 1,
Ok(false) => {}
Err(error) => {
let error_detail = error.to_string().chars().take(500).collect::<String>();
tracing::warn!(
event_name = "overview_bucket_rebuild_failed",
log_type = "ops",
projection_version = "overview-v2",
granularity = %granularity,
bucket_start = %bucket,
elapsed_ms = started.elapsed().as_millis() as u64,
retry_after_secs = 600,
error = %error_detail,
"overview bucket rebuild failed; retry deferred"
);
sqlx::query("UPDATE stats_bucket_state SET last_error=$3,last_failed_at=NOW() WHERE projection_version='overview-v2' AND granularity=$1 AND bucket_start=$2")
.bind(&granularity).bind(bucket).bind(error.to_string().chars().take(500).collect::<String>())
.bind(&granularity).bind(bucket).bind(error_detail)
.execute(&self.pool).await.map_postgres_err()?;
}
}
@@ -144,6 +157,13 @@ impl SqlxUsageReadRepository {
.execute(&mut *tx)
.await
.map_postgres_err()?;
// These bounded aggregates have many expressions but run only once per
// bucket. JIT compilation consumes a significant part of their timeout.
// Keep the setting transaction-local so other pool users retain theirs.
sqlx::query("SET LOCAL jit = off")
.execute(&mut *tx)
.await
.map_postgres_err()?;
let acquired: bool =
sqlx::query_scalar("SELECT pg_try_advisory_xact_lock(hashtextextended($1, 19))")
.bind(format!("overview-v2:{granularity}:{}", bucket.timestamp()))
+8 -2
View File
@@ -33,12 +33,17 @@ The schema workspace has three normal source areas:
| `drivers/postgres/` | Current maintenance fragments for executable SQL. | Edit only for deployment compatibility, ordering, or generator gaps. |
| `bootstrap/postgres/` | Source fragments for the Postgres empty-database bootstrap snapshot. | Edit here when the bootstrap snapshot changes, then rebuild `aether-data` so `build.rs` regenerates the embedded snapshot. |
Everything else is output:
Generated schema and the composed baseline are outputs:
| Path | Role | Edit policy |
|---|---|---|
| `generated/postgres/` | Machine-written SQL emitted from `logical/*.toml` for audit and drift detection. | Do not edit; regenerate with `compose_schema.sh generate`. |
| `../../adapters/postgres/migrations/` | Runtime SQL artifacts embedded by each database adapter. | Regenerate through `compose_schema.sh compose`; do not edit independently. |
| `../../adapters/postgres/migrations/20260403000000_baseline.sql` | Composed PostgreSQL baseline embedded by the adapter. | Regenerate through `compose_schema.sh compose`; do not edit independently. |
Later incremental migrations are maintained directly under
`../../adapters/postgres/migrations/`; they have no compose target. Add a new
version for an upgrade and preserve the checksums of already-applied scripts.
Keep any corresponding maintained bootstrap definitions in sync.
`generated/**` is deliberately checked in so reviews and CI can see exactly
what the logical schema compiler emits for each driver. It is not a fourth SQL
@@ -131,6 +136,7 @@ cannot run them inside a transaction.
| `20260921020000` | Add the attribution-owner lookup index concurrently on existing databases. |
| `20260921020100` | Create the usage metadata actor index concurrently. |
| `20261001000000` | Remove deleted-user attribution from dashboard activity on future user deletion; schema-only upgrade without rewriting historical rows. |
| `20261004000000` | Parse request metadata once per overview fact; replace only the view definition without rewriting facts or statistics. |
Do not remove an applied migration after folding its changes into an earlier
schema definition. Existing databases retain its version in `_sqlx_migrations`
@@ -215,23 +215,23 @@ SELECT u.request_id, COALESCE(u.id, u.request_id) AS id, u.created_at,
u.status, u.status_code, u.error_category, u.failure_origin, u.failure_stage, u.failure_reason,
u.failure_schema_version, u.response_time_ms, u.first_byte_time_ms,
COALESCE(s.billing_status, u.billing_status) AS settlement_status,
COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb AS usage_available,
COALESCE(u.request_metadata::jsonb->'usage_pricing_available', 'true'::jsonb) <> 'false'::jsonb
COALESCE(metadata.value->'usage_available', 'true'::jsonb) <> 'false'::jsonb AS usage_available,
COALESCE(metadata.value->'usage_pricing_available', 'true'::jsonb) <> 'false'::jsonb
AND (s.billing_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status, u.billing_status) = 'settled') AS pricing_available,
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb
CASE WHEN COALESCE(metadata.value->'usage_available', 'true'::jsonb) <> 'false'::jsonb
THEN b.input_tokens END AS input_tokens,
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb
CASE WHEN COALESCE(metadata.value->'usage_available', 'true'::jsonb) <> 'false'::jsonb
THEN b.output_tokens END AS output_tokens,
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb
CASE WHEN COALESCE(metadata.value->'usage_available', 'true'::jsonb) <> 'false'::jsonb
THEN b.total_tokens END AS total_tokens,
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb
CASE WHEN COALESCE(metadata.value->'usage_available', 'true'::jsonb) <> 'false'::jsonb
THEN b.cache_read_input_tokens END AS cache_read_input_tokens,
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb
CASE WHEN COALESCE(metadata.value->'usage_available', 'true'::jsonb) <> 'false'::jsonb
THEN b.cache_creation_input_tokens END AS cache_creation_input_tokens,
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_pricing_available', 'true'::jsonb) <> 'false'::jsonb
CASE WHEN COALESCE(metadata.value->'usage_pricing_available', 'true'::jsonb) <> 'false'::jsonb
AND (s.billing_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status, u.billing_status) = 'settled')
THEN round(COALESCE(s.billing_total_cost_usd::numeric, u.total_cost_usd::numeric), 8) END AS rated_amount,
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_pricing_available', 'true'::jsonb) <> 'false'::jsonb
CASE WHEN COALESCE(metadata.value->'usage_pricing_available', 'true'::jsonb) <> 'false'::jsonb
AND (s.billing_actual_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status, u.billing_status) = 'settled')
THEN round(COALESCE(s.billing_actual_total_cost_usd::numeric, u.actual_total_cost_usd::numeric), 8) END AS billable_amount,
s.quota_covered_amount_usd AS quota_covered_amount,
@@ -243,21 +243,24 @@ SELECT u.request_id, COALESCE(u.id, u.request_id) AS id, u.created_at,
s.allocation_status, s.finalized_at AS settled_at,
CASE WHEN s.billing_total_cost_usd IS NOT NULL THEN 'settlement_snapshot' ELSE 'legacy_float' END AS amount_source,
b.upstream_is_stream,
CASE WHEN u.request_metadata #>> '{analytics_measurement,source}' IN ('reported','estimated','mixed')
THEN u.request_metadata #>> '{analytics_measurement,source}' ELSE 'unknown' END AS token_source,
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available','true'::jsonb) <> 'false'::jsonb
AND COALESCE(u.request_metadata::jsonb->'usage_pricing_available','true'::jsonb) <> 'false'::jsonb
CASE WHEN metadata.value #>> '{analytics_measurement,source}' IN ('reported','estimated','mixed')
THEN metadata.value #>> '{analytics_measurement,source}' ELSE 'unknown' END AS token_source,
CASE WHEN COALESCE(metadata.value->'usage_available','true'::jsonb) <> 'false'::jsonb
AND COALESCE(metadata.value->'usage_pricing_available','true'::jsonb) <> 'false'::jsonb
AND s.input_price_per_1m IS NOT NULL AND s.billing_cache_read_cost_usd IS NOT NULL
THEN round(s.input_price_per_1m::numeric * b.cache_read_input_tokens::numeric / 1000000,8) END AS cache_estimated_full_cost_amount,
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available','true'::jsonb) <> 'false'::jsonb
AND COALESCE(u.request_metadata::jsonb->'usage_pricing_available','true'::jsonb) <> 'false'::jsonb
CASE WHEN COALESCE(metadata.value->'usage_available','true'::jsonb) <> 'false'::jsonb
AND COALESCE(metadata.value->'usage_pricing_available','true'::jsonb) <> 'false'::jsonb
AND s.input_price_per_1m IS NOT NULL AND s.billing_cache_read_cost_usd IS NOT NULL
THEN round(s.billing_cache_read_cost_usd::numeric,8) END AS cache_read_cost_amount,
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available','true'::jsonb) <> 'false'::jsonb
AND COALESCE(u.request_metadata::jsonb->'usage_pricing_available','true'::jsonb) <> 'false'::jsonb
CASE WHEN COALESCE(metadata.value->'usage_available','true'::jsonb) <> 'false'::jsonb
AND COALESCE(metadata.value->'usage_pricing_available','true'::jsonb) <> 'false'::jsonb
AND s.input_price_per_1m IS NOT NULL AND s.billing_cache_creation_cost_usd IS NOT NULL
THEN round(s.billing_cache_creation_cost_usd::numeric,8) END AS cache_creation_cost_amount
FROM public.usage u
-- OFFSET 0 keeps this projection from being flattened: large metadata is
-- detoasted and parsed once per request, rather than once per metric expression.
CROSS JOIN LATERAL (SELECT u.request_metadata::jsonb AS value OFFSET 0) metadata
LEFT JOIN public.usage_settlement_snapshots s USING (request_id)
LEFT JOIN public.usage_attribution_snapshots a USING (request_id)
JOIN public.usage_billing_facts b USING (request_id)
@@ -266,10 +269,10 @@ CROSS JOIN LATERAL (
SELECT CASE WHEN a.request_id IS NOT NULL THEN a.credential_owner_id
WHEN EXISTS (SELECT 1 FROM public.users WHERE id=u.user_id AND NOT is_deleted) THEN u.user_id END AS owner_id,
COALESCE(k.is_standalone,
CASE WHEN jsonb_typeof(u.request_metadata::jsonb #> '{analytics_attribution,is_standalone}')='boolean'
THEN (u.request_metadata #>> '{analytics_attribution,is_standalone}')::boolean END,
CASE WHEN jsonb_typeof(u.request_metadata::jsonb->'api_key_is_standalone')='boolean'
THEN (u.request_metadata->>'api_key_is_standalone')::boolean END,
CASE WHEN jsonb_typeof(metadata.value #> '{analytics_attribution,is_standalone}')='boolean'
THEN (metadata.value #>> '{analytics_attribution,is_standalone}')::boolean END,
CASE WHEN jsonb_typeof(metadata.value->'api_key_is_standalone')='boolean'
THEN (metadata.value->>'api_key_is_standalone')::boolean END,
CASE WHEN a.attribution_source='user_account' THEN false
WHEN a.attribution_source='standalone_key' THEN true END,
CASE WHEN u.api_key_id IS NULL THEN false END) AS is_standalone
@@ -31,6 +31,7 @@ mod dashboard_user_anonymization;
mod legacy_overview_upgrade;
mod migration_deadlines;
mod overview_dirty_events;
mod overview_fact_metadata;
mod overview_migration_safety;
mod policy_nulls;
mod provider_expenses;
@@ -1594,6 +1595,7 @@ fn pending_migrations_from_applied_skips_versions_already_applied() {
20260921020100,
20260923000000,
20261001000000,
20261004000000,
]
);
}
@@ -0,0 +1,180 @@
use super::*;
use serde_json::Value;
const OPTIMIZATION_VERSION: i64 = 20261004000000;
async fn read_facts(pool: &PgPool) -> Value {
query_scalar(
"SELECT jsonb_agg(to_jsonb(f) ORDER BY request_id) FROM usage_analytics_facts_v1 f",
)
.fetch_one(pool)
.await
.unwrap()
}
async fn stored_rows(pool: &PgPool) -> Vec<Value> {
let mut rows = Vec::new();
for table in [
"usage",
"usage_settlement_snapshots",
"usage_attribution_snapshots",
"stats_overview_dirty_events",
"stats_bucket_state",
"stats_overview_hourly",
"stats_overview_daily",
"dashboard_request_contributions",
"dashboard_stats_total",
"dashboard_activity_minute",
] {
rows.push(
query_scalar(&format!(
"SELECT COALESCE(jsonb_agg(to_jsonb(t) ORDER BY to_jsonb(t)), '[]') FROM {table} t"
))
.fetch_one(pool)
.await
.unwrap(),
);
}
rows
}
#[tokio::test]
async fn overview_fact_metadata_optimization_preserves_facts_without_backfill() {
let Some(server) = ManagedPostgresServer::try_start().await.unwrap() else {
return;
};
let mut connection = PgConnection::connect(server.database_url()).await.unwrap();
connection.ensure_migrations_table().await.unwrap();
for migration in POSTGRES_MIGRATOR
.iter()
.filter(|migration| migration.version < OPTIMIZATION_VERSION)
{
connection.apply(migration).await.unwrap();
}
let pool = PgPool::connect(server.database_url()).await.unwrap();
sqlx::raw_sql(
r#"
INSERT INTO users(id,username,email_verified) VALUES('owner','owner',false);
INSERT INTO api_keys(id,user_id,key_hash,is_standalone)
VALUES('employee-key','owner',repeat('e',64),false),
('standalone-key','owner',repeat('f',64),true);
"#,
)
.execute(&pool)
.await
.unwrap();
// Preserve distinctions between absent/null flags, booleans and strings,
// malformed nested shapes, key overrides and pre-attribution legacy rows.
let metadata_cases = [
None,
Some("null"),
Some("[]"),
Some("false"),
Some("\"metadata\""),
Some("{}"),
Some(r#"{"usage_available":false,"usage_pricing_available":false}"#),
Some(r#"{"usage_available":null,"usage_pricing_available":null}"#),
Some(r#"{"usage_available":"false","usage_pricing_available":"false"}"#),
Some(
r#"{"analytics_attribution":{"is_standalone":true},"analytics_measurement":{"source":"reported"}}"#,
),
Some(
r#"{"analytics_attribution":{"is_standalone":false},"analytics_measurement":{"source":"estimated"}}"#,
),
Some(
r#"{"analytics_attribution":{"is_standalone":"true"},"api_key_is_standalone":false,"analytics_measurement":{"source":"mixed"}}"#,
),
Some(
r#"{"analytics_attribution":[],"api_key_is_standalone":true,"analytics_measurement":{"source":false}}"#,
),
Some(
r#"{"analytics_attribution":null,"api_key_is_standalone":"true","analytics_measurement":[]}"#,
),
Some(
r#"{"usage_available":true,"usage_available":false,"analytics_attribution":{"is_standalone":false},"analytics_attribution":{"is_standalone":true}}"#,
),
];
for (index, metadata) in metadata_cases.into_iter().enumerate() {
for key in [None, Some("employee-key"), Some("standalone-key")] {
let request = format!("metadata-{index}-{}", key.unwrap_or("legacy"));
query(
r#"INSERT INTO usage(id,request_id,user_id,api_key_id,model,provider_name,
status,billing_status,input_tokens,output_tokens,total_tokens,
cache_read_input_tokens,cache_creation_input_tokens,total_cost_usd,
actual_total_cost_usd,response_time_ms,first_byte_time_ms,is_stream,
created_at,request_metadata)
VALUES($1,$1,'owner',$2,'test','test','completed','settled',100,10,110,
20,5,0.12345678,0.11111111,1000,100,true,'2026-01-01 00:00:00+00',$3::json)"#,
)
.bind(&request)
.bind(key)
.bind(metadata)
.execute(&pool)
.await
.unwrap();
if index % 2 == 0 {
query(
r#"INSERT INTO usage_settlement_snapshots(request_id,billing_status,
billing_input_tokens,billing_effective_input_tokens,billing_output_tokens,
billing_cache_read_tokens,billing_cache_creation_tokens,
billing_total_cost_usd,billing_actual_total_cost_usd,input_price_per_1m,
billing_cache_read_cost_usd,billing_cache_creation_cost_usd,
quota_covered_amount_usd,wallet_consumed_amount_usd,wallet_debit_amount_usd,
wallet_recharge_debit_usd,wallet_gift_debit_usd,wallet_overdraft_usd,
allocation_status)
VALUES($1,'settled',200,175,20,15,10,0.3,0.25,2,0.00001,0.00002,
0.05,0.2,0.2,0.1,0.1,0,'complete')"#,
)
.bind(&request)
.execute(&pool)
.await
.unwrap();
}
if key.is_none() {
query("DELETE FROM usage_attribution_snapshots WHERE request_id=$1")
.bind(&request)
.execute(&pool)
.await
.unwrap();
}
}
}
// Keep a large nested payload so parity also covers toasted JSON metadata.
query("UPDATE usage SET request_metadata=json_build_object('usage_available',true,'payload',repeat('metadata payload ',4096)) WHERE request_id='metadata-5-legacy'")
.execute(&pool).await.unwrap();
let before_facts = read_facts(&pool).await;
let before_rows = stored_rows(&pool).await;
// The definition-only upgrade must not touch historical projections/queues.
let mut blocked_history = pool.begin().await.unwrap();
query("LOCK TABLE stats_bucket_state,stats_overview_dirty_events,stats_overview_hourly,stats_overview_daily IN ACCESS EXCLUSIVE MODE")
.execute(&mut *blocked_history).await.unwrap();
query("SET lock_timeout='500ms'")
.execute(&mut connection)
.await
.unwrap();
let migration = POSTGRES_MIGRATOR
.iter()
.find(|migration| migration.version == OPTIMIZATION_VERSION)
.unwrap();
connection.apply(migration).await.unwrap();
blocked_history.rollback().await.unwrap();
assert_eq!(read_facts(&pool).await, before_facts);
assert_eq!(stored_rows(&pool).await, before_rows);
// The maintained bootstrap fragment must install the same read model.
let bootstrap =
include_str!("../../../../schema/bootstrap/postgres/190_overview_analytics.sql");
let view_start = bootstrap
.find("CREATE OR REPLACE VIEW public.usage_analytics_facts_v1 AS")
.unwrap();
sqlx::raw_sql(&bootstrap[view_start..])
.execute(&pool)
.await
.unwrap();
assert_eq!(read_facts(&pool).await, before_facts);
assert_eq!(stored_rows(&pool).await, before_rows);
pool.close().await;
}
@@ -113,4 +113,13 @@ describe('adminApi analytics cache options', () => {
})
})
it('refreshes both leaderboards for the same precise period as user accounts', async () => {
const range = { from: '2026-09-10T15:20:42Z', to: '2026-09-10T16:20:42Z', timezone: 'Asia/Shanghai' }
await adminApi.getLeaderboardUsers(range, { skipCache: true })
await adminApi.getLeaderboardUserGroups(range, { skipCache: true })
expect(getMock).toHaveBeenCalledWith('/api/admin/stats/leaderboard/users', { params: range })
expect(getMock).toHaveBeenCalledWith('/api/admin/stats/leaderboard/user-groups', { params: range })
for (const call of cachedRequestMock.mock.calls as unknown[][]) expect(call[2]).toBe(0)
})
})
+10 -4
View File
@@ -1288,6 +1288,8 @@ export const adminApi = {
// Stats / Leaderboards
async getLeaderboardUsers(params?: {
from?: string
to?: string
start_date?: string
end_date?: string
preset?: string
@@ -1302,7 +1304,7 @@ export const adminApi = {
include_inactive?: boolean
exclude_admin?: boolean
user_group_id?: string
}): Promise<LeaderboardResponse> {
}, options?: AdminAnalyticsRequestOptions): Promise<LeaderboardResponse> {
const cacheKey = buildCacheKey('admin:stats:leaderboard:users', params)
return cachedRequest(
cacheKey,
@@ -1312,11 +1314,13 @@ export const adminApi = {
})
return response.data
},
20 * 1000
options?.skipCache ? 0 : 20 * 1000
)
},
async getLeaderboardUserGroups(params?: {
from?: string
to?: string
start_date?: string
end_date?: string
preset?: string
@@ -1330,7 +1334,7 @@ export const adminApi = {
model?: string
include_inactive?: boolean
exclude_admin?: boolean
}): Promise<LeaderboardResponse> {
}, options?: AdminAnalyticsRequestOptions): Promise<LeaderboardResponse> {
const cacheKey = buildCacheKey('admin:stats:leaderboard:user-groups', params)
return cachedRequest(
cacheKey,
@@ -1341,7 +1345,7 @@ export const adminApi = {
)
return response.data
},
20 * 1000
options?.skipCache ? 0 : 20 * 1000
)
},
@@ -1621,6 +1625,8 @@ export const adminApi = {
async getTimeSeries(
params?: {
from?: string
to?: string
start_date?: string
end_date?: string
preset?: string
@@ -1,15 +1,14 @@
import { createApp, nextTick } from 'vue'
import { createApp, h, nextTick, reactive } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setI18nLocale } from '@/i18n'
import UserUsageStats from '../users/UserUsageStats.vue'
const api = vi.hoisted(() => ({
users: vi.fn(), groups: vi.fn(), members: vi.fn(),
userLeaderboard: vi.fn(), groupLeaderboard: vi.fn(), summary: vi.fn(), series: vi.fn(),
users: vi.fn(), groups: vi.fn(),
userLeaderboard: vi.fn(), groupLeaderboard: vi.fn(), series: vi.fn(),
}))
vi.mock('@/api/users', () => ({ usersApi: { getAllUsers: api.users, listUserGroups: api.groups, listUserGroupMembers: api.members } }))
vi.mock('@/api/users', () => ({ usersApi: { getAllUsers: api.users, listUserGroups: api.groups } }))
vi.mock('@/api/admin', () => ({ adminApi: { getLeaderboardUsers: api.userLeaderboard, getLeaderboardUserGroups: api.groupLeaderboard, getTimeSeries: api.series } }))
vi.mock('@/api/usage', () => ({ usageApi: { getUsageStats: api.summary } }))
vi.mock('@/components/charts/LineChart.vue', () => ({ default: { render: () => null } }))
vi.mock('@/components/common', () => ({ EmptyState: { render: () => null }, LoadingState: { render: () => null }, TimeRangePicker: { render: () => null } }))
vi.mock('@/components/ui', async importOriginal => {
@@ -31,13 +30,22 @@ vi.mock('@/components/ui', async importOriginal => {
})
let unmount = () => {}
const range = { from: '2026-09-10T15:20:42.000Z', to: '2026-09-10T16:20:42.000Z', timezone: 'Asia/Shanghai' }
let props = reactive({ range: { ...range }, revision: 0 })
async function settle() {
for (let index = 0; index < 10; index += 1) await Promise.resolve()
await nextTick()
}
async function mount() {
const root = document.createElement('div')
const app = createApp(UserUsageStats)
const app = createApp({
render: () => h(UserUsageStats, props, {
'user-leaderboard': ({ selectUser }: { selectUser: (user: { user_id: string; username: string }) => void }) => h('button', {
'data-select-account': '',
onClick: () => selectUser({ user_id: 'user-3', username: 'Charlie' }),
}, 'Merged user accounts'),
}),
})
app.mount(root)
unmount = () => app.unmount()
await settle()
@@ -55,39 +63,113 @@ beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
setI18nLocale('en-US')
props = reactive({ range: { ...range }, revision: 0 })
api.users.mockResolvedValue([{ id: 'user-1', username: 'Alice', is_active: true, groups: [{ id: 'group-1' }] }, { id: 'user-2', username: 'Bob', is_active: true, groups: [] }])
api.groups.mockResolvedValue({ items: [{ id: 'group-1', name: 'Engineering' }, { id: 'group-2', name: 'Support' }] })
api.members.mockResolvedValue([{ id: 'user-1', is_active: true, is_deleted: false }])
api.userLeaderboard.mockResolvedValue({ items: [], total: 0 })
api.groupLeaderboard.mockResolvedValue({ items: [], total: 0 })
api.summary.mockResolvedValue({ total_requests: 5, total_tokens: 100, total_cost: 2 })
api.series.mockResolvedValue([])
})
afterEach(() => { unmount(); vi.useRealTimers(); setI18nLocale('zh-CN') })
describe('user and group usage statistics', () => {
it('applies group scope to summary, trends and member rankings, and can compare groups', async () => {
it('uses the merged account table without fetching another user leaderboard', async () => {
const root = await mount()
expect(root.textContent).toContain('Merged user accounts')
expect(api.userLeaderboard).not.toHaveBeenCalled()
expect(api.groupLeaderboard).not.toHaveBeenCalled()
expect(root.textContent).not.toContain('User summary')
expect(root.textContent).not.toContain('User-group summary')
root.querySelector<HTMLButtonElement>('[data-select-account]')!.click()
await nextTick()
await vi.advanceTimersByTimeAsync(120)
await settle()
expect(root.querySelectorAll('select')[1].value).toBe('user-3')
expect(root.querySelector('h3')?.parentElement?.textContent).toContain('Charlie')
expect(api.series).toHaveBeenLastCalledWith(expect.objectContaining({ ...range, user_id: 'user-3' }), { skipCache: true })
await select(root, 0, 'user_group')
expect(root.querySelector('[data-select-account]')).toBeNull()
await select(root, 0, 'user')
expect(root.querySelectorAll('select')[1].value).toBe('user-3')
expect(root.querySelector('h3')?.parentElement?.textContent).toContain('Charlie')
expect(api.userLeaderboard.mock.calls.every(([params]) => params.user_group_id)).toBe(true)
})
it('applies group scope to trends and member rankings, and can compare groups', async () => {
const root = await mount()
expect(api.summary).toHaveBeenLastCalledWith(expect.objectContaining({ user_id: 'user-1' }))
await select(root, 0, 'user_group')
expect(api.groupLeaderboard).toHaveBeenCalled()
expect(api.summary).toHaveBeenLastCalledWith(expect.objectContaining({ user_group_id: 'group-1' }))
expect(api.summary.mock.lastCall?.[0]).not.toHaveProperty('user_id')
expect(api.userLeaderboard).toHaveBeenLastCalledWith(expect.objectContaining({ user_group_id: 'group-1', limit: 10 }))
expect(api.members).toHaveBeenLastCalledWith('group-1')
expect(api.userLeaderboard).toHaveBeenLastCalledWith(expect.objectContaining({ ...range, user_group_id: 'group-1', limit: 10 }), { skipCache: true })
await select(root, 2, 'group-2')
expect(api.series).toHaveBeenCalledWith(expect.objectContaining({ user_group_id: 'group-2' }))
expect(api.series).toHaveBeenCalledWith(expect.objectContaining({ ...range, user_group_id: 'group-2' }), { skipCache: true })
expect(root.textContent).toContain('Group member leaderboard')
})
it('keeps the ungrouped sentinel across scoped queries without fetching a fictitious group', async () => {
const root = await mount()
await select(root, 0, 'user_group')
api.members.mockClear()
await select(root, 1, '__ungrouped__')
expect(api.summary).toHaveBeenLastCalledWith(expect.objectContaining({ user_group_id: '__ungrouped__' }))
expect(api.series).toHaveBeenLastCalledWith(expect.objectContaining({ user_group_id: '__ungrouped__' }))
expect(api.userLeaderboard).toHaveBeenLastCalledWith(expect.objectContaining({ user_group_id: '__ungrouped__' }))
expect(api.members).not.toHaveBeenCalled()
expect(api.series).toHaveBeenLastCalledWith(expect.objectContaining({ user_group_id: '__ungrouped__' }), { skipCache: true })
expect(api.userLeaderboard).toHaveBeenLastCalledWith(expect.objectContaining({ user_group_id: '__ungrouped__' }), { skipCache: true })
})
it('uses the page range and refresh for trends and comparisons without losing selection', async () => {
const root = await mount()
await select(root, 2, 'user-2')
api.userLeaderboard.mockClear()
api.series.mockClear()
props.range = { from: '2026-09-03T02:47:00.000Z', to: '2026-10-03T02:47:00.000Z', timezone: 'Asia/Shanghai' }
await nextTick()
await vi.advanceTimersByTimeAsync(120)
expect(api.userLeaderboard).not.toHaveBeenCalled()
expect(api.series).toHaveBeenCalledWith(expect.objectContaining({ ...props.range, user_id: 'user-2' }), { skipCache: true })
props.revision += 1
await nextTick()
await vi.advanceTimersByTimeAsync(120)
expect(api.userLeaderboard).not.toHaveBeenCalled()
expect(api.series).toHaveBeenCalledTimes(4)
expect(root.querySelector('h1')).toBeNull()
expect(root.querySelectorAll('select')[2].value).toBe('user-2')
})
it('discards the old trend when the page range changes during a request', async () => {
let resolveOld: (value: unknown) => void = () => {}
api.series.mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
const root = await mount()
props.range = { ...range, from: '2026-09-10T16:00:00.000Z' }
await nextTick()
resolveOld([{ date: 'old', total_cost: 999 }])
await settle()
expect(root.textContent).not.toContain('999')
await vi.advanceTimersByTimeAsync(120)
expect(api.series.mock.lastCall?.[0]).toMatchObject(props.range)
})
it('reports failed requests and retries them without unmounting the usage section', async () => {
api.series.mockRejectedValueOnce(new Error('Trend unavailable'))
const root = await mount()
expect(root.textContent).toContain('Trend unavailable')
const retry = [...root.querySelectorAll('button')].find(item => item.textContent?.includes('Retry'))
expect(retry).toBeDefined()
retry!.click()
await settle()
expect(api.series).toHaveBeenCalledTimes(2)
expect(api.userLeaderboard).not.toHaveBeenCalled()
expect(root.textContent).not.toContain('Trend unavailable')
})
it('retries failed group rankings while preserving scoped member rankings', async () => {
api.groupLeaderboard.mockRejectedValueOnce(new Error('Group rankings unavailable'))
const root = await mount()
await select(root, 0, 'user_group')
expect(root.textContent).toContain('Group rankings unavailable')
const retry = [...root.querySelectorAll('button')].find(item => item.textContent?.includes('Retry'))
retry!.click()
await settle()
expect(api.groupLeaderboard).toHaveBeenCalledTimes(2)
expect(api.userLeaderboard).toHaveBeenLastCalledWith(expect.objectContaining({ user_group_id: 'group-1' }), { skipCache: true })
expect(root.textContent).not.toContain('Group rankings unavailable')
})
})
@@ -1,4 +1,4 @@
import { createApp, h, nextTick, type Component } from 'vue'
import { createApp, nextTick, type Component } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import UserStats from '@/views/admin/UserStats.vue'
@@ -7,12 +7,18 @@ import { setI18nLocale } from '@/i18n'
const api = vi.hoisted(() => ({ users: vi.fn(), user: vi.fn(), timeseries: vi.fn(), breakdown: vi.fn(), consumption: vi.fn(), exportCsv: vi.fn() }))
const accountApi = vi.hoisted(() => ({ wallets: vi.fn(), transactions: vi.fn(), plans: vi.fn() }))
const usageStats = vi.hoisted(() => ({ selectUser: vi.fn() }))
vi.mock('@/api/overview', () => ({ overviewApi: api }))
vi.mock('@/api/admin-wallets', () => ({ adminWalletApi: { listWallets: accountApi.wallets, getWalletTransactions: accountApi.transactions } }))
vi.mock('@/api/users', () => ({ usersApi: { listUserPlanEntitlements: accountApi.plans } }))
vi.mock('@/components/charts/BarChart.vue', () => ({ default: { render: () => null } }))
vi.mock('@/components/charts/LineChart.vue', () => ({ default: { render: () => null } }))
vi.mock('@/features/overview/users/UserUsageStats.vue', () => ({ __esModule: true, default: { render: () => h('div', { 'data-user-usage-stats': '' }, 'Usage statistics') } }))
vi.mock('@/features/overview/users/UserUsageStats.vue', async () => {
const { defineComponent, h } = await import('vue')
return { default: defineComponent({
setup: (_, { slots }) => () => h('div', { 'data-user-usage-stats': '' }, slots['user-leaderboard']?.({ selectUser: usageStats.selectUser })),
}) }
})
const range = 'from=2026-09-01T00:00:00Z&to=2026-09-02T00:00:00Z&timezone=UTC'
const meta = {
schema_version: 1, metric_version: 'overview-v2', scope: { kind: 'installation' },
@@ -64,17 +70,15 @@ beforeEach(() => {
afterEach(() => { cleanup.splice(0).forEach(fn => fn()); vi.useRealTimers(); setI18nLocale('zh-CN') })
describe('enterprise user accounts', () => {
it('loads usage and group statistics only when selected, preserving the account view', async () => {
it('shows account and usage statistics together on one page', async () => {
const { root } = await mount(UserStats, `/admin/user-stats?${range}`)
expect(root.querySelector('[data-user-usage-stats]')).toBeNull()
button(root, '使用统计').click()
await settle()
await settle()
expect(root.querySelector('[data-user-usage-stats]')).not.toBeNull()
root.querySelector<HTMLButtonElement>('button[data-value="accounts"]')?.click()
await settle()
expect(root.querySelector('[data-user-usage-stats]')).toBeNull()
expect(section(root, '[data-user-accounts]').textContent).toContain(employee.username)
expect(section(root, '[data-user-reports]').querySelector('[data-user-accounts]')).not.toBeNull()
expect(root.querySelectorAll('[data-user-accounts]')).toHaveLength(1)
expect(section(root, '[data-user-accounts]').textContent).toContain('用户排行与账目')
expect(root.querySelector('[role="tablist"]')).toBeNull()
expect(root.querySelector('button[data-value="accounts"]')).toBeNull()
expect(api.users).toHaveBeenCalledTimes(1)
})
@@ -84,25 +88,39 @@ describe('enterprise user accounts', () => {
expect(section(root, '[data-user-summary="consumption"]').textContent).toContain('432.25')
expect(section(root, '[data-user-summary="activity"]').textContent).toMatch(/7\s*\/ 62/)
expect(root.querySelector('select[aria-label="归属"]')).toBeNull()
expect(root.querySelector('button[data-value="accounts"][data-state="active"]')).not.toBeNull()
expect(api.users.mock.lastCall?.[0]).toMatchObject({ sort: 'billable_amount', order: 'desc', limit: 25, offset: 0 })
expect(api.users.mock.lastCall?.[0]).not.toHaveProperty('attribution_kind')
expect(api.users.mock.lastCall?.[0]).not.toHaveProperty('model')
expect(section(root, '[data-user-rank]').textContent?.trim()).toBe('1')
button(root, '第 2 页').click()
await settle()
expect(api.users.mock.lastCall?.[0]).toMatchObject({ offset: 25 })
expect(section(root, '[data-user-rank]').textContent?.trim()).toBe('26')
button(root, 'Tokens').click()
await settle()
expect(api.users.mock.lastCall?.[0]).toMatchObject({ offset: 0, sort: 'total_tokens', order: 'desc' })
expect(section(root, '[data-user-rank]').textContent?.trim()).toBe('1')
expect(root.querySelector('a[href*="employee-0"]')).toBeNull()
expect(root.querySelector('a[href*="/admin/usage"]')).toBeNull()
expect([...section(root, '[data-user-accounts]').querySelectorAll('a, button')].some(item => item.textContent?.trim() === '使用记录')).toBe(false)
})
it('places reports first and opens reusable account history without leaving the page', async () => {
it('opens trends from the merged table and labels ascending rows as positions', async () => {
const { root } = await mount(UserStats, `/admin/user-stats?${range}`)
const accounts = section(root, '[data-user-accounts]')
button(accounts, '使用趋势').click()
expect(usageStats.selectUser).toHaveBeenCalledWith(expect.objectContaining({ user_id: employee.user_id, username: employee.username }))
expect(accountApi.wallets).not.toHaveBeenCalled()
button(accounts, '消费').click()
await settle()
expect(api.users.mock.lastCall?.[0]).toMatchObject({ sort: 'billable_amount', order: 'asc', offset: 0 })
expect(accounts.querySelector('thead')?.textContent).toContain('序号')
expect(accounts.querySelector('thead')?.textContent).not.toContain('排名')
})
it('includes accounts in reports and opens reusable account history without leaving the page', async () => {
const { root, router } = await mount(UserStats, `/admin/user-stats?${range}`)
const reports = section(root, '[data-user-reports]')
const accounts = section(root, '[data-user-accounts]')
expect(reports.compareDocumentPosition(accounts) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(reports.contains(accounts)).toBe(true)
expect(accountApi.wallets).not.toHaveBeenCalled()
expect(accountApi.plans).not.toHaveBeenCalled()
const before = router.currentRoute.value.fullPath
@@ -8,52 +8,54 @@
</div>
<div class="flex max-w-full flex-wrap items-center gap-2">
<slot />
<TimeRangePicker
v-if="presetsOnly"
:model-value="pickerRange"
:preset-options="['last1hour', 'today', 'last24hours', 'last7days', 'last30days']"
:show-granularity="false"
preset-only
@update:model-value="selectSharedPreset"
/>
<select
v-else
class="h-9 rounded-md border bg-background px-2 text-sm"
:aria-label="t('时间范围', 'Time range')"
:value="selectedPreset"
@change="selectPreset"
>
<option value="custom">
{{ t('自定义', 'Custom') }}
</option>
<option value="last1hour">
{{ t('最近 1 小时', 'Last hour') }}
</option>
<option value="today">
{{ t('今天', 'Today') }}
</option>
<option value="last24hours">
{{ t('最近 24 小时', 'Last 24 hours') }}
</option>
<option value="last7days">
{{ t('最近 7 天', 'Last 7 days') }}
</option>
<option value="last30days">
{{ t('最近 30 天', 'Last 30 days') }}
</option>
</select>
<Button
v-if="!presetsOnly"
variant="outline"
size="icon"
class="h-9 w-9"
:title="t('精确时间', 'Exact time')"
:aria-label="t('精确时间', 'Exact time')"
:aria-expanded="editing"
@click="editing = !editing"
>
<CalendarRange class="h-4 w-4" />
</Button>
<slot name="range-picker">
<TimeRangePicker
v-if="presetsOnly"
:model-value="pickerRange"
:preset-options="['last1hour', 'today', 'last24hours', 'last7days', 'last30days']"
:show-granularity="false"
preset-only
@update:model-value="selectSharedPreset"
/>
<select
v-else
class="h-9 rounded-md border bg-background px-2 text-sm"
:aria-label="t('时间范围', 'Time range')"
:value="selectedPreset"
@change="selectPreset"
>
<option value="custom">
{{ t('自定义', 'Custom') }}
</option>
<option value="last1hour">
{{ t('最近 1 小时', 'Last hour') }}
</option>
<option value="today">
{{ t('今天', 'Today') }}
</option>
<option value="last24hours">
{{ t('最近 24 小时', 'Last 24 hours') }}
</option>
<option value="last7days">
{{ t('最近 7 天', 'Last 7 days') }}
</option>
<option value="last30days">
{{ t('最近 30 天', 'Last 30 days') }}
</option>
</select>
<Button
v-if="!presetsOnly"
variant="outline"
size="icon"
class="h-9 w-9"
:title="t('精确时间', 'Exact time')"
:aria-label="t('精确时间', 'Exact time')"
:aria-expanded="editing"
@click="editing = !editing"
>
<CalendarRange class="h-4 w-4" />
</Button>
</slot>
<RefreshButton
:loading="loading"
:active="refreshActive"
@@ -12,7 +12,7 @@
v-if="!userId"
class="mt-1 text-xs text-muted-foreground"
>
{{ t('全站用量 · 包含独立余额 Key · 不受用户搜索影响', 'Installation usage · Includes standalone balance keys · Independent of user search') }}
{{ t('模型用量与消费趋势为全站用量 · 包含独立余额 Key · 不受用户搜索影响', 'Model usage and consumption trends cover installation usage · Includes standalone balance keys · Independent of user search') }}
</p>
</div>
<Button
@@ -122,6 +122,7 @@
</div>
</section>
</div>
<slot name="additional" />
</section>
</template>
@@ -5,9 +5,9 @@
>
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div>
<h1 class="text-lg font-semibold">
<h2 class="text-sm font-semibold">
{{ t('userStats.title') }}
</h1>
</h2>
<p class="text-xs text-muted-foreground">
{{ t('userStats.description') }}
</p>
@@ -63,20 +63,54 @@
</SelectItem>
</SelectContent>
</Select>
<TimeRangePicker
v-model="timeRange"
:allow-hourly="true"
/>
<Select v-model="granularity">
<SelectTrigger
class="h-8 w-24 text-xs"
:aria-label="legacyT('粒度')"
>
<SelectValue :placeholder="legacyT('粒度')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-if="canUseHourly"
value="hour"
>
{{ legacyT('小时') }}
</SelectItem>
<SelectItem value="day">
{{ legacyT('天') }}
</SelectItem>
<SelectItem value="week">
{{ legacyT('周') }}
</SelectItem>
<SelectItem value="month">
{{ legacyT('月') }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<OverviewStatus
:error="entitiesError || leaderboardError || panelsError"
@retry="reload"
/>
<slot
v-if="scope === 'user'"
name="user-leaderboard"
:select-user="selectUser"
/>
<div
v-else
class="grid grid-cols-1 gap-4"
>
<LeaderboardTable
:title="scope === 'user' ? t('userStats.leaderboard.user') : t('userStats.leaderboard.userGroup')"
:title="t('userStats.leaderboard.userGroup')"
:items="leaderboard"
:metric="metric"
:loading="leaderboardLoading"
:show-member-count="scope === 'user_group'"
show-member-count
selectable
@update:metric="metric = $event"
@select="selectLeaderboardItem"
@@ -105,78 +139,6 @@
</div>
</template>
</LeaderboardTable>
<Card class="space-y-3 p-4">
<div>
<h3 class="text-sm font-semibold">
{{ 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
v-if="summaryLoading"
class="p-6"
>
<LoadingState />
</div>
<div
v-else
class="grid grid-cols-2 gap-3 text-sm"
>
<div>
<div class="text-xs text-muted-foreground">
{{ t('stats.metric.requests') }}
</div>
<div class="font-semibold">
{{ usageSummary?.total_requests ?? 0 }}
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">
{{ t('stats.metric.tokens') }}
</div>
<div class="font-semibold">
{{ formatTokens(usageSummary?.total_tokens ?? 0) }}
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">
{{ t('stats.metric.cost') }}
</div>
<div class="font-semibold">
{{ formatCurrency(usageSummary?.total_cost ?? 0) }}
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">
{{ t('stats.metric.errorRate') }}
</div>
<div class="font-semibold">
{{ usageSummary?.error_rate ?? 0 }}%
</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>
</Card>
</div>
<LeaderboardTable
@@ -192,7 +154,10 @@
<Card class="space-y-4 p-4">
<div>
<h3 class="text-sm font-semibold">
<h3
ref="trendHeading"
class="text-sm font-semibold scroll-mt-4"
>
{{ scope === 'user' ? t('userStats.trend.user') : t('userStats.trend.userGroup') }}
</h3>
<p class="mt-0.5 truncate text-xs text-muted-foreground">
@@ -228,7 +193,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import {
Button,
Card,
@@ -239,40 +204,39 @@ import {
SelectValue
} from '@/components/ui'
import LineChart from '@/components/charts/LineChart.vue'
import { LoadingState, TimeRangePicker } from '@/components/common'
import { LoadingState } from '@/components/common'
import { LeaderboardTable } from '@/components/stats'
import { adminApi, type LeaderboardItem } from '@/api/admin'
import { usersApi, type User, type UserGroup, type UserGroupMember } from '@/api/users'
import { usageApi } from '@/api/usage'
import { formatCurrency, formatTokens } from '@/utils/format'
import { usersApi, type User, type UserGroup } from '@/api/users'
import { useI18n } from '@/i18n'
import { getDateRangeFromPeriod } from '@/features/usage/composables'
import type { DateRangeParams } from '@/features/usage/types'
import type { OverviewRange } from '@/api/overview'
import OverviewStatus from '../components/OverviewStatus.vue'
type StatsScope = 'user' | 'user_group'
type SelectableEntity = { id: string; name: string }
interface UsageSummary {
total_requests: number
total_tokens: number
total_cost: number
error_rate: number
}
type SelectedUser = { user_id: string; username: string }
interface TimeSeriesItem {
date: string
total_cost: number
}
const { t } = useI18n()
const props = defineProps<{ range: OverviewRange; revision: number }>()
defineSlots<{
'user-leaderboard'(props: { selectUser: (user: SelectedUser) => void }): unknown
}>()
const { t, legacyT } = useI18n()
const PAGE_SIZE = 10
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last7days'))
const granularity = ref<'hour' | 'day' | 'week' | 'month'>('day')
const canUseHourly = computed(() => Date.parse(props.range.to) - Date.parse(props.range.from) <= 48 * 3_600_000)
const metric = ref<'requests' | 'tokens' | 'cost'>('requests')
const scope = ref<StatsScope>('user')
const users = ref<User[]>([])
const selectedUserNames = ref(new Map<string, string>())
const userGroups = ref<UserGroup[]>([])
const trendHeading = ref<HTMLElement | null>(null)
const selectedUserId = ref('')
const selectedUserGroupId = ref('')
const compareUserId = ref('__none__')
@@ -284,24 +248,31 @@ 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 comparisonSeries = ref<TimeSeriesItem[]>([])
const seriesLoading = ref(false)
const entitiesError = ref<string | null>(null)
const leaderboardError = ref<string | null>(null)
const panelsError = ref<string | null>(null)
let leaderboardRequestId = 0
let panelRequestId = 0
let leaderboardDebounceTimer: ReturnType<typeof setTimeout> | null = null
let panelDebounceTimer: ReturnType<typeof setTimeout> | null = null
let ready = false
let disposed = false
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 })),
{ id: '__ungrouped__', name: t('userStats.ungrouped') }])
const allEntities = computed<SelectableEntity[]>(() => {
if (scope.value === 'user_group') {
return [...userGroups.value.map(group => ({ id: group.id, name: group.name })),
{ id: '__ungrouped__', name: t('userStats.ungrouped') }]
}
const entities = new Map(users.value.map(user => [user.id, {
id: user.id, name: user.username || user.email || user.id,
}]))
selectedUserNames.value.forEach((name, id) => entities.set(id, { id, name }))
return [...entities.values()]
})
const selectedEntityId = computed({
get: () => scope.value === 'user' ? selectedUserId.value : selectedUserGroupId.value,
@@ -335,12 +306,8 @@ const hasNextLeaderboardPage = computed(
function buildTimeRangeParams() {
return {
start_date: timeRange.value.start_date,
end_date: timeRange.value.end_date,
preset: timeRange.value.preset,
timezone: timeRange.value.timezone,
tz_offset_minutes: timeRange.value.tz_offset_minutes,
granularity: timeRange.value.granularity || 'day'
...props.range,
granularity: canUseHourly.value || granularity.value !== 'hour' ? granularity.value : 'day' as const,
}
}
@@ -359,18 +326,32 @@ function ensureSelectedEntity() {
}
async function loadEntities() {
const [loadedUsers, groupsResponse] = await Promise.all([
usersApi.getAllUsers(),
usersApi.listUserGroups()
])
users.value = loadedUsers
userGroups.value = groupsResponse.items
ensureSelectedEntity()
entitiesError.value = null
try {
const [loadedUsers, groupsResponse] = await Promise.all([
usersApi.getAllUsers(),
usersApi.listUserGroups()
])
if (disposed) return
users.value = loadedUsers
userGroups.value = groupsResponse.items
ensureSelectedEntity()
} catch (error) {
if (!disposed) entitiesError.value = error instanceof Error ? error.message : String(error)
}
}
async function loadLeaderboard() {
const requestId = ++leaderboardRequestId
if (scope.value !== 'user_group') {
leaderboard.value = []
leaderboardTotal.value = 0
leaderboardLoading.value = false
leaderboardError.value = null
return
}
leaderboardLoading.value = true
leaderboardError.value = null
try {
const params = {
...buildTimeRangeParams(),
@@ -378,12 +359,12 @@ async function loadLeaderboard() {
limit: PAGE_SIZE,
offset: leaderboardOffset.value
}
const response = scope.value === 'user'
? await adminApi.getLeaderboardUsers(params)
: await adminApi.getLeaderboardUserGroups(params)
const response = await adminApi.getLeaderboardUserGroups(params, { skipCache: true })
if (requestId !== leaderboardRequestId) return
leaderboard.value = response.items
leaderboardTotal.value = response.total
} catch (error) {
if (requestId === leaderboardRequestId) leaderboardError.value = error instanceof Error ? error.message : String(error)
} finally {
if (requestId === leaderboardRequestId) leaderboardLoading.value = false
}
@@ -392,16 +373,15 @@ async function loadLeaderboard() {
async function loadPanels() {
const selectedId = selectedEntityId.value
const requestId = ++panelRequestId
panelsError.value = null
if (!selectedId) {
usageSummary.value = null
series.value = []
comparisonSeries.value = []
memberLeaderboard.value = []
groupMemberCount.value = 0
activeGroupMemberCount.value = 0
seriesLoading.value = false
memberLeaderboardLoading.value = false
return
}
summaryLoading.value = true
seriesLoading.value = true
memberLeaderboardLoading.value = scope.value === 'user_group'
try {
@@ -411,7 +391,7 @@ async function loadPanels() {
? adminApi.getTimeSeries({
...buildTimeRangeParams(),
...scopeParams(compareEntityId.value)
})
}, { skipCache: true })
: Promise.resolve([])
const memberPromise: Promise<{ items: LeaderboardItem[] }> = scope.value === 'user_group'
? adminApi.getLeaderboardUsers({
@@ -419,33 +399,21 @@ async function loadPanels() {
metric: metric.value,
user_group_id: selectedId,
limit: PAGE_SIZE
})
}, { skipCache: true })
: Promise.resolve({ items: [] })
const groupMembersPromise: Promise<UserGroupMember[]> = scope.value === 'user_group' && selectedId !== '__ungrouped__'
? usersApi.listUserGroupMembers(selectedId)
: Promise.resolve([])
const [summary, primarySeries, compareSeries, members, groupMembers] = await Promise.all([
usageApi.getUsageStats(primaryParams),
adminApi.getTimeSeries(primaryParams),
const [primarySeries, compareSeries, members] = await Promise.all([
adminApi.getTimeSeries(primaryParams, { skipCache: true }),
comparisonPromise,
memberPromise,
groupMembersPromise
])
if (requestId !== panelRequestId) return
usageSummary.value = { ...summary, error_rate: summary.error_rate ?? 0 }
series.value = primarySeries
comparisonSeries.value = compareSeries
memberLeaderboard.value = members.items
const ungroupedUsers = users.value.filter(user => user.groups?.length === 0)
groupMemberCount.value = selectedId === '__ungrouped__'
? ungroupedUsers.length : groupMembers.filter(member => !member.is_deleted).length
activeGroupMemberCount.value = selectedId === '__ungrouped__'
? ungroupedUsers.filter(user => user.is_active).length
: groupMembers.filter(member => !member.is_deleted && member.is_active).length
} catch (error) {
if (requestId === panelRequestId) panelsError.value = error instanceof Error ? error.message : String(error)
} finally {
if (requestId === panelRequestId) {
summaryLoading.value = false
seriesLoading.value = false
memberLeaderboardLoading.value = false
}
@@ -457,8 +425,14 @@ function selectLeaderboardItem(item: LeaderboardItem) {
}
function selectMember(item: LeaderboardItem) {
selectUser({ user_id: item.id, username: item.name })
}
function selectUser(user: SelectedUser) {
selectedUserNames.value.set(user.user_id, user.username || user.user_id)
scope.value = 'user'
selectedUserId.value = item.id
selectedUserId.value = user.user_id
void nextTick(() => trendHeading.value?.scrollIntoView?.({ behavior: 'smooth', block: 'start' }))
}
function changeLeaderboardPage(direction: -1 | 1) {
@@ -499,6 +473,7 @@ const comparisonChartData = computed(() => ({
function scheduleLeaderboardLoad() {
if (!ready) return
leaderboardRequestId += 1
if (leaderboardDebounceTimer) clearTimeout(leaderboardDebounceTimer)
leaderboardDebounceTimer = setTimeout(() => {
leaderboardDebounceTimer = null
@@ -508,6 +483,7 @@ function scheduleLeaderboardLoad() {
function schedulePanelLoad() {
if (!ready) return
panelRequestId += 1
if (panelDebounceTimer) clearTimeout(panelDebounceTimer)
panelDebounceTimer = setTimeout(() => {
panelDebounceTimer = null
@@ -521,20 +497,24 @@ watch(scope, () => {
scheduleLeaderboardLoad()
schedulePanelLoad()
})
watch([timeRange, metric], () => {
leaderboardOffset.value = 0
watch([() => props.range, () => props.revision, granularity, metric], (next, previous) => {
if (next[1] === previous[1]) leaderboardOffset.value = 0
if (!canUseHourly.value && granularity.value === 'hour') granularity.value = 'day'
scheduleLeaderboardLoad()
schedulePanelLoad()
}, { deep: true })
watch([selectedEntityId, compareEntityId], schedulePanelLoad)
onMounted(async () => {
await loadEntities()
async function reload() {
if (!ready || entitiesError.value) await loadEntities()
if (disposed) return
ready = true
await Promise.all([loadLeaderboard(), loadPanels()])
})
}
onMounted(reload)
onUnmounted(() => {
disposed = true
if (leaderboardDebounceTimer) clearTimeout(leaderboardDebounceTimer)
if (panelDebounceTimer) clearTimeout(panelDebounceTimer)
leaderboardRequestId += 1
@@ -6,6 +6,7 @@
:preset="relativePreset"
:refresh-active="autoRefresh"
:refresh-title="refreshTitle"
:show-range="false"
presets-only
@update:range="setRange"
@refresh="toggleAutoRefresh"
@@ -6,6 +6,7 @@
:preset="relativePreset"
:refresh-active="autoRefresh"
:refresh-title="refreshTitle"
:show-range="false"
presets-only
:loading="loading"
@update:range="setRange"
+248 -207
View File
@@ -1,235 +1,253 @@
<template>
<main class="space-y-5 px-4 pb-8 sm:px-6 lg:px-0">
<Tabs v-model="activeTab">
<TabsList class="mb-4">
<TabsTrigger value="accounts">
{{ t('用户账目', 'User accounts') }}
</TabsTrigger>
<TabsTrigger value="usage">
{{ t('使用统计', 'Usage statistics') }}
</TabsTrigger>
</TabsList>
<TabsContent
value="accounts"
class="space-y-5"
<OverviewToolbar
:title="t('用户分析', 'User analysis')"
:range="range"
:show-range="false"
:refresh-active="autoRefresh"
:refresh-title="refreshTitle"
@update:range="setRange"
@refresh="toggleAutoRefresh"
>
<template #range-picker>
<TimeRangePicker
:model-value="{ ...range, preset: relativePreset || undefined }"
:preset-options="['last1hour', 'today', 'yesterday', 'last24hours', 'last7days', 'last30days', 'last90days', 'custom']"
:show-granularity="false"
@update:model-value="handleRangePicker"
/>
</template>
<Button
variant="outline"
size="sm"
:disabled="exporting"
@click="exportCsv('users', requestQuery)"
>
<OverviewToolbar
:title="t('用户分析', 'User analysis')"
<Download class="mr-2 h-4 w-4" />{{ t('导出用户报表', 'Export user report') }}
</Button>
</OverviewToolbar>
<OverviewStatus
:error="error || exportError"
@retry="refreshAll"
/>
<UserFinanceSummary
:summary="data?.data.summary"
:finance="data?.data.finance_summary"
:user-count="data?.data.summary?.user_count"
:active-user-count="data?.data.summary?.active_user_count"
/>
<UserReports :revision="revision">
<template #additional>
<UserUsageStats
:range="range"
:preset="relativePreset"
:show-range="false"
:refresh-active="autoRefresh"
:refresh-title="refreshTitle"
presets-only
@update:range="setRange"
@refresh="toggleAutoRefresh"
:revision="revision"
>
<Button
variant="outline"
size="sm"
:disabled="exporting"
@click="exportCsv('users', requestQuery)"
>
<Download class="mr-2 h-4 w-4" />{{ t('导出用户报表', 'Export user report') }}
</Button>
</OverviewToolbar>
<OverviewStatus
:error="error || exportError"
@retry="refreshAll"
/>
<UserFinanceSummary
:summary="data?.data.summary"
:finance="data?.data.finance_summary"
:user-count="data?.data.summary?.user_count"
:active-user-count="data?.data.summary?.active_user_count"
/>
<UserReports :revision="revision" />
<TableCard
:title="t('用户账目', 'User accounts')"
:description="t('消费与到账按所选时间统计,余额为当前值', 'Consumption and credits follow the selected period; balances are current')"
class="relative min-w-0"
data-user-accounts
>
<template #actions>
<form
class="flex w-full items-center gap-2 md:w-auto"
@submit.prevent="patch({ search: search.trim() || undefined, offset: undefined })"
<template #user-leaderboard="{ selectUser }">
<TableCard
:title="t('用户排行与账目', 'User rankings and accounts')"
:description="t('点击消费、请求数或 Tokens 排序;消费与到账按所选时间统计,余额为当前值', 'Sort by consumption, requests or Tokens; consumption and credits follow the selected period, balances are current')"
class="relative min-w-0"
data-user-accounts
>
<div class="relative min-w-0 flex-1 md:w-48 md:flex-none">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
v-model="search"
type="search"
:placeholder="t('搜索用户', 'Search users')"
:aria-label="t('搜索用户', 'Search users')"
class="h-8 w-full text-xs border-border/60 pl-8"
/>
</div>
<Button
type="submit"
size="sm"
variant="outline"
class="h-8 text-xs"
>
{{ t('查询', 'Search') }}
</Button>
</form>
</template>
<p
v-if="query.search"
class="border-b border-border/60 px-4 py-3 text-xs text-muted-foreground sm:px-6"
>
{{ t('上方汇总与账目仅包含匹配的用户', 'The summary and accounts include matching users only') }}
</p>
<div
class="min-w-0"
:aria-busy="loading"
>
<Table class="min-w-[1040px]">
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="h-12 font-semibold">
{{ t('用户', 'User') }}
</TableHead>
<SortableTableHead
v-for="column in columns"
:key="column.key"
class="h-12 font-semibold text-right"
:column-key="column.key"
:active-key="requestQuery.sort"
:direction="requestQuery.order"
default-direction="desc"
align="right"
@sort="sort"
>
{{ column.label }}
</SortableTableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('充值', 'Recharges') }}
</TableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('余额', 'Balance') }}
</TableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('最近使用', 'Last used') }}
</TableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('记录', 'Records') }}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="user in data?.data.items || []"
:key="user.user_id"
class="border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]"
<template #actions>
<form
class="flex w-full items-center gap-2 md:w-auto"
@submit.prevent="patch({ search: search.trim() || undefined, offset: undefined })"
>
<TableCell class="max-w-64 py-4 text-xs">
<button
type="button"
class="break-words hover:text-primary hover:underline"
@click="accountUser = user"
>
{{ user.username }}
</button>
<span
v-if="!user.is_active"
class="ml-2 text-xs text-muted-foreground"
>{{ t('停用', 'Disabled') }}</span>
<p class="mt-0.5 break-words text-xs text-muted-foreground">
{{ user.email }}
</p>
</TableCell>
<TableCell
class="whitespace-nowrap py-4 text-right text-xs font-medium tabular-nums"
:title="user.billable_amount?.value ?? ''"
<div class="relative min-w-0 flex-1 md:w-48 md:flex-none">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
v-model="search"
type="search"
:placeholder="t('搜索用户', 'Search users')"
:aria-label="t('搜索用户', 'Search users')"
class="h-8 w-full text-xs border-border/60 pl-8"
/>
</div>
<Button
type="submit"
size="sm"
variant="outline"
class="h-8 text-xs"
>
{{ money(user.billable_amount) }}
</TableCell>
<TableCell class="py-4 text-right text-xs tabular-nums">
{{ count(user.request_count) }}
</TableCell>
<TableCell class="py-4 text-right text-xs tabular-nums">
{{ count(user.total_tokens) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs tabular-nums">
{{ money(user.finance?.recharge_amount) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs tabular-nums">
{{ money(user.finance?.wallet_balance) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs text-muted-foreground">
{{ timestamp(user.last_used_at, range.timezone) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs">
<Button
type="button"
variant="ghost"
size="icon"
:title="t('查看', 'View') + ' ' + user.username + ' ' + t('账目', 'account')"
:aria-label="t('查看', 'View') + ' ' + user.username + ' ' + t('账目', 'account')"
@click="accountUser = user"
{{ t('查询', 'Search') }}
</Button>
</form>
</template>
<p
v-if="query.search"
class="border-b border-border/60 px-4 py-3 text-xs text-muted-foreground sm:px-6"
>
{{ t('上方汇总与账目仅包含匹配的用户', 'The summary and accounts include matching users only') }}
</p>
<div
class="min-w-0"
:aria-busy="loading"
>
<Table class="min-w-[1120px]">
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="h-12 w-16 font-semibold">
{{ t('序号', 'No.') }}
</TableHead>
<TableHead class="h-12 font-semibold">
{{ t('用户', 'User') }}
</TableHead>
<SortableTableHead
v-for="column in columns"
:key="column.key"
class="h-12 font-semibold text-right"
:column-key="column.key"
:active-key="requestQuery.sort"
:direction="requestQuery.order"
default-direction="desc"
align="right"
@sort="sort"
>
{{ column.label }}
</SortableTableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('充值', 'Recharges') }}
</TableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('余额', 'Balance') }}
</TableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('最近使用', 'Last used') }}
</TableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('操作', 'Actions') }}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="(user, index) in data?.data.items || []"
:key="user.user_id"
class="border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]"
>
<ReceiptText class="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
<TableRow v-if="!data?.data.items.length">
<TableCell
colspan="8"
class="py-12 text-center text-muted-foreground"
>
{{ loading ? t('加载中', 'Loading') : error ? t('用户账目暂不可用', 'User accounts unavailable') : t('没有符合条件的用户', 'No matching users') }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<template #pagination>
<Pagination
v-if="data"
:total="data.data.total"
:current="Math.floor(requestQuery.offset / requestQuery.limit) + 1"
:page-size="requestQuery.limit"
:page-size-options="[25, 50, 100]"
@update:current="changePage"
@update:page-size="changePageSize"
/>
<TableCell
class="py-4 text-xs font-medium tabular-nums"
data-user-rank
>
{{ (data?.data.offset ?? requestQuery.offset) + index + 1 }}
</TableCell>
<TableCell class="max-w-64 py-4 text-xs">
<button
type="button"
class="break-words hover:text-primary hover:underline"
@click="accountUser = user"
>
{{ user.username }}
</button>
<span
v-if="!user.is_active"
class="ml-2 text-xs text-muted-foreground"
>{{ t('停用', 'Disabled') }}</span>
<p class="mt-0.5 break-words text-xs text-muted-foreground">
{{ user.email }}
</p>
</TableCell>
<TableCell
class="whitespace-nowrap py-4 text-right text-xs font-medium tabular-nums"
:title="user.billable_amount?.value ?? ''"
>
{{ money(user.billable_amount) }}
</TableCell>
<TableCell class="py-4 text-right text-xs tabular-nums">
{{ count(user.request_count) }}
</TableCell>
<TableCell class="py-4 text-right text-xs tabular-nums">
{{ count(user.total_tokens) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs tabular-nums">
{{ money(user.finance?.recharge_amount) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs tabular-nums">
{{ money(user.finance?.wallet_balance) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs text-muted-foreground">
{{ timestamp(user.last_used_at, range.timezone) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs">
<Button
type="button"
variant="ghost"
size="icon"
:title="t('查看', 'View') + ' ' + user.username + ' ' + t('使用趋势', 'usage trend')"
:aria-label="t('查看', 'View') + ' ' + user.username + ' ' + t('使用趋势', 'usage trend')"
@click="selectUser(user)"
>
<ChartNoAxesCombined class="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
:title="t('查看', 'View') + ' ' + user.username + ' ' + t('账目', 'account')"
:aria-label="t('查看', 'View') + ' ' + user.username + ' ' + t('账目', 'account')"
@click="accountUser = user"
>
<ReceiptText class="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
<TableRow v-if="!data?.data.items.length">
<TableCell
colspan="9"
class="py-12 text-center text-muted-foreground"
>
{{ loading ? t('加载中', 'Loading') : error ? t('用户账目暂不可用', 'User accounts unavailable') : t('没有符合条件的用户', 'No matching users') }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<template #pagination>
<Pagination
v-if="data"
:total="data.data.total"
:current="Math.floor(requestQuery.offset / requestQuery.limit) + 1"
:page-size="requestQuery.limit"
:page-size-options="[25, 50, 100]"
@update:current="changePage"
@update:page-size="changePageSize"
/>
</template>
</TableCard>
</template>
</TableCard>
<UserAccountDrawer
v-if="accountUser"
:key="accountUser.user_id"
:user="accountUser"
:timezone="range.timezone"
@close="accountUser = null"
/>
</TabsContent>
<TabsContent value="usage">
<UserUsageStats v-if="activeTab === 'usage'" />
</TabsContent>
</Tabs>
</UserUsageStats>
</template>
</UserReports>
<UserAccountDrawer
v-if="accountUser"
:key="accountUser.user_id"
:user="accountUser"
:timezone="range.timezone"
@close="accountUser = null"
/>
</main>
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, ref, watch } from 'vue'
import { Download, Search, ReceiptText } from 'lucide-vue-next'
import { Button, Input, Pagination, SortableTableHead, Table, TableBody, TableCard, TableCell, TableHead, TableHeader, TableRow, Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import { computed, ref, watch } from 'vue'
import { Download, Search, ReceiptText, ChartNoAxesCombined } from 'lucide-vue-next'
import { Button, Input, Pagination, SortableTableHead, Table, TableBody, TableCard, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui'
import TimeRangePicker from '@/components/common/TimeRangePicker.vue'
import type { DateRangeParams } from '@/features/usage/types'
import { overviewApi } from '@/api/overview'
import OverviewToolbar from '@/features/overview/components/OverviewToolbar.vue'
import OverviewStatus from '@/features/overview/components/OverviewStatus.vue'
import UserFinanceSummary from '@/features/overview/users/UserFinanceSummary.vue'
import UserReports from '@/features/overview/users/UserReports.vue'
import UserAccountDrawer from '@/features/overview/users/UserAccountDrawer.vue'
import UserUsageStats from '@/features/overview/users/UserUsageStats.vue'
import { useUserAnalysisRefresh } from '@/features/overview/users/useUserAnalysisRefresh'
import { useOverviewQuery } from '@/features/overview/query'
import { presetRange, rangeFromQuery, useOverviewQuery } from '@/features/overview/query'
import { useOverviewRequest } from '@/features/overview/useOverviewRequest'
import { useOverviewExport } from '@/features/overview/useOverviewExport'
import { useOverviewI18n } from '@/features/overview/i18n'
import { count, money, timestamp } from '@/features/overview/format'
const UserUsageStats = defineAsyncComponent(() => import('@/features/overview/users/UserUsageStats.vue'))
const activeTab = ref('accounts')
const { t } = useOverviewI18n()
const { query, range, relativePreset, refreshRange, setRange, patch } = useOverviewQuery('today', { rolling: true })
const { revision, autoRefresh, refreshTitle, refreshAll, toggleAutoRefresh } = useUserAnalysisRefresh(refreshRange)
@@ -249,6 +267,29 @@ const scope = computed(() => JSON.stringify({ ...requestQuery.value, ...(relativ
const { data, loading, error } = useOverviewRequest(() => JSON.stringify([requestQuery.value, revision.value]), signal => overviewApi.users(requestQuery.value, signal), { scopeKey: scope })
const { exporting, exportError, exportCsv } = useOverviewExport()
const columns = computed(() => [{ key: 'billable_amount', label: t('消费', 'Consumption') }, { key: 'request_count', label: t('请求数', 'Requests') }, { key: 'total_tokens', label: 'Tokens' }])
function sameRange(left: DateRangeParams, right: typeof range.value): boolean {
return left.from === right.from && left.to === right.to && (left.timezone || right.timezone) === right.timezone
}
function handleRangePicker(value: DateRangeParams) {
const timezone = value.timezone || range.value.timezone
if (value.preset) {
if (value.preset === relativePreset.value) return
const next = value.preset === 'yesterday'
? rangeFromQuery({ preset: value.preset, timezone }, range.value)
: presetRange(value.preset, timezone)
void setRange(next, value.preset)
return
}
if (value.from && value.to) {
if (sameRange(value, range.value)) return
void setRange({ from: value.from, to: value.to, timezone }, undefined)
return
}
if (value.start_date && value.end_date) {
const next = rangeFromQuery({ start_date: value.start_date, end_date: value.end_date, timezone }, range.value)
if (!sameRange(next, range.value)) void setRange(next)
}
}
function sort({ key, direction }: { key: string; direction: 'asc' | 'desc' }) {
void patch({ sort: key, order: direction, offset: undefined })
}
+1 -17
View File
@@ -46,15 +46,6 @@
/>
</div>
<!-- 分析统计 -->
<div
v-if="isAdminPage"
class="flex flex-wrap items-baseline gap-2 text-xs text-muted-foreground"
>
<h2 class="text-sm font-medium text-foreground">
{{ overviewT('全站时段统计', 'Site-wide period statistics') }}
</h2><span>{{ analyticsRangeLabel }}</span>
</div>
<!-- 管理员:模型 + 提供商 + API格式(3列) -->
<div
v-if="isAdminPage"
@@ -229,7 +220,7 @@ import type { ActivityHeatmap } from '@/types/activity'
import { useToast } from '@/composables/useToast'
import { queryString, rangeFromQuery, presetRange } from '@/features/overview/query'
import { useOverviewI18n } from '@/features/overview/i18n'
import { attributionLabel, timestamp } from '@/features/overview/format'
import { attributionLabel } from '@/features/overview/format'
import { Button } from '@/components/ui'
const route = useRoute()
@@ -314,13 +305,6 @@ const hasScopedRecordFilters = computed(() => isAdminPage.value && (
filterSearch.value.trim() !== '' || [filterUser.value, filterModel.value, filterProvider.value, filterApiFormat.value, filterStatus.value, filterClientFamily.value].some(value => value !== '__all__') ||
!!filterProviderId.value || !!filterApiKeyId.value || !!filterRequestId.value || !!filterAttribution.value || !!filterEndpointKind.value || !!filterRequestType.value || filterIsStream.value !== undefined || filterFormatConversion.value !== undefined || filterSlowThreshold.value !== undefined || hideUnknownRecords.value
))
const analyticsRangeLabel = computed(() => {
const range = timeRange.value
if (range.from && range.to) return `${timestamp(range.from, range.timezone)} - ${timestamp(range.to, range.timezone)} · ${range.timezone || 'UTC'}`
if (range.start_date && range.end_date) return `${range.start_date} - ${range.end_date} · ${range.timezone || 'UTC'}`
const presets: Record<string, string> = { today: overviewT('今天', 'Today'), yesterday: overviewT('昨天', 'Yesterday'), last7days: overviewT('最近 7 天', 'Last 7 days'), last30days: overviewT('最近 30 天', 'Last 30 days'), last90days: overviewT('最近 90 天', 'Last 90 days') }
return `${presets[range.preset || ''] || overviewT('全部时间', 'All time')} · ${range.timezone || 'UTC'}`
})
function clearRecordFilters() {
const fields = ['search', 'user_id', 'model', 'provider', 'api_format', 'status', 'client_family', 'provider_id', 'api_key_id', 'request_id', 'attribution_kind', 'endpoint_kind', 'request_type', 'is_stream', 'has_format_conversion', 'slow_threshold_ms', 'detail_id', 'page']
hideUnknownRecords.value = false