mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 00:05:49 +00:00
feat: improve usage insights and dashboard trends
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
//! 成功请求事实。
|
||||
|
||||
pub mod insights;
|
||||
pub mod query;
|
||||
pub mod store;
|
||||
pub mod types;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
@@ -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: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
function finiteOrZero(value: number) {
|
||||
return Number.isFinite(value) ? value : 0
|
||||
}
|
||||
Reference in New Issue
Block a user