mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-14 00:06:36 +00:00
1014 lines
31 KiB
Rust
1014 lines
31 KiB
Rust
//! 管理端 Dashboard 聚合视图。
|
|
|
|
use std::{cmp::Reverse, collections::HashMap};
|
|
|
|
use axum::{
|
|
extract::{Query, State},
|
|
http::{HeaderMap, StatusCode},
|
|
response::IntoResponse,
|
|
Extension,
|
|
};
|
|
use chrono::{DateTime, Duration, Timelike, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
|
|
use crate::{
|
|
admin::{
|
|
auth::session::require_admin_session,
|
|
monitoring::{
|
|
billing,
|
|
diagnostics::{
|
|
fingerprint_diagnostics, AccountCapacityDiagnostics, AccountPoolDiagnostics,
|
|
},
|
|
event_store::AdminLogFilter,
|
|
events::{EventLevel, EventLog},
|
|
service::{AdminUsageRecord, AdminUsageSummary},
|
|
},
|
|
response::{AdminEnvelope, AdminError, AdminResponse},
|
|
},
|
|
http::middleware::request_id::RequestId,
|
|
infra::time::{china_datetime, china_datetime_rfc3339_str, china_relative_time, china_time},
|
|
runtime::state::AppState,
|
|
upstream::accounts::model::{Account, AccountStatus},
|
|
};
|
|
|
|
const DASHBOARD_LOG_LIMIT: u32 = 1000;
|
|
const HEALTH_TIMELINE_SLOT_MINUTES: i64 = 15;
|
|
const HEALTH_TIMELINE_SLOTS: i64 = 7 * 24 * 4;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct DashboardTrendQuery {
|
|
pub kind: Option<DashboardTrendKind>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum DashboardTrendKind {
|
|
#[default]
|
|
Usage,
|
|
Latency,
|
|
Errors,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct DashboardSummaryData {
|
|
pub cards: DashboardCardsData,
|
|
pub trend: DashboardTrendData,
|
|
pub health_timeline: DashboardHealthTimelineData,
|
|
pub account_usage: Vec<DashboardAccountUsageData>,
|
|
pub service_statuses: Vec<DashboardServiceStatusData>,
|
|
pub event_logs: Vec<DashboardEventLogData>,
|
|
pub pool_summary: AccountPoolDiagnostics,
|
|
pub capacity_info: AccountCapacityDiagnostics,
|
|
pub rotation_strategy: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct DashboardHealthTimelineData {
|
|
pub title: String,
|
|
pub description: String,
|
|
pub range_display: String,
|
|
pub reliability_display: String,
|
|
pub oldest_label: String,
|
|
pub newest_label: String,
|
|
pub points: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct DashboardCardsData {
|
|
pub accounts: DashboardAccountsCardData,
|
|
pub traffic: DashboardTrafficCardData,
|
|
pub tokens: DashboardTokenCardData,
|
|
pub cache: DashboardCacheCardData,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct DashboardAccountsCardData {
|
|
pub total: u64,
|
|
pub enabled: u64,
|
|
pub abnormal: u64,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct DashboardTrafficCardData {
|
|
pub today_requests: u64,
|
|
pub yesterday_requests: u64,
|
|
pub total_requests: u64,
|
|
pub rpm: u64,
|
|
pub tpm: u64,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct DashboardTokenCardData {
|
|
pub today_tokens: u64,
|
|
pub yesterday_tokens: u64,
|
|
pub total_tokens: u64,
|
|
pub today_cost_usd: Option<f64>,
|
|
pub total_cost_usd: Option<f64>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct DashboardCacheCardData {
|
|
pub today_hit_rate: Option<f64>,
|
|
pub yesterday_hit_rate: Option<f64>,
|
|
pub total_hit_rate: Option<f64>,
|
|
pub total_cached_tokens: u64,
|
|
pub first_token_latency_ms: Option<u64>,
|
|
pub completion_latency_ms: Option<u64>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct DashboardTrendData {
|
|
pub kind: DashboardTrendKind,
|
|
pub points: Vec<DashboardTrendPointData>,
|
|
pub summary: Vec<DashboardTrendSummaryData>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct DashboardTrendPointData {
|
|
pub time: String,
|
|
pub requests: u64,
|
|
pub input_tokens: u64,
|
|
pub output_tokens: u64,
|
|
pub cached_tokens: u64,
|
|
pub tokens: u64,
|
|
pub errors: u64,
|
|
pub latency: u64,
|
|
pub max_latency: u64,
|
|
pub min_latency: u64,
|
|
pub success_rate: f64,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct DashboardTrendSummaryData {
|
|
pub label: String,
|
|
pub value: u64,
|
|
pub ratio: Option<f64>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct DashboardAccountUsageData {
|
|
pub name: String,
|
|
pub email: String,
|
|
pub plan: String,
|
|
pub requests: u64,
|
|
pub tokens: u64,
|
|
pub quota_used_percent: Option<f64>,
|
|
pub last_used: String,
|
|
pub status: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct DashboardEventLogData {
|
|
pub id: String,
|
|
pub time: String,
|
|
pub level: EventLevel,
|
|
pub request_id: Option<String>,
|
|
pub route: Option<String>,
|
|
pub model: Option<String>,
|
|
pub status_code: Option<i64>,
|
|
pub latency_ms: Option<i64>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct DashboardServiceStatusData {
|
|
pub label: String,
|
|
pub value: String,
|
|
pub detail: String,
|
|
pub tone: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Default)]
|
|
struct UsageWindow {
|
|
requests: u64,
|
|
input_tokens: u64,
|
|
output_tokens: u64,
|
|
cached_tokens: u64,
|
|
errors: u64,
|
|
first_token_latency_sum: u64,
|
|
first_token_latency_count: u64,
|
|
latency_sum: u64,
|
|
latency_count: u64,
|
|
max_latency: u64,
|
|
min_latency: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
struct BillingContext<'a> {
|
|
default_model: &'a str,
|
|
default_service_tier: Option<&'a str>,
|
|
}
|
|
|
|
impl UsageWindow {
|
|
fn tokens(self) -> u64 {
|
|
self.input_tokens + self.output_tokens
|
|
}
|
|
|
|
fn cache_hit_rate(self) -> Option<f64> {
|
|
if self.input_tokens > 0 {
|
|
Some(self.cached_tokens as f64 / self.input_tokens as f64)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn avg_first_token_latency(self) -> Option<u64> {
|
|
self.first_token_latency_sum
|
|
.checked_div(self.first_token_latency_count)
|
|
}
|
|
|
|
fn avg_latency(self) -> Option<u64> {
|
|
self.latency_sum.checked_div(self.latency_count)
|
|
}
|
|
}
|
|
|
|
/// `GET /api/admin/dashboard/summary`
|
|
pub async fn dashboard_summary(
|
|
State(state): State<AppState>,
|
|
Extension(request_id): Extension<RequestId>,
|
|
headers: HeaderMap,
|
|
) -> Result<impl IntoResponse, AdminError> {
|
|
let request_id = request_id.as_str().to_string();
|
|
require_admin_session(&state, &headers, &request_id).await?;
|
|
|
|
let accounts = state
|
|
.services
|
|
.accounts
|
|
.list_pool_accounts()
|
|
.await
|
|
.unwrap_or_default();
|
|
let capacity = state.services.account_pool.capacity_summary_now().await;
|
|
let summary = state
|
|
.services
|
|
.usage
|
|
.summary()
|
|
.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 logs = state
|
|
.services
|
|
.logs
|
|
.list(None, DASHBOARD_LOG_LIMIT, AdminLogFilter::default())
|
|
.await
|
|
.map(|page| page.items)
|
|
.unwrap_or_default();
|
|
|
|
let pool_summary = account_pool_summary(&accounts);
|
|
let dashboard_logs = logs.iter().take(10).map(dashboard_event_log_data).collect();
|
|
let trend = dashboard_trend_data(&logs, DashboardTrendKind::Usage);
|
|
let quota_used_by_account = account_quota_used_percent_by_id(&state, &usage_records).await;
|
|
let settings = state.services.settings.current();
|
|
|
|
Ok(AdminResponse::new(
|
|
StatusCode::OK,
|
|
AdminEnvelope::ok(
|
|
DashboardSummaryData {
|
|
cards: dashboard_cards(
|
|
&accounts,
|
|
&summary,
|
|
&logs,
|
|
&settings.model.default_model,
|
|
settings.model.service_tier.as_deref(),
|
|
),
|
|
trend,
|
|
health_timeline: dashboard_health_timeline_data(&logs),
|
|
account_usage: account_usage_data(
|
|
&accounts,
|
|
&usage_records,
|
|
"a_used_by_account,
|
|
),
|
|
service_statuses: service_status_data(&state),
|
|
event_logs: dashboard_logs,
|
|
pool_summary,
|
|
capacity_info: AccountCapacityDiagnostics::from(capacity),
|
|
rotation_strategy: Some(settings.auth.rotation_strategy.clone()),
|
|
},
|
|
request_id,
|
|
),
|
|
))
|
|
}
|
|
|
|
/// `GET /api/admin/dashboard/trend?kind=usage|latency|errors`
|
|
pub async fn dashboard_trend(
|
|
State(state): State<AppState>,
|
|
Extension(request_id): Extension<RequestId>,
|
|
headers: HeaderMap,
|
|
Query(query): Query<DashboardTrendQuery>,
|
|
) -> Result<impl IntoResponse, AdminError> {
|
|
let request_id = request_id.as_str().to_string();
|
|
require_admin_session(&state, &headers, &request_id).await?;
|
|
let logs = state
|
|
.services
|
|
.logs
|
|
.list(None, DASHBOARD_LOG_LIMIT, AdminLogFilter::default())
|
|
.await
|
|
.map(|page| page.items)
|
|
.unwrap_or_default();
|
|
|
|
Ok(AdminResponse::new(
|
|
StatusCode::OK,
|
|
AdminEnvelope::ok(
|
|
dashboard_trend_data(&logs, query.kind.unwrap_or_default()),
|
|
request_id,
|
|
),
|
|
))
|
|
}
|
|
|
|
fn dashboard_cards(
|
|
accounts: &[Account],
|
|
summary: &AdminUsageSummary,
|
|
logs: &[EventLog],
|
|
default_model: &str,
|
|
default_service_tier: Option<&str>,
|
|
) -> DashboardCardsData {
|
|
let now = Utc::now();
|
|
let today_start = day_start(now);
|
|
let yesterday_start = today_start - Duration::days(1);
|
|
let billing_context = BillingContext {
|
|
default_model,
|
|
default_service_tier,
|
|
};
|
|
|
|
let today = usage_window(logs, today_start, now);
|
|
let yesterday = usage_window(logs, yesterday_start, today_start);
|
|
let today_cost = cost_window(logs, today_start, now, billing_context).unwrap_or(0.0);
|
|
let total_cost = total_cost(logs, summary, billing_context);
|
|
|
|
let total_input = nonnegative_i64_to_u64(summary.input_tokens);
|
|
let total_cached = nonnegative_i64_to_u64(summary.cached_tokens);
|
|
|
|
DashboardCardsData {
|
|
accounts: DashboardAccountsCardData {
|
|
total: accounts.len() as u64,
|
|
enabled: accounts
|
|
.iter()
|
|
.filter(|account| account.status == AccountStatus::Active)
|
|
.count() as u64,
|
|
abnormal: accounts
|
|
.iter()
|
|
.filter(|account| {
|
|
matches!(
|
|
account.status,
|
|
AccountStatus::Expired
|
|
| AccountStatus::QuotaExhausted
|
|
| AccountStatus::Disabled
|
|
| AccountStatus::Banned
|
|
)
|
|
})
|
|
.count() as u64,
|
|
},
|
|
traffic: DashboardTrafficCardData {
|
|
today_requests: today.requests,
|
|
yesterday_requests: yesterday.requests,
|
|
total_requests: nonnegative_i64_to_u64(summary.request_count),
|
|
rpm: today.requests,
|
|
tpm: today.tokens(),
|
|
},
|
|
tokens: DashboardTokenCardData {
|
|
today_tokens: today.tokens(),
|
|
yesterday_tokens: yesterday.tokens(),
|
|
total_tokens: nonnegative_i64_to_u64(summary.input_tokens + summary.output_tokens),
|
|
today_cost_usd: Some(today_cost),
|
|
total_cost_usd: Some(total_cost),
|
|
},
|
|
cache: DashboardCacheCardData {
|
|
today_hit_rate: today.cache_hit_rate(),
|
|
yesterday_hit_rate: yesterday.cache_hit_rate(),
|
|
total_hit_rate: if total_input > 0 {
|
|
Some(total_cached as f64 / total_input as f64)
|
|
} else {
|
|
None
|
|
},
|
|
total_cached_tokens: total_cached,
|
|
first_token_latency_ms: today.avg_first_token_latency(),
|
|
completion_latency_ms: today.avg_latency(),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn dashboard_trend_data(logs: &[EventLog], kind: DashboardTrendKind) -> DashboardTrendData {
|
|
let now = Utc::now();
|
|
let current_hour = hour_start(now);
|
|
let start = current_hour - Duration::hours(23);
|
|
let mut buckets = (0..24)
|
|
.map(|index| {
|
|
let bucket_start = start + Duration::hours(index);
|
|
(bucket_start, UsageWindow::default())
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
for log in logs {
|
|
if log.created_at < start || log.created_at > now {
|
|
continue;
|
|
}
|
|
let log_hour = hour_start(log.created_at);
|
|
if let Some((_, bucket)) = buckets
|
|
.iter_mut()
|
|
.find(|(bucket_start, _)| *bucket_start == log_hour)
|
|
{
|
|
apply_log(bucket, log);
|
|
}
|
|
}
|
|
|
|
let points = buckets
|
|
.iter()
|
|
.map(|(bucket_start, bucket)| {
|
|
let latency = bucket.avg_latency().unwrap_or(0);
|
|
DashboardTrendPointData {
|
|
time: format!("{:02}", bucket_start.hour()),
|
|
requests: bucket.requests,
|
|
input_tokens: bucket.input_tokens,
|
|
output_tokens: bucket.output_tokens,
|
|
cached_tokens: bucket.cached_tokens,
|
|
tokens: bucket.tokens(),
|
|
errors: bucket.errors,
|
|
latency,
|
|
max_latency: bucket.max_latency,
|
|
min_latency: bucket.min_latency,
|
|
success_rate: if bucket.requests > 0 {
|
|
((bucket.requests - bucket.errors) as f64 / bucket.requests as f64 * 1000.0)
|
|
.round()
|
|
/ 10.0
|
|
} else {
|
|
0.0
|
|
},
|
|
}
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
DashboardTrendData {
|
|
kind,
|
|
summary: trend_summary(kind, &points),
|
|
points,
|
|
}
|
|
}
|
|
|
|
fn dashboard_health_timeline_data(logs: &[EventLog]) -> DashboardHealthTimelineData {
|
|
let now = Utc::now();
|
|
let current_slot = quarter_hour_start(now);
|
|
let start = current_slot
|
|
- Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * (HEALTH_TIMELINE_SLOTS - 1));
|
|
let mut buckets = (0..HEALTH_TIMELINE_SLOTS)
|
|
.map(|index| {
|
|
let bucket_start = start + Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * index);
|
|
(bucket_start, UsageWindow::default())
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
for log in logs {
|
|
if log.created_at < start || log.created_at > now {
|
|
continue;
|
|
}
|
|
let log_slot = quarter_hour_start(log.created_at);
|
|
if let Some((_, bucket)) = buckets
|
|
.iter_mut()
|
|
.find(|(bucket_start, _)| *bucket_start == log_slot)
|
|
{
|
|
apply_log(bucket, log);
|
|
}
|
|
}
|
|
|
|
let requests = buckets
|
|
.iter()
|
|
.map(|(_, bucket)| bucket.requests)
|
|
.sum::<u64>();
|
|
let errors = buckets.iter().map(|(_, bucket)| bucket.errors).sum::<u64>();
|
|
let reliability = if requests > 0 {
|
|
((requests - errors) as f64 / requests as f64 * 1000.0).round() / 10.0
|
|
} else {
|
|
0.0
|
|
};
|
|
let end = current_slot + Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES);
|
|
|
|
DashboardHealthTimelineData {
|
|
title: "请求健康时间线".to_string(),
|
|
description: "最近 7 天请求可靠性".to_string(),
|
|
range_display: format!("{} - {}", china_datetime(&start), china_datetime(&end)),
|
|
reliability_display: if requests > 0 {
|
|
format!("{reliability:.1}%")
|
|
} else {
|
|
"-".to_string()
|
|
},
|
|
oldest_label: "最早".to_string(),
|
|
newest_label: "最新".to_string(),
|
|
points: buckets
|
|
.into_iter()
|
|
.map(|(_, bucket)| {
|
|
let success_rate = if bucket.requests > 0 {
|
|
((bucket.requests - bucket.errors) as f64 / bucket.requests as f64 * 1000.0)
|
|
.round()
|
|
/ 10.0
|
|
} else {
|
|
0.0
|
|
};
|
|
health_tone(bucket, success_rate)
|
|
})
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
fn health_tone(bucket: UsageWindow, success_rate: f64) -> char {
|
|
if bucket.requests == 0 {
|
|
'0'
|
|
} else if bucket.errors == 0 {
|
|
'1'
|
|
} else if success_rate >= 90.0 {
|
|
'2'
|
|
} else {
|
|
'3'
|
|
}
|
|
}
|
|
|
|
fn trend_summary(
|
|
kind: DashboardTrendKind,
|
|
points: &[DashboardTrendPointData],
|
|
) -> Vec<DashboardTrendSummaryData> {
|
|
match kind {
|
|
DashboardTrendKind::Usage => vec![
|
|
DashboardTrendSummaryData {
|
|
label: "输入".to_string(),
|
|
value: points.iter().map(|point| point.input_tokens).sum(),
|
|
ratio: None,
|
|
},
|
|
DashboardTrendSummaryData {
|
|
label: "输出".to_string(),
|
|
value: points.iter().map(|point| point.output_tokens).sum(),
|
|
ratio: None,
|
|
},
|
|
DashboardTrendSummaryData {
|
|
label: "缓存".to_string(),
|
|
value: points.iter().map(|point| point.cached_tokens).sum(),
|
|
ratio: None,
|
|
},
|
|
],
|
|
DashboardTrendKind::Latency => {
|
|
let samples = points
|
|
.iter()
|
|
.filter(|point| point.latency > 0)
|
|
.collect::<Vec<_>>();
|
|
let avg = if samples.is_empty() {
|
|
0
|
|
} else {
|
|
samples.iter().map(|point| point.latency).sum::<u64>() / samples.len() as u64
|
|
};
|
|
vec![
|
|
DashboardTrendSummaryData {
|
|
label: "平均".to_string(),
|
|
value: avg,
|
|
ratio: None,
|
|
},
|
|
DashboardTrendSummaryData {
|
|
label: "最高".to_string(),
|
|
value: samples
|
|
.iter()
|
|
.map(|point| point.max_latency)
|
|
.max()
|
|
.unwrap_or(0),
|
|
ratio: None,
|
|
},
|
|
DashboardTrendSummaryData {
|
|
label: "最低".to_string(),
|
|
value: samples
|
|
.iter()
|
|
.filter_map(|point| (point.min_latency > 0).then_some(point.min_latency))
|
|
.min()
|
|
.unwrap_or(0),
|
|
ratio: None,
|
|
},
|
|
]
|
|
}
|
|
DashboardTrendKind::Errors => {
|
|
let errors = points.iter().map(|point| point.errors).sum::<u64>();
|
|
let requests = points.iter().map(|point| point.requests).sum::<u64>();
|
|
let success_rate = if requests > 0 {
|
|
Some(((requests - errors) as f64 / requests as f64 * 1000.0).round() / 10.0)
|
|
} else {
|
|
None
|
|
};
|
|
vec![
|
|
DashboardTrendSummaryData {
|
|
label: "错误数".to_string(),
|
|
value: errors,
|
|
ratio: None,
|
|
},
|
|
DashboardTrendSummaryData {
|
|
label: "成功率".to_string(),
|
|
value: 0,
|
|
ratio: success_rate,
|
|
},
|
|
DashboardTrendSummaryData {
|
|
label: "总请求".to_string(),
|
|
value: requests,
|
|
ratio: None,
|
|
},
|
|
]
|
|
}
|
|
}
|
|
}
|
|
|
|
fn usage_window(logs: &[EventLog], start: DateTime<Utc>, end: DateTime<Utc>) -> UsageWindow {
|
|
let mut window = UsageWindow::default();
|
|
for log in logs {
|
|
if log.created_at >= start && log.created_at < end {
|
|
apply_log(&mut window, log);
|
|
}
|
|
}
|
|
window
|
|
}
|
|
|
|
fn cost_window(
|
|
logs: &[EventLog],
|
|
start: DateTime<Utc>,
|
|
end: DateTime<Utc>,
|
|
context: BillingContext<'_>,
|
|
) -> Option<f64> {
|
|
let mut total = 0.0;
|
|
let mut has_usage = false;
|
|
for log in logs {
|
|
if log.created_at >= start && log.created_at < end {
|
|
if let Some(cost) = log_cost(log, context) {
|
|
total += cost;
|
|
has_usage = true;
|
|
}
|
|
}
|
|
}
|
|
has_usage.then_some(total)
|
|
}
|
|
|
|
fn total_cost(logs: &[EventLog], summary: &AdminUsageSummary, context: BillingContext<'_>) -> f64 {
|
|
let mut total = 0.0;
|
|
let mut has_usage = false;
|
|
for log in logs {
|
|
if let Some(cost) = log_cost(log, context) {
|
|
total += cost;
|
|
has_usage = true;
|
|
}
|
|
}
|
|
if has_usage {
|
|
return total;
|
|
}
|
|
|
|
let input_tokens = nonnegative_i64_to_u64(summary.input_tokens);
|
|
let output_tokens = nonnegative_i64_to_u64(summary.output_tokens);
|
|
let cached_tokens = nonnegative_i64_to_u64(summary.cached_tokens);
|
|
billing::calculate_cost(
|
|
input_tokens,
|
|
output_tokens,
|
|
cached_tokens,
|
|
context.default_model,
|
|
context.default_service_tier,
|
|
)
|
|
}
|
|
|
|
fn log_cost(log: &EventLog, context: BillingContext<'_>) -> Option<f64> {
|
|
let input_tokens = metadata_usage_number(&log.metadata, "inputTokens");
|
|
let output_tokens = metadata_usage_number(&log.metadata, "outputTokens");
|
|
let cached_tokens = metadata_usage_number(&log.metadata, "cachedTokens");
|
|
if input_tokens == 0 && output_tokens == 0 && cached_tokens == 0 {
|
|
return None;
|
|
}
|
|
|
|
let model = log
|
|
.model
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.unwrap_or(context.default_model);
|
|
let service_tier = metadata_service_tier(&log.metadata).or(context.default_service_tier);
|
|
|
|
Some(billing::calculate_cost(
|
|
input_tokens,
|
|
output_tokens,
|
|
cached_tokens,
|
|
model,
|
|
service_tier,
|
|
))
|
|
}
|
|
|
|
fn apply_log(window: &mut UsageWindow, log: &EventLog) {
|
|
window.requests += 1;
|
|
window.input_tokens += metadata_usage_number(&log.metadata, "inputTokens");
|
|
window.output_tokens += metadata_usage_number(&log.metadata, "outputTokens");
|
|
window.cached_tokens += metadata_usage_number(&log.metadata, "cachedTokens");
|
|
if let Some(first_token_latency) = metadata_first_token_latency(&log.metadata) {
|
|
window.first_token_latency_sum += first_token_latency;
|
|
window.first_token_latency_count += 1;
|
|
}
|
|
if is_error_log(log) {
|
|
window.errors += 1;
|
|
}
|
|
if let Some(latency) = log
|
|
.latency_ms
|
|
.filter(|value| *value > 0)
|
|
.map(nonnegative_i64_to_u64)
|
|
{
|
|
window.latency_sum += latency;
|
|
window.latency_count += 1;
|
|
window.max_latency = window.max_latency.max(latency);
|
|
window.min_latency = if window.min_latency == 0 {
|
|
latency
|
|
} else {
|
|
window.min_latency.min(latency)
|
|
};
|
|
}
|
|
}
|
|
|
|
fn metadata_first_token_latency(metadata: &Value) -> Option<u64> {
|
|
[
|
|
"firstTokenMs",
|
|
"first_token_ms",
|
|
"firstTokenLatencyMs",
|
|
"first_token_latency_ms",
|
|
]
|
|
.into_iter()
|
|
.find_map(|field| {
|
|
metadata
|
|
.get(field)
|
|
.or_else(|| metadata.get("usage").and_then(|usage| usage.get(field)))
|
|
.and_then(Value::as_u64)
|
|
.filter(|value| *value > 0)
|
|
})
|
|
}
|
|
|
|
fn metadata_usage_number(metadata: &Value, field: &str) -> u64 {
|
|
metadata
|
|
.get("usage")
|
|
.and_then(|usage| usage.get(field))
|
|
.or_else(|| metadata.get(field))
|
|
.and_then(Value::as_u64)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
fn metadata_service_tier(metadata: &Value) -> Option<&str> {
|
|
let direct = metadata_service_tier_value(metadata);
|
|
direct.or_else(|| metadata.get("usage").and_then(metadata_service_tier_value))
|
|
}
|
|
|
|
fn metadata_service_tier_value(value: &Value) -> Option<&str> {
|
|
[
|
|
"billingServiceTier",
|
|
"billing_service_tier",
|
|
"actualServiceTier",
|
|
"actual_service_tier",
|
|
"serviceTier",
|
|
"service_tier",
|
|
]
|
|
.into_iter()
|
|
.find_map(|field| value.get(field).and_then(Value::as_str))
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
}
|
|
|
|
fn is_error_log(log: &EventLog) -> bool {
|
|
log.level == EventLevel::Error || log.status_code.is_some_and(|status| status >= 400)
|
|
}
|
|
|
|
fn account_pool_summary(accounts: &[Account]) -> AccountPoolDiagnostics {
|
|
let mut summary = AccountPoolDiagnostics {
|
|
total: accounts.len(),
|
|
..AccountPoolDiagnostics::default()
|
|
};
|
|
for account in accounts {
|
|
match account.status {
|
|
AccountStatus::Active => summary.active += 1,
|
|
AccountStatus::Expired => summary.expired += 1,
|
|
AccountStatus::QuotaExhausted => summary.quota_exhausted += 1,
|
|
AccountStatus::Refreshing => summary.refreshing += 1,
|
|
AccountStatus::Disabled => summary.disabled += 1,
|
|
AccountStatus::Banned => summary.banned += 1,
|
|
}
|
|
}
|
|
summary
|
|
}
|
|
|
|
fn account_usage_data(
|
|
accounts: &[Account],
|
|
usage_records: &[AdminUsageRecord],
|
|
quota_used_by_account: &HashMap<String, f64>,
|
|
) -> Vec<DashboardAccountUsageData> {
|
|
let account_by_id = accounts
|
|
.iter()
|
|
.map(|account| (account.id.as_str(), account))
|
|
.collect::<HashMap<_, _>>();
|
|
let mut rows = 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));
|
|
DashboardAccountUsageData {
|
|
name,
|
|
email: usage.email.clone().unwrap_or_else(|| "-".to_string()),
|
|
plan: usage
|
|
.plan_type
|
|
.clone()
|
|
.unwrap_or_else(|| "free".to_string()),
|
|
requests: nonnegative_i64_to_u64(usage.request_count),
|
|
tokens: nonnegative_i64_to_u64(usage.input_tokens + usage.output_tokens),
|
|
quota_used_percent,
|
|
last_used: china_relative_time(usage.last_used_at, Utc::now()),
|
|
status,
|
|
}
|
|
})
|
|
.collect::<Vec<_>>();
|
|
rows.sort_by_key(|row| Reverse(row.requests));
|
|
rows.truncate(4);
|
|
rows
|
|
}
|
|
|
|
fn dashboard_event_log_data(log: &EventLog) -> DashboardEventLogData {
|
|
DashboardEventLogData {
|
|
id: log.id.clone(),
|
|
time: china_time(&log.created_at),
|
|
level: log.level,
|
|
request_id: log.request_id.clone(),
|
|
route: log.route.clone(),
|
|
model: log.model.clone(),
|
|
status_code: log.status_code,
|
|
latency_ms: log.latency_ms,
|
|
}
|
|
}
|
|
|
|
async fn account_quota_used_percent_by_id(
|
|
state: &AppState,
|
|
usage_records: &[AdminUsageRecord],
|
|
) -> HashMap<String, f64> {
|
|
let mut records = usage_records.iter().collect::<Vec<_>>();
|
|
records.sort_by_key(|record| Reverse(record.request_count));
|
|
records.truncate(4);
|
|
|
|
let mut quota_used_by_account = HashMap::with_capacity(records.len());
|
|
for usage in records {
|
|
let Ok(Some(quota_json)) = state
|
|
.services
|
|
.accounts
|
|
.get_quota_json(&usage.account_id)
|
|
.await
|
|
else {
|
|
continue;
|
|
};
|
|
if let Some(used_percent) = quota_used_percent("a_json) {
|
|
quota_used_by_account.insert(usage.account_id.clone(), used_percent);
|
|
}
|
|
}
|
|
quota_used_by_account
|
|
}
|
|
|
|
fn quota_used_percent(quota_json: &str) -> Option<f64> {
|
|
let quota = serde_json::from_str::<Value>(quota_json).ok()?;
|
|
quota
|
|
.pointer("/rate_limit/used_percent")
|
|
.and_then(percent_value)
|
|
}
|
|
|
|
fn percent_value(value: &Value) -> Option<f64> {
|
|
let percent = value
|
|
.as_f64()
|
|
.or_else(|| value.as_str().and_then(|value| value.parse::<f64>().ok()))?;
|
|
percent.is_finite().then_some(percent.clamp(0.0, 100.0))
|
|
}
|
|
|
|
fn nonnegative_i64_to_u64(value: i64) -> u64 {
|
|
u64::try_from(value).unwrap_or(0)
|
|
}
|
|
|
|
fn service_status_data(state: &AppState) -> Vec<DashboardServiceStatusData> {
|
|
let fingerprint = fingerprint_diagnostics(&state.services.fingerprint);
|
|
vec![
|
|
DashboardServiceStatusData {
|
|
label: "客户端版本".to_string(),
|
|
value: empty_dash(fingerprint.app_version),
|
|
detail: if fingerprint.build_number.trim().is_empty() {
|
|
"-".to_string()
|
|
} else {
|
|
format!("Build {}", fingerprint.build_number)
|
|
},
|
|
tone: "info".to_string(),
|
|
},
|
|
DashboardServiceStatusData {
|
|
label: "平台架构".to_string(),
|
|
value: empty_dash(fingerprint.platform),
|
|
detail: empty_dash(fingerprint.arch),
|
|
tone: "info".to_string(),
|
|
},
|
|
DashboardServiceStatusData {
|
|
label: "Chromium".to_string(),
|
|
value: if fingerprint.chromium_version.trim().is_empty() {
|
|
"-".to_string()
|
|
} else {
|
|
format!("v{}", fingerprint.chromium_version)
|
|
},
|
|
detail: empty_dash(fingerprint.originator),
|
|
tone: "normal".to_string(),
|
|
},
|
|
DashboardServiceStatusData {
|
|
label: "更新时间".to_string(),
|
|
value: format_fingerprint_updated_at(fingerprint.updated_at),
|
|
detail: String::new(),
|
|
tone: "normal".to_string(),
|
|
},
|
|
DashboardServiceStatusData {
|
|
label: "User Agent".to_string(),
|
|
value: fingerprint.user_agent,
|
|
detail: String::new(),
|
|
tone: "normal".to_string(),
|
|
},
|
|
]
|
|
}
|
|
|
|
fn empty_dash(value: String) -> String {
|
|
if value.trim().is_empty() {
|
|
"-".to_string()
|
|
} else {
|
|
value
|
|
}
|
|
}
|
|
|
|
fn format_fingerprint_updated_at(value: Option<String>) -> String {
|
|
let Some(value) = value else {
|
|
return "-".to_string();
|
|
};
|
|
let value = value.trim();
|
|
if value.is_empty() {
|
|
return "-".to_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,
|
|
}
|
|
}
|
|
|
|
fn day_start(value: DateTime<Utc>) -> DateTime<Utc> {
|
|
value
|
|
.date_naive()
|
|
.and_hms_opt(0, 0, 0)
|
|
.expect("valid midnight")
|
|
.and_utc()
|
|
}
|
|
|
|
fn hour_start(value: DateTime<Utc>) -> DateTime<Utc> {
|
|
value
|
|
.date_naive()
|
|
.and_hms_opt(value.hour(), 0, 0)
|
|
.expect("valid hour")
|
|
.and_utc()
|
|
}
|
|
|
|
fn quarter_hour_start(value: DateTime<Utc>) -> DateTime<Utc> {
|
|
value
|
|
.date_naive()
|
|
.and_hms_opt(value.hour(), value.minute() / 15 * 15, 0)
|
|
.expect("valid quarter hour")
|
|
.and_utc()
|
|
}
|