From 7cd17aff421ca8bd1234caee830841f35d00422e Mon Sep 17 00:00:00 2001 From: mike zhuang <33995945+mikezhuang2022@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:22:28 +0800 Subject: [PATCH] feat(usage): add request concurrency trends (#537) * feat(usage): add request concurrency trends * perf(usage)!: unify load insights and optimize concurrency Aggregate concurrency on a single event timeline and reuse lifecycle indexes instead of joining every request against every time bucket. Align load summaries and trend points with the four chart metrics, combine concurrency and scheduling in one view, and right-align the statistics card selectors. Update the API contract and regression tests. BREAKING CHANGE: Usage insights concurrency and scheduling P95 fields move under performance.load and performance.points[].load. Unused scheduling P50, capacity, and performance coverage fields are removed. Co-authored-by: Codex --------- Co-authored-by: Zyy Co-authored-by: Codex --- .gitignore | 1 + .../gateway-admin/src/model/analytics.rs | 7 +- .../gateway-admin/src/model/observability.rs | 36 +- .../src/use_case/observability/mod.rs | 100 ++++-- .../tests/use_case/observability/mod.rs | 78 +++- .../src/admin/observability/presenter.rs | 72 +--- .../src/admin/observability/wire.rs | 29 +- backend/crates/gateway-api/tests/admin/mod.rs | 2 + .../tests/admin/observability/response.rs | 96 +++++ .../postgres/observability/admin_adapter.rs | 3 +- .../postgres/observability/analytics/query.rs | 97 ++++- .../gateway-store/tests/postgres/analytics.rs | 150 ++++++++ docs/api.md | 15 +- frontend/src/api/modules/usage.ts | 41 +-- frontend/src/i18n/locales/en/usage.json | 16 +- frontend/src/i18n/locales/zh-Hans/usage.json | 20 +- frontend/src/i18n/locales/zh-Hant/usage.json | 20 +- .../views/usage/components/UsageCostCard.vue | 4 +- .../usage/components/UsageDiagnosticCard.vue | 14 +- .../usage/components/UsagePerformanceCard.vue | 333 +++++++----------- .../usage/composables/useUsageRecordsTable.ts | 17 +- 21 files changed, 739 insertions(+), 412 deletions(-) diff --git a/.gitignore b/.gitignore index a13f02972..504aefc29 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ Thumbs.db # Local tools and worktrees /.codegraph/ +/.playwright-cli/ /.codex-ws-audit/ /.reasonix/ /.claude diff --git a/backend/crates/gateway-admin/src/model/analytics.rs b/backend/crates/gateway-admin/src/model/analytics.rs index 678a08b52..51e6793b8 100644 --- a/backend/crates/gateway-admin/src/model/analytics.rs +++ b/backend/crates/gateway-admin/src/model/analytics.rs @@ -2,7 +2,9 @@ use chrono::{DateTime, Utc}; -use super::observability::{DecimalAmount, RequestMetricPoint, UsageOverview}; +use super::observability::{ + DecimalAmount, RequestMetricPoint, UsageConcurrencyPoint, UsageOverview, +}; /// 数据可见性状态,不以缺失投影冒充零统计 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] @@ -57,9 +59,10 @@ pub struct UsageProjectedCostPoint { } /// 同一数据库快照内的完整洞察输入 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct UsageInsightsObservation { pub overview: UsageOverview, pub trend: Vec, pub costs: Vec, + pub concurrency: Vec, } diff --git a/backend/crates/gateway-admin/src/model/observability.rs b/backend/crates/gateway-admin/src/model/observability.rs index 719f0dc77..0970e8149 100644 --- a/backend/crates/gateway-admin/src/model/observability.rs +++ b/backend/crates/gateway-admin/src/model/observability.rs @@ -1260,38 +1260,44 @@ pub struct UsageInsightsHealth { pub points: Vec, } +/// 一个时间桶内按重叠时长加权的逻辑请求并发 +#[derive(Debug, Clone, PartialEq)] +pub struct UsageConcurrencyPoint { + pub bucket_start: DateTime, + pub average_concurrency: f64, + pub peak_concurrency: u64, +} + +/// 请求并发与调度等待的负载指标 +#[derive(Debug, Clone, PartialEq)] +pub struct UsageInsightsLoad { + pub average_concurrency: f64, + pub peak_concurrency: u64, + pub admission_decision_p95_ms: Option, + pub account_selection_wait_p95_ms: Option, +} + /// 洞察性能时间点 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct UsageInsightsPerformancePoint { pub bucket_start: DateTime, pub latency_percentiles: LatencyPercentiles, pub first_token_latency_percentiles: LatencyPercentiles, - pub admission_decision_percentiles: LatencyPercentiles, - pub account_selection_wait_percentiles: LatencyPercentiles, pub output_throughput_p10: Option, pub output_throughput_p50: Option, pub output_throughput_p90: Option, - pub capacity_utilization_avg_basis_points: Option, - pub capacity_utilization_p95_basis_points: Option, + pub load: UsageInsightsLoad, } -/// 洞察性能汇总与可观测覆盖率 +/// 洞察性能汇总 #[derive(Debug, Clone, PartialEq)] pub struct UsageInsightsPerformance { pub latency_percentiles: LatencyPercentiles, pub first_token_latency_percentiles: LatencyPercentiles, - pub latency_coverage: f64, - pub first_token_coverage: f64, - pub admission_decision_percentiles: LatencyPercentiles, - pub account_selection_wait_percentiles: LatencyPercentiles, - pub admission_decision_coverage: f64, - pub account_selection_wait_coverage: f64, pub output_throughput_p10: Option, pub output_throughput_p50: Option, pub output_throughput_p90: Option, - pub capacity_utilization_avg_basis_points: Option, - pub capacity_utilization_p95_basis_points: Option, - pub capacity_coverage: f64, + pub load: UsageInsightsLoad, pub points: Vec, } diff --git a/backend/crates/gateway-admin/src/use_case/observability/mod.rs b/backend/crates/gateway-admin/src/use_case/observability/mod.rs index 818332de9..0ee5a7192 100644 --- a/backend/crates/gateway-admin/src/use_case/observability/mod.rs +++ b/backend/crates/gateway-admin/src/use_case/observability/mod.rs @@ -20,9 +20,10 @@ use crate::{ HealthTimeline, HealthTimelinePoint, OpsErrorPage, OpsErrorQuery, ProviderBillingInput, RequestMetricPoint, RequestMetrics, TimeRange, Trend, TrendKind, TrendPoint, TrendSummary, UsageBilling, UsageBillingProjection, UsageCalculatedBillingFact, - UsageDetail, UsageFilter, UsageInsights, UsageInsightsCost, UsageInsightsCostPoint, - UsageInsightsHealth, UsageInsightsHealthPoint, UsageInsightsPerformance, - UsageInsightsPerformancePoint, UsageOverview, UsagePage, UsageQuery, UsageSummary, + UsageConcurrencyPoint, UsageDetail, UsageFilter, UsageInsights, UsageInsightsCost, + UsageInsightsCostPoint, UsageInsightsHealth, UsageInsightsHealthPoint, + UsageInsightsLoad, UsageInsightsPerformance, UsageInsightsPerformancePoint, + UsageOverview, UsagePage, UsageQuery, UsageSummary, }, provider_credentials::ProviderQuotaRequest, }, @@ -304,7 +305,7 @@ impl ObservabilityService for DefaultObservabilityService { } let overview = observation.overview; let trend = observation.trend; - build_usage_insights(overview, trend, standard_costs) + build_usage_insights(overview, trend, standard_costs, observation.concurrency) } async fn diagnostics( @@ -385,6 +386,7 @@ fn build_usage_insights( overview: UsageOverview, trend: Vec, standard_costs: UsageCostScenarios, + concurrency: Vec, ) -> Result { let granularity = trend.first().map_or( crate::model::observability::Granularity::FifteenMinutes, @@ -432,49 +434,69 @@ fn build_usage_insights( }) .collect(), }; + let concurrency_by_bucket = concurrency + .iter() + .map(|point| (point.bucket_start, point)) + .collect::>(); + let mut weighted_concurrency_micros = 0.0; + let mut effective_micros = 0_i64; + for (index, point) in concurrency.iter().enumerate() { + let start = point.bucket_start.max(overview.range.start); + let end = concurrency + .get(index + 1) + .map_or(overview.range.end, |next| next.bucket_start) + .min(overview.range.end); + let micros = (end - start).num_microseconds().unwrap_or(0).max(0); + effective_micros = effective_micros.saturating_add(micros); + weighted_concurrency_micros += point.average_concurrency * micros as f64; + } + let average_concurrency = if effective_micros > 0 { + weighted_concurrency_micros / effective_micros as f64 + } else { + 0.0 + }; + let peak_concurrency = concurrency + .iter() + .map(|point| point.peak_concurrency) + .max() + .unwrap_or(0); let performance = UsageInsightsPerformance { latency_percentiles: requests.latency_percentiles, first_token_latency_percentiles: requests.first_token_latency_percentiles, - latency_coverage: rate_or_zero(requests.latency_count, requests.request_count), - first_token_coverage: rate_or_zero( - requests.first_token_latency_count, - requests.request_count, - ), - admission_decision_percentiles: requests.admission_decision_percentiles, - account_selection_wait_percentiles: requests.account_selection_wait_percentiles, - admission_decision_coverage: rate_or_zero( - requests.admission_decision_count, - requests.request_count, - ), - account_selection_wait_coverage: rate_or_zero( - requests.account_selection_wait_count, - requests.request_count, - ), output_throughput_p10: requests.output_throughput_p10, output_throughput_p50: requests.output_throughput_p50, output_throughput_p90: requests.output_throughput_p90, - capacity_utilization_avg_basis_points: requests.capacity_utilization_avg_basis_points, - capacity_utilization_p95_basis_points: requests.capacity_utilization_p95_basis_points, - capacity_coverage: rate_or_zero(requests.capacity_sample_count, requests.request_count), + load: UsageInsightsLoad { + average_concurrency, + peak_concurrency, + admission_decision_p95_ms: requests.admission_decision_percentiles.p95_ms, + account_selection_wait_p95_ms: requests.account_selection_wait_percentiles.p95_ms, + }, points: trend .iter() - .map(|point| UsageInsightsPerformancePoint { - bucket_start: point.bucket_start, - latency_percentiles: point.metrics.latency_percentiles, - first_token_latency_percentiles: point.metrics.first_token_latency_percentiles, - admission_decision_percentiles: point.metrics.admission_decision_percentiles, - account_selection_wait_percentiles: point - .metrics - .account_selection_wait_percentiles, - output_throughput_p10: point.metrics.output_throughput_p10, - output_throughput_p50: point.metrics.output_throughput_p50, - output_throughput_p90: point.metrics.output_throughput_p90, - capacity_utilization_avg_basis_points: point - .metrics - .capacity_utilization_avg_basis_points, - capacity_utilization_p95_basis_points: point - .metrics - .capacity_utilization_p95_basis_points, + .map(|point| { + let concurrency = concurrency_by_bucket.get(&point.bucket_start); + UsageInsightsPerformancePoint { + bucket_start: point.bucket_start, + latency_percentiles: point.metrics.latency_percentiles, + first_token_latency_percentiles: point.metrics.first_token_latency_percentiles, + output_throughput_p10: point.metrics.output_throughput_p10, + output_throughput_p50: point.metrics.output_throughput_p50, + output_throughput_p90: point.metrics.output_throughput_p90, + load: UsageInsightsLoad { + average_concurrency: concurrency + .map_or(0.0, |point| point.average_concurrency), + peak_concurrency: concurrency.map_or(0, |point| point.peak_concurrency), + admission_decision_p95_ms: point + .metrics + .admission_decision_percentiles + .p95_ms, + account_selection_wait_p95_ms: point + .metrics + .account_selection_wait_percentiles + .p95_ms, + }, + } }) .collect(), }; diff --git a/backend/crates/gateway-admin/tests/use_case/observability/mod.rs b/backend/crates/gateway-admin/tests/use_case/observability/mod.rs index 4c4b02a46..997b86784 100644 --- a/backend/crates/gateway-admin/tests/use_case/observability/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/observability/mod.rs @@ -22,8 +22,9 @@ use gateway_admin::{ DiagnosticDimension, DiagnosticObservation, DiagnosticsObservation, Granularity, HealthStatus, LatencyPercentiles, ObservabilityPageSize, OpsErrorPage, OpsErrorQuery, PercentileMilliseconds, RequestMetricPoint, RequestMetrics, TimeRange, TrendKind, - UsageBilling, UsageDetail, UsageFilter, UsageInsightsObservation, UsageListRecord, - UsageOverview, UsagePage, UsageProjectedCostPoint, UsageQuery, UsageSummaryObservation, + UsageBilling, UsageConcurrencyPoint, UsageDetail, UsageFilter, + UsageInsightsObservation, UsageListRecord, UsageOverview, UsagePage, + UsageProjectedCostPoint, UsageQuery, UsageSummaryObservation, }, settings::{ AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy, @@ -678,10 +679,26 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s assert_eq!(insights.health.success_rate, 0.75); assert_eq!(insights.health.completion_rate, 1.0); assert_eq!(insights.granularity, Granularity::FifteenMinutes); - assert_eq!(insights.performance.latency_coverage, 0.5); - assert_eq!(insights.performance.admission_decision_coverage, 1.0); - assert_eq!(insights.performance.account_selection_wait_coverage, 0.8); - assert_eq!(insights.performance.capacity_coverage, 0.8); + assert_eq!( + insights + .performance + .load + .admission_decision_p95_ms + .map(|value| value.as_f64()), + Some(5.0) + ); + assert_eq!( + insights + .performance + .load + .account_selection_wait_p95_ms + .map(|value| value.as_f64()), + Some(40.0) + ); + assert_eq!( + insights.performance.points[0].load, + insights.performance.load + ); assert_eq!(insights.performance.output_throughput_p50, Some(50)); assert_eq!(insights.cost.tokens_per_request, 100.0); assert_eq!( @@ -742,6 +759,52 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s assert_eq!(diagnostics.items[1].request_share, 0.25); } +#[tokio::test] +async fn usage_insights_should_weight_concurrency_by_effective_bucket_duration() { + let bucket_start = DateTime::parse_from_rfc3339("2026-09-01T10:00:00Z") + .unwrap() + .with_timezone(&Utc); + let range = TimeRange::new( + bucket_start + Duration::minutes(5), + bucket_start + Duration::minutes(20), + ) + .unwrap(); + let store = Arc::new(FixtureObservabilityStore::new(range)); + store.replace_trend(vec![ + health_metric_point(bucket_start, RequestMetrics::default()), + health_metric_point( + bucket_start + Duration::minutes(15), + RequestMetrics::default(), + ), + ]); + *store.concurrency.lock().unwrap() = vec![ + UsageConcurrencyPoint { + bucket_start, + average_concurrency: 1.0, + peak_concurrency: 1, + }, + UsageConcurrencyPoint { + bucket_start: bucket_start + Duration::minutes(15), + average_concurrency: 3.0, + peak_concurrency: 4, + }, + ]; + + let insights = observability_services(store) + .await + .observability() + .usage_insights(range, UsageFilter::default()) + .await + .unwrap(); + + assert!((insights.performance.load.average_concurrency - 5.0 / 3.0).abs() < 1e-10); + assert_eq!(insights.performance.load.peak_concurrency, 4); + assert_eq!(insights.performance.points[0].load.average_concurrency, 1.0); + assert_eq!(insights.performance.points[0].load.peak_concurrency, 1); + assert_eq!(insights.performance.points[1].load.average_concurrency, 3.0); + assert_eq!(insights.performance.points[1].load.peak_concurrency, 4); +} + #[tokio::test] async fn diagnostics_should_preserve_request_order_when_a_small_sample_fails() { let now = Utc::now(); @@ -987,6 +1050,7 @@ struct FixtureObservabilityStore { trend: Mutex>, overview: Mutex, projected_costs: Mutex>, + concurrency: Mutex>, insights_fail: AtomicBool, summary_fail: AtomicBool, diagnostics: Mutex, @@ -1014,6 +1078,7 @@ impl FixtureObservabilityStore { providers: Vec::new(), }), projected_costs: Mutex::new(Vec::new()), + concurrency: Mutex::new(Vec::new()), insights_fail: AtomicBool::new(false), summary_fail: AtomicBool::new(false), diagnostics: Mutex::new(DiagnosticsObservation::default()), @@ -1144,6 +1209,7 @@ impl ObservabilityStore for FixtureObservabilityStore { .lock() .expect("projected costs") .clone(), + concurrency: self.concurrency.lock().expect("concurrency").clone(), }) } diff --git a/backend/crates/gateway-api/src/admin/observability/presenter.rs b/backend/crates/gateway-api/src/admin/observability/presenter.rs index e5ddedcd2..ed4235d4f 100644 --- a/backend/crates/gateway-api/src/admin/observability/presenter.rs +++ b/backend/crates/gateway-api/src/admin/observability/presenter.rs @@ -41,10 +41,6 @@ pub(crate) fn display_rate(value: f64) -> String { } } -fn basis_points_rate(value: u64) -> f64 { - value as f64 / 10_000.0 -} - pub(crate) fn outcome_name(outcome: &domain::RequestOutcome) -> &str { outcome.as_str() } @@ -1050,6 +1046,17 @@ pub(crate) fn usage_summary_view( } } +fn overview_load_view(load: &domain::UsageInsightsLoad) -> OverviewLoadView { + OverviewLoadView { + average_concurrency: load.average_concurrency, + peak_concurrency: load.peak_concurrency, + admission_decision_p95_ms: load.admission_decision_p95_ms.map(|value| value.as_f64()), + account_selection_wait_p95_ms: load + .account_selection_wait_p95_ms + .map(|value| value.as_f64()), + } +} + pub(crate) fn usage_insights_view( insights: domain::UsageInsights, time: crate::time::TimePresenter, @@ -1092,31 +1099,10 @@ pub(crate) fn usage_insights_view( .first_token_latency_percentiles .p99_ms .map(|value| value.as_f64()), - admission_decision_p50_ms: point - .admission_decision_percentiles - .p50_ms - .map(|value| value.as_f64()), - admission_decision_p95_ms: point - .admission_decision_percentiles - .p95_ms - .map(|value| value.as_f64()), - account_selection_wait_p50_ms: point - .account_selection_wait_percentiles - .p50_ms - .map(|value| value.as_f64()), - account_selection_wait_p95_ms: point - .account_selection_wait_percentiles - .p95_ms - .map(|value| value.as_f64()), output_throughput_p10: point.output_throughput_p10, output_throughput_p50: point.output_throughput_p50, output_throughput_p90: point.output_throughput_p90, - capacity_utilization: point - .capacity_utilization_avg_basis_points - .map(basis_points_rate), - capacity_utilization_p95: point - .capacity_utilization_p95_basis_points - .map(basis_points_rate), + load: overview_load_view(&point.load), }) .collect(); let cost_points = insights @@ -1185,42 +1171,10 @@ pub(crate) fn usage_insights_view( .first_token_latency_percentiles .p99_ms .map(|value| value.as_f64()), - admission_decision_p50_ms: insights - .performance - .admission_decision_percentiles - .p50_ms - .map(|value| value.as_f64()), - admission_decision_p95_ms: insights - .performance - .admission_decision_percentiles - .p95_ms - .map(|value| value.as_f64()), - account_selection_wait_p50_ms: insights - .performance - .account_selection_wait_percentiles - .p50_ms - .map(|value| value.as_f64()), - account_selection_wait_p95_ms: insights - .performance - .account_selection_wait_percentiles - .p95_ms - .map(|value| value.as_f64()), output_throughput_p10: insights.performance.output_throughput_p10, output_throughput_p50: insights.performance.output_throughput_p50, output_throughput_p90: insights.performance.output_throughput_p90, - capacity_utilization: insights - .performance - .capacity_utilization_avg_basis_points - .map(basis_points_rate), - capacity_utilization_p95: insights - .performance - .capacity_utilization_p95_basis_points - .map(basis_points_rate), - latency_coverage: insights.performance.latency_coverage, - first_token_coverage: insights.performance.first_token_coverage, - admission_decision_coverage: insights.performance.admission_decision_coverage, - account_selection_wait_coverage: insights.performance.account_selection_wait_coverage, - capacity_coverage: insights.performance.capacity_coverage, + load: overview_load_view(&insights.performance.load), points: performance_points, }, cost: OverviewCostView { diff --git a/backend/crates/gateway-api/src/admin/observability/wire.rs b/backend/crates/gateway-api/src/admin/observability/wire.rs index 8cabcc265..ceb49536d 100644 --- a/backend/crates/gateway-api/src/admin/observability/wire.rs +++ b/backend/crates/gateway-api/src/admin/observability/wire.rs @@ -648,6 +648,16 @@ pub struct OverviewHealthView { pub points: Vec, } +/// 并发与调度等待的负载指标 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OverviewLoadView { + pub average_concurrency: f64, + pub peak_concurrency: u64, + pub admission_decision_p95_ms: Option, + pub account_selection_wait_p95_ms: Option, +} + /// 洞察性能趋势点 #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -660,15 +670,10 @@ pub struct OverviewPerformancePointView { pub first_token_p50_ms: Option, pub first_token_p95_ms: Option, pub first_token_p99_ms: Option, - pub admission_decision_p50_ms: Option, - pub admission_decision_p95_ms: Option, - pub account_selection_wait_p50_ms: Option, - pub account_selection_wait_p95_ms: Option, pub output_throughput_p10: Option, pub output_throughput_p50: Option, pub output_throughput_p90: Option, - pub capacity_utilization: Option, - pub capacity_utilization_p95: Option, + pub load: OverviewLoadView, } /// 洞察性能摘要 @@ -681,20 +686,10 @@ pub struct OverviewPerformanceView { pub first_token_p50_ms: Option, pub first_token_p95_ms: Option, pub first_token_p99_ms: Option, - pub admission_decision_p50_ms: Option, - pub admission_decision_p95_ms: Option, - pub account_selection_wait_p50_ms: Option, - pub account_selection_wait_p95_ms: Option, pub output_throughput_p10: Option, pub output_throughput_p50: Option, pub output_throughput_p90: Option, - pub capacity_utilization: Option, - pub capacity_utilization_p95: Option, - pub latency_coverage: f64, - pub first_token_coverage: f64, - pub admission_decision_coverage: f64, - pub account_selection_wait_coverage: f64, - pub capacity_coverage: f64, + pub load: OverviewLoadView, pub points: Vec, } diff --git a/backend/crates/gateway-api/tests/admin/mod.rs b/backend/crates/gateway-api/tests/admin/mod.rs index 796ea6624..63dd63c92 100644 --- a/backend/crates/gateway-api/tests/admin/mod.rs +++ b/backend/crates/gateway-api/tests/admin/mod.rs @@ -697,6 +697,7 @@ pub(super) struct MemoryClientKeyStore(Arc>>); pub(super) struct MemoryObservations { pub summary: Option, pub trend: Option>, + pub concurrency: Vec, pub summaries: Vec<(TimeRange, UsageFilter)>, pub trends: Vec<(TimeRange, UsageFilter)>, pub records: Vec, @@ -1526,6 +1527,7 @@ impl ObservabilityStore for UnusedStore { .clone() .ok_or_else(|| unavailable("usage trend"))?, costs: Vec::new(), + concurrency: data.concurrency.clone(), }, ) } diff --git a/backend/crates/gateway-api/tests/admin/observability/response.rs b/backend/crates/gateway-api/tests/admin/observability/response.rs index 738326453..caa5f883f 100644 --- a/backend/crates/gateway-api/tests/admin/observability/response.rs +++ b/backend/crates/gateway-api/tests/admin/observability/response.rs @@ -1286,6 +1286,102 @@ async fn calendar_query_rejects_an_anchor_outside_the_local_calendar() { assert!(fixture.dashboard_summary_range.lock().unwrap().is_none()); } +#[tokio::test] +async fn usage_insights_maps_load_summary_and_points_to_camel_case() { + use crate::admin::{AdminTestFixture, AdminTestState}; + use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode, header}, + }; + use chrono::Duration; + use gateway_admin::model::observability::{ + Granularity, LatencyPercentiles, PercentileMilliseconds, RequestMetricPoint, + RequestMetrics, TimeRange, UsageConcurrencyPoint, UsageOverview, + }; + use gateway_api::admin::observability; + use tower::ServiceExt as _; + + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let now = Utc::now(); + let range = TimeRange::new(now - Duration::minutes(15), now).unwrap(); + let bucket = range.start; + { + let mut observations = fixture.observations.lock().unwrap(); + observations.summary = Some(UsageOverview { + analytics: Default::default(), + range, + requests: RequestMetrics { + admission_decision_percentiles: LatencyPercentiles { + p95_ms: Some(PercentileMilliseconds::new(12.5).unwrap()), + ..Default::default() + }, + account_selection_wait_percentiles: LatencyPercentiles { + p95_ms: Some(PercentileMilliseconds::new(128.75).unwrap()), + ..Default::default() + }, + ..Default::default() + }, + attempts: Default::default(), + providers: Vec::new(), + }); + observations.trend = Some(vec![RequestMetricPoint { + bucket_start: bucket, + granularity: Granularity::FifteenMinutes, + metrics: RequestMetrics { + admission_decision_percentiles: LatencyPercentiles { + p95_ms: Some(PercentileMilliseconds::new(2.5).unwrap()), + ..Default::default() + }, + ..Default::default() + }, + cost_coverage: Default::default(), + costs: Vec::new(), + }]); + observations.concurrency = vec![UsageConcurrencyPoint { + bucket_start: bucket, + average_concurrency: 1.25, + peak_concurrency: 3, + }]; + } + + let response = observability::router::() + .with_state(fixture.state()) + .oneshot( + Request::builder() + .uri("/api/admin/usage/insights/overview") + .header(header::COOKIE, "cpr_session=valid-session") + .header("x-request-id", "req_usage_insights_load") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(status, StatusCode::OK, "{value}"); + let performance = &value["data"]["performance"]; + assert_eq!( + performance["load"], + json!({ + "averageConcurrency": 1.25, + "peakConcurrency": 3, + "admissionDecisionP95Ms": 12.5, + "accountSelectionWaitP95Ms": 128.75, + }) + ); + assert_eq!( + performance["points"][0]["load"], + json!({ + "averageConcurrency": 1.25, + "peakConcurrency": 3, + "admissionDecisionP95Ms": 2.5, + "accountSelectionWaitP95Ms": null, + }) + ); +} + #[tokio::test] async fn observability_queries_reject_group_filters_and_analytics_search() { use crate::admin::{AdminTestFixture, AdminTestState}; diff --git a/backend/crates/gateway-store/src/postgres/observability/admin_adapter.rs b/backend/crates/gateway-store/src/postgres/observability/admin_adapter.rs index 0e5161ce3..8a37c5f7c 100644 --- a/backend/crates/gateway-store/src/postgres/observability/admin_adapter.rs +++ b/backend/crates/gateway-store/src/postgres/observability/admin_adapter.rs @@ -294,7 +294,7 @@ impl PgObservabilityRepository { "load usage insights snapshot", |mut connection| async move { let mut snapshot = analytics::AnalyticsSnapshot::new(&mut connection).await?; - let (mut overview, trend, costs) = snapshot + let (mut overview, trend, costs, concurrency) = snapshot .insights(range, &filter, granularity, self.timezone) .await?; overview.analytics = snapshot.metadata(); @@ -303,6 +303,7 @@ impl PgObservabilityRepository { overview: admin_usage_overview(overview), trend: trend.into_iter().map(admin_request_metric_point).collect(), costs, + concurrency, }) }, ) diff --git a/backend/crates/gateway-store/src/postgres/observability/analytics/query.rs b/backend/crates/gateway-store/src/postgres/observability/analytics/query.rs index 04c4e904f..5038811f3 100644 --- a/backend/crates/gateway-store/src/postgres/observability/analytics/query.rs +++ b/backend/crates/gateway-store/src/postgres/observability/analytics/query.rs @@ -339,6 +339,7 @@ impl<'a> AnalyticsSnapshot<'a> { UsageOverview, Vec, Vec, + Vec, )> { let states = self .load(range, filter, None, Some(granularity), timezone, true) @@ -354,6 +355,9 @@ impl<'a> AnalyticsSnapshot<'a> { }) }) .collect::>>()?; + let concurrency = self + .concurrency(range, filter, granularity, timezone) + .await?; // 趋势已包含可合并的完整指标和情景费用,概览直接复用同一份状态 // 精确分位数不可跨桶合并,明细回退时仍按完整区间单独计算概览 let overview = if states.values().any(MetricState::has_exact_percentiles) { @@ -362,7 +366,98 @@ impl<'a> AnalyticsSnapshot<'a> { self.overview_from_state(range, filter, merge_states(states.into_values())?) .await? }; - Ok((overview, trend, costs)) + Ok((overview, trend, costs, concurrency)) + } + + async fn concurrency( + &mut self, + range: ObservabilityRange, + filter: &UsageRecordFilter, + granularity: ObservationGranularity, + timezone: gateway_core::time::DeploymentTimeZone, + ) -> StoreResult> { + filter.validate()?; + self.require_visible(Some(range))?; + let boundaries = if granularity == ObservationGranularity::Day { + calendar_buckets(range, timezone)? + } else { + let seconds = granularity.seconds(); + let mut bucket = + DateTime::from_timestamp(range.start.timestamp().div_euclid(seconds) * seconds, 0) + .ok_or_else(|| invalid("invalid concurrency bucket"))?; + let step = TimeDelta::seconds(seconds); + let mut boundaries = Vec::new(); + while bucket < range.end { + boundaries.push(bucket); + bucket = bucket + .checked_add_signed(step) + .ok_or_else(|| invalid("invalid next concurrency bucket"))?; + } + boundaries + }; + // 按生命周期分开筛选,使终态请求复用 completed_at 索引,运行中请求沿用独立索引 + let mut query = QueryBuilder::::new( + "with buckets as materialized (select greatest(bucket_start,", + ); + query + .push_bind(range.start) + .push("::timestamptz) as effective_start,least(coalesce(lead(bucket_start) over(order by bucket_start),") + .push_bind(range.end) + .push("::timestamptz),") + .push_bind(range.end) + .push("::timestamptz) as bucket_end,bucket_start from unnest(") + .push_bind(boundaries) + .push("::timestamptz[]) input(bucket_start)), matching as (select mr.started_at,mr.completed_at from model_request_observations mr where mr.outcome <> 'running' and mr.completed_at > ") + .push_bind(range.start) + .push(" and mr.started_at < ") + .push_bind(range.end); + push_analytics_filter(&mut query, filter, "mr"); + query + .push(" union all select mr.started_at,") + .push_bind(range.end) + .push("::timestamptz as completed_at from model_request_observations mr where mr.outcome = 'running' and mr.started_at < ") + .push_bind(range.end); + push_analytics_filter(&mut query, filter, "mr"); + // 每个请求只产生两个事件,桶边界只切分时间轴,避免请求与所有桶逐一连接 + // 同时刻先合并增减,再计算下一段并发,遵循左闭右开的执行区间 + query + .push("), events as (select e.occurred_at,e.delta,null::timestamptz as bucket_start from matching m cross join lateral (values (greatest(m.started_at,") + .push_bind(range.start) + .push("::timestamptz),1::bigint),(least(m.completed_at,") + .push_bind(range.end) + .push("::timestamptz),-1::bigint)) e(occurred_at,delta) union all select effective_start,0::bigint,bucket_start from buckets union all select ") + .push_bind(range.end) + .push("::timestamptz,0::bigint,null::timestamptz), + event_points as ( + select occurred_at,sum(delta)::bigint as delta,max(bucket_start) as bucket_start + from events group by occurred_at + ), timeline as ( + select occurred_at,sum(delta) over timeline_order::bigint as concurrency, + max(bucket_start) over timeline_order as bucket_start, + lead(occurred_at) over(order by occurred_at) as next_occurred_at + from event_points + window timeline_order as (order by occurred_at rows between unbounded preceding and current row) + ) + select b.bucket_start, + coalesce(sum(t.concurrency*extract(epoch from t.next_occurred_at-t.occurred_at)) + /nullif(extract(epoch from b.bucket_end-b.effective_start),0),0)::float8 as average_concurrency, + coalesce(max(t.concurrency),0)::bigint as peak_concurrency + from buckets b left join timeline t on t.bucket_start=b.bucket_start and t.next_occurred_at>t.occurred_at + group by b.bucket_start,b.effective_start,b.bucket_end order by b.bucket_start"); + let rows = query + .build() + .fetch_all(&mut *self.tx) + .await + .map_err(|error| postgres_unavailable("aggregate request concurrency", error))?; + rows.iter() + .map(|row| { + Ok(admin_observability::UsageConcurrencyPoint { + bucket_start: get(row, "bucket_start")?, + average_concurrency: get(row, "average_concurrency")?, + peak_concurrency: unsigned(row, "peak_concurrency")?, + }) + }) + .collect() } pub async fn diagnostics( diff --git a/backend/crates/gateway-store/tests/postgres/analytics.rs b/backend/crates/gateway-store/tests/postgres/analytics.rs index dc9fea1ad..a7c497091 100644 --- a/backend/crates/gateway-store/tests/postgres/analytics.rs +++ b/backend/crates/gateway-store/tests/postgres/analytics.rs @@ -1691,6 +1691,156 @@ async fn daily_rank_counts_follow_rebuilds_rollbacks_deletions_and_dirty_days() database.close().await; } +#[tokio::test] +async fn insights_concurrency_counts_logical_request_overlap_once_per_bucket() { + let Some(database) = TestDatabase::create("analytics_concurrency").await else { + return; + }; + let start = DateTime::parse_from_rfc3339("2026-09-01T10:00:00Z") + .unwrap() + .with_timezone(&Utc); + for (id, from, to) in [ + ("concurrency_serial_a", 1, Some(4)), + ("concurrency_serial_b", 4, Some(6)), + ("concurrency_cross_bucket", 10, Some(25)), + ("concurrency_overlap", 12, Some(14)), + ("concurrency_running", 30, None), + ("concurrency_xai", 5, Some(20)), + ("concurrency_instant", 7, Some(7)), + ] { + seed(&database.pool, id, start, &[]).await; + sqlx::query("update model_requests set started_at=$2,completed_at=$3,recovery_lease_expires_at=$2+interval '1 hour',provider_kind=case when id='concurrency_xai' then 'xai' else 'openai' end,provider_account_ref='account_concurrency',upstream_transport='http_sse',upstream_send_state='sent',attempt_count=case when id='concurrency_serial_a' then 3 else 1 end,outcome=case when $3 is null then 'running' else 'succeeded' end where id=$1") + .bind(id) + .bind(start + TimeDelta::minutes(from)) + .bind(to.map(|minute| start + TimeDelta::minutes(minute))) + .execute(&database.pool) + .await + .unwrap(); + } + drain_analytics(&database.pool).await; + let repo = observability_repository(&database.pool); + use gateway_admin::model::observability::Granularity::{FifteenMinutes, Hour}; + + for (provider, from, to, granularity, expected) in [ + ( + Some("openai"), + 0, + 60, + FifteenMinutes, + vec![(0.8, 2), (2.0 / 3.0, 1), (1.0, 1), (1.0, 1)], + ), + (Some("openai"), 0, 10, FifteenMinutes, vec![(0.5, 1)]), + (Some("openai"), 4, 6, FifteenMinutes, vec![(1.0, 1)]), + ( + Some("openai"), + 3, + 17, + FifteenMinutes, + vec![(5.0 / 6.0, 2), (1.0, 1)], + ), + (Some("openai"), 35, 40, FifteenMinutes, vec![(1.0, 1)]), + (Some("openai"), -15, 0, FifteenMinutes, vec![(0.0, 0)]), + (Some("openai"), 0, 60, Hour, vec![(13.0 / 15.0, 2)]), + ( + Some("xai"), + 0, + 60, + FifteenMinutes, + vec![(2.0 / 3.0, 1), (1.0 / 3.0, 1), (0.0, 0), (0.0, 0)], + ), + ( + Some("xai"), + 10, + 17, + FifteenMinutes, + vec![(1.0, 1), (1.0, 1)], + ), + ( + None, + 0, + 60, + FifteenMinutes, + vec![(22.0 / 15.0, 3), (1.0, 2), (1.0, 1), (1.0, 1)], + ), + ] { + let insights = repo + .usage_insights_observation( + ObservabilityRange::new( + start + TimeDelta::minutes(from), + start + TimeDelta::minutes(to), + ) + .unwrap(), + UsageRecordFilter { + provider_kind: provider.map(str::to_owned), + ..Default::default() + }, + granularity, + ) + .await + .unwrap(); + assert_eq!(insights.concurrency.len(), expected.len()); + for (point, (average, peak)) in insights.concurrency.iter().zip(expected) { + assert!( + (point.average_concurrency - average).abs() < 1e-10, + "{provider:?} {from}..{to}: {point:?}" + ); + assert_eq!(point.peak_concurrency, peak, "{provider:?} {from}..{to}"); + } + } + database.close().await; +} + +#[tokio::test] +async fn insights_concurrency_weights_calendar_days_across_dst() { + let Some(database) = TestDatabase::create("analytics_concurrency_days").await else { + return; + }; + let repo = + observability_repository(&database.pool).with_timezone("America/New_York".parse().unwrap()); + for (date, hours) in [("2026-03-08T05:00:00Z", 23), ("2026-11-01T04:00:00Z", 25)] { + let start = DateTime::parse_from_rfc3339(date) + .unwrap() + .with_timezone(&Utc); + let next_day = start + TimeDelta::hours(hours); + for (suffix, from, to) in [("completed", -1, Some(1)), ("running", 12, None)] { + let id = format!("concurrency_{date}_{suffix}"); + seed(&database.pool, &id, start, &[]).await; + sqlx::query("update model_requests set started_at=$2,completed_at=$3,recovery_lease_expires_at=$2+interval '2 days',outcome=case when $3 is null then 'running' else 'succeeded' end,requested_model_id=$4 where id=$1") + .bind(id) + .bind(start + TimeDelta::hours(from)) + .bind(to.map(|hour| start + TimeDelta::hours(hour))) + .bind(date) + .execute(&database.pool) + .await + .unwrap(); + } + drain_analytics(&database.pool).await; + let insights = repo + .usage_insights_observation( + ObservabilityRange::new(start, next_day + TimeDelta::hours(6)).unwrap(), + UsageRecordFilter { + model: Some(date.to_owned()), + ..Default::default() + }, + gateway_admin::model::observability::Granularity::Day, + ) + .await + .unwrap(); + assert_eq!(insights.concurrency.len(), 2); + assert_eq!(insights.concurrency[0].bucket_start, start); + assert!( + (insights.concurrency[0].average_concurrency - (hours - 11) as f64 / hours as f64) + .abs() + < 1e-10 + ); + assert_eq!(insights.concurrency[0].peak_concurrency, 1); + assert_eq!(insights.concurrency[1].bucket_start, next_day); + assert_eq!(insights.concurrency[1].average_concurrency, 1.0); + assert_eq!(insights.concurrency[1].peak_concurrency, 1); + } + database.close().await; +} + #[tokio::test] async fn sql_edge_histograms_preserve_repeated_samples_and_power_of_two_boundaries() { let Some(database) = TestDatabase::create("analytics_sql_histogram_boundaries").await else { diff --git a/docs/api.md b/docs/api.md index 3d697f936..878a77571 100644 --- a/docs/api.md +++ b/docs/api.md @@ -2124,7 +2124,7 @@ errorCode, errorMessage, startedAt, completedAt, expiresAt, createdAt, updatedAt `points[]` 提供趋势图与首页卡片使用的请求数、Tokens、缓存比例、错误数、成功率、 `latencyValue`、首 Token P95、总延迟 P95 和输出吞吐 P50,以及时间标签与展示值 -内部调度分位数和容量分布通过用量洞察查询 +请求并发与内部调度等待通过用量洞察的 `performance.load` 查询 ### Dashboard 客户端身份 @@ -2222,6 +2222,19 @@ OpenAI 推理强度优先取 `input` 中最后一条 `type: "configuration_updat 已登记的尝试即使尚未收到首个上游事件,也会保留真实 attempt。 鉴权、解析、路由和准入等入口拒绝不属于该范围;请求观测仍是可能延迟或丢弃的异步投影 +性能洞察的 `performance.load` 和 `performance.points[].load` 使用相同的负载结构,分别表示查询范围汇总与时间桶指标: + +| 字段 | 含义 | +| --- | --- | +| `averageConcurrency` | 按有效时长加权的平均请求并发 | +| `peakConcurrency` | 最大重叠请求数 | +| `admissionDecisionP95Ms` | 准入判定耗时 P95,单位毫秒,无样本为 `null` | +| `accountSelectionWaitP95Ms` | 账号选择等待耗时 P95,单位毫秒,无样本为 `null` | + +并发按逻辑请求的执行区间计算,不因重试 attempt 重复计数。 +区间从 `started_at` 到 `completed_at`,执行中请求按查询范围末端仍在处理计算;与时间桶重叠的请求参与该桶, +平均值按重叠时长加权,峰值取桶内最大重叠请求数,无请求的时间桶为零。全范围平均按有效查询时长加权,峰值取各桶最大值 + 汇总与洞察中的请求数与 outcome 分布覆盖筛选范围内全部请求;token、缓存、延迟与成本聚合仅统计 已完整交付客户端的成功推理响应。OpenAI 的 `generate: false` 连接与上下文准备记录归类为 `requestKind: "prewarm"`,不进入用量列表、账号用量或额度预测的 Token / 费用覆盖统计,但仍可按 diff --git a/frontend/src/api/modules/usage.ts b/frontend/src/api/modules/usage.ts index a48b00b23..a45eea578 100644 --- a/frontend/src/api/modules/usage.ts +++ b/frontend/src/api/modules/usage.ts @@ -256,47 +256,32 @@ export interface UsageOverviewHealth { points: UsageOverviewHealthPoint[] } -export interface UsageOverviewPerformancePoint { - bucket: string - label: string - latencyP50Ms: number | null - latencyP95Ms: number | null - latencyP99Ms: number | null - firstTokenP50Ms: number | null - firstTokenP95Ms: number | null - firstTokenP99Ms: number | null - admissionDecisionP50Ms: number | null +export interface UsageOverviewLoad { + averageConcurrency: number + peakConcurrency: number admissionDecisionP95Ms: number | null - accountSelectionWaitP50Ms: number | null accountSelectionWaitP95Ms: number | null - outputThroughputP10: number | null - outputThroughputP50: number | null - outputThroughputP90: number | null - capacityUtilization: number | null - capacityUtilizationP95: number | null } -export interface UsageOverviewPerformance { +export interface UsageOverviewPerformanceMetrics { latencyP50Ms: number | null latencyP95Ms: number | null latencyP99Ms: number | null firstTokenP50Ms: number | null firstTokenP95Ms: number | null firstTokenP99Ms: number | null - admissionDecisionP50Ms: number | null - admissionDecisionP95Ms: number | null - accountSelectionWaitP50Ms: number | null - accountSelectionWaitP95Ms: number | null outputThroughputP10: number | null outputThroughputP50: number | null outputThroughputP90: number | null - capacityUtilization: number | null - capacityUtilizationP95: number | null - latencyCoverage: number - firstTokenCoverage: number - admissionDecisionCoverage: number - accountSelectionWaitCoverage: number - capacityCoverage: number + load: UsageOverviewLoad +} + +export interface UsageOverviewPerformancePoint extends UsageOverviewPerformanceMetrics { + bucket: string + label: string +} + +export interface UsageOverviewPerformance extends UsageOverviewPerformanceMetrics { points: UsageOverviewPerformancePoint[] } diff --git a/frontend/src/i18n/locales/en/usage.json b/frontend/src/i18n/locales/en/usage.json index f0d4a3b89..3fd7ec253 100644 --- a/frontend/src/i18n/locales/en/usage.json +++ b/frontend/src/i18n/locales/en/usage.json @@ -61,21 +61,21 @@ }, "performance": { "ttftHelp": "TTFT: time to first token", - "scheduling": "Scheduling", + "concurrency": "Concurrency", + "averageConcurrency": "Average concurrency", + "peakConcurrency": "Peak concurrency", + "load": "Load", + "waitTime": "Duration", "admissionP95": "Admission P95", "accountSelectionP95": "Selection P95", - "capacityP95": "Capacity P95", - "decisionCoverage": "Decision coverage", - "accountSelectionCoverage": "Selection coverage", - "capacityCoverage": "Capacity coverage", "title": "Performance", - "description": "Latency, throughput, scheduling", + "description": "Total latency, TTFT, rate, and load", "performanceIndicators": "Performance indicators", "loading": "Loading performance", "noTtftSamples": "No TTFT samples in this range", "noThroughput": "No throughput samples in this range", - "noScheduling": "No scheduling or capacity samples in this range", - "noLatency": "No latency samples in this range" + "noLatency": "No latency samples in this range", + "noLoad": "No load data in this range" }, "reasoning": { "subagentRequest": "Subagent request", diff --git a/frontend/src/i18n/locales/zh-Hans/usage.json b/frontend/src/i18n/locales/zh-Hans/usage.json index 288dd42c7..b9bab0dde 100644 --- a/frontend/src/i18n/locales/zh-Hans/usage.json +++ b/frontend/src/i18n/locales/zh-Hans/usage.json @@ -61,21 +61,21 @@ }, "performance": { "ttftHelp": "TTFT:首个 Token 耗时", - "scheduling": "调度", + "concurrency": "并发", + "averageConcurrency": "平均并发", + "peakConcurrency": "峰值并发", + "load": "负载", + "waitTime": "耗时", "admissionP95": "准入 P95", - "accountSelectionP95": "选号 P95", - "capacityP95": "容量 P95", - "decisionCoverage": "判定覆盖", - "accountSelectionCoverage": "选号覆盖", - "capacityCoverage": "容量覆盖", + "accountSelectionP95": "选号等待 P95", "title": "响应性能", - "description": "延迟、速率与调度分位", + "description": "总耗时、首个 Token 耗时、速率与负载", "performanceIndicators": "性能指标", "loading": "正在加载性能数据", "noTtftSamples": "当前范围暂无 TTFT 样本", "noThroughput": "当前范围暂无速率样本", - "noScheduling": "当前范围暂无调度或容量样本", - "noLatency": "当前范围暂无延迟样本" + "noLatency": "当前范围暂无延迟样本", + "noLoad": "当前范围暂无负载数据" }, "reasoning": { "subagentRequest": "子代理请求", @@ -119,7 +119,7 @@ "subscription": "订阅", "platformType": "平台" }, - "outputThroughput": "输出速率", + "outputThroughput": "速率", "noValidSample": "无有效样本", "notReachable": "不可达", "unstable": "不稳定", diff --git a/frontend/src/i18n/locales/zh-Hant/usage.json b/frontend/src/i18n/locales/zh-Hant/usage.json index 945c6e786..4bc49b0ac 100644 --- a/frontend/src/i18n/locales/zh-Hant/usage.json +++ b/frontend/src/i18n/locales/zh-Hant/usage.json @@ -61,21 +61,21 @@ }, "performance": { "ttftHelp": "TTFT:首個 Token 耗時", - "scheduling": "調度", + "concurrency": "並發", + "averageConcurrency": "平均並發", + "peakConcurrency": "峰值並發", + "load": "負載", + "waitTime": "耗時", "admissionP95": "准入 P95", - "accountSelectionP95": "選號 P95", - "capacityP95": "容量 P95", - "decisionCoverage": "判定覆蓋", - "accountSelectionCoverage": "選號覆蓋", - "capacityCoverage": "容量覆蓋", + "accountSelectionP95": "選號等待 P95", "title": "響應性能", - "description": "延遲、速率與調度分位", + "description": "總耗時、首個 Token 耗時、速率與負載", "performanceIndicators": "性能指標", "loading": "正在加載性能數據", "noTtftSamples": "當前範圍暫無 TTFT 樣本", "noThroughput": "當前範圍暫無速率樣本", - "noScheduling": "當前範圍暫無調度或容量樣本", - "noLatency": "當前範圍暫無延遲樣本" + "noLatency": "當前範圍暫無延遲樣本", + "noLoad": "當前範圍暫無負載資料" }, "reasoning": { "subagentRequest": "子代理請求", @@ -119,7 +119,7 @@ "subscription": "訂閱", "platformType": "平臺" }, - "outputThroughput": "輸出速率", + "outputThroughput": "速率", "noValidSample": "無有效樣本", "notReachable": "不可達", "unstable": "不穩定", diff --git a/frontend/src/views/usage/components/UsageCostCard.vue b/frontend/src/views/usage/components/UsageCostCard.vue index f5830cf60..2ceb36eca 100644 --- a/frontend/src/views/usage/components/UsageCostCard.vue +++ b/frontend/src/views/usage/components/UsageCostCard.vue @@ -265,7 +265,9 @@ function costAmount(value: string | null, pointIndex: number) { class="@container/cost min-h-90 xl:h-full" >