fix: align dashboard daily metrics

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-07-06 13:19:02 +08:00
co-authored by Codex
parent 6e6627b0c9
commit bd95adadf9
18 changed files with 675 additions and 515 deletions
+218 -177
View File
@@ -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, &quota_used_by_account),
account_usage: account_usage_data(
&accounts,
&account_usage_records,
&quota_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 {
+14 -8
View File
@@ -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}")]
+260 -74
View File
@@ -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())
+2 -2
View File
@@ -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,
}
+1 -17
View File
@@ -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()
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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,
},
{