From 0d342f2ca9adbb871f003d82ab81deb5fb5743eb Mon Sep 17 00:00:00 2001 From: Zyy Date: Mon, 13 Jul 2026 19:08:09 +0800 Subject: [PATCH] feat: improve usage insights and dashboard trends --- backend/src/api/admin/router.rs | 11 +- backend/src/api/admin/usage_routes.rs | 76 +- backend/src/telemetry/billing.rs | 86 +- backend/src/telemetry/dashboard.rs | 130 +-- backend/src/telemetry/usage/insights.rs | 777 ++++++++++++++++++ backend/src/telemetry/usage/mod.rs | 1 + backend/src/telemetry/usage/query.rs | 33 +- backend/tests/api/admin/dashboard_routes.rs | 87 +- backend/tests/api/admin/usage_routes.rs | 67 ++ frontend/docs/codex-proxy-rs.pen | 4 +- frontend/src/api/modules/accounts.ts | 16 +- frontend/src/api/modules/api-keys.ts | 51 +- frontend/src/api/modules/auth.ts | 4 +- frontend/src/api/modules/settings.ts | 4 +- frontend/src/api/modules/system.ts | 55 +- frontend/src/api/modules/usage.ts | 24 +- frontend/src/api/request.ts | 2 - .../src/components/base/BaseTable/index.vue | 33 +- frontend/src/composables/useSystemUpdate.ts | 8 +- frontend/src/layout/components/AppSidebar.vue | 2 +- .../layout/components/SystemUpdateModal.vue | 3 +- frontend/src/plugins/echarts.ts | 12 +- .../composables/useApiKeyMutations.ts | 9 +- .../api-keys/composables/useApiKeysTable.ts | 7 +- frontend/src/views/api-keys/index.vue | 7 +- .../components/AccountOverviewCard.vue | 2 +- .../dashboard/components/RequestTrendCard.vue | 373 +++++++-- .../dashboard/composables/useDashboard.ts | 92 ++- .../views/usage/components/UsageCostCard.vue | 256 ++++++ .../usage/components/UsageDiagnosticCard.vue | 197 +++++ .../components/UsageDistributionPanel.vue | 227 ----- .../views/usage/components/UsageFilters.vue | 10 +- .../usage/components/UsageHealthCard.vue | 177 ++++ .../usage/components/UsageInsightsGrid.vue | 357 +------- .../usage/components/UsagePerformanceCard.vue | 201 +++++ .../usage/components/UsageSummaryCards.vue | 25 +- .../usage/composables/useOpsErrorsTable.ts | 6 +- .../usage/composables/useUsageChartPalette.ts | 32 + .../usage/composables/useUsageFilters.ts | 16 +- .../usage/composables/useUsageRecordDetail.ts | 8 +- .../usage/composables/useUsageRecordsTable.ts | 165 ++-- frontend/src/views/usage/index.vue | 23 +- frontend/src/views/usage/utils/chart.ts | 71 ++ frontend/src/views/usage/utils/format.ts | 78 ++ 44 files changed, 2867 insertions(+), 958 deletions(-) create mode 100644 backend/src/telemetry/usage/insights.rs create mode 100644 frontend/src/views/usage/components/UsageCostCard.vue create mode 100644 frontend/src/views/usage/components/UsageDiagnosticCard.vue delete mode 100644 frontend/src/views/usage/components/UsageDistributionPanel.vue create mode 100644 frontend/src/views/usage/components/UsageHealthCard.vue create mode 100644 frontend/src/views/usage/components/UsagePerformanceCard.vue create mode 100644 frontend/src/views/usage/composables/useUsageChartPalette.ts create mode 100644 frontend/src/views/usage/utils/chart.ts create mode 100644 frontend/src/views/usage/utils/format.ts diff --git a/backend/src/api/admin/router.rs b/backend/src/api/admin/router.rs index 8764e5aee..0da724519 100644 --- a/backend/src/api/admin/router.rs +++ b/backend/src/api/admin/router.rs @@ -30,7 +30,8 @@ use super::{ }, usage_routes::{ clear_usage_records, usage_record_detail, usage_records, - usage_records_endpoint_distribution, usage_records_latency_trend, + usage_records_endpoint_distribution, usage_records_insights_diagnostics, + usage_records_insights_overview, usage_records_latency_trend, usage_records_model_distribution, usage_records_summary, usage_records_token_trend, }, }; @@ -101,6 +102,14 @@ pub fn router() -> Router { "/api/admin/usage/records/insights/latency-trend", get(usage_records_latency_trend), ) + .route( + "/api/admin/usage/records/insights/overview", + get(usage_records_insights_overview), + ) + .route( + "/api/admin/usage/records/insights/diagnostics", + get(usage_records_insights_diagnostics), + ) .route("/api/admin/usage/records/delete", post(clear_usage_records)) .route("/api/admin/usage/records/detail", get(usage_record_detail)) .route("/api/admin/ops/errors", get(ops_errors)) diff --git a/backend/src/api/admin/usage_routes.rs b/backend/src/api/admin/usage_routes.rs index 0a314ed86..859a028d4 100644 --- a/backend/src/api/admin/usage_routes.rs +++ b/backend/src/api/admin/usage_routes.rs @@ -1,4 +1,4 @@ -//! 使用记录 HTTP 处理器。 +//! 使用统计与请求记录 HTTP 处理器。 use std::collections::HashMap; @@ -26,6 +26,7 @@ use crate::{ time::china_datetime, }, telemetry::{ + usage::insights::{UsageDiagnosticsDimension, default_time_range}, usage::query::{ UsageQueryError, UsageQueryFilter, UsageRecordBreakdown, UsageRecordModelSource, UsageRecordSummary, UsageRecordTrendPoint, usage_record_billing, @@ -64,6 +65,21 @@ pub(crate) struct UsageRecordDistributionQuery { source: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UsageInsightsQuery { + start_time: Option, + end_time: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UsageDiagnosticsQuery { + #[serde(flatten)] + range: UsageInsightsQuery, + dimension: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct UsageRecordDetailQuery { @@ -304,6 +320,39 @@ pub(crate) async fn usage_records_latency_trend( usage_records_trend_response(state, query).await } +/// `GET /api/admin/usage/records/insights/overview` +pub(crate) async fn usage_records_insights_overview( + State(state): State, + _auth: AdminAuth, + Query(query): Query, +) -> Result { + let (start, end) = insights_time_range(query)?; + state + .services + .usage_records + .insights_overview(start, end) + .await + .map(|insights| AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(insights))) + .map_err(|error| log_error(&error)) +} + +/// `GET /api/admin/usage/records/insights/diagnostics` +pub(crate) async fn usage_records_insights_diagnostics( + State(state): State, + _auth: AdminAuth, + Query(query): Query, +) -> Result { + let dimension = diagnostics_dimension(query.dimension)?; + let (start, end) = insights_time_range(query.range)?; + state + .services + .usage_records + .insights_diagnostics(start, end, dimension) + .await + .map(|insights| AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(insights))) + .map_err(|error| log_error(&error)) +} + async fn usage_records_trend_response( state: AppState, query: UsageRecordsQuery, @@ -405,6 +454,25 @@ fn optional_datetime( .map_err(|_| AdminError::invalid_time_range("Invalid time range")) } +fn insights_time_range( + query: UsageInsightsQuery, +) -> Result<(chrono::DateTime, chrono::DateTime), AdminError> { + let end = optional_datetime(query.end_time)?.unwrap_or_else(chrono::Utc::now); + let start = optional_datetime(query.start_time)?.unwrap_or_else(|| default_time_range(end).0); + if start >= end { + return Err(AdminError::invalid_time_range( + "Start time must be earlier than end time", + )); + } + Ok((start, end)) +} + +fn diagnostics_dimension(value: Option) -> Result { + let value = non_empty(value).unwrap_or_else(|| "model".to_string()); + UsageDiagnosticsDimension::parse(&value) + .ok_or_else(|| AdminError::bad_request("Invalid diagnostics dimension")) +} + fn log_error(error: &UsageQueryError) -> AdminError { match error { UsageQueryError::List | UsageQueryError::Get | UsageQueryError::Clear => { @@ -616,9 +684,9 @@ fn usage_record_breakdown_items(items: Vec) -> Vec for UsageRecordTrendPointData { fn from(point: UsageRecordTrendPoint) -> Self { - let prompt_tokens = point.input_tokens.saturating_add(point.cached_tokens); - let cache_hit_rate_value = if prompt_tokens > 0 { - (point.cached_tokens as f64 / prompt_tokens as f64 * 100.0).round() + let cache_hit_rate_value = if point.input_tokens > 0 { + (point.cached_tokens.min(point.input_tokens) as f64 / point.input_tokens as f64 * 100.0) + .round() } else { 0.0 }; diff --git a/backend/src/telemetry/billing.rs b/backend/src/telemetry/billing.rs index ad5313220..34ba0c875 100644 --- a/backend/src/telemetry/billing.rs +++ b/backend/src/telemetry/billing.rs @@ -1,6 +1,6 @@ //! 管理端用量计费规则。 -const LONG_CONTEXT_THRESHOLD: u64 = 272_000; +pub(crate) const LONG_CONTEXT_THRESHOLD: u64 = 272_000; #[derive(Debug, Clone, Copy)] struct ModelPricing { @@ -226,6 +226,24 @@ fn calculate_billing_with_pricing( ) -> BillingBreakdown { let is_long = input_tokens > LONG_CONTEXT_THRESHOLD; + calculate_billing_with_pricing_for_context( + input_tokens, + output_tokens, + cached_tokens, + pricing, + service_tier, + is_long, + ) +} + +fn calculate_billing_with_pricing_for_context( + input_tokens: u64, + output_tokens: u64, + cached_tokens: u64, + pricing: ModelPricing, + service_tier: Option<&str>, + is_long: bool, +) -> BillingBreakdown { let mut input_price = pricing.input_price_per_mtoken; let mut output_price = pricing.output_price_per_mtoken; let mut cache_read_price = pricing.cache_read_price_per_mtoken; @@ -284,6 +302,36 @@ fn calculate_billing_with_pricing( } } +/// 按模型、档位和已确定的上下文长度档位计算聚合用量费用。 +/// +/// 聚合查询必须显式传入 `is_long`,避免许多短请求相加后被误判为单次长上下文请求。 +pub(crate) fn calculate_aggregate_billing( + input_tokens: u64, + output_tokens: u64, + cached_tokens: u64, + model: &str, + service_tier: Option<&str>, + is_long: bool, +) -> BillingBreakdown { + let pricing = model_pricing(model).unwrap_or_else(|| { + highest_pricing_for_context( + input_tokens, + output_tokens, + cached_tokens, + service_tier, + is_long, + ) + }); + calculate_billing_with_pricing_for_context( + input_tokens, + output_tokens, + cached_tokens, + pricing, + service_tier, + is_long, + ) +} + /// 按已登记的官方价格计算美元计费金额,未知模型按最高费率计算。 pub(crate) fn calculate_billing_amount( input_tokens: u64, @@ -346,6 +394,42 @@ fn highest_pricing_for_request( }) } +fn highest_pricing_for_context( + input_tokens: u64, + output_tokens: u64, + cached_tokens: u64, + service_tier: Option<&str>, + is_long: bool, +) -> ModelPricing { + MODEL_PRICING_RULES + .iter() + .fold(ModelPricing::new(0.0, 0.0), |highest, rule| { + let highest_amount = calculate_billing_with_pricing_for_context( + input_tokens, + output_tokens, + cached_tokens, + highest, + service_tier, + is_long, + ) + .total_amount; + let candidate_amount = calculate_billing_with_pricing_for_context( + input_tokens, + output_tokens, + cached_tokens, + rule.pricing, + service_tier, + is_long, + ) + .total_amount; + if candidate_amount >= highest_amount { + rule.pricing + } else { + highest + } + }) +} + fn normalize_billing_model_name(model: &str) -> String { let mut model = model.trim().to_ascii_lowercase(); model = model.trim_start_matches('/').to_string(); diff --git a/backend/src/telemetry/dashboard.rs b/backend/src/telemetry/dashboard.rs index 8c71c581b..8cdc21eba 100644 --- a/backend/src/telemetry/dashboard.rs +++ b/backend/src/telemetry/dashboard.rs @@ -8,7 +8,7 @@ use crate::infra::{ format_billing_amount, format_compact_number, format_duration_ms, format_percent, format_rate, format_tokens, nonnegative_i64_to_u64, }, - time::{china_day_start, china_hour, china_hour_start, china_quarter_hour_start}, + time::{china_day_start, china_quarter_hour_start}, }; use super::account_usage::query::{AccountUsageSummary, AccountUsageTimeBucket}; @@ -16,6 +16,7 @@ use super::account_usage::query::{AccountUsageSummary, AccountUsageTimeBucket}; const HEALTH_TIMELINE_SLOT_MINUTES: i64 = 15; const HEALTH_TIMELINE_SLOTS: i64 = 24 * 4; const HEALTH_TIMELINE_STABLE_SUCCESS_THRESHOLD: u64 = 3; +const TREND_SLOT_MINUTES: i64 = 15; #[derive(Debug, Clone, Copy, Default)] pub struct DashboardAccountCounts { @@ -117,13 +118,13 @@ struct DashboardTrendPointData { errors: String, errors_value: u64, latency: String, - latency_value: u64, + latency_value: Option, max_latency: String, - max_latency_value: u64, + max_latency_value: Option, min_latency: String, - min_latency_value: u64, + min_latency_value: Option, success_rate: String, - success_rate_value: f64, + success_rate_value: Option, } #[derive(Debug, Serialize)] @@ -240,41 +241,51 @@ pub fn dashboard_trend_data_at( kind: DashboardTrendKind, now: DateTime, ) -> DashboardTrendData { - let current_hour = china_hour_start(now); + let current_slot = china_quarter_hour_start(now); 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| (start + Duration::hours(index), UsageWindow::default())) + let elapsed_slots = + current_slot.signed_duration_since(start).num_minutes() / TREND_SLOT_MINUTES; + let mut buckets = (0..=elapsed_slots) + .map(|index| { + ( + start + Duration::minutes(TREND_SLOT_MINUTES * index), + UsageWindow::default(), + ) + }) .collect::>(); for record in records { if record.bucket_start < start || record.bucket_start > now { continue; } - let record_hour = china_hour_start(record.bucket_start); - if let Some((_, bucket)) = buckets - .iter_mut() - .find(|(bucket_start, _)| *bucket_start == record_hour) - { - apply_bucket(bucket, record); - } + let record_slot = china_quarter_hour_start(record.bucket_start); + let slot_index = + record_slot.signed_duration_since(start).num_minutes() / TREND_SLOT_MINUTES; + let Ok(slot_index) = usize::try_from(slot_index) else { + continue; + }; + let Some((_, bucket)) = buckets.get_mut(slot_index) else { + continue; + }; + apply_bucket(bucket, record); } let points = buckets .iter() .map(|(bucket_start, bucket)| { - let latency = bucket.avg_first_token_latency().unwrap_or(0); - let max_latency = bucket.max_first_token_bucket_latency().unwrap_or(0); - let min_latency = bucket.min_first_token_bucket_latency().unwrap_or(0); + let latency = bucket.avg_first_token_latency(); + let max_latency = bucket.max_first_token_bucket_latency(); + let min_latency = bucket.min_first_token_bucket_latency(); let cache_hit_rate = bucket.cache_hit_rate().unwrap_or(0.0); - let success_rate = if bucket.requests > 0 { - ((bucket.requests - bucket.errors) as f64 / bucket.requests as f64 * 1000.0).round() + let success_rate = (bucket.requests > 0).then(|| { + (bucket.requests.saturating_sub(bucket.errors) as f64 / bucket.requests as f64 + * 1000.0) + .round() / 10.0 - } else { - 0.0 - }; + }); + let elapsed_minutes = bucket_start.signed_duration_since(start).num_minutes(); DashboardTrendPointData { - time: format!("{:02}", china_hour(bucket_start)), + time: format!("{:02}:{:02}", elapsed_minutes / 60, elapsed_minutes % 60), requests: format_compact_number(bucket.requests), requests_value: bucket.requests, input_tokens: format_tokens(bucket.input_tokens), @@ -287,13 +298,15 @@ pub fn dashboard_trend_data_at( tokens_value: bucket.tokens(), errors: format_compact_number(bucket.errors), errors_value: bucket.errors, - latency: format_optional_duration_ms(Some(latency)), + latency: format_optional_duration_ms(latency), latency_value: latency, - max_latency: format_optional_duration_ms(Some(max_latency)), + max_latency: format_optional_duration_ms(max_latency), max_latency_value: max_latency, - min_latency: format_optional_duration_ms(Some(min_latency)), + min_latency: format_optional_duration_ms(min_latency), min_latency_value: min_latency, - success_rate: format_percent(success_rate), + success_rate: success_rate + .map(format_percent) + .unwrap_or_else(|| "—".to_string()), success_rate_value: success_rate, } }) @@ -379,52 +392,48 @@ fn trend_summary( trend_summary_item( kind, "输入", - points.iter().map(|p| p.input_tokens_value).sum(), + Some(points.iter().map(|p| p.input_tokens_value).sum()), None, ), trend_summary_item( kind, "输出", - points.iter().map(|p| p.output_tokens_value).sum(), + Some(points.iter().map(|p| p.output_tokens_value).sum()), None, ), trend_summary_item( kind, "缓存", - points.iter().map(|p| p.cached_tokens_value).sum(), + Some(points.iter().map(|p| p.cached_tokens_value).sum()), None, ), ], DashboardTrendKind::Latency => { - let samples = points + let (latency_sum, latency_count) = points .iter() - .filter(|point| point.latency_value > 0) - .collect::>(); - let average = if samples.is_empty() { - 0 - } else { - samples.iter().map(|point| point.latency_value).sum::() / samples.len() as u64 - }; + .filter_map(|point| point.latency_value) + .fold((0_u64, 0_u64), |(sum, count), latency| { + (sum + latency, count + 1) + }); + let average = (latency_count > 0).then(|| latency_sum / latency_count); vec![ trend_summary_item(kind, "平均", average, None), trend_summary_item( kind, "最高", - samples + points .iter() - .map(|p| p.max_latency_value) - .max() - .unwrap_or(0), + .filter_map(|point| point.max_latency_value) + .max(), None, ), trend_summary_item( kind, "最低", - samples + points .iter() - .filter_map(|p| (p.min_latency_value > 0).then_some(p.min_latency_value)) - .min() - .unwrap_or(0), + .filter_map(|point| point.min_latency_value) + .min(), None, ), ] @@ -432,12 +441,13 @@ fn trend_summary( DashboardTrendKind::Errors => { let errors = points.iter().map(|point| point.errors_value).sum::(); let requests = points.iter().map(|point| point.requests_value).sum::(); - let success_rate = (requests > 0) - .then(|| ((requests - errors) as f64 / requests as f64 * 1000.0).round() / 10.0); + let success_rate = (requests > 0).then(|| { + (requests.saturating_sub(errors) as f64 / requests as f64 * 1000.0).round() / 10.0 + }); vec![ - trend_summary_item(kind, "错误数", errors, None), - trend_summary_item(kind, "成功率", 0, success_rate), - trend_summary_item(kind, "总请求", requests, None), + trend_summary_item(kind, "错误数", Some(errors), None), + trend_summary_item(kind, "成功率", None, success_rate), + trend_summary_item(kind, "总请求", Some(requests), None), ] } } @@ -446,16 +456,18 @@ fn trend_summary( fn trend_summary_item( kind: DashboardTrendKind, label: &str, - value: u64, + value: Option, ratio: Option, ) -> DashboardTrendSummaryData { DashboardTrendSummaryData { label: label.to_string(), - value: match kind { - DashboardTrendKind::Usage => format_tokens(value), - DashboardTrendKind::Latency => format_optional_duration_ms(Some(value)), - DashboardTrendKind::Errors => format_compact_number(value), - }, + value: value + .map(|value| match kind { + DashboardTrendKind::Usage => format_tokens(value), + DashboardTrendKind::Latency => format_optional_duration_ms(Some(value)), + DashboardTrendKind::Errors => format_compact_number(value), + }) + .unwrap_or_else(|| "—".to_string()), ratio: ratio.map(format_percent), } } diff --git a/backend/src/telemetry/usage/insights.rs b/backend/src/telemetry/usage/insights.rs new file mode 100644 index 000000000..cc83dcc9f --- /dev/null +++ b/backend/src/telemetry/usage/insights.rs @@ -0,0 +1,777 @@ +//! 管理端请求观测聚合。 + +use std::collections::{BTreeMap, HashMap}; + +use chrono::{DateTime, Duration, Utc}; +use serde::Serialize; +use sqlx::{AssertSqlSafe, PgPool, Row}; + +use crate::telemetry::billing::{LONG_CONTEXT_THRESHOLD, calculate_aggregate_billing}; + +use super::store::PgUsageRecordStoreResult; + +const DEFAULT_RANGE_DAYS: i64 = 7; + +// 一个请求可能写入多个错误事实。查询时优先把存在成功事实的 request_id 视为成功, +// 否则只保留该 request_id 的最新错误;缺少 request_id 时退化到事实 id。 +const TERMINAL_REQUESTS_CTE: &str = r#" +success_terminals as ( + select distinct on (request_key) + request_key, id, request_id, terminal_at, true as is_success, + client_api_key_id, provider, account_id, transport, model_name, + null::text as failure_class, latency_ms, first_token_ms, billing_model, + service_tier, input_tokens, output_tokens, cached_tokens + from ( + select + case when request_id is null then 'event:' || id else 'request:' || request_id end as request_key, + id, request_id, created_at as terminal_at, client_api_key_id, provider, + account_id, transport, + coalesce(nullif(requested_model, ''), model) || ' → ' || + coalesce(nullif(upstream_model, ''), model) as model_name, + latency_ms, first_token_ms, + coalesce(nullif(upstream_model, ''), model) as billing_model, + service_tier, input_tokens, output_tokens, cached_tokens + from usage_records + where created_at >= $1 and created_at < $2 + ) candidates + order by request_key, terminal_at desc, id desc +), +error_terminals as ( + select distinct on (request_key) + request_key, id, request_id, terminal_at, false as is_success, + client_api_key_id, provider, account_id, transport, model_name, + failure_class, latency_ms, null::bigint as first_token_ms, + null::text as billing_model, null::text as service_tier, + null::bigint as input_tokens, null::bigint as output_tokens, + null::bigint as cached_tokens + from ( + select + case when request_id is null then 'event:' || id else 'request:' || request_id end as request_key, + id, request_id, created_at as terminal_at, client_api_key_id, provider, + account_id, transport, coalesce(nullif(model, ''), '未知模型') as model_name, + coalesce(nullif(failure_class, ''), '未分类错误') as failure_class, + latency_ms + from ops_error_logs + where created_at >= $1 and created_at < $2 + ) candidates + order by request_key, terminal_at desc, id desc +), +final_errors as ( + select errors.* + from error_terminals errors + where not exists ( + select 1 + from usage_records successes + where + (errors.request_id is not null and successes.request_id = errors.request_id) + or (errors.request_id is null and successes.id = errors.id) + ) +), +terminal_requests as ( + select * from success_terminals + union all + select * from final_errors +) +"#; + +/// 观测时间粒度。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InsightsGranularity { + QuarterHour, + Hour, + Day, +} + +impl InsightsGranularity { + pub fn for_range(start: DateTime, end: DateTime) -> Self { + let range = end.signed_duration_since(start); + if range <= Duration::days(2) { + Self::QuarterHour + } else if range <= Duration::days(14) { + Self::Hour + } else { + Self::Day + } + } + + fn as_str(self) -> &'static str { + match self { + Self::QuarterHour => "15m", + Self::Hour => "1h", + Self::Day => "1d", + } + } + + fn step_seconds(self) -> i64 { + match self { + Self::QuarterHour => 15 * 60, + Self::Hour => 60 * 60, + Self::Day => 24 * 60 * 60, + } + } + + fn bucket_expression(self, column: &str) -> String { + let interval = match self { + Self::QuarterHour => "15 minutes", + Self::Hour => "1 hour", + Self::Day => "1 day", + }; + let origin = if self == Self::Day { + "1970-01-01 16:00:00+00" + } else { + "1970-01-01 00:00:00+00" + }; + format!("date_bin(interval '{interval}', {column}, timestamptz '{origin}')") + } +} + +/// 热点诊断维度。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UsageDiagnosticsDimension { + Model, + Account, + ApiKey, + Provider, + Transport, + FailureClass, +} + +impl UsageDiagnosticsDimension { + pub fn parse(value: &str) -> Option { + match value { + "model" => Some(Self::Model), + "account" => Some(Self::Account), + "apiKey" | "api_key" => Some(Self::ApiKey), + "provider" => Some(Self::Provider), + "transport" => Some(Self::Transport), + "failureClass" | "failure_class" => Some(Self::FailureClass), + _ => None, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Model => "model", + Self::Account => "account", + Self::ApiKey => "apiKey", + Self::Provider => "provider", + Self::Transport => "transport", + Self::FailureClass => "failureClass", + } + } + + fn expression(self) -> &'static str { + match self { + Self::Model => "model_name", + Self::Account => "coalesce(nullif(account_id, ''), '未知账号')", + Self::ApiKey => "coalesce(nullif(client_api_key_id, ''), '未知 API Key')", + Self::Provider => "coalesce(nullif(provider, ''), '未知 Provider')", + Self::Transport => "coalesce(nullif(transport, ''), '未知传输')", + Self::FailureClass => "failure_class", + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageInsightsOverview { + granularity: String, + health: UsageHealthInsights, + performance: UsagePerformanceInsights, + cost: UsageCostInsights, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct UsageHealthInsights { + total_requests: u64, + success_requests: u64, + failed_requests: u64, + success_rate: f64, + request_change_rate: Option, + success_rate_change: Option, + points: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct UsageHealthPoint { + bucket: DateTime, + label: String, + success_requests: u64, + failed_requests: u64, + error_rate: f64, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct UsagePerformanceInsights { + latency_p50_ms: Option, + latency_p95_ms: Option, + latency_p99_ms: Option, + ttft_p50_ms: Option, + ttft_p95_ms: Option, + ttft_p99_ms: Option, + latency_coverage: f64, + ttft_coverage: f64, + points: Vec, +} + +#[derive(Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +struct UsagePerformancePoint { + bucket: DateTime, + label: String, + latency_p50_ms: Option, + latency_p95_ms: Option, + latency_p99_ms: Option, + ttft_p50_ms: Option, + ttft_p95_ms: Option, + ttft_p99_ms: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct UsageCostInsights { + estimated_cost: f64, + standard_cost: f64, + cost_per_request: f64, + tokens_per_request: f64, + cached_token_rate: f64, + cache_hit_request_rate: f64, + input_tokens: u64, + output_tokens: u64, + cached_tokens: u64, + total_tokens: u64, + points: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct UsageCostPoint { + bucket: DateTime, + label: String, + input_tokens: u64, + output_tokens: u64, + cached_tokens: u64, + total_tokens: u64, + estimated_cost: f64, + standard_cost: f64, + cached_token_rate: f64, + cache_hit_request_rate: f64, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageDiagnosticsInsights { + dimension: String, + items: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct UsageDiagnosticsItem { + name: String, + request_count: u64, + success_count: u64, + error_count: u64, + error_rate: f64, + request_share: f64, + latency_p95_ms: Option, + estimated_cost: f64, +} + +pub async fn overview( + pool: &PgPool, + start: DateTime, + end: DateTime, +) -> PgUsageRecordStoreResult { + let granularity = InsightsGranularity::for_range(start, end); + let (health, performance, cost) = tokio::try_join!( + load_health(pool, start, end, granularity), + load_performance(pool, start, end, granularity), + load_cost(pool, start, end, granularity), + )?; + Ok(UsageInsightsOverview { + granularity: granularity.as_str().to_string(), + health, + performance, + cost, + }) +} + +pub async fn diagnostics( + pool: &PgPool, + start: DateTime, + end: DateTime, + dimension: UsageDiagnosticsDimension, +) -> PgUsageRecordStoreResult { + let expression = dimension.expression(); + let failure_filter = if dimension == UsageDiagnosticsDimension::FailureClass { + "where not is_success" + } else { + "" + }; + let sql = format!( + "with {TERMINAL_REQUESTS_CTE}\n\ + select {expression} as name,\n\ + count(*)::bigint as request_count,\n\ + count(*) filter (where is_success)::bigint as success_count,\n\ + count(*) filter (where not is_success)::bigint as error_count,\n\ + percentile_cont(0.95) within group (order by latency_ms)\n\ + filter (where is_success and latency_ms is not null) as latency_p95_ms,\n\ + (select count(*) from terminal_requests)::bigint as total_requests\n\ + from terminal_requests {failure_filter}\n\ + group by name\n\ + order by request_count desc, name asc\n\ + limit 8" + ); + // SAFETY: SQL fragments come only from closed enums and module constants; values remain bound. + let rows = sqlx::query(AssertSqlSafe(sql)) + .bind(start) + .bind(end) + .fetch_all(pool) + .await?; + + let costs = if dimension == UsageDiagnosticsDimension::FailureClass { + HashMap::new() + } else { + load_diagnostic_costs(pool, start, end, dimension).await? + }; + let items = rows + .into_iter() + .map(|row| { + let name: String = row.get("name"); + let request_count = nonnegative(row.get("request_count")); + let success_count = nonnegative(row.get("success_count")); + let error_count = nonnegative(row.get("error_count")); + let total_requests = nonnegative(row.get("total_requests")); + UsageDiagnosticsItem { + estimated_cost: costs.get(&name).copied().unwrap_or_default(), + name, + request_count, + success_count, + error_count, + error_rate: rate(error_count, request_count), + request_share: rate(request_count, total_requests), + latency_p95_ms: row.get("latency_p95_ms"), + } + }) + .collect(); + Ok(UsageDiagnosticsInsights { + dimension: dimension.as_str().to_string(), + items, + }) +} + +async fn load_health( + pool: &PgPool, + start: DateTime, + end: DateTime, + granularity: InsightsGranularity, +) -> PgUsageRecordStoreResult { + let range = end.signed_duration_since(start); + let previous_start = start.checked_sub_signed(range).unwrap_or(start); + let bucket = granularity.bucket_expression("terminal_at"); + let sql = format!( + "with {TERMINAL_REQUESTS_CTE}\n\ + select case when terminal_at >= $3 then 'current' else 'previous' end as period,\n\ + {bucket} as bucket_start,\n\ + count(*) filter (where is_success)::bigint as success_requests,\n\ + count(*) filter (where not is_success)::bigint as failed_requests\n\ + from terminal_requests\n\ + group by period, bucket_start\n\ + order by bucket_start" + ); + // SAFETY: granularity selects one of three module-owned date_bin expressions. + let rows = sqlx::query(AssertSqlSafe(sql)) + .bind(previous_start) + .bind(end) + .bind(start) + .fetch_all(pool) + .await?; + + let mut current = HashMap::new(); + let mut current_success = 0_u64; + let mut current_failed = 0_u64; + let mut previous_success = 0_u64; + let mut previous_failed = 0_u64; + for row in rows { + let success = nonnegative(row.get("success_requests")); + let failed = nonnegative(row.get("failed_requests")); + if row.get::("period") == "current" { + current_success = current_success.saturating_add(success); + current_failed = current_failed.saturating_add(failed); + current.insert( + row.get::, _>("bucket_start"), + (success, failed), + ); + } else { + previous_success = previous_success.saturating_add(success); + previous_failed = previous_failed.saturating_add(failed); + } + } + let total = current_success.saturating_add(current_failed); + let previous_total = previous_success.saturating_add(previous_failed); + let success_rate = rate(current_success, total); + let previous_success_rate = rate(previous_success, previous_total); + let points = bucket_starts(start, end, granularity) + .into_iter() + .map(|bucket| { + let (success_requests, failed_requests) = + current.get(&bucket).copied().unwrap_or_default(); + let point_total = success_requests.saturating_add(failed_requests); + UsageHealthPoint { + bucket, + label: bucket_label(bucket, granularity), + success_requests, + failed_requests, + error_rate: rate(failed_requests, point_total), + } + }) + .collect(); + Ok(UsageHealthInsights { + total_requests: total, + success_requests: current_success, + failed_requests: current_failed, + success_rate, + request_change_rate: relative_change(total, previous_total), + success_rate_change: (previous_total > 0).then_some(success_rate - previous_success_rate), + points, + }) +} + +async fn load_performance( + pool: &PgPool, + start: DateTime, + end: DateTime, + granularity: InsightsGranularity, +) -> PgUsageRecordStoreResult { + let summary = sqlx::query( + r#" +select + count(*)::bigint as request_count, + count(latency_ms)::bigint as latency_count, + count(first_token_ms)::bigint as ttft_count, + percentile_cont(0.50) within group (order by latency_ms) as latency_p50_ms, + percentile_cont(0.95) within group (order by latency_ms) as latency_p95_ms, + percentile_cont(0.99) within group (order by latency_ms) as latency_p99_ms, + percentile_cont(0.50) within group (order by first_token_ms) as ttft_p50_ms, + percentile_cont(0.95) within group (order by first_token_ms) as ttft_p95_ms, + percentile_cont(0.99) within group (order by first_token_ms) as ttft_p99_ms +from usage_records +where created_at >= $1 and created_at < $2 +"#, + ) + .bind(start) + .bind(end) + .fetch_one(pool) + .await?; + let bucket = granularity.bucket_expression("created_at"); + let point_sql = format!( + "select {bucket} as bucket_start,\n\ + percentile_cont(0.50) within group (order by latency_ms) as latency_p50_ms,\n\ + percentile_cont(0.95) within group (order by latency_ms) as latency_p95_ms,\n\ + percentile_cont(0.99) within group (order by latency_ms) as latency_p99_ms,\n\ + percentile_cont(0.50) within group (order by first_token_ms) as ttft_p50_ms,\n\ + percentile_cont(0.95) within group (order by first_token_ms) as ttft_p95_ms,\n\ + percentile_cont(0.99) within group (order by first_token_ms) as ttft_p99_ms\n\ + from usage_records\n\ + where created_at >= $1 and created_at < $2\n\ + group by bucket_start order by bucket_start" + ); + // SAFETY: granularity selects one of three module-owned date_bin expressions. + let rows = sqlx::query(AssertSqlSafe(point_sql)) + .bind(start) + .bind(end) + .fetch_all(pool) + .await?; + let mut by_bucket = HashMap::new(); + for row in rows { + let bucket: DateTime = row.get("bucket_start"); + by_bucket.insert( + bucket, + UsagePerformancePoint { + bucket, + label: bucket_label(bucket, granularity), + latency_p50_ms: row.get("latency_p50_ms"), + latency_p95_ms: row.get("latency_p95_ms"), + latency_p99_ms: row.get("latency_p99_ms"), + ttft_p50_ms: row.get("ttft_p50_ms"), + ttft_p95_ms: row.get("ttft_p95_ms"), + ttft_p99_ms: row.get("ttft_p99_ms"), + }, + ); + } + let points = bucket_starts(start, end, granularity) + .into_iter() + .map(|bucket| { + by_bucket + .remove(&bucket) + .unwrap_or_else(|| UsagePerformancePoint { + bucket, + label: bucket_label(bucket, granularity), + ..Default::default() + }) + }) + .collect(); + let request_count = nonnegative(summary.get("request_count")); + Ok(UsagePerformanceInsights { + latency_p50_ms: summary.get("latency_p50_ms"), + latency_p95_ms: summary.get("latency_p95_ms"), + latency_p99_ms: summary.get("latency_p99_ms"), + ttft_p50_ms: summary.get("ttft_p50_ms"), + ttft_p95_ms: summary.get("ttft_p95_ms"), + ttft_p99_ms: summary.get("ttft_p99_ms"), + latency_coverage: rate(nonnegative(summary.get("latency_count")), request_count), + ttft_coverage: rate(nonnegative(summary.get("ttft_count")), request_count), + points, + }) +} + +async fn load_cost( + pool: &PgPool, + start: DateTime, + end: DateTime, + granularity: InsightsGranularity, +) -> PgUsageRecordStoreResult { + let bucket = granularity.bucket_expression("created_at"); + let sql = format!( + "select {bucket} as bucket_start,\n\ + coalesce(nullif(upstream_model, ''), model) as billing_model,\n\ + service_tier, coalesce(input_tokens, 0) > $3 as is_long,\n\ + count(*)::bigint as request_count,\n\ + count(*) filter (where coalesce(input_tokens, 0) > 0)::bigint as cache_eligible_count,\n\ + count(*) filter (\n\ + where coalesce(input_tokens, 0) > 0 and coalesce(cached_tokens, 0) > 0\n\ + )::bigint as cache_hit_count,\n\ + coalesce(sum(input_tokens), 0)::bigint as input_tokens,\n\ + coalesce(sum(output_tokens), 0)::bigint as output_tokens,\n\ + coalesce(sum(least(coalesce(cached_tokens, 0), coalesce(input_tokens, 0))), 0)::bigint as cached_tokens\n\ + from usage_records\n\ + where created_at >= $1 and created_at < $2\n\ + group by bucket_start, billing_model, service_tier, is_long\n\ + order by bucket_start" + ); + // SAFETY: granularity selects one of three module-owned date_bin expressions. + let rows = sqlx::query(AssertSqlSafe(sql)) + .bind(start) + .bind(end) + .bind(i64::try_from(LONG_CONTEXT_THRESHOLD).unwrap_or(i64::MAX)) + .fetch_all(pool) + .await?; + let mut total = CostAccumulator::default(); + let mut by_bucket = BTreeMap::, CostAccumulator>::new(); + for row in rows { + let sample = cost_sample(&row); + total.push(sample); + by_bucket + .entry(row.get("bucket_start")) + .or_default() + .push(sample); + } + let points = bucket_starts(start, end, granularity) + .into_iter() + .map(|bucket| { + let item = by_bucket.remove(&bucket).unwrap_or_default(); + UsageCostPoint { + bucket, + label: bucket_label(bucket, granularity), + input_tokens: item.input_tokens, + output_tokens: item.output_tokens, + cached_tokens: item.cached_tokens, + total_tokens: item.total_tokens(), + estimated_cost: item.estimated_cost, + standard_cost: item.standard_cost, + cached_token_rate: rate(item.cached_tokens, item.input_tokens), + cache_hit_request_rate: rate(item.cache_hit_count, item.cache_eligible_count), + } + }) + .collect(); + Ok(UsageCostInsights { + estimated_cost: total.estimated_cost, + standard_cost: total.standard_cost, + cost_per_request: amount_per(total.estimated_cost, total.request_count), + tokens_per_request: amount_per(total.total_tokens() as f64, total.request_count), + cached_token_rate: rate(total.cached_tokens, total.input_tokens), + cache_hit_request_rate: rate(total.cache_hit_count, total.cache_eligible_count), + input_tokens: total.input_tokens, + output_tokens: total.output_tokens, + cached_tokens: total.cached_tokens, + total_tokens: total.total_tokens(), + points, + }) +} + +async fn load_diagnostic_costs( + pool: &PgPool, + start: DateTime, + end: DateTime, + dimension: UsageDiagnosticsDimension, +) -> PgUsageRecordStoreResult> { + let expression = dimension.expression(); + let sql = format!( + "with {TERMINAL_REQUESTS_CTE}\n\ + select {expression} as name, billing_model, service_tier,\n\ + coalesce(input_tokens, 0) > $3 as is_long,\n\ + count(*)::bigint as request_count,\n\ + count(*) filter (where coalesce(input_tokens, 0) > 0)::bigint as cache_eligible_count,\n\ + count(*) filter (\n\ + where coalesce(input_tokens, 0) > 0 and coalesce(cached_tokens, 0) > 0\n\ + )::bigint as cache_hit_count,\n\ + coalesce(sum(input_tokens), 0)::bigint as input_tokens,\n\ + coalesce(sum(output_tokens), 0)::bigint as output_tokens,\n\ + coalesce(sum(least(coalesce(cached_tokens, 0), coalesce(input_tokens, 0))), 0)::bigint as cached_tokens\n\ + from terminal_requests\n\ + where is_success\n\ + group by name, billing_model, service_tier, is_long" + ); + // SAFETY: the grouping expression comes from the closed diagnostics dimension enum. + let rows = sqlx::query(AssertSqlSafe(sql)) + .bind(start) + .bind(end) + .bind(i64::try_from(LONG_CONTEXT_THRESHOLD).unwrap_or(i64::MAX)) + .fetch_all(pool) + .await?; + let mut costs = HashMap::new(); + for row in rows { + let name: String = row.get("name"); + let sample = cost_sample(&row); + *costs.entry(name).or_insert(0.0) += sample.estimated_cost; + } + Ok(costs) +} + +#[derive(Debug, Clone, Copy, Default)] +struct CostSample { + request_count: u64, + cache_eligible_count: u64, + cache_hit_count: u64, + input_tokens: u64, + output_tokens: u64, + cached_tokens: u64, + estimated_cost: f64, + standard_cost: f64, +} + +#[derive(Debug, Default)] +struct CostAccumulator { + request_count: u64, + cache_eligible_count: u64, + cache_hit_count: u64, + input_tokens: u64, + output_tokens: u64, + cached_tokens: u64, + estimated_cost: f64, + standard_cost: f64, +} + +impl CostAccumulator { + fn push(&mut self, sample: CostSample) { + self.request_count = self.request_count.saturating_add(sample.request_count); + self.cache_eligible_count = self + .cache_eligible_count + .saturating_add(sample.cache_eligible_count); + self.cache_hit_count = self.cache_hit_count.saturating_add(sample.cache_hit_count); + self.input_tokens = self.input_tokens.saturating_add(sample.input_tokens); + self.output_tokens = self.output_tokens.saturating_add(sample.output_tokens); + self.cached_tokens = self.cached_tokens.saturating_add(sample.cached_tokens); + self.estimated_cost += sample.estimated_cost; + self.standard_cost += sample.standard_cost; + } + + fn total_tokens(&self) -> u64 { + self.input_tokens.saturating_add(self.output_tokens) + } +} + +fn cost_sample(row: &sqlx::postgres::PgRow) -> CostSample { + let input_tokens = nonnegative(row.get("input_tokens")); + let output_tokens = nonnegative(row.get("output_tokens")); + let cached_tokens = nonnegative(row.get("cached_tokens")).min(input_tokens); + let billing_model: String = row.get("billing_model"); + let service_tier: Option = row.get("service_tier"); + let billing = calculate_aggregate_billing( + input_tokens, + output_tokens, + cached_tokens, + &billing_model, + service_tier.as_deref(), + row.get("is_long"), + ); + CostSample { + request_count: nonnegative(row.get("request_count")), + cache_eligible_count: nonnegative(row.get("cache_eligible_count")), + cache_hit_count: nonnegative(row.get("cache_hit_count")), + input_tokens, + output_tokens, + cached_tokens, + estimated_cost: billing.total_amount, + standard_cost: billing.standard_amount, + } +} + +fn bucket_starts( + start: DateTime, + end: DateTime, + granularity: InsightsGranularity, +) -> Vec> { + let step = granularity.step_seconds(); + let offset = if granularity == InsightsGranularity::Day { + 8 * 60 * 60 + } else { + 0 + }; + let aligned = (start.timestamp() + offset).div_euclid(step) * step - offset; + let Some(mut cursor) = DateTime::::from_timestamp(aligned, 0) else { + return Vec::new(); + }; + let mut points = Vec::new(); + while cursor < end { + points.push(cursor); + let Some(next) = cursor.checked_add_signed(Duration::seconds(step)) else { + break; + }; + cursor = next; + } + points +} + +fn bucket_label(bucket: DateTime, granularity: InsightsGranularity) -> String { + let china = bucket + Duration::hours(8); + match granularity { + InsightsGranularity::Day => china.format("%Y-%m-%d").to_string(), + InsightsGranularity::QuarterHour | InsightsGranularity::Hour => { + china.format("%m-%d %H:%M").to_string() + } + } +} + +fn nonnegative(value: Option) -> u64 { + value.unwrap_or_default().max(0) as u64 +} + +fn rate(numerator: u64, denominator: u64) -> f64 { + if denominator == 0 { + 0.0 + } else { + numerator as f64 / denominator as f64 + } +} + +fn amount_per(amount: f64, count: u64) -> f64 { + if count == 0 { + 0.0 + } else { + amount / count as f64 + } +} + +fn relative_change(current: u64, previous: u64) -> Option { + (previous > 0).then(|| (current as f64 - previous as f64) / previous as f64) +} + +/// 返回缺省观测时间范围。 +pub fn default_time_range(now: DateTime) -> (DateTime, DateTime) { + (now - Duration::days(DEFAULT_RANGE_DAYS), now) +} diff --git a/backend/src/telemetry/usage/mod.rs b/backend/src/telemetry/usage/mod.rs index b45762b9d..6078f0b4b 100644 --- a/backend/src/telemetry/usage/mod.rs +++ b/backend/src/telemetry/usage/mod.rs @@ -1,5 +1,6 @@ //! 成功请求事实。 +pub mod insights; pub mod query; pub mod store; pub mod types; diff --git a/backend/src/telemetry/usage/query.rs b/backend/src/telemetry/usage/query.rs index 04271dec4..c58e9407d 100644 --- a/backend/src/telemetry/usage/query.rs +++ b/backend/src/telemetry/usage/query.rs @@ -1,4 +1,4 @@ -//! 管理端使用记录服务。 +//! 管理端使用统计与请求记录查询服务。 use std::collections::{BTreeMap, HashMap}; @@ -10,6 +10,10 @@ use crate::{ infra::{format::optional_nonnegative_i64_to_u64, json::NumberedPage}, telemetry::{ billing, + usage::insights::{ + UsageDiagnosticsDimension, UsageDiagnosticsInsights, UsageInsightsOverview, + diagnostics, overview, + }, usage::store::{ PgUsageRecordStore, PgUsageRecordStoreResult, UsageRecordFilter, push_filter, }, @@ -114,14 +118,14 @@ pub enum UsageQueryError { Accounts, } -/// 管理端使用记录服务。 +/// 管理端使用统计与请求记录查询服务。 #[derive(Clone)] pub struct UsageQueryService { store: PgUsageRecordStore, } impl UsageQueryService { - /// 构造管理端使用记录服务。 + /// 构造管理端使用统计与请求记录查询服务。 pub fn new(store: PgUsageRecordStore) -> Self { Self { store } } @@ -224,6 +228,29 @@ impl UsageQueryService { .map_err(|_| UsageQueryError::List) } + /// 读取请求健康、性能与成本观测概览。 + pub async fn insights_overview( + &self, + start: DateTime, + end: DateTime, + ) -> Result { + overview(self.store.pool(), start, end) + .await + .map_err(|_| UsageQueryError::List) + } + + /// 按维度读取请求热点诊断。 + pub async fn insights_diagnostics( + &self, + start: DateTime, + end: DateTime, + dimension: UsageDiagnosticsDimension, + ) -> Result { + diagnostics(self.store.pool(), start, end, dimension) + .await + .map_err(|_| UsageQueryError::List) + } + /// 清空日志。 pub async fn clear(&self) -> Result { self.store diff --git a/backend/tests/api/admin/dashboard_routes.rs b/backend/tests/api/admin/dashboard_routes.rs index eb4a9d5ee..4e25b615d 100644 --- a/backend/tests/api/admin/dashboard_routes.rs +++ b/backend/tests/api/admin/dashboard_routes.rs @@ -6,7 +6,7 @@ use chrono::{Duration, Utc}; use codex_proxy_rs::{ api::AppState, bootstrap::services::Services, - infra::time::{china_datetime, china_day_start, china_hour}, + infra::time::{china_datetime, china_day_start, china_quarter_hour_start}, telemetry::{usage::store::PgUsageRecordStore, usage::types::UsageRecord}, upstream::openai::fingerprint::Fingerprint, }; @@ -176,20 +176,15 @@ async fn dashboard_summary_should_keep_trend_after_usage_records_are_cleared() { } #[tokio::test] -async fn dashboard_trend_should_bucket_usage_by_china_hour() { +async fn dashboard_trend_should_bucket_usage_by_china_quarter_hour() { let (app, store, _pool, _dir) = dashboard_test_app( - "dashboard-trend-china-hour", + "dashboard-trend-china-quarter-hour", crate::support::fingerprint::test_fingerprint(), ) .await; - let now = Utc::now(); + let record_at = Utc::now(); store - .append(&usage_record_with_token_parts( - now - Duration::seconds(1), - 40, - 60, - 10, - )) + .append(&usage_record_with_token_parts(record_at, 40, 60, 10)) .await .unwrap(); @@ -201,7 +196,8 @@ async fn dashboard_trend_should_bucket_usage_by_china_hour() { .find(|point| point["requestsValue"] == 1) .expect("trend should include the inserted usage bucket"); - assert_eq!(point["time"], format!("{:02}", china_hour(&now))); + let slot_display = china_datetime(&china_quarter_hour_start(record_at)); + assert_eq!(point["time"], &slot_display[11..16]); assert_eq!(point["tokensValue"], 100); assert_eq!(point["cachedTokensValue"], 10); assert_f64_eq(point["cacheHitRateValue"].as_f64().unwrap(), 0.25); @@ -235,7 +231,7 @@ async fn dashboard_trend_should_only_include_china_today() { .map(|point| point["tokensValue"].as_u64().unwrap()) .sum::(); - assert_eq!(points.first().unwrap()["time"], "00"); + assert_eq!(points.first().unwrap()["time"], "00:00"); assert_eq!(token_total, 7); } @@ -279,6 +275,73 @@ async fn dashboard_latency_trend_should_use_first_token_latency() { assert_eq!(summary[2]["value"], "100 ms"); } +#[tokio::test] +async fn dashboard_latency_trend_should_serialize_missing_samples_as_null() { + let (app, _store, _pool, _dir) = dashboard_test_app( + "dashboard-trend-empty-latency", + crate::support::fingerprint::test_fingerprint(), + ) + .await; + + let body = dashboard_trend(app, "latency").await; + let point = &body["data"]["points"][0]; + let summary_values = body["data"]["summary"] + .as_array() + .unwrap() + .iter() + .map(|item| item["value"].as_str().unwrap()) + .collect::>(); + + assert_eq!( + ( + point["latencyValue"].clone(), + point["maxLatencyValue"].clone(), + point["minLatencyValue"].clone(), + point["latency"].clone(), + summary_values, + ), + ( + Value::Null, + Value::Null, + Value::Null, + Value::from("—"), + vec!["—", "—", "—"], + ) + ); +} + +#[tokio::test] +async fn dashboard_error_trend_should_serialize_missing_success_rate_as_null() { + let (app, _store, _pool, _dir) = dashboard_test_app( + "dashboard-trend-empty-success-rate", + crate::support::fingerprint::test_fingerprint(), + ) + .await; + + let body = dashboard_trend(app, "errors").await; + let point = &body["data"]["points"][0]; + let success_summary = &body["data"]["summary"][1]; + + assert_eq!( + ( + point["requestsValue"].clone(), + point["errorsValue"].clone(), + point["successRateValue"].clone(), + point["successRate"].clone(), + success_summary["value"].clone(), + success_summary["ratio"].clone(), + ), + ( + Value::from(0), + Value::from(0), + Value::Null, + Value::from("—"), + Value::from("—"), + Value::Null, + ) + ); +} + #[tokio::test] async fn dashboard_summary_should_return_requested_trend_kind() { let (app, store, _pool, _dir) = dashboard_test_app( diff --git a/backend/tests/api/admin/usage_routes.rs b/backend/tests/api/admin/usage_routes.rs index 21bdf4830..9285d1a11 100644 --- a/backend/tests/api/admin/usage_routes.rs +++ b/backend/tests/api/admin/usage_routes.rs @@ -116,9 +116,76 @@ async fn usage_summary_and_trend_expose_only_real_success_metrics() { let trend = admin_get(&app, "/api/admin/usage/records/insights/token-trend").await; assert_eq!(trend["data"][0]["inputTokensValue"], 100); + assert_eq!(trend["data"][0]["cacheHitRateValue"], 30.0); assert!(trend["data"][0].get("cacheCreationTokens").is_none()); } +#[tokio::test] +async fn usage_insights_should_collapse_retry_errors_to_terminal_requests() { + let (app, usage, ops, _guard) = monitoring_test_app("monitoring-insights", true).await; + let now = chrono::Utc::now(); + + let mut success = success_record("eventually succeeded"); + success.id = "usage_insights_success".to_string(); + success.request_id = Some("req_retried".to_string()); + success.input_tokens = Some(100); + success.output_tokens = Some(20); + success.cached_tokens = Some(20); + success.latency_ms = Some(800); + success.first_token_ms = Some(200); + success.created_at = now; + usage.append(&success).await.unwrap(); + + let mut retried_error = OpsErrorLog::new("upstream", "retryable"); + retried_error.id = "ops_insights_retried".to_string(); + retried_error.request_id = Some("req_retried".to_string()); + retried_error.failure_class = Some("rate_limited".to_string()); + retried_error.created_at = now - chrono::Duration::seconds(2); + ops.append(&retried_error).await.unwrap(); + + for (id, failure_class, seconds) in [ + ("ops_insights_failed_older", "upstream", 2), + ("ops_insights_failed_latest", "response_failed", 1), + ] { + let mut error = OpsErrorLog::new("upstream", failure_class); + error.id = id.to_string(); + error.request_id = Some("req_failed".to_string()); + error.failure_class = Some(failure_class.to_string()); + error.created_at = now - chrono::Duration::seconds(seconds); + ops.append(&error).await.unwrap(); + } + + let overview = admin_get(&app, "/api/admin/usage/records/insights/overview").await; + assert_eq!(overview["data"]["health"]["totalRequests"], 2); + assert_eq!(overview["data"]["health"]["successRequests"], 1); + assert_eq!(overview["data"]["health"]["failedRequests"], 1); + assert_eq!(overview["data"]["health"]["successRate"], 0.5); + assert_eq!(overview["data"]["granularity"], "1h"); + assert_eq!(overview["data"]["cost"]["cachedTokenRate"], 0.2); + assert_eq!(overview["data"]["cost"]["cacheHitRequestRate"], 1.0); + + let diagnostics = admin_get( + &app, + "/api/admin/usage/records/insights/diagnostics?dimension=failureClass", + ) + .await; + assert_eq!(diagnostics["data"]["items"].as_array().unwrap().len(), 1); + assert_eq!(diagnostics["data"]["items"][0]["name"], "response_failed"); + assert_eq!(diagnostics["data"]["items"][0]["requestShare"], 0.5); + + let model_diagnostics = admin_get( + &app, + "/api/admin/usage/records/insights/diagnostics?dimension=model", + ) + .await; + assert!( + !model_diagnostics["data"]["items"] + .as_array() + .unwrap() + .is_empty() + ); +} + #[tokio::test] async fn usage_route_applies_official_gpt_5_6_sol_pricing() { let (app, usage, _, _guard) = monitoring_test_app("monitoring-gpt-5-6-pricing", true).await; diff --git a/frontend/docs/codex-proxy-rs.pen b/frontend/docs/codex-proxy-rs.pen index 466574b31..baab96c1a 100644 --- a/frontend/docs/codex-proxy-rs.pen +++ b/frontend/docs/codex-proxy-rs.pen @@ -2693,7 +2693,7 @@ "fill": "$cp-text-secondary", "textGrowth": "fixed-width", "width": 300, - "content": "已触发或接近额度阈值", + "content": "已触发额度阈值", "lineHeight": 1.15, "fontFamily": "Inter", "fontSize": 12, @@ -7544,7 +7544,7 @@ "fill": "$cp-text-secondary", "textGrowth": "fixed-width", "width": 300, - "content": "已触发或接近额度阈值", + "content": "已触发额度阈值", "lineHeight": 1.15, "fontFamily": "Inter", "fontSize": 12, diff --git a/frontend/src/api/modules/accounts.ts b/frontend/src/api/modules/accounts.ts index a13a9201d..4a8691fcb 100644 --- a/frontend/src/api/modules/accounts.ts +++ b/frontend/src/api/modules/accounts.ts @@ -1,4 +1,4 @@ -import request, { type ApiPayload } from '../request' +import request from '../request' const ACCOUNT_EXPORT_CONFIRMATION = 'export_sensitive_accounts' @@ -10,7 +10,7 @@ export function getAccounts(params?: any) { }) } -export function importAccounts(data: ApiPayload) { +export function importAccounts(data: any) { return request({ url: '/api/admin/accounts/import', method: 'POST', @@ -29,7 +29,7 @@ export function exportAccounts(params?: any) { }) } -export function authorizeAccountOAuth(data: ApiPayload = {}) { +export function authorizeAccountOAuth(data = {}) { return request({ url: '/api/admin/accounts/oauth/authorize', method: 'POST', @@ -37,7 +37,7 @@ export function authorizeAccountOAuth(data: ApiPayload = {}) { }) } -export function exchangeAccountOAuth(data: ApiPayload) { +export function exchangeAccountOAuth(data: any) { return request({ url: '/api/admin/accounts/oauth/exchange', method: 'POST', @@ -45,7 +45,7 @@ export function exchangeAccountOAuth(data: ApiPayload) { }) } -export function deleteAccounts(data: ApiPayload) { +export function deleteAccounts(data: any) { return request({ url: '/api/admin/accounts/delete', method: 'POST', @@ -53,7 +53,7 @@ export function deleteAccounts(data: ApiPayload) { }) } -export function refreshAccount(data: ApiPayload) { +export function refreshAccount(data: any) { return request({ url: '/api/admin/accounts/refresh', method: 'POST', @@ -61,7 +61,7 @@ export function refreshAccount(data: ApiPayload) { }) } -export function updateAccount(data: ApiPayload) { +export function updateAccount(data: any) { return request({ url: '/api/admin/accounts/update', method: 'POST', @@ -77,7 +77,7 @@ export function getAccountQuota(params: any) { }) } -export function getAccountModels(data: { id: string | number }) { +export function getAccountModels(data: any) { return request({ url: '/api/admin/accounts/models', method: 'GET', diff --git a/frontend/src/api/modules/api-keys.ts b/frontend/src/api/modules/api-keys.ts index 88897afb3..0dc1929cc 100644 --- a/frontend/src/api/modules/api-keys.ts +++ b/frontend/src/api/modules/api-keys.ts @@ -1,57 +1,22 @@ -import request, { type ApiPayload } from '../request' +import request from '../request' -// ==================== API Keys 管理 ==================== - -export interface ApiKeyListParams { - page: number - pageSize: number - search?: string - sortBy?: string - sortDirection?: 'asc' | 'desc' -} - -export interface ApiKeyPageMeta { - page: number - pageSize: number - total: number - totalPages: number -} - -export interface ClientApiKey { - id: string - name: string - label: string | null - prefix: string - key: string - enabled: boolean - createdAt: string - createdAtDisplay: string - lastUsedAt: string | null - lastUsedAtDisplay: string -} - -export interface ApiKeyListResult { - items: ClientApiKey[] - page: ApiKeyPageMeta -} - -export function getApiKeys(params: ApiKeyListParams) { - return request({ +export function getApiKeys(params: any) { + return request({ url: '/api/admin/keys', method: 'GET', params, }) } -export function createApiKey(data: ApiPayload) { - return request({ +export function createApiKey(data: any) { + return request({ url: '/api/admin/keys', method: 'POST', data, }) } -export function deleteApiKeys(data: ApiPayload) { +export function deleteApiKeys(data: any) { return request({ url: '/api/admin/keys/delete', method: 'POST', @@ -59,8 +24,8 @@ export function deleteApiKeys(data: ApiPayload) { }) } -export function updateApiKey(data: ApiPayload) { - return request({ +export function updateApiKey(data: any) { + return request({ url: '/api/admin/keys/update', method: 'POST', data, diff --git a/frontend/src/api/modules/auth.ts b/frontend/src/api/modules/auth.ts index 4cdde1fcb..c6599d1d1 100644 --- a/frontend/src/api/modules/auth.ts +++ b/frontend/src/api/modules/auth.ts @@ -1,6 +1,6 @@ -import request, { type ApiPayload } from '../request' +import request from '../request' -export function login(data: ApiPayload) { +export function login(data: any) { return request({ url: '/api/admin/login', method: 'POST', diff --git a/frontend/src/api/modules/settings.ts b/frontend/src/api/modules/settings.ts index 06935b01b..5d918de97 100644 --- a/frontend/src/api/modules/settings.ts +++ b/frontend/src/api/modules/settings.ts @@ -1,4 +1,4 @@ -import request, { type ApiPayload } from '../request' +import request from '../request' export function getSettings() { return request({ @@ -7,7 +7,7 @@ export function getSettings() { }) } -export function updateSettings(data: ApiPayload) { +export function updateSettings(data: any) { return request({ url: '/api/admin/settings', method: 'POST', diff --git a/frontend/src/api/modules/system.ts b/frontend/src/api/modules/system.ts index b8a11f22b..8c5ac70dc 100644 --- a/frontend/src/api/modules/system.ts +++ b/frontend/src/api/modules/system.ts @@ -3,51 +3,8 @@ import { API_BASE_URL } from '../constants' export const SYSTEM_UPDATE_EVENTS_URL = `${API_BASE_URL}/api/admin/system/update-events` -export interface SystemVersion { - version: string - gitSha: string - buildTime: string - deploymentMode: string - deploymentModeLabel: string - updateChannel: string - latestVersion: string - hasUpdate: boolean - updateCached: boolean - updateWarning?: string -} - -export interface SystemUpdateInfo { - currentVersion: string - latestVersion: string - hasUpdate: boolean - deploymentMode: string - deploymentModeLabel: string - buildType: string - buildTypeLabel: string - releaseUrl?: string - notes?: string - cached: boolean - updateSupported: boolean - unsupportedReason?: string - warning?: string -} - -export interface SystemUpdateStarted { - operationId: string - deploymentMode: string - message: string - needRestart: boolean - targetVersion: string -} - -export interface SystemOperationStarted { - operationId?: string - message: string - needRestart?: boolean -} - -export function getSystemVersion(options: { timeoutMs?: number } = {}) { - return request({ +export function getSystemVersion(options: any = {}) { + return request({ url: '/api/admin/system/version', method: 'GET', ...(options.timeoutMs ? { timeout: options.timeoutMs } : {}), @@ -55,15 +12,15 @@ export function getSystemVersion(options: { timeoutMs?: number } = {}) { } export function getSystemUpdateDetail(refresh = false) { - return request({ + return request({ url: '/api/admin/system/update-detail', method: 'GET', params: { refresh }, }) } -export function performSystemUpdate(targetVersion: string) { - return request({ +export function performSystemUpdate(targetVersion: any) { + return request({ url: '/api/admin/system/update', method: 'POST', data: { targetVersion }, @@ -71,7 +28,7 @@ export function performSystemUpdate(targetVersion: string) { } export function restartSystem() { - return request({ + return request({ url: '/api/admin/system/restart', method: 'POST', }) diff --git a/frontend/src/api/modules/usage.ts b/frontend/src/api/modules/usage.ts index 765957853..6416dffcf 100644 --- a/frontend/src/api/modules/usage.ts +++ b/frontend/src/api/modules/usage.ts @@ -32,33 +32,17 @@ export function getUsageRecordSummary(params?: any) { }) } -export function getUsageRecordModelDistribution(params?: any) { +export function getUsageRecordInsightsOverview(params?: any) { return request({ - url: '/api/admin/usage/records/insights/models', + url: '/api/admin/usage/records/insights/overview', method: 'GET', params, }) } -export function getUsageRecordEndpointDistribution(params?: any) { +export function getUsageRecordInsightsDiagnostics(params: any) { return request({ - url: '/api/admin/usage/records/insights/endpoints', - method: 'GET', - params, - }) -} - -export function getUsageRecordTokenTrend(params?: any) { - return request({ - url: '/api/admin/usage/records/insights/token-trend', - method: 'GET', - params, - }) -} - -export function getUsageRecordLatencyTrend(params?: any) { - return request({ - url: '/api/admin/usage/records/insights/latency-trend', + url: '/api/admin/usage/records/insights/diagnostics', method: 'GET', params, }) diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts index 98e5a3f9a..997b1df85 100644 --- a/frontend/src/api/request.ts +++ b/frontend/src/api/request.ts @@ -7,8 +7,6 @@ import axios, { import { API_BASE_URL, API_TIMEOUT_MS } from './constants' -export type ApiPayload = Record - interface ApiEnvelope { code: number message: string diff --git a/frontend/src/components/base/BaseTable/index.vue b/frontend/src/components/base/BaseTable/index.vue index 62107b3ea..8f3003329 100644 --- a/frontend/src/components/base/BaseTable/index.vue +++ b/frontend/src/components/base/BaseTable/index.vue @@ -31,6 +31,7 @@ const props = withDefaults( rowKey?: RowKey selectedRowKeys?: Array stripe?: boolean + compact?: boolean loading?: boolean emptyText?: string maxHeight?: string @@ -44,6 +45,7 @@ const props = withDefaults( selectedRowKeys: () => [], expandedRowKeys: () => [], stripe: true, + compact: false, loading: false, emptyText: '暂无数据', maxHeight: undefined, @@ -58,12 +60,19 @@ const emit = defineEmits<{ }>() const slots = useSlots() -const headerRowClass = 'h-10 text-[12px] font-bold text-(--cp-text-muted)' -const bodyRowClass = 'h-13' -const headerCellClass = - 'min-w-0 bg-(--cp-bg-subtle) px-4 first:pl-3 shadow-[0_10px_16px_-18px_#0e172638]' -const bodyCellClass = - 'min-w-0 px-4 first:pl-3 text-[13px] text-(--cp-text-primary) first:rounded-l-lg' +const headerRowClass = computed(() => [ + props.compact ? 'h-8 text-[11px]' : 'h-10 text-[12px]', + 'font-bold text-(--cp-text-muted)', +]) +const bodyRowClass = computed(() => (props.compact ? 'h-10' : 'h-13')) +const headerCellClass = computed(() => [ + 'min-w-0 bg-(--cp-bg-subtle) first:pl-3 shadow-[0_10px_16px_-18px_#0e172638]', + props.compact ? 'px-3' : 'px-4', +]) +const bodyCellClass = computed(() => [ + 'min-w-0 first:pl-3 text-(--cp-text-primary) first:rounded-l-lg', + props.compact ? 'px-3 text-[12px]' : 'px-4 text-[13px]', +]) const computedColumns = computed(() => resolveColumns(props.columns, props.minWidth)) const tableViewStyle = computed(() => resolveTableStyle(props.minWidth)) @@ -149,7 +158,7 @@ function isRowExpanded(row: TableRow, index: number) { function rowClass(row: TableRow, index: number) { return [ - bodyRowClass, + bodyRowClass.value, 'hover:[&>td]:bg-(--cp-default-bg-hover)', props.stripe && index % 2 === 1 ? 'bg-(--cp-bg-subtle)' : undefined, isRowSelected(row, index) ? 'bg-(--cp-bg-tertiary)' : undefined, @@ -208,7 +217,8 @@ function sortButtonLabel(column: BaseTableColumn) {
@@ -256,7 +266,7 @@ function sortButtonLabel(column: BaseTableColumn) { aria-hidden="true" > diff --git a/frontend/src/composables/useSystemUpdate.ts b/frontend/src/composables/useSystemUpdate.ts index 274eac1d5..c53ab3a88 100644 --- a/frontend/src/composables/useSystemUpdate.ts +++ b/frontend/src/composables/useSystemUpdate.ts @@ -6,8 +6,6 @@ import { getSystemUpdateDetail, performSystemUpdate, restartSystem, - type SystemUpdateInfo, - type SystemVersion, } from '@/api' import { ApiError } from '@/api/request' @@ -23,8 +21,8 @@ export interface SystemUpdateLog { at: string } -const version = shallowRef(null) -const updateInfo = shallowRef(null) +const version = shallowRef(null) +const updateInfo = shallowRef(null) const loading = shallowRef(false) const checking = shallowRef(false) const updating = shallowRef(false) @@ -39,7 +37,7 @@ const updateStreamError = shallowRef('') const updateEventSource = shallowRef(null) const restartTargetVersion = shallowRef('') -let loadVersionPromise: Promise | undefined +let loadVersionPromise: Promise | undefined let loadSystemPromise: Promise | undefined const maxUpdateLogs = 200 const restartReadyTimeoutMs = 60_000 diff --git a/frontend/src/layout/components/AppSidebar.vue b/frontend/src/layout/components/AppSidebar.vue index 3995d3652..3f0443050 100644 --- a/frontend/src/layout/components/AppSidebar.vue +++ b/frontend/src/layout/components/AppSidebar.vue @@ -58,7 +58,7 @@ const navItems = [ { label: '概览', icon: LayoutDashboard, path: '/' }, { label: '账号管理', icon: Users, path: '/accounts' }, { label: 'API 密钥', icon: KeyRound, path: '/api-keys' }, - { label: '使用记录', icon: ChartNoAxesColumn, path: '/usage' }, + { label: '使用统计', icon: ChartNoAxesColumn, path: '/usage' }, { label: '系统设置', icon: Settings, path: '/settings' }, ] diff --git a/frontend/src/layout/components/SystemUpdateModal.vue b/frontend/src/layout/components/SystemUpdateModal.vue index 00926f1aa..e8d8b5df6 100644 --- a/frontend/src/layout/components/SystemUpdateModal.vue +++ b/frontend/src/layout/components/SystemUpdateModal.vue @@ -19,7 +19,6 @@ import BaseScrollbar from '@/components/base/BaseScrollbar.vue' import { toast } from '@/components/base/BaseToast' import { useSystemUpdate, type SystemUpdateLogLevel } from '@/composables/useSystemUpdate' import { renderMarkdown } from '@/utils/markdown' -import type { SystemUpdateInfo } from '@/api' const open = defineModel({ default: false }) @@ -48,7 +47,7 @@ const { const updateLogScrollbar = useTemplateRef>('updateLogScrollbar') const updateConfirmOpen = shallowRef(false) -const updateConfirmInfo = shallowRef(null) +const updateConfirmInfo = shallowRef(null) const updateConfirmPreviousTarget = shallowRef('') const preparingUpdate = shallowRef(false) diff --git a/frontend/src/plugins/echarts.ts b/frontend/src/plugins/echarts.ts index 35ce068aa..b960e912c 100644 --- a/frontend/src/plugins/echarts.ts +++ b/frontend/src/plugins/echarts.ts @@ -1,6 +1,14 @@ -import { LineChart, PieChart } from 'echarts/charts' +import { BarChart, LineChart, PieChart } from 'echarts/charts' import { GridComponent, LegendPlainComponent, TooltipComponent } from 'echarts/components' import { use } from 'echarts/core' import { CanvasRenderer } from 'echarts/renderers' -use([LineChart, PieChart, GridComponent, LegendPlainComponent, TooltipComponent, CanvasRenderer]) +use([ + BarChart, + LineChart, + PieChart, + GridComponent, + LegendPlainComponent, + TooltipComponent, + CanvasRenderer, +]) diff --git a/frontend/src/views/api-keys/composables/useApiKeyMutations.ts b/frontend/src/views/api-keys/composables/useApiKeyMutations.ts index 9fb830e93..f2eaea5fb 100644 --- a/frontend/src/views/api-keys/composables/useApiKeyMutations.ts +++ b/frontend/src/views/api-keys/composables/useApiKeyMutations.ts @@ -3,7 +3,6 @@ import { clamp } from 'es-toolkit' import { onMounted, ref, type Ref } from 'vue' import { createApiKey, deleteApiKeys, getApiKeys, updateApiKey } from '@/api' -import type { ClientApiKey } from '@/api/modules/api-keys' import { toast } from '@/components/base/BaseToast' import type { BaseTableSort } from '@/components/base/BaseTable/columns' import { useAsyncAction } from '@/composables/useAsyncAction' @@ -19,14 +18,14 @@ export function useApiKeyMutations(options: { }) { const { copy } = useClipboard() const loading = ref(true) - const apiKeys = ref([]) + const apiKeys = ref([]) const showCreateModal = ref(false) const showDeleteModal = ref(false) const showSingleDeleteModal = ref(false) const showKeyModal = ref(false) const createdKey = ref('') const createdKeyName = ref('') - const pendingDeleteKey = ref(null) + const pendingDeleteKey = ref(null) const creatingKeyAction = useAsyncAction() const deletingKeyAction = useAsyncAction() const batchDeletingAction = useAsyncAction() @@ -94,7 +93,7 @@ export function useApiKeyMutations(options: { ) } - function requestDeleteKey(key: ClientApiKey) { + function requestDeleteKey(key: any) { pendingDeleteKey.value = key showSingleDeleteModal.value = true } @@ -134,7 +133,7 @@ export function useApiKeyMutations(options: { ) } - async function handleToggleStatus(key: ClientApiKey) { + async function handleToggleStatus(key: any) { await updatingStatusKeys.run(key.id, async () => { try { await updateApiKey({ id: key.id, status: key.enabled ? 'disabled' : 'active' }) diff --git a/frontend/src/views/api-keys/composables/useApiKeysTable.ts b/frontend/src/views/api-keys/composables/useApiKeysTable.ts index aeba812a3..c7b5b03d7 100644 --- a/frontend/src/views/api-keys/composables/useApiKeysTable.ts +++ b/frontend/src/views/api-keys/composables/useApiKeysTable.ts @@ -1,11 +1,6 @@ import { computed, ref, type Ref } from 'vue' -import type { ClientApiKey } from '@/api/modules/api-keys' - -export function useApiKeysTable( - pageKeys: Ref, - selectedIds = ref>(new Set()), -) { +export function useApiKeysTable(pageKeys: Ref, selectedIds = ref>(new Set())) { const allSelected = computed( () => pageKeys.value.length > 0 && pageKeys.value.every((key) => selectedIds.value.has(key.id)), ) diff --git a/frontend/src/views/api-keys/index.vue b/frontend/src/views/api-keys/index.vue index 49c6c2257..028c41724 100644 --- a/frontend/src/views/api-keys/index.vue +++ b/frontend/src/views/api-keys/index.vue @@ -2,7 +2,6 @@ import { computed, ref, shallowRef } from 'vue' import { API_BASE_URL } from '@/api/constants' -import type { ClientApiKey } from '@/api/modules/api-keys' import BaseCard from '@/components/base/BaseCard.vue' import BaseCheckbox from '@/components/base/BaseCheckbox.vue' import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue' @@ -23,7 +22,7 @@ import ApiKeyUseModal from './components/ApiKeyUseModal.vue' const selectedIds = ref>(new Set()) const totalApiKeys = ref(0) const showUseKeyModal = shallowRef(false) -const selectedUseKey = shallowRef(null) +const selectedUseKey = shallowRef(null) const { page, @@ -100,12 +99,12 @@ function importCreatedKeyToCcs() { }) } -function openUseKeyModal(apiKey: ClientApiKey) { +function openUseKeyModal(apiKey: any) { selectedUseKey.value = apiKey showUseKeyModal.value = true } -function importToCcs(apiKey: ClientApiKey) { +function importToCcs(apiKey: any) { if (!apiKey.key) return window.location.href = buildCodexCcSwitchImportDeeplink({ diff --git a/frontend/src/views/dashboard/components/AccountOverviewCard.vue b/frontend/src/views/dashboard/components/AccountOverviewCard.vue index d9d9547b0..28f68765f 100644 --- a/frontend/src/views/dashboard/components/AccountOverviewCard.vue +++ b/frontend/src/views/dashboard/components/AccountOverviewCard.vue @@ -74,7 +74,7 @@ const statusRows = computed(() => { }, { label: '额度受限', - description: '已触发或接近额度阈值', + description: '已触发额度阈值', value: String(quota), tone: 'warning', icon: TriangleAlert, diff --git a/frontend/src/views/dashboard/components/RequestTrendCard.vue b/frontend/src/views/dashboard/components/RequestTrendCard.vue index 2e4a38df2..5dceea4c5 100644 --- a/frontend/src/views/dashboard/components/RequestTrendCard.vue +++ b/frontend/src/views/dashboard/components/RequestTrendCard.vue @@ -39,31 +39,27 @@ const hasSamples = computed(() => ), ) +const activeRequestBucketCount = computed( + () => props.points.filter((point) => Number(point.requestsValue) > 0).length, +) +const isSparseTrend = computed( + () => activeRequestBucketCount.value > 0 && activeRequestBucketCount.value <= 3, +) + const chartOption = computed(() => { - const times = props.points.map((p) => `${p.time}:00`) + const times = props.points.map((p) => p.time) const series = getSeries() + const coordinate = getCoordinateSystem(times) + return { - grid: { left: 4, right: 0, top: 8, bottom: 24 }, - xAxis: { - type: 'category', - data: times, - axisLabel: { - color: themeColor('--cp-text-muted', '#94A3B8'), - fontSize: 10, - fontFamily: 'JetBrains Mono Variable, JetBrains Mono', - }, - axisLine: { show: false }, - axisTick: { show: false }, - }, - yAxis: [ - { - type: 'value', - splitLine: { lineStyle: { color: themeColor('--cp-bg-muted', '#F1F5F9') } }, - axisLabel: { show: false }, - }, - { type: 'value', min: 0, max: 100, splitLine: { show: false }, axisLabel: { show: false } }, - ], + ...coordinate, series, + axisPointer: + activeKind.value === 'usage' + ? { + link: [{ xAxisIndex: [0, 1, 2] }], + } + : undefined, tooltip: { trigger: 'axis', backgroundColor: themeColor('--cp-bg-surface', '#fff'), @@ -86,6 +82,98 @@ const chartOption = computed(() => { } }) +function getCoordinateSystem(times: string[]) { + const muted = themeColor('--cp-text-muted', '#94A3B8') + const gridLine = themeColor('--cp-bg-muted', '#F1F5F9') + const timeLabels = { + color: muted, + fontSize: 10, + fontFamily: 'JetBrains Mono Variable, JetBrains Mono', + hideOverlap: true, + interval: showTwoHourLabel, + } + + if (activeKind.value === 'usage') { + return getUsageCoordinateSystem(times, timeLabels, gridLine) + } + + const xAxis = { + type: 'category' as const, + data: times, + axisLine: { show: false }, + axisTick: { show: false }, + } + const yAxis = { + type: 'value' as const, + min: 0, + splitNumber: 2, + axisLabel: { show: false }, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { lineStyle: { color: gridLine } }, + } + + return { + grid: { left: 4, right: 0, top: 8, bottom: 24 }, + xAxis: { + ...xAxis, + boundaryGap: activeKind.value !== 'latency', + axisLabel: timeLabels, + }, + yAxis: [ + yAxis, + { + type: 'value' as const, + min: 0, + max: 100, + splitLine: { show: false }, + axisLabel: { show: false }, + }, + ], + } +} + +function getUsageCoordinateSystem(times: string[], timeLabels: any, gridLine: string) { + const xAxis = (gridIndex: number, showLabels = false) => ({ + type: 'category' as const, + data: times, + gridIndex, + boundaryGap: false, + axisLine: { + show: true, + lineStyle: { color: gridLine }, + }, + axisTick: { show: false }, + axisLabel: showLabels ? timeLabels : { show: false }, + axisPointer: { show: true }, + }) + const yAxis = (gridIndex: number) => ({ + type: 'value' as const, + gridIndex, + min: 0, + max: (range: any) => (range.max > 0 ? range.max * 1.12 : 1), + axisLabel: { show: false }, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { show: false }, + }) + + return { + grid: [ + { left: 4, right: 0, top: 0, height: 76 }, + { left: 4, right: 0, top: 91, height: 66 }, + { left: 4, right: 0, top: 172, bottom: 18 }, + ], + xAxis: [xAxis(0), xAxis(1), xAxis(2, true)], + yAxis: [yAxis(0), yAxis(1), yAxis(2)], + } +} + +function showTwoHourLabel(_index: number, value: string) { + const [hour, minute] = value.split(':').map(Number) + return minute === 0 && hour % 2 === 0 +} + function themeColor(name: string, fallback: string) { themeRevision.value return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback @@ -95,6 +183,24 @@ function formatTooltip(params: unknown) { const rows = Array.isArray(params) ? params : [params] const title = tooltipValue(rows[0], 'axisValueLabel') const point = props.points[tooltipIndex(rows[0])] + + if (activeKind.value === 'usage') { + return [ + title, + tooltipItem('输入', point?.inputTokens, trendColor('输入', '--cp-info', '#2563EB')), + tooltipItem('输出', point?.outputTokens, trendColor('输出', '--cp-success', '#10B981')), + tooltipItem('缓存', point?.cachedTokens, trendColor('缓存', '--cp-text-tertiary', '#94A3B8')), + tooltipItem( + '缓存命中', + point?.cacheHitRate, + trendColor('缓存', '--cp-text-tertiary', '#94A3B8'), + ), + tooltipItem('请求', point?.requests, themeColor('--cp-text-secondary', '#64748B')), + ] + .filter(Boolean) + .join('
') + } + const lines = rows.map((row) => { const name = tooltipValue(row, 'seriesName') const value = tooltipValue(row, 'value') @@ -105,6 +211,12 @@ function formatTooltip(params: unknown) { return [title, ...lines].filter(Boolean).join('
') } +function tooltipItem(label: string, value: string | undefined, color: string) { + if (!value) return '' + const marker = `` + return `${marker}${label}: ${value}` +} + function tooltipDisplayValue(point: any, name: string, value: string): string { if (activeKind.value === 'usage') { if (name === '输入') return point?.inputTokens ?? value @@ -139,73 +251,182 @@ function getSeries() { return [ lineSeries( '输入', - props.points.map((p) => p.inputTokensValue), - themeColor('--cp-info', '#2563EB'), - true, + activeSeriesValues('inputTokensValue'), + trendColor('输入', '--cp-info', '#2563EB'), + { + area: true, + smooth: 0.22, + width: 2, + xAxisIndex: 0, + yAxisIndex: 0, + }, ), lineSeries( '输出', - props.points.map((p) => p.outputTokensValue), - themeColor('--cp-success', '#10B981'), + activeSeriesValues('outputTokensValue'), + trendColor('输出', '--cp-success', '#10B981'), + { + area: true, + smooth: 0.22, + width: 1.9, + xAxisIndex: 1, + yAxisIndex: 1, + }, ), lineSeries( '缓存', - props.points.map((p) => p.cachedTokensValue), - themeColor('--cp-text-tertiary', '#94A3B8'), + activeSeriesValues('cachedTokensValue'), + trendColor('缓存', '--cp-text-tertiary', '#94A3B8'), + { + area: true, + smooth: 0.22, + width: 1.8, + xAxisIndex: 2, + yAxisIndex: 2, + }, ), ] } + + if (isSparseTrend.value) return getSparseSeries() + if (activeKind.value === 'latency') { return [ lineSeries( '平均', - props.points.map((p) => p.latencyValue), - themeColor('--cp-normal', '#0F9F9A'), - true, + seriesValues('latencyValue'), + trendColor('平均', '--cp-normal', '#0F9F9A'), + { + area: true, + width: 2.6, + }, ), lineSeries( '最高', - props.points.map((p) => p.maxLatencyValue), - themeColor('--cp-warning', '#F59E0B'), + seriesValues('maxLatencyValue'), + trendColor('最高', '--cp-warning', '#F59E0B'), + { + lineType: 'dashed', + width: 1.8, + }, ), lineSeries( '最低', - props.points.map((p) => p.minLatencyValue), - themeColor('--cp-success', '#10B981'), + seriesValues('minLatencyValue'), + trendColor('最低', '--cp-success', '#10B981'), + { + lineType: 'dotted', + width: 1.8, + }, ), ] } return [ - lineSeries( - '错误数', - props.points.map((p) => p.errorsValue), - themeColor('--cp-danger', '#EF4444'), - true, - ), + barSeries('错误数', seriesValues('errorsValue'), trendColor('错误', '--cp-danger', '#EF4444')), lineSeries( '成功率', - props.points.map((p) => p.successRateValue), - themeColor('--cp-success', '#10B981'), - true, - 1, + seriesValues('successRateValue'), + trendColor('成功', '--cp-success', '#10B981'), + { + width: 2.4, + yAxisIndex: 1, + }, ), lineSeries( '总请求', - props.points.map((p) => p.requestsValue), - themeColor('--cp-info', '#2563EB'), + seriesValues('requestsValue'), + trendColor('请求', '--cp-info', '#2563EB'), + { + lineType: 'dashed', + width: 1.8, + }, ), ] } -function lineSeries(name: string, data: number[], color: string, area = true, yAxisIndex = 0) { +function getSparseSeries() { + if (activeKind.value === 'latency') { + return [ + barSeries( + '平均', + activeSeriesValues('latencyValue'), + trendColor('平均', '--cp-normal', '#0F9F9A'), + { + maxWidth: 9, + opacity: 0.78, + }, + ), + barSeries( + '最高', + activeSeriesValues('maxLatencyValue'), + trendColor('最高', '--cp-warning', '#F59E0B'), + { maxWidth: 9, opacity: 0.58 }, + ), + barSeries( + '最低', + activeSeriesValues('minLatencyValue'), + trendColor('最低', '--cp-success', '#10B981'), + { maxWidth: 9, opacity: 0.58 }, + ), + ] + } + + return [ + barSeries( + '错误数', + activeSeriesValues('errorsValue'), + trendColor('错误', '--cp-danger', '#EF4444'), + { + maxWidth: 10, + opacity: 0.72, + }, + ), + pulseSeries( + '成功率', + activeSeriesValues('successRateValue'), + trendColor('成功', '--cp-success', '#10B981'), + { yAxisIndex: 1 }, + ), + barSeries( + '总请求', + activeSeriesValues('requestsValue'), + trendColor('请求', '--cp-info', '#2563EB'), + { + maxWidth: 10, + opacity: 0.5, + }, + ), + ] +} + +function seriesValues(key: string) { + return props.points.map((point) => point[key] ?? null) +} + +function activeSeriesValues(key: string) { + return props.points.map((point) => + Number(point.requestsValue) > 0 ? (point[key] ?? null) : null, + ) +} + +function lineSeries(name: string, data: (number | null)[], color: string, options: any = {}) { + const area = options.area ?? false + return { name, type: 'line' as const, data, - smooth: true, - symbol: 'none', - yAxisIndex, - lineStyle: { color, width: 2.5 }, + connectNulls: false, + smooth: options.smooth ?? true, + symbol: options.symbol ?? 'none', + symbolSize: options.symbolSize ?? 5, + xAxisIndex: options.xAxisIndex ?? 0, + yAxisIndex: options.yAxisIndex ?? 0, + lineStyle: { + color, + type: options.lineType ?? 'solid', + width: options.width ?? 2.2, + }, itemStyle: { color }, areaStyle: area ? { @@ -225,10 +446,48 @@ function lineSeries(name: string, data: number[], color: string, area = true, yA } } +function pulseSeries(name: string, data: (number | null)[], color: string, options: any = {}) { + return { + name, + type: 'line' as const, + data, + connectNulls: false, + smooth: false, + symbol: 'roundRect', + symbolSize: [5, 16], + yAxisIndex: options.yAxisIndex ?? 0, + lineStyle: { opacity: 0 }, + itemStyle: { color }, + } +} + +function barSeries(name: string, data: (number | null)[], color: string, options: any = {}) { + return { + name, + type: 'bar' as const, + data, + barMaxWidth: options.maxWidth ?? 16, + xAxisIndex: options.xAxisIndex ?? 0, + yAxisIndex: options.yAxisIndex ?? 0, + silent: options.silent ?? false, + z: options.z ?? 2, + itemStyle: { + color, + opacity: options.opacity ?? 0.72, + borderRadius: options.borderRadius ?? [3, 3, 0, 0], + }, + } +} + function summaryMarkerStyle(item: any) { return item.colorVar ? { backgroundColor: `var(${item.colorVar})` } : undefined } +function trendColor(label: string, fallbackVar: string, fallback: string) { + const colorVar = props.summary.find((item) => String(item.label).includes(label))?.colorVar + return themeColor(colorVar || fallbackVar, fallback) +} + function handleTrendChange(value: string) { emit('trendChange', value as TrendKind) } @@ -247,10 +506,10 @@ function handleTrendChange(value: string) {