feat: improve usage insights and dashboard trends

This commit is contained in:
Zyy
2026-07-13 19:09:22 +08:00
parent 6ac38083f6
commit 0d342f2ca9
44 changed files with 2867 additions and 958 deletions
+10 -1
View File
@@ -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<AppState> {
"/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))
+72 -4
View File
@@ -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<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UsageInsightsQuery {
start_time: Option<String>,
end_time: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UsageDiagnosticsQuery {
#[serde(flatten)]
range: UsageInsightsQuery,
dimension: Option<String>,
}
#[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<AppState>,
_auth: AdminAuth,
Query(query): Query<UsageInsightsQuery>,
) -> Result<impl IntoResponse, AdminError> {
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<AppState>,
_auth: AdminAuth,
Query(query): Query<UsageDiagnosticsQuery>,
) -> Result<impl IntoResponse, AdminError> {
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::Utc>, chrono::DateTime<chrono::Utc>), 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<String>) -> Result<UsageDiagnosticsDimension, AdminError> {
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<UsageRecordBreakdown>) -> Vec<UsageRe
impl From<UsageRecordTrendPoint> 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
};
+85 -1
View File
@@ -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();
+71 -59
View File
@@ -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<u64>,
max_latency: String,
max_latency_value: u64,
max_latency_value: Option<u64>,
min_latency: String,
min_latency_value: u64,
min_latency_value: Option<u64>,
success_rate: String,
success_rate_value: f64,
success_rate_value: Option<f64>,
}
#[derive(Debug, Serialize)]
@@ -240,41 +241,51 @@ pub fn dashboard_trend_data_at(
kind: DashboardTrendKind,
now: DateTime<Utc>,
) -> 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::<Vec<_>>();
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::<Vec<_>>();
let average = if samples.is_empty() {
0
} else {
samples.iter().map(|point| point.latency_value).sum::<u64>() / 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::<u64>();
let requests = points.iter().map(|point| point.requests_value).sum::<u64>();
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<u64>,
ratio: Option<f64>,
) -> 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),
}
}
+777
View File
@@ -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<Utc>, end: DateTime<Utc>) -> 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<Self> {
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<f64>,
success_rate_change: Option<f64>,
points: Vec<UsageHealthPoint>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct UsageHealthPoint {
bucket: DateTime<Utc>,
label: String,
success_requests: u64,
failed_requests: u64,
error_rate: f64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct UsagePerformanceInsights {
latency_p50_ms: Option<f64>,
latency_p95_ms: Option<f64>,
latency_p99_ms: Option<f64>,
ttft_p50_ms: Option<f64>,
ttft_p95_ms: Option<f64>,
ttft_p99_ms: Option<f64>,
latency_coverage: f64,
ttft_coverage: f64,
points: Vec<UsagePerformancePoint>,
}
#[derive(Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct UsagePerformancePoint {
bucket: DateTime<Utc>,
label: String,
latency_p50_ms: Option<f64>,
latency_p95_ms: Option<f64>,
latency_p99_ms: Option<f64>,
ttft_p50_ms: Option<f64>,
ttft_p95_ms: Option<f64>,
ttft_p99_ms: Option<f64>,
}
#[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<UsageCostPoint>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct UsageCostPoint {
bucket: DateTime<Utc>,
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<UsageDiagnosticsItem>,
}
#[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<f64>,
estimated_cost: f64,
}
pub async fn overview(
pool: &PgPool,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> PgUsageRecordStoreResult<UsageInsightsOverview> {
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<Utc>,
end: DateTime<Utc>,
dimension: UsageDiagnosticsDimension,
) -> PgUsageRecordStoreResult<UsageDiagnosticsInsights> {
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<Utc>,
end: DateTime<Utc>,
granularity: InsightsGranularity,
) -> PgUsageRecordStoreResult<UsageHealthInsights> {
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::<String, _>("period") == "current" {
current_success = current_success.saturating_add(success);
current_failed = current_failed.saturating_add(failed);
current.insert(
row.get::<DateTime<Utc>, _>("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<Utc>,
end: DateTime<Utc>,
granularity: InsightsGranularity,
) -> PgUsageRecordStoreResult<UsagePerformanceInsights> {
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<Utc> = 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<Utc>,
end: DateTime<Utc>,
granularity: InsightsGranularity,
) -> PgUsageRecordStoreResult<UsageCostInsights> {
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::<DateTime<Utc>, 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<Utc>,
end: DateTime<Utc>,
dimension: UsageDiagnosticsDimension,
) -> PgUsageRecordStoreResult<HashMap<String, f64>> {
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<String> = 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<Utc>,
end: DateTime<Utc>,
granularity: InsightsGranularity,
) -> Vec<DateTime<Utc>> {
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::<Utc>::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<Utc>, 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<i64>) -> 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<f64> {
(previous > 0).then(|| (current as f64 - previous as f64) / previous as f64)
}
/// 返回缺省观测时间范围。
pub fn default_time_range(now: DateTime<Utc>) -> (DateTime<Utc>, DateTime<Utc>) {
(now - Duration::days(DEFAULT_RANGE_DAYS), now)
}
+1
View File
@@ -1,5 +1,6 @@
//! 成功请求事实。
pub mod insights;
pub mod query;
pub mod store;
pub mod types;
+30 -3
View File
@@ -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<Utc>,
end: DateTime<Utc>,
) -> Result<UsageInsightsOverview, UsageQueryError> {
overview(self.store.pool(), start, end)
.await
.map_err(|_| UsageQueryError::List)
}
/// 按维度读取请求热点诊断。
pub async fn insights_diagnostics(
&self,
start: DateTime<Utc>,
end: DateTime<Utc>,
dimension: UsageDiagnosticsDimension,
) -> Result<UsageDiagnosticsInsights, UsageQueryError> {
diagnostics(self.store.pool(), start, end, dimension)
.await
.map_err(|_| UsageQueryError::List)
}
/// 清空日志。
pub async fn clear(&self) -> Result<ClearUsageRecords, UsageQueryError> {
self.store
+75 -12
View File
@@ -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::<u64>();
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::<Vec<_>>();
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(
+67
View File
@@ -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;
+2 -2
View File
@@ -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,
+8 -8
View File
@@ -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',
+8 -43
View File
@@ -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<ApiKeyListResult>({
export function getApiKeys(params: any) {
return request({
url: '/api/admin/keys',
method: 'GET',
params,
})
}
export function createApiKey(data: ApiPayload) {
return request<ClientApiKey>({
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<ClientApiKey>({
export function updateApiKey(data: any) {
return request({
url: '/api/admin/keys/update',
method: 'POST',
data,
+2 -2
View File
@@ -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',
+2 -2
View File
@@ -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',
+6 -49
View File
@@ -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<SystemVersion>({
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<SystemUpdateInfo>({
return request({
url: '/api/admin/system/update-detail',
method: 'GET',
params: { refresh },
})
}
export function performSystemUpdate(targetVersion: string) {
return request<SystemUpdateStarted>({
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<SystemOperationStarted>({
return request({
url: '/api/admin/system/restart',
method: 'POST',
})
+4 -20
View File
@@ -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,
})
-2
View File
@@ -7,8 +7,6 @@ import axios, {
import { API_BASE_URL, API_TIMEOUT_MS } from './constants'
export type ApiPayload = Record<string, unknown>
interface ApiEnvelope<T = unknown> {
code: number
message: string
@@ -31,6 +31,7 @@ const props = withDefaults(
rowKey?: RowKey
selectedRowKeys?: Array<string | number>
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) {
<div class="flex min-h-0 max-w-full flex-1 flex-col overflow-hidden">
<div ref="headerWrap" class="max-w-full overflow-hidden">
<table
class="w-full shrink-0 table-fixed border-separate border-spacing-y-1 text-left"
class="w-full shrink-0 table-fixed border-separate text-left"
:class="compact ? 'border-spacing-y-0.5' : 'border-spacing-y-1'"
:style="tableViewStyle"
role="table"
>
@@ -256,7 +266,7 @@ function sortButtonLabel(column: BaseTableColumn) {
aria-hidden="true"
>
<Triangle
class="size-[5px] fill-current"
class="size-1.25 fill-current"
:class="
columnSortDirection(column) === 'asc'
? 'text-(--cp-info)'
@@ -265,7 +275,7 @@ function sortButtonLabel(column: BaseTableColumn) {
:stroke-width="0"
/>
<Triangle
class="size-[5px] rotate-180 fill-current"
class="size-1.25 rotate-180 fill-current"
:class="
columnSortDirection(column) === 'desc'
? 'text-(--cp-info)'
@@ -296,7 +306,8 @@ function sortButtonLabel(column: BaseTableColumn) {
>
<table
ref="tableView"
class="w-full table-fixed border-separate border-spacing-y-2 text-left"
class="w-full table-fixed border-separate text-left"
:class="compact ? 'border-spacing-y-1' : 'border-spacing-y-2'"
:style="tableViewStyle"
role="table"
>
+3 -5
View File
@@ -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<SystemVersion | null>(null)
const updateInfo = shallowRef<SystemUpdateInfo | null>(null)
const version = shallowRef<any>(null)
const updateInfo = shallowRef<any>(null)
const loading = shallowRef(false)
const checking = shallowRef(false)
const updating = shallowRef(false)
@@ -39,7 +37,7 @@ const updateStreamError = shallowRef('')
const updateEventSource = shallowRef<EventSource | null>(null)
const restartTargetVersion = shallowRef('')
let loadVersionPromise: Promise<SystemVersion> | undefined
let loadVersionPromise: Promise<any> | undefined
let loadSystemPromise: Promise<void> | undefined
const maxUpdateLogs = 200
const restartReadyTimeoutMs = 60_000
@@ -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' },
]
@@ -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<boolean>({ default: false })
@@ -48,7 +47,7 @@ const {
const updateLogScrollbar = useTemplateRef<InstanceType<typeof BaseScrollbar>>('updateLogScrollbar')
const updateConfirmOpen = shallowRef(false)
const updateConfirmInfo = shallowRef<SystemUpdateInfo | null>(null)
const updateConfirmInfo = shallowRef<any>(null)
const updateConfirmPreviousTarget = shallowRef('')
const preparingUpdate = shallowRef(false)
+10 -2
View File
@@ -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,
])
@@ -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<ClientApiKey[]>([])
const apiKeys = ref<any[]>([])
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<ClientApiKey | null>(null)
const pendingDeleteKey = ref<any>(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' })
@@ -1,11 +1,6 @@
import { computed, ref, type Ref } from 'vue'
import type { ClientApiKey } from '@/api/modules/api-keys'
export function useApiKeysTable(
pageKeys: Ref<ClientApiKey[]>,
selectedIds = ref<Set<string>>(new Set()),
) {
export function useApiKeysTable(pageKeys: Ref<any[]>, selectedIds = ref<Set<string>>(new Set())) {
const allSelected = computed(
() => pageKeys.value.length > 0 && pageKeys.value.every((key) => selectedIds.value.has(key.id)),
)
+3 -4
View File
@@ -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<Set<string>>(new Set())
const totalApiKeys = ref(0)
const showUseKeyModal = shallowRef(false)
const selectedUseKey = shallowRef<ClientApiKey | null>(null)
const selectedUseKey = shallowRef<any>(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({
@@ -74,7 +74,7 @@ const statusRows = computed(() => {
},
{
label: '额度受限',
description: '已触发或接近额度阈值',
description: '已触发额度阈值',
value: String(quota),
tone: 'warning',
icon: TriangleAlert,
@@ -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<EChartsOption>(() => {
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<EChartsOption>(() => {
}
})
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('<br/>')
}
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('<br/>')
}
function tooltipItem(label: string, value: string | undefined, color: string) {
if (!value) return ''
const marker = `<span style="display:inline-block;width:7px;height:7px;margin-right:6px;border-radius:999px;background:${color}"></span>`
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) {
<template #body>
<div
class="mt-4.75 grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(150px,180px)] lg:gap-7.5"
class="mt-4.75 grid grid-cols-1 gap-5 lg:h-70 lg:grid-cols-[minmax(0,1fr)_minmax(150px,180px)] lg:gap-7.5"
>
<div class="relative h-67 w-full overflow-hidden">
<BaseChart v-if="hasSamples" :option="chartOption" :height="268" />
<div class="relative h-70 w-full overflow-hidden lg:h-full">
<BaseChart v-if="hasSamples" :option="chartOption" :height="280" />
<BaseEmpty
v-if="!hasSamples"
compact
@@ -260,7 +519,9 @@ function handleTrendChange(value: string) {
/>
</div>
<aside class="grid h-67 w-full grid-rows-3 rounded-2xl bg-(--cp-bg-subtle) px-5 py-4.5">
<aside
class="grid h-70 w-full grid-rows-3 rounded-2xl bg-(--cp-bg-subtle) px-5 py-4.5 lg:h-full"
>
<div
v-for="item in props.summary"
:key="item.label"
@@ -185,7 +185,7 @@ export function useDashboard(): any {
function metricCards(summary: any) {
const { accounts, traffic, tokens, cache } = summary.cards
const points = summary.trend.points as any[]
const points = recentTrendWindow(summary.trend.points as any[])
return [
{
title: '账号',
@@ -281,6 +281,16 @@ export function useDashboard(): any {
return values.some((value) => value > 0) ? { values, tone } : undefined
}
function recentTrendWindow(points: any[]) {
let lastActiveIndex = points.length - 1
while (lastActiveIndex >= 0 && Number(points[lastActiveIndex]?.requestsValue) <= 0) {
lastActiveIndex -= 1
}
if (lastActiveIndex < 0) return []
return points.slice(Math.max(0, lastActiveIndex - 8), lastActiveIndex + 1)
}
function formatDashboardCompactNumber(value: number) {
const normalized = Math.max(0, Math.round(value))
@@ -313,7 +323,14 @@ export function useDashboard(): any {
}
function applyTrend(trend: any) {
trendPoints.value = trend.points
const points = trend.kind === 'usage' ? aggregateUsageTrend(trend.points) : trend.points
trendPoints.value = points
if (trend.kind === 'usage') {
trendSummary.value = usageTrendSummary(points)
return
}
trendSummary.value = (trend.summary as any[]).map((item) => {
if (trend.kind === 'latency') {
return {
@@ -340,6 +357,77 @@ export function useDashboard(): any {
})
}
function aggregateUsageTrend(points: any[]) {
return Array.from({ length: Math.ceil(points.length / 2) }, (_, groupIndex) => {
const group = points.slice(groupIndex * 2, groupIndex * 2 + 2)
const first = group[0]
const sum = (key: string) =>
group.reduce((total, point) => total + Number(point[key] ?? 0), 0)
const requestsValue = sum('requestsValue')
const errorsValue = sum('errorsValue')
const inputTokensValue = sum('inputTokensValue')
const outputTokensValue = sum('outputTokensValue')
const cachedTokensValue = Math.min(inputTokensValue, sum('cachedTokensValue'))
const uncachedInputTokensValue = inputTokensValue - cachedTokensValue
const effectiveTokensValue = uncachedInputTokensValue + outputTokensValue
const cacheHitRateValue = inputTokensValue ? cachedTokensValue / inputTokensValue : null
const successRateValue = requestsValue
? ((requestsValue - errorsValue) / requestsValue) * 100
: null
return {
...first,
requests: formatDashboardCompactNumber(requestsValue),
requestsValue,
inputTokens: formatDashboardCompactNumber(inputTokensValue),
inputTokensValue,
outputTokens: formatDashboardCompactNumber(outputTokensValue),
outputTokensValue,
cachedTokens: formatDashboardCompactNumber(cachedTokensValue),
cachedTokensValue,
uncachedInputTokens: formatDashboardCompactNumber(uncachedInputTokensValue),
uncachedInputTokensValue,
effectiveTokens: formatDashboardCompactNumber(effectiveTokensValue),
effectiveTokensValue: effectiveTokensValue > 0 ? effectiveTokensValue : null,
cacheHitRate: cacheHitRateValue === null ? '—' : formatDashboardRate(cacheHitRateValue),
cacheHitRateValue,
tokensValue: inputTokensValue + outputTokensValue,
errors: formatDashboardCompactNumber(errorsValue),
errorsValue,
successRate: successRateValue === null ? '—' : `${successRateValue.toFixed(1)}%`,
successRateValue,
}
})
}
function usageTrendSummary(points: any[]) {
const total = (key: string) => points.reduce((sum, point) => sum + Number(point[key] ?? 0), 0)
const inputTokens = total('inputTokensValue')
const outputTokens = total('outputTokensValue')
const cachedTokens = Math.min(inputTokens, total('cachedTokensValue'))
return [
{
label: '输入',
value: formatDashboardCompactNumber(inputTokens),
tone: 'info',
colorVar: '--cp-info',
},
{
label: '输出',
value: formatDashboardCompactNumber(outputTokens),
tone: 'success',
colorVar: '--cp-success',
},
{
label: '缓存',
value: formatDashboardCompactNumber(cachedTokens),
tone: 'normal',
colorVar: '--cp-text-tertiary',
},
]
}
function accountUsageItem(item: any) {
const quotaPercent = quotaUsedPercent(item.quotaUsedPercent)
return {
@@ -0,0 +1,256 @@
<script setup lang="ts">
import type { EChartsOption, LineSeriesOption } from 'echarts'
import { computed, shallowRef } from 'vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
import BaseChart from '@/components/charts/BaseChart.vue'
import { useUsageChartPalette } from '../composables/useUsageChartPalette'
import {
tooltipIndex,
tooltipRows,
usageCategoryAxis,
usageTooltip,
usageValueAxis,
} from '../utils/chart'
import {
escapeTooltip,
formatCompactNumber,
formatPercent,
formatUsd,
formatUsdAxis,
} from '../utils/format'
const props = withDefaults(
defineProps<{
cost: any
loading?: boolean
}>(),
{
loading: false,
},
)
const activeView = shallowRef('cost')
const { palette } = useUsageChartPalette()
const points = computed<any[]>(() => props.cost.points ?? [])
const viewOptions = [
{ label: '费用', value: 'cost' },
{ label: 'Token', value: 'tokens' },
{ label: '缓存', value: 'cache' },
]
const hasData = computed(() => {
if (props.loading || points.value.length === 0) return false
if (activeView.value === 'cost') {
return points.value.some((point) => point.estimatedCost > 0 || point.standardCost > 0)
}
if (activeView.value === 'tokens') {
return points.value.some((point) => point.totalTokens > 0 || point.cachedTokens > 0)
}
return points.value.some((point) => point.cachedTokenRate > 0 || point.cacheHitRequestRate > 0)
})
const chartOption = computed<EChartsOption>(() => {
const theme = palette.value
const legend = legendNames()
return {
animationDuration: 240,
grid: { left: 52, right: 12, top: 40, bottom: 28 },
legend: {
top: 0,
right: 4,
itemWidth: 8,
itemHeight: 8,
icon: 'circle',
data: legend,
textStyle: {
color: theme.textSecondary,
fontSize: 11,
fontFamily: 'Inter Variable, Inter, system-ui, sans-serif',
fontWeight: 650,
},
},
tooltip: usageTooltip(theme, formatTooltip),
xAxis: usageCategoryAxis(
points.value.map((point) => point.label),
theme,
),
yAxis: usageValueAxis(theme, axisFormatter(), {
min: activeView.value === 'cache' ? 0 : undefined,
max: activeView.value === 'cache' ? 1 : undefined,
}),
series: chartSeries(theme),
}
})
function legendNames() {
if (activeView.value === 'tokens') return ['未缓存输入', '缓存输入', '输出']
if (activeView.value === 'cache') return ['缓存 Token 占比', '命中请求率']
return ['预估费用', '标准费用']
}
function axisFormatter() {
if (activeView.value === 'cost') return formatUsdAxis
if (activeView.value === 'cache') return (value: number) => formatPercent(value)
return (value: number) => formatCompactNumber(value)
}
function chartSeries(theme: any): LineSeriesOption[] {
const chartPoints = points.value
if (activeView.value === 'tokens') {
return [
lineSeries(
'未缓存输入',
chartPoints.map((point) => Math.max(0, point.inputTokens - point.cachedTokens)),
theme.info,
'tokens',
),
lineSeries(
'缓存输入',
chartPoints.map((point) => point.cachedTokens),
theme.normal,
'tokens',
),
lineSeries(
'输出',
chartPoints.map((point) => point.outputTokens),
theme.success,
'tokens',
),
]
}
if (activeView.value === 'cache') {
return [
lineSeries(
'缓存 Token 占比',
chartPoints.map((point) => point.cachedTokenRate),
theme.normal,
),
lineSeries(
'命中请求率',
chartPoints.map((point) => point.cacheHitRequestRate),
theme.success,
),
]
}
return [
lineSeries(
'预估费用',
chartPoints.map((point) => point.estimatedCost),
theme.success,
undefined,
true,
),
lineSeries(
'标准费用',
chartPoints.map((point) => point.standardCost),
theme.textMuted,
),
]
}
function lineSeries(
name: string,
data: number[],
color: string,
stack?: string,
area = Boolean(stack),
): LineSeriesOption {
return {
name,
type: 'line',
data,
stack,
smooth: true,
showSymbol: data.length <= 12,
symbol: 'circle',
symbolSize: 5,
lineStyle: { color, width: 2.2 },
itemStyle: { color },
areaStyle: area
? {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: `${color}30` },
{ offset: 1, color: `${color}08` },
],
},
}
: undefined,
}
}
function formatTooltip(params: unknown) {
const rows = tooltipRows(params)
const point = points.value[tooltipIndex(rows[0])]
if (!point) return ''
const title = escapeTooltip(point.label)
if (activeView.value === 'tokens') {
return [
title,
`未缓存输入: ${formatCompactNumber(Math.max(0, point.inputTokens - point.cachedTokens))}`,
`缓存输入: ${formatCompactNumber(point.cachedTokens)}`,
`输出: ${formatCompactNumber(point.outputTokens)}`,
`总 Token: ${formatCompactNumber(point.totalTokens)}`,
].join('<br/>')
}
if (activeView.value === 'cache') {
return [
title,
`缓存 Token 占比: ${formatPercent(point.cachedTokenRate)}`,
`命中请求率: ${formatPercent(point.cacheHitRequestRate)}`,
].join('<br/>')
}
return [
title,
`预估费用: ${formatUsd(point.estimatedCost)}`,
`标准费用: ${formatUsd(point.standardCost)}`,
].join('<br/>')
}
</script>
<template>
<BaseCard
as="article"
:padded="false"
title="成本效率"
description="预估费用、Token 与缓存收益"
header-collapse-at="none"
header-class="px-5 pt-4"
body-class="px-5 pt-3 pb-4"
class="h-full min-h-90"
>
<template #actions>
<BaseSegmented v-model="activeView" :options="viewOptions" :disabled="loading" class="w-50" />
</template>
<template #body>
<div class="min-h-66">
<BaseChart v-if="hasData" :option="chartOption" :height="264" />
<BaseEmpty
v-else
compact
plain
:title="loading ? '正在加载成本数据' : '暂无成本效率数据'"
description="当前范围没有可绘制的费用或 Token 数据"
class="h-66 place-content-center"
/>
</div>
</template>
</BaseCard>
</template>
@@ -0,0 +1,197 @@
<script setup lang="ts">
import { CornerDownRight } from '@lucide/vue'
import { computed } from 'vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
import BaseTable from '@/components/base/BaseTable/index.vue'
import { formatCompactNumber, formatDuration, formatPercent, formatUsd } from '../utils/format'
const props = withDefaults(
defineProps<{
diagnostics: any
loading?: boolean
}>(),
{
loading: false,
},
)
const dimension = defineModel('dimension', { type: String, required: true })
const dimensionOptions = [
{ label: '模型', value: 'model' },
{ label: '账号', value: 'account' },
{ label: '密钥', value: 'apiKey' },
{ label: '上游', value: 'provider' },
{ label: '传输', value: 'transport' },
{ label: '错误', value: 'failureClass' },
]
const diagnosticColumns = [
{ key: 'nameDisplay', label: '维度', width: '220px', ellipsis: false },
{
key: 'requestCount',
label: '请求 / 占比',
width: '104px',
align: 'right' as const,
ellipsis: false,
},
{
key: 'errorCount',
label: '错误 / 错误率',
width: '114px',
align: 'right' as const,
ellipsis: false,
},
{ key: 'latencyP95Ms', label: 'P95', width: '90px', align: 'right' as const, ellipsis: false },
{
key: 'estimatedCost',
label: '预估费用',
width: '112px',
align: 'right' as const,
ellipsis: false,
headerClass: '!pr-5',
cellClass: '!pr-5',
},
]
const dimensionLabel = computed(
() => dimensionOptions.find((option) => option.value === dimension.value)?.label ?? '维度',
)
const isStale = computed(() => props.diagnostics.dimension !== dimension.value)
const sortedItems = computed(() =>
[...props.diagnostics.items].sort(
(left, right) => right.errorCount - left.errorCount || right.requestCount - left.requestCount,
),
)
const displayItems = computed(() =>
sortedItems.value.map((item) => ({
...item,
nameDisplay: diagnosticNameDisplay(item.name),
})),
)
const tableRows = computed(() => (isStale.value ? [] : displayItems.value))
function diagnosticNameDisplay(name: string) {
const full = name.trim() || '未知'
if (dimension.value !== 'model') {
return { primary: full, secondary: '', full }
}
const [primary, secondary = ''] = full.split(/\s+(?:→|->)\s+/, 2)
return {
primary: primary || full,
secondary,
full,
}
}
</script>
<template>
<BaseCard
as="article"
:padded="false"
title="热点诊断"
:description="`按${dimensionLabel}定位错误、慢请求与费用热点`"
header-collapse-at="lg"
header-class="px-5 pt-4"
body-class="min-h-0 min-w-0 px-5 pt-3"
class="grid h-105 min-h-105 max-h-105 min-w-0 w-full grid-rows-[auto_minmax(0,1fr)] lg:h-90 lg:min-h-90 lg:max-h-90"
>
<template #actions>
<BaseSegmented
v-model="dimension"
:options="dimensionOptions"
:disabled="loading"
class="w-full min-w-0 lg:w-80"
/>
</template>
<template #body>
<BaseTable
:key="dimension"
class="min-h-0 w-full"
:columns="diagnosticColumns"
:rows="tableRows"
:loading="loading || isStale"
compact
row-key="name"
empty-text="暂无诊断数据"
max-height="230px"
min-width="640px"
>
<template #header-nameDisplay>
{{ dimensionLabel }}
</template>
<template #nameDisplay="{ row }">
<div class="inline-grid max-w-full min-w-0 gap-1" :title="row.nameDisplay.full">
<code
class="block max-w-full truncate font-mono text-[12px] leading-none font-[760] text-(--cp-text-primary)"
>
{{ row.nameDisplay.primary }}
</code>
<div
v-if="row.nameDisplay.secondary"
class="flex min-w-0 items-center gap-1.25 text-(--cp-text-secondary)"
>
<CornerDownRight class="size-3.25 shrink-0 text-(--cp-info)" stroke-width="2.4" />
<code class="block truncate font-mono text-[11px] leading-none font-bold">
{{ row.nameDisplay.secondary }}
</code>
</div>
</div>
</template>
<template #requestCount="{ row }">
<span
class="grid justify-items-end gap-1 font-mono leading-none tabular-nums"
:title="`成功 ${formatCompactNumber(row.successCount)}`"
>
<strong class="font-[720] text-(--cp-text-primary)">
{{ formatCompactNumber(row.requestCount) }}
</strong>
<small class="text-[10px] font-[650] text-(--cp-text-muted)">
{{ formatPercent(row.requestShare) }}
</small>
</span>
</template>
<template #errorCount="{ row }">
<span class="grid justify-items-end gap-1 font-mono leading-none tabular-nums">
<strong
class="font-[720]"
:class="row.errorCount > 0 ? 'text-(--cp-danger-text)' : 'text-(--cp-text-primary)'"
>
{{ formatCompactNumber(row.errorCount) }}
</strong>
<small
class="text-[10px] font-[650]"
:class="row.errorRate > 0 ? 'text-(--cp-danger-text)' : 'text-(--cp-text-muted)'"
>
{{ formatPercent(row.errorRate) }}
</small>
</span>
</template>
<template #latencyP95Ms="{ row }">
<span class="font-mono font-[680] tabular-nums text-(--cp-warning-text)">
{{ formatDuration(row.latencyP95Ms) }}
</span>
</template>
<template #estimatedCost="{ row }">
<span class="font-mono font-[680] tabular-nums text-(--cp-success-text)">
{{ formatUsd(row.estimatedCost) }}
</span>
</template>
</BaseTable>
</template>
</BaseCard>
</template>
@@ -1,227 +0,0 @@
<script setup lang="ts">
import { CornerDownRight } from '@lucide/vue'
import { computed } from 'vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
const props = defineProps<{
title: string
description: string
nameLabel: string
items: any[]
color: string
sourceOptions?: any[]
showAccountBillingColumn?: boolean
}>()
const source = defineModel<string>('source', { default: '' })
const sortedItems = computed(() =>
[...(props.items || [])].sort((left, right) => metricValue(right) - metricValue(left)),
)
const totalMetric = computed(() =>
sortedItems.value.reduce((sum, item) => sum + metricValue(item), 0),
)
const totalMetricText = computed(() => sortedItems.value[0]?.totalTokensTotal ?? '0')
const tableGridClass = computed(() =>
props.showAccountBillingColumn === false
? 'grid-cols-[minmax(128px,1fr)_repeat(4,minmax(66px,0.75fr))]'
: 'grid-cols-[minmax(128px,1fr)_repeat(5,minmax(64px,0.75fr))]',
)
const tableWidthClass = computed(() =>
props.showAccountBillingColumn === false ? 'min-w-[414px] w-full' : 'min-w-[492px] w-full',
)
const hasData = computed(() =>
sortedItems.value.some(
(item) => Number(item.requestCountValue || 0) > 0 || Number(item.totalTokensValue || 0) > 0,
),
)
const chartColors = computed(() => [
props.color,
'--cp-success',
'--cp-normal',
'--cp-warning',
'--cp-text-tertiary',
])
const donutStyle = computed(() => {
if (!totalMetric.value) {
return {
background: 'var(--cp-bg-muted)',
}
}
let cursor = 0
const stops = sortedItems.value.map((item, index) => {
const start = cursor
const size = (metricValue(item) / totalMetric.value) * 100
cursor += size
const color = `var(${chartColors.value[index % chartColors.value.length]})`
return `${color} ${start}% ${cursor}%`
})
return {
background: `conic-gradient(${stops.join(', ')})`,
}
})
function metricValue(item: any) {
return Number(item.totalTokensValue || 0)
}
function distributionNameParts(name: unknown) {
const text = typeof name === 'string' && name.trim() ? name.trim() : '未知'
const [primary, secondary] = text.split(' -> ', 2).map((part) => part.trim())
return {
primary: primary || text,
secondary: secondary || '',
full: text,
}
}
</script>
<template>
<BaseCard
:padded="false"
:title="title"
:description="description"
header-class="px-5 pt-4"
body-class="px-5 pt-3 pb-4"
>
<template #actions>
<BaseSegmented
v-if="sourceOptions?.length"
v-model="source"
:options="sourceOptions"
class="w-56"
/>
</template>
<template #body>
<div
v-if="hasData"
class="grid h-70 min-h-0 grid-cols-1 gap-4 lg:h-64 lg:grid-cols-[190px_minmax(0,1fr)]"
>
<div class="relative flex h-34 min-h-0 shrink-0 items-center justify-center lg:h-auto">
<div
class="size-34 rounded-full transition-transform duration-200 hover:scale-[1.02] lg:size-42"
:style="donutStyle"
/>
<div class="absolute size-18 rounded-full bg-(--cp-bg-surface) lg:size-23" />
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<div class="grid text-center">
<span class="text-[11px] font-bold text-(--cp-text-muted)">Token</span>
<strong
class="mt-1 font-mono text-[16px] leading-none font-extrabold tabular-nums text-(--cp-text-primary) lg:text-[18px]"
:title="totalMetricText"
>
{{ totalMetricText }}
</strong>
</div>
</div>
</div>
<div class="min-h-0 min-w-0 overflow-hidden">
<BaseScrollbar horizontal view-class="pb-2">
<div class="flex min-h-0 flex-col" :class="tableWidthClass">
<div
class="grid shrink-0 gap-2 px-3 pb-2 text-[12px] font-bold text-(--cp-text-muted)"
:class="tableGridClass"
>
<span>{{ nameLabel }}</span>
<span class="text-center">请求</span>
<span class="text-center">Token</span>
<span class="text-center">实际</span>
<span v-if="showAccountBillingColumn !== false" class="text-center">计费</span>
<span class="text-center">标准</span>
</div>
<div class="grid gap-1.5">
<div
v-for="item in sortedItems"
:key="item.name"
class="grid items-center gap-2 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3 py-2.5 text-[12px] font-[650]"
:class="tableGridClass"
>
<div class="min-w-0">
<div class="flex min-w-0 items-start">
<div
class="inline-grid min-w-0 gap-1"
:title="distributionNameParts(item.name).full"
>
<code
class="block max-w-full truncate font-mono text-[12px] leading-none font-[760] text-(--cp-text-primary)"
>
{{ distributionNameParts(item.name).primary }}
</code>
<div
v-if="distributionNameParts(item.name).secondary"
class="flex min-w-0 items-center gap-1.25 text-(--cp-text-secondary)"
>
<CornerDownRight
class="size-3.25 shrink-0 text-(--cp-info)"
stroke-width="2.4"
/>
<code
class="block truncate font-mono text-[11px] leading-none font-[700]"
>
{{ distributionNameParts(item.name).secondary }}
</code>
</div>
</div>
</div>
</div>
<span class="text-center font-mono tabular-nums text-(--cp-text-primary)">
{{ item.requestCount }}
</span>
<span
class="text-center font-mono tabular-nums text-(--cp-text-secondary)"
:title="item.totalTokens"
>
{{ item.totalTokens }}
</span>
<span
class="text-center font-mono tabular-nums text-(--cp-success-text)"
:title="item.actualBillingAmount"
>
{{ item.actualBillingAmount }}
</span>
<span
v-if="showAccountBillingColumn !== false"
class="text-center font-mono tabular-nums text-(--cp-warning-text)"
:title="item.accountBillingAmount"
>
{{ item.accountBillingAmount }}
</span>
<span
class="text-center font-mono tabular-nums text-(--cp-text-secondary)"
:title="item.standardBillingAmount"
>
{{ item.standardBillingAmount }}
</span>
</div>
</div>
</div>
</BaseScrollbar>
</div>
</div>
<BaseEmpty
v-else
compact
plain
title="暂无分布数据"
description="当前范围暂无使用记录"
class="h-70 place-content-center lg:h-64"
/>
</template>
</BaseCard>
</template>
@@ -17,12 +17,8 @@ const emit = defineEmits<{
</script>
<template>
<div
class="flex w-full flex-col gap-3 md:flex-row md:flex-wrap md:items-center"
role="group"
aria-label="使用记录筛选与操作"
>
<div class="min-w-0 w-full md:w-96 md:flex-none">
<div class="flex w-full items-center gap-3" role="group" aria-label="使用记录筛选与操作">
<div class="min-w-0 flex-1 sm:w-96 sm:flex-none">
<BaseInput v-model="search" placeholder="搜索请求 ID、端点、模型或消息" class="w-full">
<template #prefix>
<Search class="size-4.5 text-(--cp-text-tertiary)" />
@@ -30,7 +26,7 @@ const emit = defineEmits<{
</BaseInput>
</div>
<div class="flex shrink-0 self-end items-center justify-end gap-2 md:ml-auto">
<div class="ml-auto flex shrink-0 items-center justify-end">
<BaseButton
icon-only
variant="ghost"
@@ -0,0 +1,177 @@
<script setup lang="ts">
import { BarChart } from 'echarts/charts'
import type { EChartsOption } from 'echarts'
import { use } from 'echarts/core'
import { computed } from 'vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseChart from '@/components/charts/BaseChart.vue'
import { useUsageChartPalette } from '../composables/useUsageChartPalette'
import {
tooltipIndex,
tooltipRows,
usageCategoryAxis,
usageTooltip,
usageValueAxis,
} from '../utils/chart'
import { escapeTooltip, formatCompactNumber, formatPercent } from '../utils/format'
use([BarChart])
const props = withDefaults(
defineProps<{
health: any
granularity: string
loading?: boolean
}>(),
{
loading: false,
},
)
const { palette } = useUsageChartPalette()
const points = computed<any[]>(() => props.health.points ?? [])
const hasData = computed(
() => !props.loading && points.value.some((point) => requestCount(point) > 0),
)
const granularityText = computed(() => {
const labels: Record<string, string> = {
'15m': '15 分钟',
'1h': '小时',
'1d': '天',
day: '天',
hour: '小时',
}
return labels[props.granularity] ?? props.granularity
})
const chartOption = computed<EChartsOption>(() => {
const theme = palette.value
const chartPoints = points.value
const activePointCount = chartPoints.filter((point) => requestCount(point) > 0).length
return {
animationDuration: 240,
grid: { left: 48, right: 44, top: 40, bottom: 28 },
legend: {
top: 0,
right: 4,
itemWidth: 8,
itemHeight: 8,
icon: 'circle',
data: ['请求量', '成功率', '失败'],
textStyle: {
color: theme.textSecondary,
fontSize: 11,
fontFamily: 'Inter Variable, Inter, system-ui, sans-serif',
fontWeight: 650,
},
},
tooltip: usageTooltip(theme, formatTooltip),
xAxis: usageCategoryAxis(
chartPoints.map((point) => point.label),
theme,
),
yAxis: [
usageValueAxis(theme, (value) => formatCompactNumber(value)),
usageValueAxis(theme, (value) => formatPercent(value), {
min: 0,
max: 1,
splitLine: false,
}),
],
series: [
{
name: '请求量',
type: 'bar',
data: chartPoints.map(requestCount),
barMaxWidth: 24,
itemStyle: { color: theme.info, opacity: 0.32, borderRadius: [3, 3, 0, 0] },
},
{
name: '成功率',
type: 'line',
yAxisIndex: 1,
data: chartPoints.map(requestSuccessRate),
connectNulls: true,
smooth: 0.25,
symbol: activePointCount <= 16 ? 'circle' : 'none',
symbolSize: 4,
lineStyle: { color: theme.success, width: 2.2 },
itemStyle: { color: theme.success },
},
{
name: '失败',
type: 'line',
data: chartPoints.map((point) => (point.failedRequests > 0 ? requestCount(point) : null)),
connectNulls: false,
showSymbol: true,
symbol: 'circle',
symbolSize: 7,
lineStyle: { opacity: 0 },
itemStyle: {
color: theme.danger,
borderColor: theme.surface,
borderWidth: 2,
},
z: 4,
},
],
}
})
function requestCount(point: any) {
return Math.max(0, point.successRequests ?? 0) + Math.max(0, point.failedRequests ?? 0)
}
function requestSuccessRate(point: any) {
const total = requestCount(point)
return total > 0 ? Math.max(0, point.successRequests ?? 0) / total : null
}
function formatTooltip(params: unknown) {
const rows = tooltipRows(params)
const point = points.value[tooltipIndex(rows[0])]
if (!point) return ''
const successRate = requestSuccessRate(point)
return [
escapeTooltip(point.label),
`请求量: ${formatCompactNumber(requestCount(point))}`,
`成功: ${formatCompactNumber(point.successRequests)}`,
`失败: ${formatCompactNumber(point.failedRequests)}`,
`成功率: ${successRate == null ? '无请求' : formatPercent(successRate)}`,
].join('<br/>')
}
</script>
<template>
<BaseCard
as="article"
:padded="false"
title="请求健康"
:description="`按${granularityText}观察请求量、成功率与失败时段`"
header-class="px-5 pt-4"
body-class="px-5 pt-3 pb-4"
class="h-full min-h-90"
>
<template #body>
<div class="min-h-66">
<BaseChart v-if="hasData" :option="chartOption" :height="264" />
<BaseEmpty
v-else
compact
plain
:title="loading ? '正在加载请求健康数据' : '暂无请求健康数据'"
description="当前范围没有可绘制的请求记录"
class="h-66 place-content-center"
/>
</div>
</template>
</BaseCard>
</template>
@@ -1,341 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { EChartsOption } from 'echarts'
import { storeToRefs } from 'pinia'
import UsageCostCard from './UsageCostCard.vue'
import UsageDiagnosticCard from './UsageDiagnosticCard.vue'
import UsageHealthCard from './UsageHealthCard.vue'
import UsagePerformanceCard from './UsagePerformanceCard.vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseChart from '@/components/charts/BaseChart.vue'
import { useUiStore } from '@/stores/modules/ui'
import UsageDistributionPanel from './UsageDistributionPanel.vue'
const props = defineProps<{
insights: any
loading?: boolean
}>()
const chartHeight = 236
const chartSplitNumber = 3
const modelSource = defineModel<string>('modelSource', { default: 'requested' })
const { themeRevision } = storeToRefs(useUiStore())
const modelSourceOptions = [
{ label: '请求模型', value: 'requested' },
{ label: '上游模型', value: 'upstream' },
{ label: '映射', value: 'mapping' },
]
const modelItems = computed(() => props.insights?.modelDistribution ?? [])
const endpointItems = computed(() => props.insights?.endpointDistribution ?? [])
const trendPoints = computed(() => props.insights?.tokenTrend ?? [])
const latencyPoints = computed(() => props.insights?.latencyTrend ?? [])
const hasTrend = computed(() =>
trendPoints.value.some(
(point: any) =>
point.totalTokensValue > 0 || point.cachedTokensValue > 0 || point.averageLatencyMsValue > 0,
),
)
const hasLatencyTrend = computed(() =>
latencyPoints.value.some((point: any) => Number(point.averageLatencyMsValue || 0) > 0),
withDefaults(
defineProps<{
overview: any
diagnostics: any
loading?: boolean
diagnosticLoading?: boolean
}>(),
{
loading: false,
diagnosticLoading: false,
},
)
const trendOption = computed<EChartsOption>(() => ({
grid: { left: 8, right: 10, top: 36, bottom: 28 },
legend: {
top: 10,
right: 12,
itemWidth: 8,
itemHeight: 8,
icon: 'circle',
data: ['输入', '输出', '缓存读取', '缓存命中率'],
textStyle: {
color: themeColor('--cp-text-secondary', '#64748B'),
fontSize: 12,
fontFamily: 'Inter Variable, Inter, system-ui, sans-serif',
fontWeight: 700,
},
},
tooltip: {
trigger: 'axis',
backgroundColor: themeColor('--cp-bg-surface', '#fff'),
borderColor: 'transparent',
borderWidth: 0,
padding: [10, 14],
textStyle: {
color: themeColor('--cp-text-primary', '#334155'),
fontSize: 12,
fontFamily: 'Inter Variable, Inter, system-ui, sans-serif',
fontWeight: 650,
},
extraCssText: 'border-radius: 12px; box-shadow: var(--cp-shadow-popover);',
axisPointer: {
type: 'line',
lineStyle: { color: chartPointerLineColor(), type: 'dashed', width: 1 },
},
formatter: formatTooltip,
},
xAxis: {
type: 'category',
data: trendPoints.value.map((point: any) => point.date),
axisLabel: {
color: themeColor('--cp-text-muted', '#94A3B8'),
fontSize: 10,
fontFamily: 'JetBrains Mono Variable, JetBrains Mono',
},
axisLine: { show: false },
axisTick: { show: false },
},
yAxis: [
{
type: 'value',
splitNumber: chartSplitNumber,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { show: false },
splitLine: { lineStyle: { color: chartGridLineColor(), width: 1 } },
},
{
type: 'value',
min: 0,
max: 100,
axisLine: { show: false },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
},
],
series: trendSeries(),
}))
const latencyOption = computed<EChartsOption>(() => ({
grid: { left: 8, right: 10, top: 18, bottom: 28 },
tooltip: {
trigger: 'axis',
backgroundColor: themeColor('--cp-bg-surface', '#fff'),
borderColor: 'transparent',
borderWidth: 0,
padding: [10, 14],
textStyle: {
color: themeColor('--cp-text-primary', '#334155'),
fontSize: 12,
fontFamily: 'Inter Variable, Inter, system-ui, sans-serif',
fontWeight: 650,
},
extraCssText: 'border-radius: 12px; box-shadow: var(--cp-shadow-popover);',
axisPointer: {
type: 'line',
lineStyle: { color: chartPointerLineColor(), type: 'dashed', width: 1 },
},
formatter: formatLatencyTooltip,
},
xAxis: {
type: 'category',
data: latencyPoints.value.map((point: any) => point.date),
axisLabel: {
color: themeColor('--cp-text-muted', '#94A3B8'),
fontSize: 10,
fontFamily: 'JetBrains Mono Variable, JetBrains Mono',
},
axisLine: { show: false },
axisTick: { show: false },
},
yAxis: {
type: 'value',
splitNumber: chartSplitNumber,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { show: false },
splitLine: { lineStyle: { color: chartGridLineColor(), width: 1 } },
},
series: [
lineSeries(
'平均延迟',
latencyPoints.value.map((point: any) => Number(point.averageLatencyMsValue || 0)),
themeColor('--cp-warning', '#D97706'),
true,
),
],
}))
function trendSeries() {
return [
lineSeries(
'输入',
trendPoints.value.map((point: any) => point.inputTokensValue),
themeColor('--cp-info', '#2563EB'),
true,
),
lineSeries(
'输出',
trendPoints.value.map((point: any) => point.outputTokensValue),
themeColor('--cp-success', '#10B981'),
),
lineSeries(
'缓存读取',
trendPoints.value.map((point: any) => point.cachedTokensValue),
themeColor('--cp-info-text', '#0EA5E9'),
),
lineSeries(
'缓存命中率',
trendPoints.value.map((point: any) => point.cacheHitRateValue),
themeColor('--cp-focus-ring', '#8B5CF6'),
false,
1,
),
]
}
function lineSeries(name: string, data: number[], color: string, area = false, yAxisIndex = 0) {
return {
name,
type: 'line' as const,
data,
smooth: true,
symbol: data.length <= 2 ? 'circle' : 'none',
symbolSize: 6,
yAxisIndex,
lineStyle: { color, width: 2.4 },
itemStyle: { color },
areaStyle: area
? {
color: {
type: 'linear' as const,
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: `${color}16` },
{ offset: 1, color: `${color}02` },
],
},
}
: undefined,
}
}
function formatTooltip(params: unknown) {
const rows = Array.isArray(params) ? params : [params]
const title = tooltipValue(rows[0], 'axisValueLabel')
const point = trendPoints.value[tooltipIndex(rows[0])]
const lines = rows.map((row) => {
const name = tooltipValue(row, 'seriesName')
const marker = tooltipValue(row, 'marker')
const display = trendPointDisplay(point, name)
return `${marker}${name}: ${display}`
})
const billingLine = point
? `实际: ${point.actualBillingAmount} | 标准: ${point.standardBillingAmount}`
: ''
return [title, ...lines, billingLine].filter(Boolean).join('<br/>')
}
function formatLatencyTooltip(params: unknown) {
const rows = Array.isArray(params) ? params : [params]
const title = tooltipValue(rows[0], 'axisValueLabel')
const point = latencyPoints.value[tooltipIndex(rows[0])]
return [title, `平均延迟: ${point?.averageLatencyMs}`].filter(Boolean).join('<br/>')
}
function tooltipValue(source: unknown, key: string): string {
if (typeof source !== 'object' || source === null || !(key in source)) return ''
const value = (source as Record<string, unknown>)[key]
return typeof value === 'number' || typeof value === 'string' ? String(value) : ''
}
function tooltipIndex(source: unknown) {
if (typeof source !== 'object' || source === null || !('dataIndex' in source)) return -1
const value = (source as Record<string, unknown>).dataIndex
return typeof value === 'number' ? value : -1
}
function trendPointDisplay(point: any, name: string) {
if (name === '输入') return point?.inputTokens
if (name === '输出') return point?.outputTokens
if (name === '缓存读取') return point?.cachedTokens
if (name === '缓存命中率') return point?.cacheHitRate
return ''
}
function chartGridLineColor() {
return themeColor('--cp-bg-muted', '#F1F5F9')
}
function chartPointerLineColor() {
return themeColor('--cp-default-border-hover', '#CBD5E1')
}
function themeColor(name: string, fallback: string) {
themeRevision.value
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback
}
const diagnosticDimension = defineModel('diagnosticDimension', {
type: String,
default: 'model',
})
</script>
<template>
<section class="mt-5 grid grid-cols-1 gap-3 xl:grid-cols-2" aria-label="使用记录分析">
<UsageDistributionPanel
v-model:source="modelSource"
title="模型分布"
description="请求模型与上游模型"
name-label="模型"
:items="modelItems"
color="--cp-info"
:source-options="modelSourceOptions"
<section class="mt-5 grid grid-cols-1 gap-3 xl:grid-cols-2" aria-label="使用统计观测">
<UsageHealthCard
:health="overview.health"
:granularity="overview.granularity"
:loading="loading"
/>
<UsageDistributionPanel
title="端点分布"
description="客户端 API 路由"
name-label="端点"
:items="endpointItems"
color="--cp-normal"
:show-account-billing-column="false"
<UsageDiagnosticCard
v-model:dimension="diagnosticDimension"
:diagnostics="diagnostics"
:loading="loading || diagnosticLoading"
/>
<BaseCard
:padded="false"
title="Token 使用趋势"
description="每日 Token 用量"
header-class="px-5 pt-4"
body-class="px-5 pt-3 pb-4"
>
<template #body>
<div class="h-64 px-3 pt-3 pb-2">
<div v-if="hasTrend" class="flex h-full min-h-50 flex-col">
<BaseChart :option="trendOption" :height="chartHeight" />
</div>
<BaseEmpty
v-else
compact
title="暂无趋势数据"
description="当前范围暂无 Token 数据"
class="min-h-50 place-content-center bg-transparent"
/>
</div>
</template>
</BaseCard>
<UsagePerformanceCard :performance="overview.performance" :loading="loading" />
<BaseCard
:padded="false"
title="延迟分布"
description="每日平均响应耗时"
header-class="px-5 pt-4"
body-class="px-5 pt-3 pb-4"
>
<template #body>
<div class="h-64 px-3 pt-3 pb-2">
<BaseChart v-if="hasLatencyTrend" :option="latencyOption" :height="chartHeight" />
<BaseEmpty
v-else
compact
title="暂无延迟数据"
description="当前范围暂无延迟数据"
class="min-h-50 place-content-center bg-transparent"
/>
</div>
</template>
</BaseCard>
<UsageCostCard :cost="overview.cost" :loading="loading" />
</section>
</template>
@@ -0,0 +1,201 @@
<script setup lang="ts">
import type { EChartsOption, LineSeriesOption } from 'echarts'
import { computed, shallowRef } from 'vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
import BaseChart from '@/components/charts/BaseChart.vue'
import { useUsageChartPalette } from '../composables/useUsageChartPalette'
import {
tooltipIndex,
tooltipRows,
usageCategoryAxis,
usageTooltip,
usageValueAxis,
} from '../utils/chart'
import { escapeTooltip, formatDuration, formatDurationAxis } from '../utils/format'
const props = withDefaults(
defineProps<{
performance: any
loading?: boolean
}>(),
{
loading: false,
},
)
const activeView = shallowRef('total')
const { palette } = useUsageChartPalette()
const performancePoints = computed<any[]>(() => props.performance.points ?? [])
const viewOptions = [
{ label: '总耗时', value: 'total' },
{ label: '首 Token', value: 'ttft' },
]
const percentileLabels = {
p50: '常规响应',
p95: '较慢响应',
p99: '极慢响应',
}
const selectedPoints = computed(() =>
performancePoints.value.map((point) => ({
point,
p50: percentileValue(point, 'p50'),
p95: percentileValue(point, 'p95'),
p99: percentileValue(point, 'p99'),
})),
)
const hasData = computed(
() =>
!props.loading &&
selectedPoints.value.some(
(point) => point.p50 != null || point.p95 != null || point.p99 != null,
),
)
const chartOption = computed<EChartsOption>(() => {
const theme = palette.value
const points = selectedPoints.value
return {
animationDuration: 240,
grid: { left: 52, right: 12, top: 40, bottom: 28 },
legend: {
top: 0,
right: 4,
itemWidth: 8,
itemHeight: 8,
icon: 'circle',
data: Object.values(percentileLabels),
textStyle: {
color: theme.textSecondary,
fontSize: 11,
fontFamily: 'Inter Variable, Inter, system-ui, sans-serif',
fontWeight: 650,
},
},
tooltip: usageTooltip(theme, formatTooltip),
xAxis: usageCategoryAxis(
points.map(({ point }) => point.label),
theme,
),
yAxis: usageValueAxis(theme, formatDurationAxis),
series: [
lineSeries(
percentileLabels.p50,
points.map((point) => point.p50),
theme.info,
true,
),
lineSeries(
percentileLabels.p95,
points.map((point) => point.p95),
theme.warning,
),
lineSeries(
percentileLabels.p99,
points.map((point) => point.p99),
theme.danger,
),
],
}
})
function percentileValue(point: any, percentile: 'p50' | 'p95' | 'p99') {
if (activeView.value === 'ttft') {
if (percentile === 'p50') return point.ttftP50Ms
if (percentile === 'p95') return point.ttftP95Ms
return point.ttftP99Ms
}
if (percentile === 'p50') return point.latencyP50Ms
if (percentile === 'p95') return point.latencyP95Ms
return point.latencyP99Ms
}
function lineSeries(
name: string,
data: Array<number | null | undefined>,
color: string,
area = false,
): LineSeriesOption {
return {
name,
type: 'line',
data: data.map((value) => value ?? null),
connectNulls: false,
smooth: true,
showSymbol: data.length <= 12,
symbol: 'circle',
symbolSize: 5,
lineStyle: { color, width: 2.2 },
itemStyle: { color },
areaStyle: area
? {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: `${color}18` },
{ offset: 1, color: `${color}02` },
],
},
}
: undefined,
}
}
function formatTooltip(params: unknown) {
const rows = tooltipRows(params)
const selected = selectedPoints.value[tooltipIndex(rows[0])]
if (!selected) return ''
return [
escapeTooltip(selected.point.label),
`${percentileLabels.p50}: ${formatDuration(selected.p50)}`,
`${percentileLabels.p95}: ${formatDuration(selected.p95)}`,
`${percentileLabels.p99}: ${formatDuration(selected.p99)}`,
].join('<br/>')
}
</script>
<template>
<BaseCard
as="article"
:padded="false"
title="响应速度"
description="常规、较慢和极慢请求的完成耗时,越低越快"
header-collapse-at="none"
header-class="px-5 pt-4"
body-class="px-5 pt-3 pb-4"
class="h-full min-h-90"
>
<template #actions>
<BaseSegmented v-model="activeView" :options="viewOptions" :disabled="loading" class="w-43" />
</template>
<template #body>
<div class="min-h-66">
<BaseChart v-if="hasData" :option="chartOption" :height="264" />
<BaseEmpty
v-else
compact
plain
:title="loading ? '正在加载性能数据' : '暂无性能数据'"
:description="
activeView === 'ttft' ? '当前范围没有首 Token 延迟样本' : '当前范围没有总耗时样本'
"
class="h-66 place-content-center"
/>
</div>
</template>
</BaseCard>
</template>
@@ -1,44 +1,45 @@
<script setup lang="ts">
import { Activity, Database, FileText, Timer } from '@lucide/vue'
import { computed } from 'vue'
const props = defineProps<{
summary: any
}>()
const items = [
const items = computed(() => [
{
key: 'requests',
label: '成功请求',
icon: Activity,
value: () => props.summary.totalRequests,
detail: () => '筛选范围内',
value: props.summary.totalRequests,
detail: '筛选范围内',
tone: 'text-(--cp-info-text) bg-(--cp-info-bg)',
},
{
key: 'tokens',
label: '总 Token',
icon: FileText,
value: () => props.summary.totalTokens,
detail: () => `输入 ${props.summary.inputTokens} / 输出 ${props.summary.outputTokens}`,
value: props.summary.totalTokens,
detail: `输入 ${props.summary.inputTokens} / 输出 ${props.summary.outputTokens}`,
tone: 'text-(--cp-success-text) bg-(--cp-success-bg)',
},
{
key: 'cached',
label: '缓存 Token',
icon: Database,
value: () => props.summary.cachedTokens,
detail: () => '缓存读取命中',
value: props.summary.cachedTokens,
detail: '缓存读取命中',
tone: 'text-(--cp-warning-text) bg-(--cp-warning-bg)',
},
{
key: 'latency',
label: '平均耗时',
icon: Timer,
value: () => props.summary.averageLatencyMs,
detail: () => '成功请求平均值',
value: props.summary.averageLatencyMs,
detail: '成功请求平均值',
tone: 'text-(--cp-normal-text) bg-(--cp-normal-bg)',
},
]
])
</script>
<template>
@@ -64,10 +65,10 @@ const items = [
<strong
class="block truncate text-[22px] leading-none font-extrabold text-(--cp-text-primary)"
>
{{ item.value() }}
{{ item.value }}
</strong>
<span class="block truncate text-[12px] leading-none font-[650] text-(--cp-text-secondary)">
{{ item.detail() }}
{{ item.detail }}
</span>
</div>
</article>
@@ -1,15 +1,15 @@
import { watchDebounced } from '@vueuse/core'
import { clamp } from 'es-toolkit'
import { computed, onMounted, ref, shallowRef, watch, type Ref } from 'vue'
import { computed, onMounted, shallowRef, watch } from 'vue'
import { getOpsErrors } from '@/api'
import { toast } from '@/components/base/BaseToast'
import { withMinimumDuration } from '@/utils/async'
export function useOpsErrorsTable(timeRangeParams: Ref<Record<string, string>>) {
export function useOpsErrorsTable(timeRangeParams: any) {
const loading = shallowRef(true)
const refreshing = shallowRef(false)
const records = ref<any[]>([])
const records = shallowRef<any[]>([])
const page = shallowRef(1)
const pageSize = shallowRef(10)
const total = shallowRef(0)
@@ -0,0 +1,32 @@
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useUiStore } from '@/stores/modules/ui'
export function useUsageChartPalette() {
const { themeRevision } = storeToRefs(useUiStore())
const palette = computed(() => {
themeRevision.value
return {
textPrimary: themeColor('--cp-text-primary', '#0E1726'),
textSecondary: themeColor('--cp-text-secondary', '#64748B'),
textMuted: themeColor('--cp-text-muted', '#94A3B8'),
surface: themeColor('--cp-bg-surface', '#FFFFFF'),
grid: themeColor('--cp-bg-muted', '#F1F5F9'),
pointer: themeColor('--cp-default-border-hover', '#CBD5E1'),
info: themeColor('--cp-info', '#2563EB'),
success: themeColor('--cp-success', '#12B981'),
warning: themeColor('--cp-warning', '#F59E0B'),
danger: themeColor('--cp-danger', '#EF4444'),
normal: themeColor('--cp-normal', '#0F9F9A'),
}
})
return { palette }
}
function themeColor(name: string, fallback: string) {
if (typeof document === 'undefined') return fallback
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback
}
@@ -1,11 +1,11 @@
import { watchDebounced } from '@vueuse/core'
import { computed, ref, type Ref } from 'vue'
import { computed, shallowRef } from 'vue'
export function useUsageFilters(totalRecords: Ref<number>) {
const page = ref(1)
const pageSize = ref(10)
const searchQuery = ref('')
let loadUsageRecords: ((scope?: 'all' | 'table') => Promise<void> | void) | undefined
export function useUsageFilters(totalRecords: any) {
const page = shallowRef(1)
const pageSize = shallowRef(10)
const searchQuery = shallowRef('')
let loadUsageRecords: any
const usagePagination = computed(() => ({
page: page.value,
@@ -14,11 +14,11 @@ export function useUsageFilters(totalRecords: Ref<number>) {
pageSizes: [10, 20, 50, 100],
}))
function bindUsageRecordLoader(loader: (scope?: 'all' | 'table') => Promise<void> | void) {
function bindUsageRecordLoader(loader: any) {
loadUsageRecords = loader
}
function requestLoad(scope: 'all' | 'table' = 'table') {
function requestLoad(scope = 'table') {
if (loadUsageRecords) {
void loadUsageRecords(scope)
}
@@ -1,11 +1,11 @@
import { ref, type Ref } from 'vue'
import { shallowRef } from 'vue'
import { getUsageRecordDetail } from '@/api'
import { toast } from '@/components/base/BaseToast'
export function useUsageRecordDetail(options?: { timeRangeParams?: Ref<Record<string, string>> }) {
const showDetailModal = ref(false)
const selectedUsageRecord = ref<any>(null)
export function useUsageRecordDetail(options: any = {}) {
const showDetailModal = shallowRef(false)
const selectedUsageRecord = shallowRef<any>(null)
async function handleViewDetail(record: any) {
try {
@@ -1,42 +1,39 @@
import { clamp } from 'es-toolkit'
import { onMounted, ref, watch, type Ref } from 'vue'
import { onMounted, shallowRef, watch } from 'vue'
import {
getUsageRecordEndpointDistribution,
getUsageRecordLatencyTrend,
getUsageRecordModelDistribution,
getUsageRecordInsightsDiagnostics,
getUsageRecordInsightsOverview,
getUsageRecordSummary,
getUsageRecordTokenTrend,
getUsageRecords,
} from '@/api'
import { toast } from '@/components/base/BaseToast'
import { withMinimumDuration } from '@/utils/async'
export function useUsageRecordsTable(options: {
page: Ref<number>
pageSize: Ref<number>
searchQuery: Ref<string>
timeRangeParams: Ref<Record<string, string>>
totalRecords: Ref<number>
}) {
const loading = ref(true)
const analyticsLoading = ref(true)
const records = ref<any[]>([])
const summary = ref(emptySummary())
const insights = ref(emptyInsights())
const refreshingList = ref(false)
const modelDistributionSource = ref('requested')
let modelDistributionRequestId = 0
export function useUsageRecordsTable(options: any) {
const loading = shallowRef(true)
const analyticsLoading = shallowRef(true)
const records = shallowRef<any[]>([])
const summary = shallowRef(emptySummary())
const insights = shallowRef(emptyInsights())
const refreshingList = shallowRef(false)
const diagnosticDimension = shallowRef('model')
const diagnosticLoading = shallowRef(false)
let loadRequestId = 0
let diagnosticRequestId = 0
const scopedParams = () => ({ ...options.timeRangeParams.value })
const filterParams = () => ({
search: options.searchQuery.value || undefined,
})
async function loadUsageRecords(scope: 'all' | 'table' = 'all') {
async function loadUsageRecords(scope = 'all') {
const requestId = ++loadRequestId
try {
loading.value = true
if (scope === 'all') {
analyticsLoading.value = true
diagnosticRequestId += 1
diagnosticLoading.value = false
}
const globalParams = scopedParams()
@@ -57,10 +54,17 @@ export function useUsageRecordsTable(options: {
insights: insights.value,
})
const [result, nextAnalytics] = await Promise.all([resultPromise, analyticsPromise])
if (requestId !== loadRequestId) return
records.value = result.items
summary.value = nextAnalytics.summary
insights.value = nextAnalytics.insights
insights.value = {
...nextAnalytics.insights,
diagnostics:
nextAnalytics.insights.diagnostics.dimension === diagnosticDimension.value
? nextAnalytics.insights.diagnostics
: insights.value.diagnostics,
}
options.pageSize.value = result.page.pageSize ?? options.pageSize.value
options.totalRecords.value = result.page.total ?? result.items.length
options.page.value = result.page.page ?? options.page.value
@@ -74,54 +78,56 @@ export function useUsageRecordsTable(options: {
await loadUsageRecords(scope)
}
} catch (error: any) {
if (requestId !== loadRequestId) return
toast.error(error.message || '加载失败')
} finally {
loading.value = false
if (scope === 'all') {
analyticsLoading.value = false
if (requestId === loadRequestId) {
loading.value = false
if (scope === 'all') {
analyticsLoading.value = false
}
}
}
}
async function loadUsageAnalytics(globalParams = scopedParams()) {
const [nextSummary, modelDistribution, endpointDistribution, tokenTrend, latencyTrend] =
await Promise.all([
getUsageRecordSummary(globalParams),
getUsageRecordModelDistribution({
...globalParams,
source: modelDistributionSource.value,
}),
getUsageRecordEndpointDistribution(globalParams),
getUsageRecordTokenTrend(globalParams),
getUsageRecordLatencyTrend(globalParams),
])
const dimension = diagnosticDimension.value
const [nextSummary, overview, diagnostics] = await Promise.all([
getUsageRecordSummary(globalParams),
getUsageRecordInsightsOverview(globalParams),
getUsageRecordInsightsDiagnostics({
...globalParams,
dimension,
}),
])
return {
summary: nextSummary,
insights: {
...emptyInsights(),
modelDistribution,
endpointDistribution,
tokenTrend,
latencyTrend,
},
insights: { overview, diagnostics },
}
}
async function loadModelDistribution() {
const requestId = ++modelDistributionRequestId
async function loadDiagnostics() {
const requestId = ++diagnosticRequestId
const dimension = diagnosticDimension.value
const params = scopedParams()
try {
const modelDistribution = await getUsageRecordModelDistribution({
...scopedParams(),
source: modelDistributionSource.value,
diagnosticLoading.value = true
const diagnostics = await getUsageRecordInsightsDiagnostics({
...params,
dimension,
})
if (requestId !== modelDistributionRequestId) return
if (requestId !== diagnosticRequestId || dimension !== diagnosticDimension.value) return
insights.value = {
...insights.value,
modelDistribution,
diagnostics,
}
} catch (error: any) {
toast.error(error.message || '加载失败')
} finally {
if (requestId === diagnosticRequestId) {
diagnosticLoading.value = false
}
}
}
@@ -139,8 +145,8 @@ export function useUsageRecordsTable(options: {
loadUsageRecords()
})
watch(modelDistributionSource, () => {
void loadModelDistribution()
watch(diagnosticDimension, () => {
void loadDiagnostics()
})
return {
@@ -150,7 +156,8 @@ export function useUsageRecordsTable(options: {
summary,
insights,
refreshingList,
modelDistributionSource,
diagnosticDimension,
diagnosticLoading,
loadUsageRecords,
refreshUsageRecords,
}
@@ -169,9 +176,53 @@ function emptySummary() {
function emptyInsights() {
return {
modelDistribution: [],
endpointDistribution: [],
tokenTrend: [],
latencyTrend: [],
overview: emptyOverview(),
diagnostics: emptyDiagnostics(),
}
}
function emptyOverview() {
return {
granularity: '1d',
health: {
totalRequests: 0,
successRequests: 0,
failedRequests: 0,
successRate: 0,
requestChangeRate: null,
successRateChange: null,
points: [],
},
performance: {
latencyP50Ms: null,
latencyP95Ms: null,
latencyP99Ms: null,
ttftP50Ms: null,
ttftP95Ms: null,
ttftP99Ms: null,
latencyCoverage: 0,
ttftCoverage: 0,
points: [],
},
cost: {
estimatedCost: 0,
standardCost: 0,
costPerRequest: 0,
tokensPerRequest: 0,
cachedTokenRate: 0,
cacheHitRequestRate: 0,
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
points: [],
},
}
}
function emptyDiagnostics() {
return {
dimension: 'model',
items: [],
}
}
+13 -10
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { CalendarDays, Eye, Minimize2 } from '@lucide/vue'
import dayjs from 'dayjs'
import { computed, ref, shallowRef, watch } from 'vue'
import { computed, shallowRef, watch } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseCard from '@/components/base/BaseCard.vue'
@@ -30,14 +30,14 @@ import UsageReasoningEffortCell from './components/UsageReasoningEffortCell.vue'
import UsageSummaryCards from './components/UsageSummaryCards.vue'
import UsageTokenCell from './components/UsageTokenCell.vue'
const totalRecords = ref(0)
const timeRange = ref('7d')
const totalRecords = shallowRef(0)
const timeRange = shallowRef('today')
const recordView = shallowRef('success')
const recordViewOptions = [
{ label: '成功记录', value: 'success' },
{ label: '错误排查', value: 'errors' },
]
const timeRangeParams = computed<Record<string, string>>(() => {
const timeRangeParams = computed(() => {
const now = dayjs()
if (timeRange.value === 'today') {
@@ -77,7 +77,8 @@ const {
summary,
insights,
refreshingList,
modelDistributionSource,
diagnosticDimension,
diagnosticLoading,
loadUsageRecords,
refreshUsageRecords,
} = useUsageRecordsTable({
@@ -105,7 +106,7 @@ watch(timeRange, () => {
<header class="flex min-h-17 shrink-0 items-start justify-between gap-4">
<div>
<h1 class="mt-0 mb-0 text-[34px] leading-[1.15] font-extrabold text-(--cp-text-primary)">
使用记录
使用统计
</h1>
<p class="mt-2.5 mb-0 text-[15px] leading-[1.15] font-semibold text-(--cp-text-secondary)">
查看请求用量、性能趋势与调用错误记录
@@ -119,9 +120,11 @@ watch(timeRange, () => {
<UsageSummaryCards :summary="summary" />
<UsageInsightsGrid
v-model:model-source="modelDistributionSource"
:insights="insights"
v-model:diagnostic-dimension="diagnosticDimension"
:overview="insights.overview"
:diagnostics="insights.diagnostics"
:loading="analyticsLoading"
:diagnostic-loading="diagnosticLoading"
/>
<BaseCard
@@ -147,7 +150,7 @@ watch(timeRange, () => {
<template #body>
<div
v-show="recordView === 'success'"
class="grid min-h-[520px] flex-1 grid-rows-[auto_minmax(0,1fr)] gap-3"
class="grid min-h-130 flex-1 grid-rows-[auto_minmax(0,1fr)] gap-3"
>
<UsageFilters
v-model:search="searchQuery"
@@ -234,7 +237,7 @@ watch(timeRange, () => {
</BaseTable>
</div>
<div v-show="recordView === 'errors'" class="min-h-[520px] flex-1">
<div v-show="recordView === 'errors'" class="min-h-130 flex-1">
<OpsErrorPanel :time-range-params="timeRangeParams" />
</div>
</template>
+71
View File
@@ -0,0 +1,71 @@
export function usageTooltip(theme: any, formatter: any) {
return {
trigger: 'axis' as const,
backgroundColor: theme.surface,
borderColor: 'transparent',
borderWidth: 0,
padding: [10, 14],
textStyle: {
color: theme.textPrimary,
fontSize: 12,
fontFamily: 'Inter Variable, Inter, system-ui, sans-serif',
fontWeight: 650,
},
extraCssText: 'border-radius: 12px; box-shadow: var(--cp-shadow-popover);',
axisPointer: {
type: 'line' as const,
lineStyle: { color: theme.pointer, type: 'dashed' as const, width: 1 },
},
formatter,
}
}
export function usageCategoryAxis(labels: string[], theme: any) {
return {
type: 'category' as const,
data: labels,
axisLabel: {
color: theme.textMuted,
fontSize: 10,
fontFamily: 'JetBrains Mono Variable, JetBrains Mono, monospace',
hideOverlap: true,
},
axisLine: { show: false },
axisTick: { show: false },
}
}
export function usageValueAxis(
theme: any,
formatter: (value: number) => string,
options: any = {},
) {
return {
type: 'value' as const,
min: options.min,
max: options.max,
splitNumber: 3,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
show: true,
color: theme.textMuted,
fontSize: 10,
fontFamily: 'JetBrains Mono Variable, JetBrains Mono, monospace',
formatter,
},
splitLine: {
show: options.splitLine !== false,
lineStyle: { color: theme.grid, width: 1 },
},
}
}
export function tooltipRows(params: any) {
const values = Array.isArray(params) ? params : [params]
return values.filter((value) => typeof value === 'object' && value !== null)
}
export function tooltipIndex(source: any) {
return typeof source?.dataIndex === 'number' ? source.dataIndex : -1
}
+78
View File
@@ -0,0 +1,78 @@
const compactNumberFormatter = new Intl.NumberFormat('zh-CN', {
notation: 'compact',
maximumFractionDigits: 1,
})
const numberFormatter = new Intl.NumberFormat('zh-CN', {
maximumFractionDigits: 1,
})
const percentFormatter = new Intl.NumberFormat('zh-CN', {
style: 'percent',
maximumFractionDigits: 1,
})
export function formatCompactNumber(value: number) {
return compactNumberFormatter.format(finiteOrZero(value))
}
export function formatNumber(value: number) {
return numberFormatter.format(finiteOrZero(value))
}
export function formatPercent(value?: number | null) {
return value == null || !Number.isFinite(value) ? '—' : percentFormatter.format(value)
}
export function formatSignedPercent(value?: number | null) {
if (value == null || !Number.isFinite(value)) return '—'
if (value === 0) return '0%'
return `${value > 0 ? '+' : ''}${percentFormatter.format(value)}`
}
export function formatDuration(value?: number | null) {
if (value == null || !Number.isFinite(value) || value < 0) return '—'
if (value < 1_000) return `${Math.round(value)} ms`
if (value < 60_000) {
const seconds = value / 1_000
return `${seconds.toFixed(seconds >= 10 ? 1 : 2).replace(/\.0+$|(?<=\.[0-9])0$/, '')} s`
}
return `${(value / 60_000).toFixed(1).replace(/\.0$/, '')} min`
}
export function formatDurationAxis(value: number) {
if (!Number.isFinite(value)) return '—'
if (value < 1_000) return `${Math.round(value)}ms`
return `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1)}s`
}
export function formatUsd(value: number, precise = false) {
const safeValue = finiteOrZero(value)
const fractionDigits = precise || (Math.abs(safeValue) > 0 && Math.abs(safeValue) < 0.01) ? 4 : 2
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
}).format(safeValue)
}
export function formatUsdAxis(value: number) {
const safeValue = finiteOrZero(value)
if (Math.abs(safeValue) >= 1_000) return `$${formatCompactNumber(safeValue)}`
if (Math.abs(safeValue) < 0.01 && safeValue !== 0) return `$${safeValue.toFixed(3)}`
return `$${safeValue.toFixed(safeValue < 1 ? 2 : 1)}`
}
export function escapeTooltip(value: string) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
function finiteOrZero(value: number) {
return Number.isFinite(value) ? value : 0
}