mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix: align dashboard daily metrics
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
//! 管理端 Dashboard 聚合视图。
|
||||
|
||||
use std::{cmp::Reverse, collections::HashMap};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
@@ -15,14 +15,19 @@ use crate::{
|
||||
admin::{
|
||||
auth::session::AdminAuth,
|
||||
monitoring::{
|
||||
account_usage_service::{
|
||||
AdminUsageRecord, AdminUsageSummary, AdminUsageTimeBucketRecord,
|
||||
},
|
||||
account_usage_service::AdminUsageTimeBucketRecord,
|
||||
diagnostics::{
|
||||
fingerprint_diagnostics, AccountCapacityDiagnostics, AccountPoolDiagnostics,
|
||||
},
|
||||
usage_record_routes::{usage_record_items, UsageRecordData},
|
||||
usage_record_model::{
|
||||
metadata_nonnegative_i64, metadata_string, UsageRecord, UsageRecordLevel,
|
||||
},
|
||||
usage_record_routes::{
|
||||
usage_cost_details, usage_record_models, usage_token_details,
|
||||
UsageRecordCostDetailsData, UsageRecordTokenDetailsData,
|
||||
},
|
||||
usage_record_service::AdminUsageRecordFilter,
|
||||
usage_record_store::UsageRecordAccountUsage,
|
||||
},
|
||||
response::{AdminEnvelope, AdminError, AdminResponse},
|
||||
},
|
||||
@@ -40,11 +45,15 @@ use crate::{
|
||||
upstream::accounts::model::{Account, AccountStatus},
|
||||
};
|
||||
|
||||
const DASHBOARD_USAGE_RECORD_LIMIT: u32 = 1000;
|
||||
const DASHBOARD_ACCOUNT_USAGE_LIMIT: u32 = 4;
|
||||
const DASHBOARD_USAGE_RECORD_LIMIT: u32 = 10;
|
||||
const HEALTH_TIMELINE_SLOT_MINUTES: i64 = 15;
|
||||
const DASHBOARD_TIME_BUCKET_SLOTS: i64 = 7 * 24 * 4;
|
||||
const HEALTH_TIMELINE_SLOTS: i64 = 24 * 4;
|
||||
const HEALTH_TIMELINE_STABLE_SUCCESS_THRESHOLD: u64 = 3;
|
||||
const FIVE_HOUR_WINDOW_SECONDS: u64 = 18_000;
|
||||
const WEEK_WINDOW_SECONDS: u64 = 604_800;
|
||||
const MONTH_WINDOW_SECONDS: u64 = 2_592_000;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -69,7 +78,7 @@ struct DashboardSummaryData {
|
||||
health_timeline: DashboardHealthTimelineData,
|
||||
account_usage: Vec<DashboardAccountUsageData>,
|
||||
service_statuses: Vec<DashboardServiceStatusData>,
|
||||
usage_records: Vec<UsageRecordData>,
|
||||
usage_records: Vec<DashboardUsageRecordData>,
|
||||
pool_summary: AccountPoolDiagnostics,
|
||||
capacity_info: AccountCapacityDiagnostics,
|
||||
rotation_strategy: Option<String>,
|
||||
@@ -80,10 +89,7 @@ struct DashboardSummaryData {
|
||||
struct DashboardHealthTimelineData {
|
||||
title: String,
|
||||
description: String,
|
||||
range_display: String,
|
||||
reliability_display: String,
|
||||
oldest_label: String,
|
||||
newest_label: String,
|
||||
points: String,
|
||||
}
|
||||
|
||||
@@ -112,11 +118,8 @@ struct DashboardAccountsCardData {
|
||||
struct DashboardTrafficCardData {
|
||||
today_requests: String,
|
||||
today_requests_value: u64,
|
||||
yesterday_requests: String,
|
||||
yesterday_requests_value: u64,
|
||||
total_requests: String,
|
||||
rpm: String,
|
||||
tpm: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -124,13 +127,9 @@ struct DashboardTrafficCardData {
|
||||
struct DashboardTokenCardData {
|
||||
today_tokens: String,
|
||||
today_tokens_value: u64,
|
||||
yesterday_tokens: String,
|
||||
yesterday_tokens_value: u64,
|
||||
total_tokens: String,
|
||||
today_cost_usd: String,
|
||||
today_cost_usd_value: f64,
|
||||
total_cost_usd: String,
|
||||
total_cost_usd_value: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -138,12 +137,10 @@ struct DashboardTokenCardData {
|
||||
struct DashboardCacheCardData {
|
||||
today_hit_rate: String,
|
||||
today_hit_rate_value: Option<f64>,
|
||||
yesterday_hit_rate: String,
|
||||
yesterday_hit_rate_value: Option<f64>,
|
||||
total_hit_rate: String,
|
||||
total_cached_tokens: String,
|
||||
first_token_latency_ms: String,
|
||||
completion_latency_ms: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -166,7 +163,6 @@ struct DashboardTrendPointData {
|
||||
output_tokens_value: u64,
|
||||
cached_tokens: String,
|
||||
cached_tokens_value: u64,
|
||||
tokens: String,
|
||||
tokens_value: u64,
|
||||
errors: String,
|
||||
errors_value: u64,
|
||||
@@ -191,14 +187,34 @@ struct DashboardTrendSummaryData {
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DashboardAccountUsageData {
|
||||
name: String,
|
||||
id: String,
|
||||
email: String,
|
||||
plan: String,
|
||||
requests: String,
|
||||
plan_type: Option<String>,
|
||||
tokens: String,
|
||||
quota_used_percent: Option<f64>,
|
||||
last_used: String,
|
||||
status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DashboardUsageRecordData {
|
||||
id: String,
|
||||
level: UsageRecordLevel,
|
||||
route: Option<String>,
|
||||
model: Option<String>,
|
||||
status_code: Option<i64>,
|
||||
transport: Option<String>,
|
||||
created_at_display: String,
|
||||
account_email: Option<String>,
|
||||
requested_model: Option<String>,
|
||||
upstream_model: Option<String>,
|
||||
client_ip: Option<String>,
|
||||
user_agent: Option<String>,
|
||||
reasoning_effort: Option<String>,
|
||||
token_details: UsageRecordTokenDetailsData,
|
||||
cost_details: Option<UsageRecordCostDetailsData>,
|
||||
first_token_latency_ms_display: String,
|
||||
latency_ms_display: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -221,8 +237,14 @@ struct UsageWindow {
|
||||
first_token_latency_count: u64,
|
||||
max_first_token_bucket_latency: u64,
|
||||
min_first_token_bucket_latency: u64,
|
||||
latency_sum: u64,
|
||||
latency_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum QuotaWindowPriority {
|
||||
FiveHour,
|
||||
Weekly,
|
||||
Monthly,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl UsageWindow {
|
||||
@@ -250,10 +272,6 @@ impl UsageWindow {
|
||||
fn min_first_token_bucket_latency(self) -> Option<u64> {
|
||||
(self.min_first_token_bucket_latency > 0).then_some(self.min_first_token_bucket_latency)
|
||||
}
|
||||
|
||||
fn avg_latency(self) -> Option<u64> {
|
||||
self.latency_sum.checked_div(self.latency_count)
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/admin/dashboard/summary`
|
||||
@@ -269,53 +287,50 @@ pub(crate) async fn dashboard_summary(
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let capacity = state.services.account_pool.capacity_summary_now().await;
|
||||
let summary = state
|
||||
let now = Utc::now();
|
||||
let today_filter = today_usage_record_filter(now);
|
||||
let account_usage_records = state
|
||||
.services
|
||||
.usage
|
||||
.summary()
|
||||
.usage_records
|
||||
.account_usage(today_filter.clone(), DASHBOARD_ACCOUNT_USAGE_LIMIT)
|
||||
.await
|
||||
.unwrap_or_else(|_| empty_usage_summary());
|
||||
let usage_records = state
|
||||
.services
|
||||
.usage
|
||||
.list(None, 200)
|
||||
.await
|
||||
.map(|page| page.items)
|
||||
.unwrap_or_default();
|
||||
let recent_events = state
|
||||
.services
|
||||
.usage_records
|
||||
.list(
|
||||
None,
|
||||
DASHBOARD_USAGE_RECORD_LIMIT,
|
||||
AdminUsageRecordFilter::default(),
|
||||
)
|
||||
.list(None, DASHBOARD_USAGE_RECORD_LIMIT, today_filter)
|
||||
.await
|
||||
.map(|page| page.items)
|
||||
.unwrap_or_default();
|
||||
|
||||
let now = Utc::now();
|
||||
let time_buckets = dashboard_time_buckets(&state, now).await;
|
||||
let pool_summary = account_pool_summary(&accounts);
|
||||
let trend = dashboard_trend_data(&time_buckets, query.kind.unwrap_or_default());
|
||||
let quota_used_by_account = account_quota_used_percent_by_id(&state, &usage_records).await;
|
||||
let quota_used_by_account =
|
||||
account_quota_used_percent_by_id(&state, &account_usage_records).await;
|
||||
let settings = state.services.settings.current();
|
||||
let recent_usage_records = recent_events.into_iter().take(10).collect::<Vec<_>>();
|
||||
let recent_usage_records = recent_events;
|
||||
let account_emails = state
|
||||
.services
|
||||
.usage_records
|
||||
.account_email_map(&recent_usage_records)
|
||||
.await
|
||||
.map_err(|error| AdminError::usage_record_accounts_failed(error.to_string()))?;
|
||||
let dashboard_usage_records = usage_record_items(recent_usage_records, &account_emails);
|
||||
let dashboard_usage_records =
|
||||
dashboard_usage_record_items(recent_usage_records, &account_emails);
|
||||
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(DashboardSummaryData {
|
||||
cards: dashboard_cards(&accounts, &summary, &time_buckets),
|
||||
cards: dashboard_cards(&accounts, &time_buckets),
|
||||
trend,
|
||||
health_timeline: dashboard_health_timeline_data(&time_buckets),
|
||||
account_usage: account_usage_data(&accounts, &usage_records, "a_used_by_account),
|
||||
account_usage: account_usage_data(
|
||||
&accounts,
|
||||
&account_usage_records,
|
||||
"a_used_by_account,
|
||||
now,
|
||||
),
|
||||
service_statuses: service_status_data(&state),
|
||||
usage_records: dashboard_usage_records,
|
||||
pool_summary,
|
||||
@@ -325,6 +340,14 @@ pub(crate) async fn dashboard_summary(
|
||||
))
|
||||
}
|
||||
|
||||
fn today_usage_record_filter(now: DateTime<Utc>) -> AdminUsageRecordFilter {
|
||||
AdminUsageRecordFilter {
|
||||
start_time: Some(china_day_start(now)),
|
||||
end_time: Some(now),
|
||||
..AdminUsageRecordFilter::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/admin/dashboard/trend?kind=usage|latency|errors`
|
||||
pub(crate) async fn dashboard_trend(
|
||||
State(state): State<AppState>,
|
||||
@@ -344,15 +367,13 @@ pub(crate) async fn dashboard_trend(
|
||||
|
||||
fn dashboard_cards(
|
||||
accounts: &[Account],
|
||||
summary: &AdminUsageSummary,
|
||||
buckets: &[AdminUsageTimeBucketRecord],
|
||||
) -> DashboardCardsData {
|
||||
dashboard_cards_at(accounts, summary, buckets, Utc::now())
|
||||
dashboard_cards_at(accounts, buckets, Utc::now())
|
||||
}
|
||||
|
||||
fn dashboard_cards_at(
|
||||
accounts: &[Account],
|
||||
summary: &AdminUsageSummary,
|
||||
buckets: &[AdminUsageTimeBucketRecord],
|
||||
now: DateTime<Utc>,
|
||||
) -> DashboardCardsData {
|
||||
@@ -361,10 +382,7 @@ fn dashboard_cards_at(
|
||||
let today = usage_window(buckets, today_start, now);
|
||||
let yesterday = usage_window(buckets, yesterday_start, today_start);
|
||||
let today_cost = cost_window(buckets, today_start, now).unwrap_or(0.0);
|
||||
let total_cost = total_cost(buckets);
|
||||
|
||||
let total_input = nonnegative_i64_to_u64(summary.input_tokens);
|
||||
let total_cached = nonnegative_i64_to_u64(summary.cached_tokens);
|
||||
let total_accounts = accounts.len() as u64;
|
||||
let enabled_accounts = accounts
|
||||
.iter()
|
||||
@@ -382,13 +400,6 @@ fn dashboard_cards_at(
|
||||
)
|
||||
})
|
||||
.count() as u64;
|
||||
let total_requests = nonnegative_i64_to_u64(summary.request_count);
|
||||
let total_tokens = nonnegative_i64_to_u64(summary.input_tokens + summary.output_tokens);
|
||||
let total_hit_rate = if total_input > 0 {
|
||||
Some(total_cached as f64 / total_input as f64)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
DashboardCardsData {
|
||||
accounts: DashboardAccountsCardData {
|
||||
@@ -402,40 +413,27 @@ fn dashboard_cards_at(
|
||||
traffic: DashboardTrafficCardData {
|
||||
today_requests: format_compact_number(today.requests),
|
||||
today_requests_value: today.requests,
|
||||
yesterday_requests: format_compact_number(yesterday.requests),
|
||||
yesterday_requests_value: yesterday.requests,
|
||||
total_requests: format_compact_number(total_requests),
|
||||
rpm: format_compact_number(today.requests),
|
||||
tpm: format_tokens(today.tokens()),
|
||||
total_requests: format_compact_number(today.requests),
|
||||
},
|
||||
tokens: DashboardTokenCardData {
|
||||
today_tokens: format_tokens(today.tokens()),
|
||||
today_tokens_value: today.tokens(),
|
||||
yesterday_tokens: format_tokens(yesterday.tokens()),
|
||||
yesterday_tokens_value: yesterday.tokens(),
|
||||
total_tokens: format_tokens(total_tokens),
|
||||
today_cost_usd: format_optional_cost(Some(today_cost)),
|
||||
today_cost_usd_value: today_cost,
|
||||
total_cost_usd: format_optional_cost(Some(total_cost)),
|
||||
total_cost_usd_value: total_cost,
|
||||
total_tokens: format_tokens(today.tokens()),
|
||||
today_cost_usd: format_cost(today_cost),
|
||||
},
|
||||
cache: DashboardCacheCardData {
|
||||
today_hit_rate: format_rate(today.cache_hit_rate()),
|
||||
today_hit_rate_value: today.cache_hit_rate(),
|
||||
yesterday_hit_rate: format_rate(yesterday.cache_hit_rate()),
|
||||
yesterday_hit_rate_value: yesterday.cache_hit_rate(),
|
||||
total_hit_rate: format_rate(total_hit_rate),
|
||||
total_cached_tokens: format_tokens(total_cached),
|
||||
total_hit_rate: format_rate(today.cache_hit_rate()),
|
||||
total_cached_tokens: format_tokens(today.cached_tokens),
|
||||
first_token_latency_ms: format_optional_duration_ms(today.avg_first_token_latency()),
|
||||
completion_latency_ms: format_optional_duration_ms(today.avg_latency()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn format_optional_cost(value: Option<f64>) -> String {
|
||||
value.map_or_else(|| "-".to_string(), format_cost)
|
||||
}
|
||||
|
||||
fn format_optional_duration_ms(value: Option<u64>) -> String {
|
||||
format_duration_ms(value.and_then(|value| i64::try_from(value).ok()))
|
||||
}
|
||||
@@ -468,8 +466,9 @@ fn dashboard_trend_data_at(
|
||||
now: DateTime<Utc>,
|
||||
) -> DashboardTrendData {
|
||||
let current_hour = china_hour_start(now);
|
||||
let start = current_hour - Duration::hours(23);
|
||||
let mut buckets = (0..24)
|
||||
let start = china_day_start(now);
|
||||
let elapsed_hours = current_hour.signed_duration_since(start).num_hours();
|
||||
let mut buckets = (0..=elapsed_hours)
|
||||
.map(|index| {
|
||||
let bucket_start = start + Duration::hours(index);
|
||||
(bucket_start, UsageWindow::default())
|
||||
@@ -512,7 +511,6 @@ fn dashboard_trend_data_at(
|
||||
output_tokens_value: bucket.output_tokens,
|
||||
cached_tokens: format_tokens(bucket.cached_tokens),
|
||||
cached_tokens_value: bucket.cached_tokens,
|
||||
tokens: format_tokens(tokens),
|
||||
tokens_value: tokens,
|
||||
errors: format_compact_number(bucket.errors),
|
||||
errors_value: bucket.errors,
|
||||
@@ -586,14 +584,11 @@ fn dashboard_health_timeline_data_at(
|
||||
DashboardHealthTimelineData {
|
||||
title: "请求健康时间线".to_string(),
|
||||
description: "今日请求可靠性".to_string(),
|
||||
range_display: format!("{} - {}", china_datetime(&start), china_datetime(&now)),
|
||||
reliability_display: if requests > 0 {
|
||||
format!("{reliability:.1}%")
|
||||
} else {
|
||||
"-".to_string()
|
||||
},
|
||||
oldest_label: "00:00".to_string(),
|
||||
newest_label: "24:00".to_string(),
|
||||
points: buckets
|
||||
.into_iter()
|
||||
.map(|(bucket_start, bucket)| {
|
||||
@@ -759,22 +754,6 @@ fn cost_window(
|
||||
has_usage.then_some(total)
|
||||
}
|
||||
|
||||
fn total_cost(records: &[AdminUsageTimeBucketRecord]) -> f64 {
|
||||
let mut total = 0.0;
|
||||
let mut has_usage = false;
|
||||
for record in records {
|
||||
if let Some(cost) = record.cost_usd {
|
||||
total += cost;
|
||||
has_usage = true;
|
||||
}
|
||||
}
|
||||
if has_usage {
|
||||
return total;
|
||||
}
|
||||
|
||||
0.0
|
||||
}
|
||||
|
||||
fn apply_bucket(window: &mut UsageWindow, record: &AdminUsageTimeBucketRecord) {
|
||||
window.requests += nonnegative_i64_to_u64(record.request_count);
|
||||
window.input_tokens += nonnegative_i64_to_u64(record.input_tokens);
|
||||
@@ -792,8 +771,6 @@ fn apply_bucket(window: &mut UsageWindow, record: &AdminUsageTimeBucketRecord) {
|
||||
window.min_first_token_bucket_latency.min(latency)
|
||||
};
|
||||
}
|
||||
window.latency_sum += nonnegative_i64_to_u64(record.latency_sum);
|
||||
window.latency_count += nonnegative_i64_to_u64(record.latency_count);
|
||||
}
|
||||
|
||||
fn first_token_bucket_latency(record: &AdminUsageTimeBucketRecord) -> Option<u64> {
|
||||
@@ -822,68 +799,51 @@ fn account_pool_summary(accounts: &[Account]) -> AccountPoolDiagnostics {
|
||||
|
||||
fn account_usage_data(
|
||||
accounts: &[Account],
|
||||
usage_records: &[AdminUsageRecord],
|
||||
usage_records: &[UsageRecordAccountUsage],
|
||||
quota_used_by_account: &HashMap<String, f64>,
|
||||
now: DateTime<Utc>,
|
||||
) -> Vec<DashboardAccountUsageData> {
|
||||
let account_by_id = accounts
|
||||
.iter()
|
||||
.map(|account| (account.id.as_str(), account))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let mut records = usage_records.iter().collect::<Vec<_>>();
|
||||
records.sort_by_key(|record| Reverse((record.last_used_at, record.account_id.clone())));
|
||||
let mut rows = records
|
||||
usage_records
|
||||
.iter()
|
||||
.map(|usage| {
|
||||
let account = account_by_id.get(usage.account_id.as_str()).copied();
|
||||
let name = account
|
||||
.and_then(|account| account.label.clone())
|
||||
.or_else(|| usage.label.clone())
|
||||
.or_else(|| {
|
||||
usage
|
||||
.email
|
||||
.as_ref()
|
||||
.and_then(|email| email.split('@').next().map(ToString::to_string))
|
||||
})
|
||||
.unwrap_or_else(|| usage.account_id.chars().take(8).collect());
|
||||
let status = account.map_or_else(
|
||||
|| "active".to_string(),
|
||||
|account| account.status.to_string(),
|
||||
);
|
||||
let quota_used_percent = quota_used_by_account
|
||||
.get(&usage.account_id)
|
||||
.copied()
|
||||
.or_else(|| (status == "quota_exhausted").then_some(100.0));
|
||||
let requests = nonnegative_i64_to_u64(usage.request_count);
|
||||
let tokens = nonnegative_i64_to_u64(usage.input_tokens + usage.output_tokens);
|
||||
.or_else(|| {
|
||||
matches!(
|
||||
account.map(|account| account.status),
|
||||
Some(AccountStatus::QuotaExhausted)
|
||||
)
|
||||
.then_some(100.0)
|
||||
});
|
||||
DashboardAccountUsageData {
|
||||
name,
|
||||
email: usage.email.clone().unwrap_or_else(|| "-".to_string()),
|
||||
plan: usage
|
||||
.plan_type
|
||||
.clone()
|
||||
.unwrap_or_else(|| "free".to_string()),
|
||||
requests: format_compact_number(requests),
|
||||
tokens: format_tokens(tokens),
|
||||
id: usage.account_id.clone(),
|
||||
email: account
|
||||
.and_then(|account| account.email.as_ref())
|
||||
.cloned()
|
||||
.expect("dashboard account usage requires email"),
|
||||
plan_type: account
|
||||
.and_then(|account| account.plan_type.as_ref())
|
||||
.cloned(),
|
||||
tokens: format_tokens(usage.total_tokens),
|
||||
quota_used_percent,
|
||||
last_used: china_relative_time(usage.last_used_at, Utc::now()),
|
||||
status,
|
||||
last_used: china_relative_time(Some(usage.last_used_at), now),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
rows.truncate(4);
|
||||
rows
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn account_quota_used_percent_by_id(
|
||||
state: &AppState,
|
||||
usage_records: &[AdminUsageRecord],
|
||||
usage_records: &[UsageRecordAccountUsage],
|
||||
) -> HashMap<String, f64> {
|
||||
let mut records = usage_records.iter().collect::<Vec<_>>();
|
||||
records.sort_by_key(|record| Reverse((record.last_used_at, record.account_id.clone())));
|
||||
records.truncate(4);
|
||||
|
||||
let mut quota_used_by_account = HashMap::with_capacity(records.len());
|
||||
for usage in records {
|
||||
let mut quota_used_by_account = HashMap::with_capacity(usage_records.len());
|
||||
for usage in usage_records {
|
||||
let Ok(Some(quota_json)) = state
|
||||
.services
|
||||
.accounts
|
||||
@@ -901,27 +861,67 @@ async fn account_quota_used_percent_by_id(
|
||||
|
||||
fn quota_used_percent(quota_json: &str) -> Option<f64> {
|
||||
let quota = serde_json::from_str::<Value>(quota_json).ok()?;
|
||||
let mut values = Vec::new();
|
||||
if let Some(used_percent) = quota
|
||||
.pointer("/monthly_limit/used_percent")
|
||||
.and_then(percent_value)
|
||||
{
|
||||
values.push(used_percent);
|
||||
let mut selected = None;
|
||||
if let Some(monthly_limit) = quota.get("monthly_limit") {
|
||||
select_quota_used_percent(
|
||||
&mut selected,
|
||||
quota_window_priority(monthly_limit, QuotaWindowPriority::Monthly),
|
||||
monthly_limit.get("used_percent").and_then(percent_value),
|
||||
);
|
||||
}
|
||||
if let Some(snapshots) = quota.get("snapshots").and_then(Value::as_array) {
|
||||
for snapshot in snapshots {
|
||||
for role in ["primary", "secondary"] {
|
||||
if let Some(used_percent) = snapshot
|
||||
.get(role)
|
||||
.and_then(|window| window.get("used_percent"))
|
||||
.and_then(percent_value)
|
||||
{
|
||||
values.push(used_percent);
|
||||
if let Some(window) = snapshot.get(role) {
|
||||
select_quota_used_percent(
|
||||
&mut selected,
|
||||
quota_window_priority(window, QuotaWindowPriority::Other),
|
||||
window.get("used_percent").and_then(percent_value),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
values.into_iter().max_by(f64::total_cmp)
|
||||
selected.map(|(_, used_percent)| used_percent)
|
||||
}
|
||||
|
||||
fn select_quota_used_percent(
|
||||
selected: &mut Option<(QuotaWindowPriority, f64)>,
|
||||
priority: QuotaWindowPriority,
|
||||
used_percent: Option<f64>,
|
||||
) {
|
||||
let Some(used_percent) = used_percent else {
|
||||
return;
|
||||
};
|
||||
match selected {
|
||||
Some((selected_priority, selected_percent))
|
||||
if priority > *selected_priority
|
||||
|| (priority == *selected_priority && used_percent <= *selected_percent) => {}
|
||||
_ => *selected = Some((priority, used_percent)),
|
||||
}
|
||||
}
|
||||
|
||||
fn quota_window_priority(window: &Value, fallback: QuotaWindowPriority) -> QuotaWindowPriority {
|
||||
let window_seconds = window
|
||||
.get("window_minutes")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|minutes| minutes.checked_mul(60));
|
||||
match window_seconds {
|
||||
Some(seconds) if quota_window_matches(seconds, FIVE_HOUR_WINDOW_SECONDS) => {
|
||||
QuotaWindowPriority::FiveHour
|
||||
}
|
||||
Some(seconds) if quota_window_matches(seconds, WEEK_WINDOW_SECONDS) => {
|
||||
QuotaWindowPriority::Weekly
|
||||
}
|
||||
Some(seconds) if quota_window_matches(seconds, MONTH_WINDOW_SECONDS) => {
|
||||
QuotaWindowPriority::Monthly
|
||||
}
|
||||
_ => fallback,
|
||||
}
|
||||
}
|
||||
|
||||
fn quota_window_matches(actual: u64, expected: u64) -> bool {
|
||||
actual > 0 && actual.abs_diff(expected) <= expected / 20
|
||||
}
|
||||
|
||||
fn percent_value(value: &Value) -> Option<f64> {
|
||||
@@ -931,6 +931,64 @@ fn percent_value(value: &Value) -> Option<f64> {
|
||||
percent.is_finite().then_some(percent.clamp(0.0, 100.0))
|
||||
}
|
||||
|
||||
fn dashboard_usage_record_items(
|
||||
items: Vec<UsageRecord>,
|
||||
account_emails: &HashMap<String, String>,
|
||||
) -> Vec<DashboardUsageRecordData> {
|
||||
items
|
||||
.into_iter()
|
||||
.map(|record| dashboard_usage_record_data(record, account_emails))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn dashboard_usage_record_data(
|
||||
record: UsageRecord,
|
||||
account_emails: &HashMap<String, String>,
|
||||
) -> DashboardUsageRecordData {
|
||||
let account_email = record
|
||||
.account_id
|
||||
.as_deref()
|
||||
.and_then(|account_id| account_emails.get(account_id))
|
||||
.cloned()
|
||||
.or_else(|| metadata_string(&record.metadata, &["accountEmail", "account_email"]));
|
||||
let (requested_model, upstream_model) = usage_record_models(&record);
|
||||
let client_ip = metadata_string(&record.metadata, &["clientIp", "ipAddress", "ip_address"]);
|
||||
let user_agent = metadata_string(&record.metadata, &["userAgent", "user_agent"]);
|
||||
let reasoning_effort =
|
||||
metadata_string(&record.metadata, &["reasoningEffort", "reasoning_effort"]);
|
||||
let token_details = usage_token_details(&record.metadata);
|
||||
let cost_details = usage_cost_details(&record, upstream_model.as_deref(), &token_details);
|
||||
let first_token_latency_ms = metadata_nonnegative_i64(
|
||||
&record.metadata,
|
||||
&[
|
||||
"firstTokenMs",
|
||||
"first_token_ms",
|
||||
"firstTokenLatencyMs",
|
||||
"first_token_latency_ms",
|
||||
],
|
||||
);
|
||||
|
||||
DashboardUsageRecordData {
|
||||
id: record.id,
|
||||
level: record.level,
|
||||
route: record.route,
|
||||
model: record.model,
|
||||
status_code: record.status_code,
|
||||
transport: record.transport,
|
||||
created_at_display: china_datetime(&record.created_at),
|
||||
account_email,
|
||||
requested_model,
|
||||
upstream_model,
|
||||
client_ip,
|
||||
user_agent,
|
||||
reasoning_effort,
|
||||
token_details,
|
||||
cost_details,
|
||||
first_token_latency_ms_display: format_duration_ms(first_token_latency_ms),
|
||||
latency_ms_display: format_duration_ms(record.latency_ms),
|
||||
}
|
||||
}
|
||||
|
||||
fn service_status_data(state: &AppState) -> Vec<DashboardServiceStatusData> {
|
||||
let fingerprint = fingerprint_diagnostics(&state.services.fingerprint);
|
||||
vec![
|
||||
@@ -993,20 +1051,3 @@ fn format_fingerprint_updated_at(value: Option<String>) -> String {
|
||||
}
|
||||
china_datetime_rfc3339_str(value)
|
||||
}
|
||||
|
||||
fn empty_usage_summary() -> AdminUsageSummary {
|
||||
AdminUsageSummary {
|
||||
account_count: 0,
|
||||
request_count: 0,
|
||||
empty_response_count: 0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cached_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
total_tokens: 0,
|
||||
image_input_tokens: 0,
|
||||
image_output_tokens: 0,
|
||||
image_request_count: 0,
|
||||
image_request_failed_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,7 +545,7 @@ fn usage_record_data(
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_record_models(record: &UsageRecord) -> (Option<String>, Option<String>) {
|
||||
pub(crate) fn usage_record_models(record: &UsageRecord) -> (Option<String>, Option<String>) {
|
||||
let stored_model = record
|
||||
.model
|
||||
.as_deref()
|
||||
@@ -560,7 +560,7 @@ fn usage_record_models(record: &UsageRecord) -> (Option<String>, Option<String>)
|
||||
(requested_model, upstream_model)
|
||||
}
|
||||
|
||||
fn usage_token_details(metadata: &Value) -> UsageRecordTokenDetailsData {
|
||||
pub(crate) fn usage_token_details(metadata: &Value) -> UsageRecordTokenDetailsData {
|
||||
let input_tokens = metadata_usage_u64(metadata, "inputTokens");
|
||||
let output_tokens = metadata_usage_u64(metadata, "outputTokens");
|
||||
let cached_tokens = metadata_usage_u64(metadata, "cachedTokens");
|
||||
@@ -582,7 +582,7 @@ fn usage_token_details(metadata: &Value) -> UsageRecordTokenDetailsData {
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_cost_details(
|
||||
pub(crate) fn usage_cost_details(
|
||||
record: &UsageRecord,
|
||||
upstream_model: Option<&str>,
|
||||
tokens: &UsageRecordTokenDetailsData,
|
||||
|
||||
@@ -11,8 +11,9 @@ use crate::{
|
||||
usage_record_model::metadata_service_tier,
|
||||
usage_record_model::{UsageRecord, UsageRecordLevel},
|
||||
usage_record_store::{
|
||||
SqliteUsageRecordStore, UsageRecordBreakdown, UsageRecordEndpointSource,
|
||||
UsageRecordFilter, UsageRecordModelSource, UsageRecordSummary, UsageRecordTrendPoint,
|
||||
SqliteUsageRecordStore, UsageRecordAccountUsage, UsageRecordBreakdown,
|
||||
UsageRecordEndpointSource, UsageRecordFilter, UsageRecordModelSource,
|
||||
UsageRecordSummary, UsageRecordTrendPoint,
|
||||
},
|
||||
},
|
||||
infra::json::{NumberedPage, Page},
|
||||
@@ -212,6 +213,18 @@ impl AdminUsageRecordService {
|
||||
.map_err(|_| AdminUsageRecordError::List)
|
||||
}
|
||||
|
||||
/// 按账号聚合使用记录。
|
||||
pub async fn account_usage(
|
||||
&self,
|
||||
filter: AdminUsageRecordFilter,
|
||||
limit: u32,
|
||||
) -> Result<Vec<UsageRecordAccountUsage>, AdminUsageRecordError> {
|
||||
self.store
|
||||
.account_usage(filter.into(), limit)
|
||||
.await
|
||||
.map_err(|_| AdminUsageRecordError::List)
|
||||
}
|
||||
|
||||
/// 按模型来源聚合使用记录分布。
|
||||
pub async fn model_distribution(
|
||||
&self,
|
||||
|
||||
@@ -198,6 +198,15 @@ impl SqliteUsageRecordStore {
|
||||
usage_summary(&self.pool, &filter).await
|
||||
}
|
||||
|
||||
/// 按账号聚合使用记录。
|
||||
pub async fn account_usage(
|
||||
&self,
|
||||
filter: UsageRecordFilter,
|
||||
limit: u32,
|
||||
) -> SqliteUsageRecordStoreResult<Vec<UsageRecordAccountUsage>> {
|
||||
usage_account_usage(&self.pool, &filter, limit).await
|
||||
}
|
||||
|
||||
/// 按模型来源聚合使用记录分布。
|
||||
pub async fn model_distribution(
|
||||
&self,
|
||||
@@ -477,6 +486,45 @@ async fn usage_summary(
|
||||
})
|
||||
}
|
||||
|
||||
async fn usage_account_usage(
|
||||
pool: &SqlitePool,
|
||||
filter: &UsageRecordFilter,
|
||||
limit: u32,
|
||||
) -> SqliteUsageRecordStoreResult<Vec<UsageRecordAccountUsage>> {
|
||||
let mut builder = QueryBuilder::<Sqlite>::new(
|
||||
"select
|
||||
account_id,
|
||||
sum(coalesce(cast(json_extract(metadata_json, '$.usage.inputTokens') as integer), cast(json_extract(metadata_json, '$.inputTokens') as integer), 0)) as input_tokens,
|
||||
sum(coalesce(cast(json_extract(metadata_json, '$.usage.outputTokens') as integer), cast(json_extract(metadata_json, '$.outputTokens') as integer), 0)) as output_tokens,
|
||||
max(created_at) as last_used_at
|
||||
from usage_records",
|
||||
);
|
||||
push_filter(&mut builder, filter, None)?;
|
||||
builder.push(" and account_id is not null and trim(account_id) <> ''");
|
||||
builder.push(" group by account_id order by last_used_at desc, account_id asc limit ");
|
||||
builder.push_bind(i64::from(limit.clamp(1, 50)));
|
||||
|
||||
builder
|
||||
.build()
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let input_tokens =
|
||||
optional_nonnegative_i64_to_u64(row.get::<Option<i64>, _>("input_tokens"));
|
||||
let output_tokens =
|
||||
optional_nonnegative_i64_to_u64(row.get::<Option<i64>, _>("output_tokens"));
|
||||
Ok(UsageRecordAccountUsage {
|
||||
account_id: row.get("account_id"),
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
total_tokens: input_tokens + output_tokens,
|
||||
last_used_at: parse_rfc3339(&row.get::<String, _>("last_used_at"))?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn usage_record_account_email_map(
|
||||
pool: &SqlitePool,
|
||||
items: &[UsageRecord],
|
||||
@@ -978,6 +1026,21 @@ pub struct UsageRecordSummary {
|
||||
pub average_latency_ms: Option<f64>,
|
||||
}
|
||||
|
||||
/// 账号使用记录聚合。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageRecordAccountUsage {
|
||||
/// 账号 ID。
|
||||
pub account_id: String,
|
||||
/// 输入 Token。
|
||||
pub input_tokens: u64,
|
||||
/// 输出 Token。
|
||||
pub output_tokens: u64,
|
||||
/// 总 Token。
|
||||
pub total_tokens: u64,
|
||||
/// 最近使用时间。
|
||||
pub last_used_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 模型分布来源。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UsageRecordModelSource {
|
||||
|
||||
@@ -43,11 +43,17 @@ pub enum ModelServiceError {
|
||||
#[error("no active accounts available for model refresh")]
|
||||
NoAccounts,
|
||||
/// 快照写入失败。
|
||||
#[error("failed to store model snapshot")]
|
||||
StoreSnapshot,
|
||||
#[error("failed to store model snapshot: {source}")]
|
||||
StoreSnapshot {
|
||||
#[source]
|
||||
source: ModelSnapshotStoreError,
|
||||
},
|
||||
/// 刷新后重新读取快照失败。
|
||||
#[error("failed to load model snapshots")]
|
||||
LoadSnapshots,
|
||||
#[error("failed to load model snapshots: {source}")]
|
||||
LoadSnapshots {
|
||||
#[source]
|
||||
source: ModelSnapshotStoreError,
|
||||
},
|
||||
/// 所有计划都刷新失败。
|
||||
#[error("all model refresh plans failed")]
|
||||
AllPlansFailed(ModelRefreshResult),
|
||||
@@ -251,10 +257,10 @@ impl ModelService {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_store_snapshot_error(_: ModelSnapshotStoreError) -> ModelServiceError {
|
||||
ModelServiceError::StoreSnapshot
|
||||
fn map_store_snapshot_error(source: ModelSnapshotStoreError) -> ModelServiceError {
|
||||
ModelServiceError::StoreSnapshot { source }
|
||||
}
|
||||
|
||||
fn map_load_snapshots_error(_: ModelSnapshotStoreError) -> ModelServiceError {
|
||||
ModelServiceError::LoadSnapshots
|
||||
fn map_load_snapshots_error(source: ModelSnapshotStoreError) -> ModelServiceError {
|
||||
ModelServiceError::LoadSnapshots { source }
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use thiserror::Error;
|
||||
use super::{info::CodexModelInfo, snapshot::ModelPlanSnapshot};
|
||||
|
||||
/// 模型快照存储错误。
|
||||
#[derive(Debug, Error)]
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum ModelSnapshotStoreError {
|
||||
/// 底层存储失败。
|
||||
#[error("model snapshot store operation failed: {message}")]
|
||||
|
||||
@@ -14,7 +14,7 @@ use codex_proxy_rs::{
|
||||
},
|
||||
infra::{
|
||||
database::connect_sqlite,
|
||||
time::{china_day_start, china_hour, china_hour_start},
|
||||
time::{china_datetime, china_day_start, china_hour, china_hour_start},
|
||||
},
|
||||
proxy::dispatch::session_affinity::SqliteSessionAffinityStore,
|
||||
runtime::{
|
||||
@@ -96,22 +96,22 @@ async fn dashboard_summary_should_calculate_today_traffic_and_latency_from_time_
|
||||
|
||||
let body = dashboard_summary(app).await;
|
||||
|
||||
assert_eq!(body["data"]["cards"]["traffic"]["rpm"], "2");
|
||||
assert_eq!(body["data"]["cards"]["traffic"]["tpm"], "30");
|
||||
assert_eq!(body["data"]["cards"]["traffic"]["todayRequests"], "2");
|
||||
assert_eq!(body["data"]["cards"]["traffic"]["todayRequestsValue"], 2);
|
||||
assert_eq!(body["data"]["cards"]["traffic"]["yesterdayRequests"], "1");
|
||||
assert_eq!(body["data"]["cards"]["traffic"]["totalRequests"], "2");
|
||||
assert_eq!(
|
||||
body["data"]["cards"]["traffic"]["yesterdayRequestsValue"],
|
||||
1
|
||||
);
|
||||
assert_eq!(body["data"]["cards"]["tokens"]["todayTokens"], "30");
|
||||
assert_eq!(body["data"]["cards"]["tokens"]["todayTokensValue"], 30);
|
||||
assert_eq!(body["data"]["cards"]["tokens"]["yesterdayTokens"], "30");
|
||||
assert_eq!(body["data"]["cards"]["tokens"]["totalTokens"], "30");
|
||||
assert_eq!(body["data"]["cards"]["tokens"]["yesterdayTokensValue"], 30);
|
||||
assert_eq!(body["data"]["cards"]["cache"]["totalCachedTokens"], "0");
|
||||
assert_eq!(
|
||||
body["data"]["cards"]["cache"]["firstTokenLatencyMs"],
|
||||
"300 ms"
|
||||
);
|
||||
assert_eq!(
|
||||
body["data"]["cards"]["cache"]["completionLatencyMs"],
|
||||
"1.50 s"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -167,6 +167,38 @@ async fn dashboard_trend_should_bucket_usage_by_china_hour() {
|
||||
assert_eq!(point["tokensValue"], 10);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_trend_should_only_include_china_today() {
|
||||
let (app, store, _pool, _dir) = dashboard_test_app(
|
||||
"dashboard-trend-china-today.sqlite",
|
||||
crate::support::fingerprint::test_fingerprint(),
|
||||
)
|
||||
.await;
|
||||
let now = Utc::now();
|
||||
let today_start = china_day_start(now);
|
||||
store
|
||||
.append(&usage_record_with_tokens(
|
||||
today_start - Duration::hours(1),
|
||||
99,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.append(&usage_record_with_tokens(now, 7))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = dashboard_trend(app, "usage").await;
|
||||
let points = body["data"]["points"].as_array().unwrap();
|
||||
let token_total = points
|
||||
.iter()
|
||||
.map(|point| point["tokensValue"].as_u64().unwrap())
|
||||
.sum::<u64>();
|
||||
|
||||
assert_eq!(points.first().unwrap()["time"], "00");
|
||||
assert_eq!(token_total, 7);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_latency_trend_should_use_first_token_latency() {
|
||||
let (app, store, _pool, _dir) = dashboard_test_app(
|
||||
@@ -241,7 +273,7 @@ async fn dashboard_summary_should_return_requested_trend_kind() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_summary_should_calculate_priority_cost_from_event_service_tier() {
|
||||
let cost = dashboard_summary_total_cost_for_usage_record(
|
||||
let cost = dashboard_summary_today_cost_for_usage_record(
|
||||
"dashboard-cost-priority.sqlite",
|
||||
"gpt-5.5",
|
||||
json!({
|
||||
@@ -255,12 +287,12 @@ async fn dashboard_summary_should_calculate_priority_cost_from_event_service_tie
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_f64_eq(cost, 8.75);
|
||||
assert_eq!(cost, "$8.75");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_summary_should_charge_cached_input_at_cache_read_price() {
|
||||
let cost = dashboard_summary_total_cost_for_usage_record(
|
||||
let cost = dashboard_summary_today_cost_for_usage_record(
|
||||
"dashboard-cost-cached.sqlite",
|
||||
"gpt-5.5",
|
||||
json!({
|
||||
@@ -273,12 +305,12 @@ async fn dashboard_summary_should_charge_cached_input_at_cache_read_price() {
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_f64_eq(cost, 0.03275);
|
||||
assert_eq!(cost, "$0.03");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_summary_should_apply_long_context_prices() {
|
||||
let cost = dashboard_summary_total_cost_for_usage_record(
|
||||
let cost = dashboard_summary_today_cost_for_usage_record(
|
||||
"dashboard-cost-long-context.sqlite",
|
||||
"gpt-5.5",
|
||||
json!({
|
||||
@@ -291,12 +323,12 @@ async fn dashboard_summary_should_apply_long_context_prices() {
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_f64_eq(cost, 3.045);
|
||||
assert_eq!(cost, "$3.04");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_summary_should_normalize_codex_model_names_for_cost() {
|
||||
let cost = dashboard_summary_total_cost_for_usage_record(
|
||||
let cost = dashboard_summary_today_cost_for_usage_record(
|
||||
"dashboard-cost-codex-model.sqlite",
|
||||
"codex-auto-review",
|
||||
json!({
|
||||
@@ -309,7 +341,7 @@ async fn dashboard_summary_should_normalize_codex_model_names_for_cost() {
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_f64_eq(cost, 1.75);
|
||||
assert_eq!(cost, "$1.75");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -323,62 +355,106 @@ async fn dashboard_summary_should_not_price_usage_summary_without_model_dimensio
|
||||
|
||||
let body = dashboard_summary(app).await;
|
||||
|
||||
assert_f64_eq(
|
||||
body["data"]["cards"]["tokens"]["totalCostUsdValue"]
|
||||
.as_f64()
|
||||
.unwrap(),
|
||||
0.0,
|
||||
);
|
||||
assert_eq!(body["data"]["cards"]["tokens"]["todayCostUsd"], "$0.00");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_summary_should_return_backend_formatted_time_fields() {
|
||||
let (app, store, pool, _dir) = dashboard_test_app(
|
||||
let (app, store, _pool, _dir) = dashboard_test_app(
|
||||
"dashboard-backend-formatted-time.sqlite",
|
||||
crate::support::fingerprint::test_fingerprint(),
|
||||
)
|
||||
.await;
|
||||
let record_at = Utc::now() - Duration::seconds(1);
|
||||
store
|
||||
.append(&usage_record_with_tokens(
|
||||
"2026-06-18T12:34:56Z".parse().unwrap(),
|
||||
10,
|
||||
))
|
||||
.append(&usage_record_with_tokens(record_at, 10))
|
||||
.await
|
||||
.unwrap();
|
||||
seed_usage_summary_with_last_used(&pool, 1, 1, "2000-01-01T00:00:00Z").await;
|
||||
|
||||
let body = dashboard_summary(app).await;
|
||||
|
||||
assert_eq!(
|
||||
body["data"]["usageRecords"][0]["createdAtDisplay"],
|
||||
"2026-06-18 20:34:56"
|
||||
china_datetime(&record_at)
|
||||
);
|
||||
assert_eq!(body["data"]["accountUsage"][0]["lastUsed"], "2000-01-01");
|
||||
assert!(body["data"]["usageRecords"][0].get("metadata").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_summary_should_only_return_today_account_usage_and_usage_records() {
|
||||
let (app, store, pool, _dir) = dashboard_test_app(
|
||||
"dashboard-summary-today-records.sqlite",
|
||||
crate::support::fingerprint::test_fingerprint(),
|
||||
)
|
||||
.await;
|
||||
seed_dashboard_account(&pool, "acct_today", "today@example.com").await;
|
||||
seed_dashboard_account(&pool, "acct_yesterday", "yesterday@example.com").await;
|
||||
let now = Utc::now();
|
||||
let today_start = china_day_start(now);
|
||||
let today_record_at = if now - today_start > Duration::minutes(1) {
|
||||
today_start + Duration::minutes(1)
|
||||
} else {
|
||||
now - Duration::seconds(1)
|
||||
};
|
||||
let yesterday_record_at = today_start - Duration::minutes(1);
|
||||
store
|
||||
.append(&usage_record_with_account_tokens(
|
||||
yesterday_record_at,
|
||||
"acct_yesterday",
|
||||
90,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.append(&usage_record_with_account_tokens(
|
||||
today_record_at,
|
||||
"acct_today",
|
||||
7,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = dashboard_summary(app).await;
|
||||
|
||||
assert_eq!(body["data"]["usageRecords"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
body["data"]["usageRecords"][0]["accountEmail"],
|
||||
"today@example.com"
|
||||
);
|
||||
assert_eq!(body["data"]["accountUsage"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
body["data"]["accountUsage"][0]["email"],
|
||||
"today@example.com"
|
||||
);
|
||||
assert_eq!(body["data"]["accountUsage"][0]["tokens"], "7");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_summary_should_order_account_usage_by_recent_usage() {
|
||||
let (app, _store, pool, _dir) = dashboard_test_app(
|
||||
let (app, store, pool, _dir) = dashboard_test_app(
|
||||
"dashboard-account-usage-recent-order.sqlite",
|
||||
crate::support::fingerprint::test_fingerprint(),
|
||||
)
|
||||
.await;
|
||||
seed_dashboard_account_usage(
|
||||
&pool,
|
||||
"acct_old_heavy",
|
||||
"old-heavy@example.com",
|
||||
2_600_000,
|
||||
"2026-06-29T09:00:00Z",
|
||||
)
|
||||
.await;
|
||||
seed_dashboard_account_usage(
|
||||
&pool,
|
||||
"acct_recent_light",
|
||||
"recent-light@example.com",
|
||||
660_000,
|
||||
"2026-06-30T08:00:00Z",
|
||||
)
|
||||
.await;
|
||||
seed_dashboard_account(&pool, "acct_old_heavy", "old-heavy@example.com").await;
|
||||
seed_dashboard_account(&pool, "acct_recent_light", "recent-light@example.com").await;
|
||||
let now = Utc::now();
|
||||
store
|
||||
.append(&usage_record_with_account_tokens(
|
||||
now - Duration::minutes(2),
|
||||
"acct_old_heavy",
|
||||
2_600_000,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.append(&usage_record_with_account_tokens(
|
||||
now - Duration::minutes(1),
|
||||
"acct_recent_light",
|
||||
660_000,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = dashboard_summary(app).await;
|
||||
|
||||
@@ -386,13 +462,117 @@ async fn dashboard_summary_should_order_account_usage_by_recent_usage() {
|
||||
body["data"]["accountUsage"][0]["email"],
|
||||
"recent-light@example.com"
|
||||
);
|
||||
assert_eq!(body["data"]["accountUsage"][0]["requests"], "660K");
|
||||
assert_eq!(body["data"]["accountUsage"][0]["id"], "acct_recent_light");
|
||||
assert_eq!(body["data"]["accountUsage"][0]["planType"], "K12");
|
||||
assert_eq!(body["data"]["accountUsage"][0]["tokens"], "660K");
|
||||
assert_eq!(
|
||||
body["data"]["accountUsage"][1]["email"],
|
||||
"old-heavy@example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_summary_should_use_five_hour_quota_window_for_account_usage() {
|
||||
let (app, store, pool, _dir) = dashboard_test_app(
|
||||
"dashboard-account-usage-five-hour-quota.sqlite",
|
||||
crate::support::fingerprint::test_fingerprint(),
|
||||
)
|
||||
.await;
|
||||
seed_dashboard_account(&pool, "acct_quota_five_hour", "quota-five-hour@example.com").await;
|
||||
store
|
||||
.append(&usage_record_with_account_tokens(
|
||||
Utc::now() - Duration::seconds(1),
|
||||
"acct_quota_five_hour",
|
||||
1,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
set_account_quota_json(
|
||||
&pool,
|
||||
"acct_quota_five_hour",
|
||||
json!({
|
||||
"monthly_limit": {
|
||||
"used_percent": 92.0,
|
||||
"window_minutes": 43_200
|
||||
},
|
||||
"snapshots": [
|
||||
{
|
||||
"source": "core",
|
||||
"primary": {
|
||||
"used_percent": 31.0,
|
||||
"window_minutes": 300
|
||||
},
|
||||
"secondary": {
|
||||
"used_percent": 88.0,
|
||||
"window_minutes": 10_080
|
||||
}
|
||||
}
|
||||
]
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let body = dashboard_summary(app).await;
|
||||
|
||||
assert_f64_eq(
|
||||
body["data"]["accountUsage"][0]["quotaUsedPercent"]
|
||||
.as_f64()
|
||||
.unwrap(),
|
||||
31.0,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_summary_should_fall_back_to_weekly_quota_window_when_five_hour_missing() {
|
||||
let (app, store, pool, _dir) = dashboard_test_app(
|
||||
"dashboard-account-usage-weekly-quota.sqlite",
|
||||
crate::support::fingerprint::test_fingerprint(),
|
||||
)
|
||||
.await;
|
||||
seed_dashboard_account(&pool, "acct_quota_weekly", "quota-weekly@example.com").await;
|
||||
store
|
||||
.append(&usage_record_with_account_tokens(
|
||||
Utc::now() - Duration::seconds(1),
|
||||
"acct_quota_weekly",
|
||||
1,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
set_account_quota_json(
|
||||
&pool,
|
||||
"acct_quota_weekly",
|
||||
json!({
|
||||
"monthly_limit": {
|
||||
"used_percent": 92.0,
|
||||
"window_minutes": 43_200
|
||||
},
|
||||
"snapshots": [
|
||||
{
|
||||
"source": "experimental",
|
||||
"primary": {
|
||||
"used_percent": 99.0,
|
||||
"window_minutes": 120
|
||||
},
|
||||
"secondary": {
|
||||
"used_percent": 47.0,
|
||||
"window_minutes": 10_080
|
||||
}
|
||||
}
|
||||
]
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let body = dashboard_summary(app).await;
|
||||
|
||||
assert_f64_eq(
|
||||
body["data"]["accountUsage"][0]["quotaUsedPercent"]
|
||||
.as_f64()
|
||||
.unwrap(),
|
||||
47.0,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_summary_should_return_today_health_timeline() {
|
||||
let (app, store, _pool, _dir) = dashboard_test_app(
|
||||
@@ -424,11 +604,11 @@ async fn dashboard_summary_should_return_today_health_timeline() {
|
||||
assert_eq!(points.matches('4').count(), 1);
|
||||
}
|
||||
|
||||
async fn dashboard_summary_total_cost_for_usage_record(
|
||||
async fn dashboard_summary_today_cost_for_usage_record(
|
||||
db_name: &str,
|
||||
model: &str,
|
||||
metadata: Value,
|
||||
) -> f64 {
|
||||
) -> String {
|
||||
let (app, store, _pool, _dir) =
|
||||
dashboard_test_app(db_name, crate::support::fingerprint::test_fingerprint()).await;
|
||||
store
|
||||
@@ -437,9 +617,10 @@ async fn dashboard_summary_total_cost_for_usage_record(
|
||||
.unwrap();
|
||||
|
||||
let body = dashboard_summary(app).await;
|
||||
body["data"]["cards"]["tokens"]["totalCostUsdValue"]
|
||||
.as_f64()
|
||||
body["data"]["cards"]["tokens"]["todayCostUsd"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn dashboard_test_app(
|
||||
@@ -533,9 +714,10 @@ async fn seed_usage_summary_with_last_used(
|
||||
last_used_at: &str,
|
||||
) {
|
||||
sqlx::query(
|
||||
"insert into accounts (id, access_token, status, added_at, updated_at) values (?, ?, ?, ?, ?)",
|
||||
"insert into accounts (id, email, access_token, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind("acct_usage")
|
||||
.bind("acct-usage@example.com")
|
||||
.bind("cipher")
|
||||
.bind("active")
|
||||
.bind("2026-06-18T00:00:00Z")
|
||||
@@ -557,18 +739,14 @@ async fn seed_usage_summary_with_last_used(
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn seed_dashboard_account_usage(
|
||||
pool: &SqlitePool,
|
||||
account_id: &str,
|
||||
email: &str,
|
||||
request_count: i64,
|
||||
last_used_at: &str,
|
||||
) {
|
||||
async fn seed_dashboard_account(pool: &SqlitePool, account_id: &str, email: &str) {
|
||||
sqlx::query(
|
||||
"insert into accounts (id, email, access_token, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?)",
|
||||
"insert into accounts (id, email, chatgpt_account_id, plan_type, access_token, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(email)
|
||||
.bind(format!("upstream_{account_id}"))
|
||||
.bind("K12")
|
||||
.bind("cipher")
|
||||
.bind("active")
|
||||
.bind("2026-06-18T00:00:00Z")
|
||||
@@ -576,18 +754,16 @@ async fn seed_dashboard_account_usage(
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"insert into account_usage (account_id, request_count, input_tokens, output_tokens, total_tokens, last_used_at) values (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(request_count)
|
||||
.bind(0_i64)
|
||||
.bind(0_i64)
|
||||
.bind(0_i64)
|
||||
.bind(last_used_at)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn set_account_quota_json(pool: &SqlitePool, account_id: &str, quota_json: Value) {
|
||||
sqlx::query("update accounts set quota_json = ?, quota_fetched_at = ? where id = ?")
|
||||
.bind(quota_json.to_string())
|
||||
.bind("2026-06-30T08:01:00Z")
|
||||
.bind(account_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn service_status_value<'a>(body: &'a Value, label: &str) -> &'a str {
|
||||
@@ -614,6 +790,16 @@ fn usage_record_with_tokens(created_at: chrono::DateTime<Utc>, total_tokens: u64
|
||||
)
|
||||
}
|
||||
|
||||
fn usage_record_with_account_tokens(
|
||||
created_at: chrono::DateTime<Utc>,
|
||||
account_id: &str,
|
||||
total_tokens: u64,
|
||||
) -> UsageRecord {
|
||||
let mut record = usage_record_with_tokens(created_at, total_tokens);
|
||||
record.account_id = Some(account_id.to_string());
|
||||
record
|
||||
}
|
||||
|
||||
fn usage_record(created_at: chrono::DateTime<Utc>, model: &str, metadata: Value) -> UsageRecord {
|
||||
let mut record = UsageRecord::new("v1.response", UsageRecordLevel::Info, "completed");
|
||||
record.model = Some(model.to_string());
|
||||
|
||||
@@ -4,9 +4,15 @@ import { computed } from 'vue'
|
||||
|
||||
import AccountPlanBadge from './AccountPlanBadge.vue'
|
||||
|
||||
type AccountIdentity = {
|
||||
id?: string | number | null
|
||||
email: string
|
||||
planType?: string | null
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
account: any
|
||||
account: AccountIdentity
|
||||
size?: 'md' | 'lg'
|
||||
showPlan?: boolean
|
||||
}>(),
|
||||
@@ -16,24 +22,11 @@ const props = withDefaults(
|
||||
},
|
||||
)
|
||||
|
||||
const displayTitle = computed(
|
||||
() =>
|
||||
props.account.label?.trim() ||
|
||||
props.account.email ||
|
||||
props.account.accountId ||
|
||||
props.account.id,
|
||||
)
|
||||
const emailText = computed(() => props.account.email.trim())
|
||||
|
||||
const secondaryText = computed(() => {
|
||||
const title = displayTitle.value
|
||||
const secondary = [
|
||||
props.account.email,
|
||||
props.account.accountId,
|
||||
props.account.userId,
|
||||
props.account.id,
|
||||
].find((value) => value && value !== title)
|
||||
return secondary || props.account.id
|
||||
})
|
||||
const displayTitle = computed(() => emailText.value.split('@')[0])
|
||||
|
||||
const secondaryText = emailText
|
||||
|
||||
const initial = computed(() => displayTitle.value.slice(0, 1).toUpperCase())
|
||||
|
||||
|
||||
@@ -156,13 +156,13 @@ bindAccountLoader(loadAccounts)
|
||||
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<BaseButton
|
||||
v-if="selectedIds.size > 0"
|
||||
variant="default"
|
||||
:loading="exportingAccounts"
|
||||
:disabled="accounts.length === 0"
|
||||
@click="handleExportAccounts"
|
||||
>
|
||||
<Download class="size-4" />
|
||||
{{ selectedIds.size > 0 ? '导出选中' : '导出' }}
|
||||
导出选中 ({{ selectedIds.size }})
|
||||
</BaseButton>
|
||||
<BaseButton variant="primary" @click="openCreateAccount">
|
||||
<Plus class="size-4" />
|
||||
|
||||
@@ -1,24 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
apiKey: any
|
||||
editing: boolean
|
||||
editingValue: string
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
startEdit: [apiKey: any]
|
||||
updateEdit: [value: string]
|
||||
submitEdit: [apiKeyId: string]
|
||||
cancelEdit: []
|
||||
}>()
|
||||
|
||||
const editValue = computed({
|
||||
get: () => props.editingValue,
|
||||
set: (value: string) => emit('updateEdit', value),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -26,36 +9,8 @@ const editValue = computed({
|
||||
<span class="text-[13px] font-bold text-(--cp-text-primary)">
|
||||
{{ apiKey.name }}
|
||||
</span>
|
||||
<div v-if="editing" class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="editValue"
|
||||
type="text"
|
||||
class="h-(--cp-input-height-inline) min-w-34 rounded-(--cp-input-radius-small) border-0 bg-(--cp-input-soft-bg) px-2.5 text-[13px] font-[650] text-(--cp-text-primary) shadow-(--cp-shadow-input) outline-none transition placeholder:text-(--cp-text-muted) focus:bg-(--cp-input-soft-bg-focus) focus:shadow-(--cp-shadow-input-focus)"
|
||||
:disabled="saving"
|
||||
@keyup.enter="emit('submitEdit', apiKey.id)"
|
||||
@keyup.escape="emit('cancelEdit')"
|
||||
/>
|
||||
<button
|
||||
class="text-[12px] text-(--cp-accent-primary) hover:underline disabled:cursor-not-allowed disabled:text-(--cp-disabled-text) disabled:no-underline"
|
||||
:disabled="saving"
|
||||
@click="emit('submitEdit', apiKey.id)"
|
||||
>
|
||||
{{ saving ? '保存中' : '保存' }}
|
||||
</button>
|
||||
<button
|
||||
class="text-[12px] text-(--cp-text-tertiary) hover:underline disabled:cursor-not-allowed disabled:text-(--cp-disabled-text) disabled:no-underline"
|
||||
:disabled="saving"
|
||||
@click="emit('cancelEdit')"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-else-if="apiKey.label"
|
||||
class="text-left text-[12px] font-[650] text-(--cp-text-tertiary) hover:text-(--cp-info-text)"
|
||||
@click="emit('startEdit', apiKey)"
|
||||
>
|
||||
<span v-if="apiKey.label" class="text-[12px] font-[650] text-(--cp-text-tertiary)">
|
||||
{{ apiKey.label }}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -16,18 +16,15 @@ export function useApiKeyMutations(selectedIds: Ref<Set<string>>) {
|
||||
const showSingleDeleteModal = ref(false)
|
||||
const showKeyModal = ref(false)
|
||||
const createdKey = ref('')
|
||||
const editingLabel = ref<{ id: string; value: string } | null>(null)
|
||||
const pendingDeleteKey = ref<any | null>(null)
|
||||
const creatingKeyAction = useAsyncAction()
|
||||
const deletingKeyAction = useAsyncAction()
|
||||
const batchDeletingAction = useAsyncAction()
|
||||
const updatingStatusKeys = useIdSet<string>()
|
||||
const savingLabelKeys = useIdSet<string>()
|
||||
const creatingKey = creatingKeyAction.loading
|
||||
const deletingKey = deletingKeyAction.loading
|
||||
const batchDeleting = batchDeletingAction.loading
|
||||
const updatingStatusKeyIds = updatingStatusKeys.ids
|
||||
const savingLabelKeyIds = savingLabelKeys.ids
|
||||
|
||||
const createForm = ref({
|
||||
name: '',
|
||||
@@ -124,40 +121,6 @@ export function useApiKeyMutations(selectedIds: Ref<Set<string>>) {
|
||||
})
|
||||
}
|
||||
|
||||
async function handleUpdateLabel(keyId: string, label: string) {
|
||||
await savingLabelKeys.run(keyId, async () => {
|
||||
try {
|
||||
await updateApiKey({ id: keyId, label: label || null })
|
||||
editingLabel.value = null
|
||||
await loadApiKeys()
|
||||
toast.success('标签已更新')
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || '标签更新失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function startEditLabel(key: any) {
|
||||
editingLabel.value = { id: key.id, value: key.label || '' }
|
||||
}
|
||||
|
||||
function cancelEditLabel() {
|
||||
editingLabel.value = null
|
||||
}
|
||||
|
||||
function currentEditingLabelValue() {
|
||||
return editingLabel.value?.value ?? ''
|
||||
}
|
||||
|
||||
function submitEditingLabel(keyId: string) {
|
||||
void handleUpdateLabel(keyId, currentEditingLabelValue())
|
||||
}
|
||||
|
||||
function updateEditingLabelValue(value: string) {
|
||||
if (!editingLabel.value) return
|
||||
editingLabel.value.value = value
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
if (!text) {
|
||||
toast.error('复制失败')
|
||||
@@ -188,24 +151,17 @@ export function useApiKeyMutations(selectedIds: Ref<Set<string>>) {
|
||||
showSingleDeleteModal,
|
||||
showKeyModal,
|
||||
createdKey,
|
||||
editingLabel,
|
||||
pendingDeleteKey,
|
||||
creatingKey,
|
||||
deletingKey,
|
||||
batchDeleting,
|
||||
updatingStatusKeyIds,
|
||||
savingLabelKeyIds,
|
||||
createForm,
|
||||
handleCreate,
|
||||
requestDeleteKey,
|
||||
handleDelete,
|
||||
handleBatchDelete,
|
||||
handleToggleStatus,
|
||||
startEditLabel,
|
||||
cancelEditLabel,
|
||||
currentEditingLabelValue,
|
||||
submitEditingLabel,
|
||||
updateEditingLabelValue,
|
||||
copyToClipboard,
|
||||
maskKey,
|
||||
}
|
||||
|
||||
@@ -30,24 +30,17 @@ const {
|
||||
showSingleDeleteModal,
|
||||
showKeyModal,
|
||||
createdKey,
|
||||
editingLabel,
|
||||
pendingDeleteKey,
|
||||
creatingKey,
|
||||
deletingKey,
|
||||
batchDeleting,
|
||||
updatingStatusKeyIds,
|
||||
savingLabelKeyIds,
|
||||
createForm,
|
||||
handleCreate,
|
||||
requestDeleteKey,
|
||||
handleDelete,
|
||||
handleBatchDelete,
|
||||
handleToggleStatus,
|
||||
startEditLabel,
|
||||
cancelEditLabel,
|
||||
currentEditingLabelValue,
|
||||
submitEditingLabel,
|
||||
updateEditingLabelValue,
|
||||
copyToClipboard,
|
||||
maskKey,
|
||||
} = useApiKeyMutations(selectedIds)
|
||||
@@ -158,16 +151,7 @@ function importToCcs(apiKey: any) {
|
||||
</template>
|
||||
|
||||
<template #identity="{ row }">
|
||||
<ApiKeyIdentityCell
|
||||
:api-key="row"
|
||||
:editing="editingLabel?.id === row.id"
|
||||
:editing-value="currentEditingLabelValue()"
|
||||
:saving="savingLabelKeyIds.has(row.id)"
|
||||
@cancel-edit="cancelEditLabel"
|
||||
@start-edit="startEditLabel"
|
||||
@submit-edit="submitEditingLabel"
|
||||
@update-edit="updateEditingLabelValue"
|
||||
/>
|
||||
<ApiKeyIdentityCell :api-key="row" />
|
||||
</template>
|
||||
|
||||
<template #prefix="{ row }">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { CircleCheck, RefreshCw, ShieldAlert, TriangleAlert } from '@lucide/vue'
|
||||
|
||||
import BaseCard from '../../../components/base/BaseCard.vue'
|
||||
import BaseEmpty from '../../../components/base/BaseEmpty.vue'
|
||||
import AccountPlanBadge from '../../accounts/components/AccountPlanBadge.vue'
|
||||
import AccountIdentityCell from '../../accounts/components/AccountIdentityCell.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
accounts: any[]
|
||||
@@ -145,46 +145,52 @@ const quotaToneClasses: Record<string, string> = {
|
||||
容量、并发与分配策略
|
||||
</p>
|
||||
|
||||
<div class="mt-7.75 grid h-30.5 content-between rounded-[14px] bg-(--cp-bg-subtle) p-4">
|
||||
<span class="block h-3.5 text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)"
|
||||
>槽位占用</span
|
||||
>
|
||||
<div>
|
||||
<div class="grid h-8.5 grid-cols-[minmax(0,1fr)_auto] items-start gap-4">
|
||||
<strong
|
||||
class="font-mono text-[32px] leading-[1.05] font-[760] tabular-nums text-(--cp-text-primary)"
|
||||
>{{ usedRatio }}</strong
|
||||
>
|
||||
<span class="mt-3.5 text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)"
|
||||
>{{ usedPercent }}% 已占用</span
|
||||
>
|
||||
</div>
|
||||
<div class="mt-4 h-2.5 w-full overflow-hidden rounded-full bg-(--cp-progress-track)">
|
||||
<i
|
||||
class="block h-2.5 rounded-full bg-(--cp-success)"
|
||||
:style="{ width: `${usedPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid h-22.5 grid-cols-3 gap-4 rounded-[14px] bg-(--cp-bg-subtle) p-4">
|
||||
<div v-for="stat in scheduleStats" :key="stat.label" class="grid content-between">
|
||||
<span class="text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)">{{
|
||||
stat.label
|
||||
}}</span>
|
||||
<strong
|
||||
class="block font-mono text-[21px] leading-[1.1] font-[760] tabular-nums text-(--cp-text-primary)"
|
||||
>{{ stat.value }}</strong
|
||||
<div class="mt-6.75 grid gap-4 xl:h-82.5 xl:grid-rows-[122px_90px_minmax(0,1fr)]">
|
||||
<div class="grid h-30.5 content-between rounded-[14px] bg-(--cp-bg-subtle) p-4 xl:h-auto">
|
||||
<span class="block h-3.5 text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)"
|
||||
>槽位占用</span
|
||||
>
|
||||
<div>
|
||||
<div class="grid h-8.5 grid-cols-[minmax(0,1fr)_auto] items-start gap-4">
|
||||
<strong
|
||||
class="font-mono text-[32px] leading-[1.05] font-[760] tabular-nums text-(--cp-text-primary)"
|
||||
>{{ usedRatio }}</strong
|
||||
>
|
||||
<span class="mt-3.5 text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)"
|
||||
>{{ usedPercent }}% 已占用</span
|
||||
>
|
||||
</div>
|
||||
<div class="mt-4 h-2.5 w-full overflow-hidden rounded-full bg-(--cp-progress-track)">
|
||||
<i
|
||||
class="block h-2.5 rounded-full bg-(--cp-success)"
|
||||
:style="{ width: `${usedPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid h-20.5 content-between rounded-[14px] bg-(--cp-bg-subtle) p-4">
|
||||
<span class="text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)">分配策略</span>
|
||||
<strong class="block text-[17px] leading-[1.15] font-[650] text-(--cp-text-primary)">{{
|
||||
strategyLabel
|
||||
}}</strong>
|
||||
<div
|
||||
class="grid h-22.5 grid-cols-3 gap-4 rounded-[14px] bg-(--cp-bg-subtle) p-4 xl:h-auto"
|
||||
>
|
||||
<div v-for="stat in scheduleStats" :key="stat.label" class="grid content-between">
|
||||
<span class="text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)">{{
|
||||
stat.label
|
||||
}}</span>
|
||||
<strong
|
||||
class="block font-mono text-[21px] leading-[1.1] font-[760] tabular-nums text-(--cp-text-primary)"
|
||||
>{{ stat.value }}</strong
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid h-20.5 content-between rounded-[14px] bg-(--cp-bg-subtle) p-4 xl:h-auto">
|
||||
<span class="text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)"
|
||||
>分配策略</span
|
||||
>
|
||||
<strong class="block text-[17px] leading-[1.15] font-[650] text-(--cp-text-primary)">{{
|
||||
strategyLabel
|
||||
}}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -201,72 +207,52 @@ const quotaToneClasses: Record<string, string> = {
|
||||
v-if="accounts.length === 0"
|
||||
compact
|
||||
title="暂无账号用量"
|
||||
description="账号产生请求后会显示 24h 排序和 Token 负载。"
|
||||
description="账号产生请求后会显示最近使用排序和 Token 负载。"
|
||||
class="min-h-40 place-content-center xl:h-full"
|
||||
/>
|
||||
<template v-else>
|
||||
<article
|
||||
v-for="account in accounts"
|
||||
:key="account.name"
|
||||
class="grid w-full shrink-0 grid-cols-[34px_minmax(0,1fr)] gap-x-3 rounded-[14px] bg-(--cp-bg-subtle) px-3.5 py-3.5 hover:bg-(--cp-bg-muted) xl:h-19.5 xl:grid-cols-[34px_minmax(0,1.28fr)_minmax(132px,0.86fr)] xl:items-center xl:gap-x-3 xl:py-0"
|
||||
:key="account.id"
|
||||
class="grid w-full shrink-0 grid-cols-1 gap-3 rounded-xl bg-(--cp-bg-subtle) px-3.5 py-3.5 transition-colors hover:bg-(--cp-bg-muted) xl:h-19.5 xl:grid-cols-[minmax(0,1fr)_minmax(70px,0.36fr)_minmax(74px,0.38fr)_minmax(82px,0.46fr)] xl:items-center xl:gap-4 xl:py-0"
|
||||
>
|
||||
<AccountIdentityCell :account="account" show-plan class="min-w-0" />
|
||||
|
||||
<span
|
||||
class="inline-flex size-8.5 items-center justify-center rounded-full"
|
||||
:class="rowToneClasses[account.tone]"
|
||||
class="grid min-w-0 grid-cols-[minmax(0,0.82fr)_minmax(0,0.64fr)_minmax(104px,1fr)] items-end gap-3 xl:contents"
|
||||
>
|
||||
{{ account.name[0] }}
|
||||
</span>
|
||||
<span class="flex min-w-0 max-w-full flex-col gap-1.5 xl:gap-4.25">
|
||||
<span class="flex min-w-0 max-w-full items-center gap-2">
|
||||
<strong
|
||||
class="min-w-0 truncate text-sm leading-[1.15] font-[650] text-(--cp-text-primary)"
|
||||
:title="account.name"
|
||||
>{{ account.name }}</strong
|
||||
>
|
||||
<AccountPlanBadge class="shrink-0" :plan-type="account.planType" />
|
||||
</span>
|
||||
<span
|
||||
class="block min-w-0 max-w-full truncate text-xs leading-[1.15] font-semibold text-(--cp-text-secondary)"
|
||||
:title="account.email"
|
||||
>{{ account.email }}</span
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
class="col-span-2 mt-3 grid grid-cols-3 gap-3 pl-11 xl:col-span-1 xl:mt-0 xl:grid-cols-[minmax(0,56px)_minmax(72px,1fr)] xl:gap-6 xl:pl-0"
|
||||
>
|
||||
<span class="grid min-w-0 gap-1.5 xl:gap-1">
|
||||
<span
|
||||
class="text-[11px] leading-[1.15] font-[650] text-(--cp-text-muted) xl:hidden"
|
||||
>最近</span
|
||||
<span class="grid min-w-0 gap-1">
|
||||
<span class="text-[11px] leading-none font-[720] text-(--cp-text-muted)"
|
||||
>Token</span
|
||||
>
|
||||
<strong
|
||||
class="hidden shrink-0 font-mono text-sm leading-[1.15] font-bold tabular-nums text-(--cp-text-primary) xl:block"
|
||||
class="font-mono text-sm leading-[1.15] font-[780] tabular-nums text-(--cp-text-primary)"
|
||||
>{{ account.tokens }}</strong
|
||||
>
|
||||
</span>
|
||||
|
||||
<span class="grid min-w-0 gap-1">
|
||||
<span class="text-[11px] leading-none font-[720] text-(--cp-text-muted)"
|
||||
>最近</span
|
||||
>
|
||||
<span
|
||||
class="min-w-0 truncate text-xs leading-[1.15] font-semibold text-(--cp-text-secondary) xl:whitespace-nowrap"
|
||||
class="min-w-0 truncate text-xs leading-[1.15] font-[680] text-(--cp-text-secondary) xl:whitespace-nowrap"
|
||||
>
|
||||
{{ account.lastUsed }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="grid min-w-0 gap-1.5 xl:hidden">
|
||||
<span
|
||||
class="text-[11px] leading-[1.15] font-[650] text-(--cp-text-muted) xl:hidden"
|
||||
>Token</span
|
||||
>
|
||||
<strong
|
||||
class="w-full font-mono text-sm leading-[1.15] font-bold tabular-nums text-(--cp-text-primary)"
|
||||
>{{ account.tokens }}</strong
|
||||
>
|
||||
</span>
|
||||
|
||||
<span class="grid min-w-0 gap-2">
|
||||
<span
|
||||
class="text-[11px] leading-[1.15] font-[650] text-(--cp-text-muted) xl:hidden"
|
||||
>额度</span
|
||||
>
|
||||
<span
|
||||
class="mt-1 block h-1.5 w-full overflow-hidden rounded-full bg-(--cp-bg-muted)"
|
||||
>
|
||||
<span class="flex items-center justify-between gap-2">
|
||||
<span class="text-[11px] leading-none font-[720] text-(--cp-text-muted)"
|
||||
>额度</span
|
||||
>
|
||||
<span
|
||||
class="font-mono text-[10px] leading-none font-[760] text-(--cp-text-muted)"
|
||||
>{{ account.quotaPercent }}%</span
|
||||
>
|
||||
</span>
|
||||
<span class="block h-1.5 w-full overflow-hidden rounded-full bg-(--cp-bg-muted)">
|
||||
<i
|
||||
class="block h-1.5 rounded-full"
|
||||
:class="quotaToneClasses[account.quotaTone]"
|
||||
|
||||
@@ -255,7 +255,7 @@ function handleTrendChange(value: string) {
|
||||
v-if="!hasSamples"
|
||||
compact
|
||||
title="暂无趋势数据"
|
||||
description="最近 24 小时暂无请求日志。"
|
||||
description="今日暂无请求日志。"
|
||||
class="h-full place-content-center bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ const valueToneClasses: Record<string, string> = {
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.label"
|
||||
class="grid h-12.5 w-full grid-cols-[22px_12px_1fr_1fr_1fr] items-center rounded-[14px] bg-(--cp-bg-subtle) px-3.5"
|
||||
class="grid h-12.5 w-full grid-cols-[22px_12px_minmax(128px,0.82fr)_minmax(0,1.08fr)_minmax(72px,0.92fr)] items-center rounded-[14px] bg-(--cp-bg-subtle) px-3.5"
|
||||
>
|
||||
<span
|
||||
class="inline-flex size-6 items-center justify-center rounded-lg"
|
||||
|
||||
@@ -131,32 +131,23 @@ export function useDashboard(): any {
|
||||
traffic: {
|
||||
todayRequests: '0',
|
||||
todayRequestsValue: 0,
|
||||
yesterdayRequests: '0',
|
||||
yesterdayRequestsValue: 0,
|
||||
totalRequests: '0',
|
||||
rpm: '0',
|
||||
tpm: '0',
|
||||
},
|
||||
tokens: {
|
||||
todayTokens: '0',
|
||||
todayTokensValue: 0,
|
||||
yesterdayTokens: '0',
|
||||
yesterdayTokensValue: 0,
|
||||
totalTokens: '0',
|
||||
todayCostUsd: '—',
|
||||
todayCostUsdValue: 0,
|
||||
totalCostUsd: '—',
|
||||
totalCostUsdValue: 0,
|
||||
},
|
||||
cache: {
|
||||
todayHitRate: '—',
|
||||
todayHitRateValue: null,
|
||||
yesterdayHitRate: '—',
|
||||
yesterdayHitRateValue: null,
|
||||
totalHitRate: '—',
|
||||
totalCachedTokens: '0',
|
||||
firstTokenLatencyMs: '—',
|
||||
completionLatencyMs: '—',
|
||||
},
|
||||
},
|
||||
trend: {
|
||||
@@ -167,10 +158,7 @@ export function useDashboard(): any {
|
||||
healthTimeline: {
|
||||
title: '请求健康时间线',
|
||||
description: '今日请求可靠性',
|
||||
rangeDisplay: '-',
|
||||
reliabilityDisplay: '-',
|
||||
oldestLabel: '最早',
|
||||
newestLabel: '最新',
|
||||
points: '',
|
||||
},
|
||||
accountUsage: [],
|
||||
@@ -234,7 +222,7 @@ export function useDashboard(): any {
|
||||
details: [
|
||||
{ label: '总请求', value: traffic.totalRequests, tone: 'info' },
|
||||
{
|
||||
label: '首 Token',
|
||||
label: '今日首字',
|
||||
value: cache.firstTokenLatencyMs,
|
||||
tone: 'info',
|
||||
},
|
||||
@@ -255,7 +243,7 @@ export function useDashboard(): any {
|
||||
details: [
|
||||
{ label: '总 Token', value: tokens.totalTokens, tone: 'success' },
|
||||
{
|
||||
label: '计费',
|
||||
label: '今日计费',
|
||||
value: tokens.todayCostUsd,
|
||||
tone: 'success',
|
||||
},
|
||||
@@ -280,7 +268,7 @@ export function useDashboard(): any {
|
||||
details: [
|
||||
{ label: '总缓存命中', value: cache.totalHitRate, tone: 'warning' },
|
||||
{
|
||||
label: '缓存 Token',
|
||||
label: '今日缓存',
|
||||
value: cache.totalCachedTokens,
|
||||
tone: 'warning',
|
||||
},
|
||||
@@ -353,16 +341,13 @@ export function useDashboard(): any {
|
||||
}
|
||||
|
||||
function accountUsageItem(item: any) {
|
||||
const tone = accountTone(item.status)
|
||||
const quotaPercent = quotaUsedPercent(item.quotaUsedPercent)
|
||||
return {
|
||||
name: item.name,
|
||||
email: item.email || '-',
|
||||
planType: item.plan || 'free',
|
||||
requests: item.requests,
|
||||
id: item.id,
|
||||
email: item.email,
|
||||
planType: item.planType,
|
||||
tokens: item.tokens,
|
||||
lastUsed: item.lastUsed || '-',
|
||||
tone,
|
||||
lastUsed: item.lastUsed,
|
||||
quotaPercent,
|
||||
quotaTone: quotaTone(quotaPercent),
|
||||
}
|
||||
@@ -435,14 +420,6 @@ export function useDashboard(): any {
|
||||
return '--cp-info'
|
||||
}
|
||||
|
||||
function accountTone(status: string) {
|
||||
if (status === 'active') return 'success'
|
||||
if (status === 'quota_exhausted' || status === 'expired') return 'warning'
|
||||
if (status === 'banned' || status === 'disabled') return 'danger'
|
||||
if (status === 'refreshing') return 'info'
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadDashboardData()
|
||||
startAutoRefresh()
|
||||
|
||||
@@ -39,7 +39,7 @@ const {
|
||||
系统概览
|
||||
</h1>
|
||||
<p class="mt-2.5 text-[15px] leading-[1.15] font-semibold mb-0 text-(--cp-text-secondary)">
|
||||
今日统计 · 自动刷新 30s
|
||||
当日统计 · 自动刷新 30s
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export const usageRecordColumns = [
|
||||
{
|
||||
key: 'route',
|
||||
label: '端点',
|
||||
width: '172px',
|
||||
width: '185px',
|
||||
ellipsis: false,
|
||||
cellClass: 'font-mono text-[12px] font-[650]',
|
||||
},
|
||||
@@ -74,7 +74,7 @@ export const usageRecordColumns = [
|
||||
{
|
||||
key: 'clientIp',
|
||||
label: 'IP',
|
||||
width: '146px',
|
||||
width: '160px',
|
||||
ellipsis: false,
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user