From 6b6d5b3a851f8b85b3aefeb80914992b5c3f301a Mon Sep 17 00:00:00 2001 From: elky Date: Fri, 9 Oct 2026 21:53:43 +0800 Subject: [PATCH] fix: align dashboard charts with customer billing and history coverage --- .../src/observability/analytics/dashboard.rs | 50 ++- ..._make_customer_billing_parallel_unsafe.sql | 3 + .../adapters/postgres/src/usage/analytics.rs | 50 +-- .../postgres/src/usage/dashboard_charts.rs | 369 ++++++++++++++++++ .../adapters/postgres/src/usage/mod.rs | 1 + .../src/repository/usage/analytics.rs | 3 + .../postgres/190_overview_analytics.sql | 2 +- .../runtime/src/lifecycle/migrate/tests.rs | 3 + .../migrate/tests/dashboard_chart_billing.rs | 278 +++++++++++++ .../migrate/tests/dashboard_chart_history.rs | 144 +++++++ .../migrate/tests/legacy_overview_upgrade.rs | 1 + .../src/repository/usage/memory/analytics.rs | 67 +++- crates/aether-usage/runtime/src/write.rs | 161 +++++++- frontend/src/api/overview.ts | 15 +- .../overview/__tests__/dashboard.spec.ts | 199 +++++++++- .../src/features/overview/dashboard/charts.ts | 39 +- frontend/src/views/shared/Dashboard.vue | 255 +++++++++--- .../__tests__/Dashboard.loading.spec.ts | 69 +++- .../views/shared/__tests__/Dashboard.spec.ts | 5 +- 19 files changed, 1561 insertions(+), 153 deletions(-) create mode 100644 crates/aether-data/adapters/postgres/migrations/20261008000000_make_customer_billing_parallel_unsafe.sql create mode 100644 crates/aether-data/adapters/postgres/src/usage/dashboard_charts.rs create mode 100644 crates/aether-data/runtime/src/lifecycle/migrate/tests/dashboard_chart_billing.rs create mode 100644 crates/aether-data/runtime/src/lifecycle/migrate/tests/dashboard_chart_history.rs diff --git a/crates/aether-admin/src/observability/analytics/dashboard.rs b/crates/aether-admin/src/observability/analytics/dashboard.rs index 05587c139..c23da45c8 100644 --- a/crates/aether-admin/src/observability/analytics/dashboard.rs +++ b/crates/aether-admin/src/observability/analytics/dashboard.rs @@ -1,7 +1,7 @@ use super::{envelope, metrics_value, parse_overview_query, OverviewRequest}; use aether_data_contracts::repository::usage::{ - StoredUsageAnalytics, StoredUsageDashboardAnalytics, UsageAnalyticsQuery, UsageAnalyticsRow, - UsageAnalyticsView, UsageDashboardAnalyticsQuery, + StoredUsageAnalytics, StoredUsageDashboardAnalytics, UsageAnalyticsMetrics, + UsageAnalyticsQuery, UsageAnalyticsRow, UsageAnalyticsView, UsageDashboardAnalyticsQuery, }; use chrono::DateTime; use serde_json::{json, Value}; @@ -39,22 +39,42 @@ pub fn parse_dashboard_charts_query(raw: Option<&str>) -> Result Value { + // The chart read deliberately computes only its displayed metrics. Avoid + // presenting unmeasured diagnostics as zero through the shared serializer. + let chart_metrics = |metrics: &UsageAnalyticsMetrics| { + let mut value = metrics_value(metrics); + for field in [ + "input_tokens", + "output_tokens", + "usage_active_users", + "slow_request_count", + "unclassified_failure_count", + ] { + value[field] = Value::Null; + } + value["usage_source"] = json!("unknown"); + value["usage_source_counts"] = json!({ + "reported": 0, "estimated": 0, "mixed": 0, "unknown": metrics.request_count, + }); + value + }; let rows = |items: &[UsageAnalyticsRow]| { items .iter() .map(|row| { - let mut value = metrics_value(&row.metrics); + let mut value = chart_metrics(&row.metrics); if snapshot.unrecoverable_bucket_count > 0 { mark_incomplete_amounts(&mut value); } value["id"] = json!(row.id); value["label"] = json!(row.label); value["bucket_start"] = json!(row.bucket_start); + value["unique_providers"] = json!(row.metrics.unique_providers); value }) .collect::>() }; - let mut summary = metrics_value(&snapshot.summary); + let mut summary = chart_metrics(&snapshot.summary); if snapshot.unrecoverable_bucket_count > 0 { mark_incomplete_amounts(&mut summary); } @@ -293,6 +313,9 @@ mod tests { ..Default::default() }; snapshot.summary.billable_amount = Some("2.00000000".into()); + snapshot.summary.request_count = 1; + snapshot.summary.total_tokens = 42; + snapshot.summary.usage_available_count = 1; let row = UsageAnalyticsRow { id: Some("model-1".into()), label: None, @@ -303,6 +326,25 @@ mod tests { snapshot.model_rows.push(row.clone()); snapshot.provider_rows.push(row); let data = dashboard_charts_value(&snapshot); + for metrics in [ + &data["summary"], + &data["series"][0], + &data["models"][0], + &data["providers"][0], + ] { + assert_eq!(metrics["total_tokens"], 42); + assert_eq!(metrics["usage_source"], "unknown"); + assert_eq!(metrics["usage_source_counts"]["unknown"], 1); + for field in [ + "input_tokens", + "output_tokens", + "usage_active_users", + "slow_request_count", + "unclassified_failure_count", + ] { + assert!(metrics[field].is_null(), "{field} was not measured"); + } + } assert_eq!( data["summary"]["billable_amount"]["status"], "known_subtotal" diff --git a/crates/aether-data/adapters/postgres/migrations/20261008000000_make_customer_billing_parallel_unsafe.sql b/crates/aether-data/adapters/postgres/migrations/20261008000000_make_customer_billing_parallel_unsafe.sql new file mode 100644 index 000000000..e331d2296 --- /dev/null +++ b/crates/aether-data/adapters/postgres/migrations/20261008000000_make_customer_billing_parallel_unsafe.sql @@ -0,0 +1,3 @@ +-- PostgreSQL 15/16 cannot enter the function's EXCEPTION subtransactions in a +-- parallel operation. Keep its validation and immutable amount semantics intact. +ALTER FUNCTION public.usage_customer_billable_amount(jsonb, numeric, numeric) PARALLEL UNSAFE; diff --git a/crates/aether-data/adapters/postgres/src/usage/analytics.rs b/crates/aether-data/adapters/postgres/src/usage/analytics.rs index 3b134e98f..c9706aa63 100644 --- a/crates/aether-data/adapters/postgres/src/usage/analytics.rs +++ b/crates/aether-data/adapters/postgres/src/usage/analytics.rs @@ -312,6 +312,9 @@ impl SqlxUsageReadRepository { query: &UsageAnalyticsQuery, ) -> Result { query.validate()?; + if query.view == UsageAnalyticsView::DashboardCharts { + return self.query_dashboard_charts(query).await; + } let mut tx = self.pool.begin().await.map_postgres_err()?; sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY") .execute(&mut *tx) @@ -370,7 +373,6 @@ impl SqlxUsageReadRepository { match query.view { UsageAnalyticsView::Timeseries | UsageAnalyticsView::Performance - | UsageAnalyticsView::DashboardCharts | UsageAnalyticsView::Breakdown => { let timeseries = query.view != UsageAnalyticsView::Breakdown; // 提供商分组的行需要额外带出名称快照,并在最外层关联提供商目录解析展示名。 @@ -531,29 +533,21 @@ impl SqlxUsageReadRepository { .map_postgres_err()?; result.consumption = decode(row.try_get("items").map_postgres_err()?)?; } - UsageAnalyticsView::Summary => unreachable!(), + UsageAnalyticsView::Summary | UsageAnalyticsView::DashboardCharts => unreachable!(), } } if matches!( query.view, - UsageAnalyticsView::Timeseries - | UsageAnalyticsView::Performance - | UsageAnalyticsView::DashboardCharts + UsageAnalyticsView::Timeseries | UsageAnalyticsView::Performance ) { fill_usage_analytics_timeseries(query, &mut result.rows); result.total = result.rows.len() as u64; } - if matches!( - query.view, - UsageAnalyticsView::Performance | UsageAnalyticsView::DashboardCharts - ) { + if query.view == UsageAnalyticsView::Performance { let mut providers = QueryBuilder::::new("SELECT COALESCE(jsonb_agg(jsonb_build_object('id',provider_id,'label',provider_label,'bucket_start',NULL,'metrics',to_jsonb(m)-'provider_id'-'provider_label')), '[]'::jsonb) AS items FROM (SELECT provider_id, max(provider_name) AS provider_label, "); providers.push(&metrics_sql); push_analytics_filter(&mut providers, query); providers.push(" GROUP BY provider_id ORDER BY count(*) DESC,provider_id"); - if query.view == UsageAnalyticsView::DashboardCharts { - providers.push(" LIMIT 10001"); - } providers.push(") m"); let row = providers .build() @@ -561,38 +555,6 @@ impl SqlxUsageReadRepository { .await .map_postgres_err()?; result.provider_rows = decode(row.try_get("items").map_postgres_err()?)?; - if result.provider_rows.len() > USAGE_DASHBOARD_CHART_ROW_LIMIT - && query.view == UsageAnalyticsView::DashboardCharts - { - return Err(DataLayerError::InvalidInput( - "dashboard provider chart exceeds 10000 groups".into(), - )); - } - } - if query.view == UsageAnalyticsView::DashboardCharts { - let timezone = if query.granularity == UsageAnalyticsGranularity::Hour { - "UTC".into() - } else { - query.timezone.clone() - }; - let mut models = QueryBuilder::::new("WITH filtered AS (SELECT *"); - push_analytics_filter(&mut models, query); - models.push("), dated AS (SELECT *,date_trunc(") - .push_bind(if query.granularity==UsageAnalyticsGranularity::Hour {"hour"}else{"day"}) - .push(",created_at AT TIME ZONE ").push_bind(timezone.clone()) - .push(") AT TIME ZONE ").push_bind(timezone).push(" AS bucket FROM filtered) SELECT COALESCE(jsonb_agg(jsonb_build_object('id',model,'label',model,'bucket_start',to_char(bucket AT TIME ZONE 'UTC','YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"'),'metrics',to_jsonb(m)-'model'-'bucket')),'[]'::jsonb) AS items FROM (SELECT model,bucket,") - .push(&metrics_sql).push(" FROM dated GROUP BY model,bucket ORDER BY bucket,model LIMIT 10001) m"); - let row = models - .build() - .fetch_one(&mut *tx) - .await - .map_postgres_err()?; - result.model_rows = decode(row.try_get("items").map_postgres_err()?)?; - if result.model_rows.len() > USAGE_DASHBOARD_CHART_ROW_LIMIT { - return Err(DataLayerError::InvalidInput( - "dashboard model chart exceeds 10000 groups; narrow the range".into(), - )); - } } if query.view == UsageAnalyticsView::Performance { // Aggregate requested models across providers in the same read snapshot. diff --git a/crates/aether-data/adapters/postgres/src/usage/dashboard_charts.rs b/crates/aether-data/adapters/postgres/src/usage/dashboard_charts.rs new file mode 100644 index 000000000..5d52c8d77 --- /dev/null +++ b/crates/aether-data/adapters/postgres/src/usage/dashboard_charts.rs @@ -0,0 +1,369 @@ +use super::{ + analytics::{analytics_metrics_sql, push_analytics_source_filter}, + projection_reader::read_projection_coverage, + SqlxUsageReadRepository, +}; +use crate::error::SqlxResultExt; +use aether_data_contracts::{repository::usage::*, DataLayerError}; +use chrono::{DateTime, Duration, Utc}; +use serde::de::DeserializeOwned; +use serde_json::Value; +use sqlx::{Postgres, QueryBuilder, Row}; + +// Committed request contributions are updated in the same transaction as the +// top dashboard counters. Reuse them without detoasting large request metadata. +// Rows predating that ledger retain DASHBOARD_TOTAL_FACTS_SQL token precedence. +const DASHBOARD_CHART_FACTS_SQL: &str = r#"( +SELECT u.created_at,u.model,u.provider_id,u.provider_name,u.status,u.response_time_ms, + COALESCE(a.record_kind,'request') AS record_kind, + COALESCE(s.billing_status,u.billing_status) AS settlement_status,s.allocation_status, + CASE WHEN c.request_id IS NOT NULL THEN (c.metrics->>'usage_available_count')::bigint>0 + ELSE COALESCE(metadata.value->'usage_available','true'::jsonb)<>'false'::jsonb END AS usage_available, + CASE WHEN c.request_id IS NOT NULL THEN + CASE WHEN (c.metrics->>'usage_available_count')::bigint>0 THEN (c.metrics->>'total_tokens')::bigint END + WHEN COALESCE(metadata.value->'usage_available', 'true'::jsonb) <> 'false'::jsonb THEN + GREATEST( + COALESCE( + CASE + WHEN s.billing_effective_input_tokens IS NOT NULL + THEN GREATEST(s.billing_effective_input_tokens, 0) + + GREATEST(COALESCE(s.billing_output_tokens, u.output_tokens, 0), 0) + + GREATEST( + COALESCE( + s.billing_cache_creation_tokens, + CASE + WHEN s.billing_cache_creation_5m_tokens IS NOT NULL + OR s.billing_cache_creation_1h_tokens IS NOT NULL + THEN COALESCE(s.billing_cache_creation_5m_tokens, 0) + + COALESCE(s.billing_cache_creation_1h_tokens, 0) + END, + CASE + WHEN COALESCE(u.cache_creation_input_tokens, 0) = 0 + AND ( + COALESCE(u.cache_creation_input_tokens_5m, 0) + + COALESCE(u.cache_creation_input_tokens_1h, 0) + ) > 0 + THEN COALESCE(u.cache_creation_input_tokens_5m, 0) + + COALESCE(u.cache_creation_input_tokens_1h, 0) + ELSE COALESCE(u.cache_creation_input_tokens, 0) + END, + 0 + ), + 0 + ) + + GREATEST( + COALESCE( + s.billing_cache_read_tokens, + u.cache_read_input_tokens, + 0 + ), + 0 + ) + WHEN s.billing_total_input_context IS NOT NULL + THEN GREATEST(s.billing_total_input_context, 0) + + GREATEST(COALESCE(s.billing_output_tokens, u.output_tokens, 0), 0) + END, + NULLIF(GREATEST(COALESCE(u.total_tokens, 0), 0), 0), + CASE + WHEN split_part(lower(COALESCE(COALESCE(u.endpoint_api_format, u.api_format), '')), ':', 1) + IN ('openai', 'gemini', 'google') + THEN GREATEST(COALESCE(u.input_tokens, 0), 0) + + GREATEST(COALESCE(u.output_tokens, 0), 0) + ELSE GREATEST(COALESCE(u.input_tokens, 0), 0) + + GREATEST(COALESCE(u.output_tokens, 0), 0) + + GREATEST( + CASE + WHEN COALESCE(u.cache_creation_input_tokens, 0) = 0 + AND ( + COALESCE(u.cache_creation_input_tokens_5m, 0) + + COALESCE(u.cache_creation_input_tokens_1h, 0) + ) > 0 + THEN COALESCE(u.cache_creation_input_tokens_5m, 0) + + COALESCE(u.cache_creation_input_tokens_1h, 0) + ELSE COALESCE(u.cache_creation_input_tokens, 0) + END, + 0 + ) + + GREATEST(COALESCE(u.cache_read_input_tokens, 0), 0) + END, + 0 + ), + 0 + )::bigint END AS total_tokens, + CASE WHEN CASE WHEN c.request_id IS NOT NULL THEN (c.metrics->>'pricing_available_count')::bigint>0 + ELSE COALESCE(metadata.value->'usage_pricing_available','true'::jsonb)<>'false'::jsonb END + AND (s.billing_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status,u.billing_status)='settled') + THEN round(COALESCE(s.billing_total_cost_usd::numeric,u.total_cost_usd::numeric),8) END AS rated_amount, + CASE WHEN c.request_id IS NOT NULL THEN + CASE WHEN (c.metrics->>'pricing_available_count')::bigint>0 + THEN round((c.metrics->>'billable_amount')::numeric,8) END + WHEN COALESCE(metadata.value->'usage_pricing_available','true'::jsonb)<>'false'::jsonb + AND (s.billing_actual_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status,u.billing_status)='settled') + THEN public.usage_customer_billable_amount(metadata.value, + COALESCE(s.billing_total_cost_usd::numeric,u.total_cost_usd::numeric), + COALESCE(s.billing_actual_total_cost_usd::numeric,u.actual_total_cost_usd::numeric)) END AS billable_amount +FROM public.usage u +LEFT JOIN public.dashboard_request_contributions c USING(request_id) +LEFT JOIN public.usage_settlement_snapshots s USING(request_id) +LEFT JOIN public.usage_attribution_snapshots a USING(request_id) +LEFT JOIN LATERAL (SELECT u.request_metadata::jsonb AS value + WHERE c.request_id IS NULL OFFSET 0) metadata ON true +) AS chart_facts"#; + +const DASHBOARD_CHART_METRICS_SQL: &str = r#" +count(*)::bigint AS request_count, +count(*) FILTER (WHERE status='completed')::bigint AS successful_request_count, +count(*) FILTER (WHERE status='failed')::bigint AS failed_request_count, +count(*) FILTER (WHERE status='cancelled')::bigint AS cancelled_request_count, +count(*) FILTER (WHERE status NOT IN ('completed','failed','cancelled'))::bigint AS in_flight_request_count, +COALESCE(sum(total_tokens),0)::bigint AS total_tokens, +count(*) FILTER (WHERE usage_available)::bigint AS usage_available_count, +count(billable_amount)::bigint AS pricing_available_count, +count(*) FILTER (WHERE settlement_status='settled')::bigint AS settled_count, +count(*) FILTER (WHERE allocation_status='complete')::bigint AS allocation_available_count, +sum(rated_amount)::text AS rated_amount, +sum(billable_amount)::text AS billable_amount, +count(response_time_ms)::bigint AS latency_sample_count, +COALESCE(sum(response_time_ms),0)::double precision AS latency_sum_ms +"#; + +fn decode(value: Value) -> Result { + serde_json::from_value(value) + .map_err(|error| DataLayerError::UnexpectedValue(error.to_string())) +} + +fn can_compare_daily_history(query: &UsageAnalyticsQuery) -> bool { + query.actor_user_id.is_none() + && query.credential_owner_id.is_none() + && query.attribution_kind.is_none() + && query.api_key_id.is_none() + && query.model.is_none() + && query.provider_id.is_none() + && query.api_format.is_none() + && query.endpoint_kind.is_none() + && query.request_type.is_none() + && query.status.is_none() + && query.is_stream.is_none() + && query.has_format_conversion.is_none() + && query.search.is_none() + && query.user_is_active.is_none() + && query.has_usage.is_none() +} + +impl SqlxUsageReadRepository { + /// Keep every chart and its summary on the same bounded canonical fact scan. + /// Historical projections can outlive raw facts and must not be mixed into + /// this summary while its model/provider breakdown still reads raw rows. + pub(super) async fn query_dashboard_charts( + &self, + query: &UsageAnalyticsQuery, + ) -> Result { + let mut tx = self.pool.begin().await.map_postgres_err()?; + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY") + .execute(&mut *tx) + .await + .map_postgres_err()?; + sqlx::query("SET LOCAL statement_timeout = '15s'") + .execute(&mut *tx) + .await + .map_postgres_err()?; + sqlx::query("SET LOCAL jit = off") + .execute(&mut *tx) + .await + .map_postgres_err()?; + let state = sqlx::query("SELECT pg_current_snapshot()::text AS revision, NOW() AS generated_at, (SELECT count(*) FROM users WHERE is_active AND NOT is_deleted) AS enabled_users") + .fetch_one(&mut *tx).await.map_postgres_err()?; + let granularity = match query.granularity { + UsageAnalyticsGranularity::Hour => "hour", + UsageAnalyticsGranularity::Day => "day", + }; + let timezone = match query.granularity { + UsageAnalyticsGranularity::Hour => "UTC", + UsageAnalyticsGranularity::Day => query.timezone.as_str(), + }; + let compare_history = can_compare_daily_history(query); + // Like dashboard_request_fact, coverage describes customer charges; + // a rated request with an invalid factor snapshot is still unpriced. + let metrics_sql = if compare_history { + DASHBOARD_CHART_METRICS_SQL.to_string() + } else { + analytics_metrics_sql(query.slow_threshold_ms.unwrap_or(5000)).replace( + "count(*) FILTER (WHERE pricing_available)::bigint AS pricing_available_count", + "count(billable_amount)::bigint AS pricing_available_count", + ) + }; + let from = DateTime::::from_timestamp_millis(query.from_unix_ms as i64) + .expect("validated timestamp"); + let to = DateTime::::from_timestamp_millis(query.to_unix_ms as i64) + .expect("validated timestamp"); + let source_from = from + .date_naive() + .and_hms_opt(0, 0, 0) + .expect("UTC midnight") + .and_utc(); + let source_to_floor = to + .date_naive() + .and_hms_opt(0, 0, 0) + .expect("UTC midnight") + .and_utc(); + let source_to = if source_to_floor == to { + to + } else { + source_to_floor + Duration::days(1) + }; + let mut builder = QueryBuilder::::new("WITH "); + if compare_history { + // Legacy daily totals include sessions. Keep them in this one raw + // scan for the coverage check, then exclude them from every chart. + builder + .push("source AS MATERIALIZED (SELECT * FROM ") + .push(DASHBOARD_CHART_FACTS_SQL) + .push(" WHERE created_at >= ") + .push_bind(source_from) + .push(" AND created_at < ") + .push_bind(source_to) + .push("), "); + } + builder.push("filtered AS MATERIALIZED (SELECT *, date_trunc("); + builder + .push_bind(granularity) + .push(", created_at AT TIME ZONE ") + .push_bind(timezone) + .push(") AT TIME ZONE ") + .push_bind(timezone) + .push(" AS bucket, COALESCE(NULLIF(provider_id,''),NULLIF(provider_name,'')) AS chart_provider_id"); + push_analytics_source_filter( + &mut builder, + query, + if compare_history { + "source" + } else { + "public.usage_analytics_facts_v1" + }, + ); + builder + .push("), summary AS (SELECT ") + .push(&metrics_sql) + .push(" FROM filtered), series AS (SELECT bucket, ") + .push(&metrics_sql) + .push(", count(DISTINCT COALESCE(NULLIF(provider_id,''),NULLIF(provider_name,''))) FILTER (WHERE NULLIF(provider_id,'') IS NOT NULL OR provider_name NOT IN ('unknown','pending'))::bigint AS unique_providers FROM filtered GROUP BY bucket ORDER BY bucket LIMIT 10001), models AS (SELECT model, bucket, ") + .push(&metrics_sql) + .push(" FROM filtered GROUP BY model,bucket ORDER BY bucket,model LIMIT 10001), providers AS (SELECT chart_provider_id, max(provider_name) AS provider_label, ") + .push(&metrics_sql) + .push(" FROM filtered GROUP BY chart_provider_id ORDER BY count(*) DESC,chart_provider_id LIMIT 10001)"); + if compare_history { + builder.push(r#", historical_cutoff AS ( + SELECT CASE WHEN pg_typeof(cutoff_date) IN ('bigint'::regtype,'integer'::regtype) + THEN to_timestamp(cutoff_date::text::double precision) + ELSE cutoff_date::text::timestamptz END AS cutoff + FROM stats_summary ORDER BY updated_at DESC,created_at DESC LIMIT 1 + ), historical_days AS ( + SELECT CASE WHEN pg_typeof(date) IN ('bigint'::regtype,'integer'::regtype) + THEN to_timestamp(date::text::double precision) + ELSE date::text::timestamptz END AS day,total_requests + FROM stats_daily WHERE is_complete AND total_requests>0 + ), retained_days AS ( + SELECT date_trunc('day',created_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AS day, + count(*) AS requests + FROM source + WHERE status NOT IN ('pending','streaming') AND provider_name NOT IN ('unknown','pending') + GROUP BY 1 + ), lost_days AS ( + SELECT d.day FROM historical_days d CROSS JOIN historical_cutoff c + LEFT JOIN retained_days r ON r.day=d.day + WHERE d.day=date_trunc('day',d.day AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' + AND d.day < "#) + .push_bind(to) + .push(" AND d.day+INTERVAL '24 hours' > ") + .push_bind(from) + .push(" AND d.day+INTERVAL '24 hours' <= c.cutoff AND d.total_requests>COALESCE(r.requests,0))"); + } + // An archived day with missing details has unknown coverage in each + // constituent hour. Union with existing markers to avoid double-counting. + builder.push( + r#", lost_hours AS ( + SELECT bucket_start FROM stats_bucket_state + WHERE projection_version IN ('overview-v1','overview-v2') + AND granularity='hour' AND coverage_status='unrecoverable' + UNION SELECT bucket_start FROM stats_overview_dirty_events + WHERE projection_version='overview-v2' AND granularity='hour' AND unrecoverable + "#, + ); + if compare_history { + builder.push(" UNION SELECT hour FROM lost_days CROSS JOIN LATERAL generate_series(day,day+INTERVAL '23 hours',INTERVAL '1 hour') AS hours(hour)"); + } + builder.push("), lost AS (SELECT count(*)::bigint AS hours FROM lost_hours WHERE bucket_start < ") + .push_bind(to) + .push(" AND bucket_start+INTERVAL '1 hour' > ") + .push_bind(from) + .push(")") + .push(r#" +SELECT + (SELECT hours FROM lost) AS unrecoverable_bucket_count, + (SELECT to_jsonb(s) FROM summary s) AS summary, + (SELECT COALESCE(jsonb_agg(jsonb_build_object( + 'id', bucket::text, 'label', bucket::text, + 'bucket_start', to_char(bucket AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"'), + 'metrics', to_jsonb(s)-'bucket') ORDER BY bucket),'[]'::jsonb) FROM series s) AS series, + (SELECT COALESCE(jsonb_agg(jsonb_build_object( + 'id',model,'label',model, + 'bucket_start',to_char(bucket AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"'), + 'metrics',to_jsonb(m)-'model'-'bucket') ORDER BY bucket,model),'[]'::jsonb) FROM models m) AS models, + (SELECT COALESCE(jsonb_agg(jsonb_build_object( + 'id',chart_provider_id,'label',provider_label,'bucket_start',NULL, + 'metrics',to_jsonb(p)-'chart_provider_id'-'provider_label') ORDER BY request_count DESC,chart_provider_id),'[]'::jsonb) FROM providers p) AS providers +"#); + let row = builder + .build() + .fetch_one(&mut *tx) + .await + .map_postgres_err()?; + let mut result = StoredUsageAnalytics { + summary: decode(row.try_get("summary").map_postgres_err()?)?, + rows: decode(row.try_get("series").map_postgres_err()?)?, + model_rows: decode(row.try_get("models").map_postgres_err()?)?, + provider_rows: decode(row.try_get("providers").map_postgres_err()?)?, + unrecoverable_bucket_count: row + .try_get::("unrecoverable_bucket_count") + .map_postgres_err()? as u64, + read_revision: state.try_get("revision").map_postgres_err()?, + generated_at: state + .try_get::, _>("generated_at") + .map_postgres_err()? + .to_rfc3339(), + ..Default::default() + }; + result.summary.enabled_users = state + .try_get::("enabled_users") + .map_postgres_err()? as u64; + for (dimension, count) in [ + ("daily", result.rows.len()), + ("model", result.model_rows.len()), + ("provider", result.provider_rows.len()), + ] { + if count > USAGE_DASHBOARD_CHART_ROW_LIMIT { + return Err(DataLayerError::InvalidInput(format!( + "dashboard {dimension} chart exceeds 10000 groups; narrow the range" + ))); + } + } + result.coverage = read_projection_coverage(&mut tx, query, false).await?; + fill_usage_analytics_timeseries(query, &mut result.rows); + // Missing raw history is not evidence of zero spend. Keep populated + // subtotals, but do not present filled empty buckets as measured zeros. + if result.unrecoverable_bucket_count > 0 { + for row in &mut result.rows { + if row.metrics.request_count == 0 { + row.metrics.rated_amount = None; + row.metrics.billable_amount = None; + } + } + } else if result.summary.request_count == 0 { + result.summary.rated_amount = Some("0.00000000".into()); + result.summary.billable_amount = Some("0.00000000".into()); + } + result.total = result.rows.len() as u64; + tx.commit().await.map_postgres_err()?; + Ok(result) + } +} diff --git a/crates/aether-data/adapters/postgres/src/usage/mod.rs b/crates/aether-data/adapters/postgres/src/usage/mod.rs index 4a2456f23..13892e3ae 100644 --- a/crates/aether-data/adapters/postgres/src/usage/mod.rs +++ b/crates/aether-data/adapters/postgres/src/usage/mod.rs @@ -66,6 +66,7 @@ mod analytics_tests; mod attribution; pub mod cleanup; mod dashboard; +mod dashboard_charts; mod dashboard_history; #[cfg(test)] mod dashboard_history_tests; diff --git a/crates/aether-data/contracts/src/repository/usage/analytics.rs b/crates/aether-data/contracts/src/repository/usage/analytics.rs index d00c0203e..a468b1f8f 100644 --- a/crates/aether-data/contracts/src/repository/usage/analytics.rs +++ b/crates/aether-data/contracts/src/repository/usage/analytics.rs @@ -196,6 +196,8 @@ impl UsageAnalyticsQuery { #[serde(default)] pub struct UsageAnalyticsMetrics { pub request_count: u64, + // Populated for dashboard chart buckets; distinct counts are not additive. + pub unique_providers: Option, pub successful_request_count: u64, pub failed_request_count: u64, pub cancelled_request_count: u64, @@ -429,6 +431,7 @@ pub fn fill_usage_analytics_timeseries( label: Some(start.clone()), bucket_start: Some(start), metrics: UsageAnalyticsMetrics { + unique_providers: Some(0), rated_amount: Some("0.00000000".into()), billable_amount: Some("0.00000000".into()), ..Default::default() diff --git a/crates/aether-data/runtime/schema/bootstrap/postgres/190_overview_analytics.sql b/crates/aether-data/runtime/schema/bootstrap/postgres/190_overview_analytics.sql index 594e83313..1fc7156b3 100644 --- a/crates/aether-data/runtime/schema/bootstrap/postgres/190_overview_analytics.sql +++ b/crates/aether-data/runtime/schema/bootstrap/postgres/190_overview_analytics.sql @@ -205,7 +205,7 @@ CREATE TRIGGER overview_usage_delete_attribution BEFORE DELETE ON public.usage -- procurement cost remains in actual_total_cost_usd for legacy reporting. CREATE OR REPLACE FUNCTION public.usage_customer_billable_amount( metadata jsonb, base_cost numeric, legacy_cost numeric -) RETURNS numeric LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE AS $$ +) RETURNS numeric LANGUAGE plpgsql IMMUTABLE PARALLEL UNSAFE AS $$ DECLARE factor jsonb; multiplier numeric; amount numeric; factor_name text; factor_value jsonb; factor_number double precision; expected_multiplier double precision := 1.0; factor_count integer := 0; diff --git a/crates/aether-data/runtime/src/lifecycle/migrate/tests.rs b/crates/aether-data/runtime/src/lifecycle/migrate/tests.rs index 1e9f1d9b1..f4de2912e 100644 --- a/crates/aether-data/runtime/src/lifecycle/migrate/tests.rs +++ b/crates/aether-data/runtime/src/lifecycle/migrate/tests.rs @@ -28,6 +28,8 @@ use crate::lifecycle::bootstrap::postgres::{ }; mod customer_billing_upgrade; +mod dashboard_chart_billing; +mod dashboard_chart_history; mod dashboard_user_anonymization; mod legacy_overview_upgrade; mod migration_deadlines; @@ -1600,6 +1602,7 @@ fn pending_migrations_from_applied_skips_versions_already_applied() { 20261001000000, 20261004000000, 20261007000000, + 20261008000000, 20261009000000, ] ); diff --git a/crates/aether-data/runtime/src/lifecycle/migrate/tests/dashboard_chart_billing.rs b/crates/aether-data/runtime/src/lifecycle/migrate/tests/dashboard_chart_billing.rs new file mode 100644 index 000000000..654ac8089 --- /dev/null +++ b/crates/aether-data/runtime/src/lifecycle/migrate/tests/dashboard_chart_billing.rs @@ -0,0 +1,278 @@ +use super::*; +use aether_data_contracts::repository::usage::{ + UsageAnalyticsQuery, UsageAnalyticsView, UsageDashboardAnalyticsQuery, +}; +use chrono::{Duration, Utc}; +use serde_json::json; + +#[tokio::test] +async fn dashboard_charts_match_summary_customer_charges_and_request_scope() { + let Some(server) = ManagedPostgresServer::try_start().await.unwrap() else { + return; + }; + let pool = PgPool::connect(server.database_url()).await.unwrap(); + prepare_and_apply_clean_postgres_database(&pool).await; + let repo = aether_data_postgres::SqlxUsageReadRepository::new(pool.clone()); + let summary_query = UsageDashboardAnalyticsQuery { + timezone: "Asia/Shanghai".into(), + }; + let start = summary_query.today_start(Utc::now()).unwrap(); + query("UPDATE dashboard_stats_state SET stats_since=$1") + .bind(start) + .execute(&pool) + .await + .unwrap(); + let composite = json!({"billing_multiplier_snapshot":{"version":1,"factors":{"routing_group":2,"user_group":0.25},"multiplier":0.5}}); + for (id, provider, status, billing, base, actual, metadata) in [ + ( + "composite", + "alpha", + "completed", + "settled", + 10.0, + Some(3.0), + composite, + ), + ( + "legacy", + "alpha", + "completed", + "settled", + 2.0, + Some(1.25), + json!({}), + ), + ( + "free", + "alpha", + "completed", + "settled", + 50.0, + Some(40.0), + json!({"routing_group_billing_multiplier":0}), + ), + ( + "invalid", + "alpha", + "completed", + "settled", + 99.0, + Some(90.0), + json!({"billing_multiplier_snapshot":null}), + ), + ( + "unpriced", + "beta", + "completed", + "settled", + 80.0, + Some(70.0), + json!({"usage_pricing_available":false}), + ), + ( + "failed", + "unknown", + "failed", + "settled", + 0.0, + Some(0.0), + json!({}), + ), + ( + "pending", + "pending", + "pending", + "pending", + 0.0, + None, + json!({}), + ), + ( + "streaming", + "beta", + "streaming", + "pending", + 0.0, + None, + json!({}), + ), + ( + "session", + "alpha", + "completed", + "settled", + 500.0, + Some(500.0), + json!({}), + ), + ] { + query("INSERT INTO usage(id,request_id,model,provider_name,status,billing_status,total_cost_usd,actual_total_cost_usd,total_tokens,created_at,request_metadata,response_time_ms) VALUES($1,$1,'model',$2,$3,$4,$5,$6,10,$7,$8,1000)") + .bind(id).bind(provider).bind(status).bind(billing).bind(base).bind(actual) + .bind(start + Duration::seconds(1)).bind(metadata).execute(&pool).await.unwrap(); + } + // The captured settlement cost, rather than the mutable audit float, is rated. + query("INSERT INTO usage_settlement_snapshots(request_id,billing_status,billing_total_cost_usd,billing_actual_total_cost_usd) VALUES('composite','settled',12,4)") + .execute(&pool).await.unwrap(); + query( + "UPDATE usage_attribution_snapshots SET record_kind='session' WHERE request_id='session'", + ) + .execute(&pool) + .await + .unwrap(); + // Exclude both the previous local day and the next day's boundary. + for (id, at) in [ + ("before", start - Duration::seconds(1)), + ("after", start + Duration::days(1)), + ] { + query("INSERT INTO usage(id,request_id,model,provider_name,status,billing_status,total_cost_usd,actual_total_cost_usd,created_at) VALUES($1,$1,'outside','outside','completed','settled',999,999,$2)") + .bind(id).bind(at).execute(&pool).await.unwrap(); + } + let summary = repo.query_dashboard_summary(&summary_query).await.unwrap(); + let charts = repo + .query_usage_analytics(&UsageAnalyticsQuery { + from_unix_ms: start.timestamp_millis() as u64, + to_unix_ms: (start + Duration::days(1)).timestamp_millis() as u64, + timezone: summary_query.timezone.clone(), + view: UsageAnalyticsView::DashboardCharts, + limit: 10_000, + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(summary.today.request_count, 8); + assert_eq!(summary.today.billable_amount.as_deref(), Some("7.25000000")); + assert_eq!(charts.summary.request_count, summary.today.request_count); + assert_eq!( + charts.summary.billable_amount, + summary.today.billable_amount + ); + assert_eq!( + charts.summary.pricing_available_count, + summary.today.pricing_available_count + ); + assert_eq!(charts.summary.in_flight_request_count, 2); + assert_eq!(charts.rows.len(), 1); + assert_eq!(charts.rows[0].metrics.request_count, 8); + assert_eq!( + charts.rows[0].metrics.billable_amount, + summary.today.billable_amount + ); + assert_eq!(charts.rows[0].metrics.unique_providers, Some(2)); + assert_eq!( + charts.rows[0].bucket_start.as_deref(), + Some( + start + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true) + .as_str() + ) + ); + for rows in [&charts.model_rows, &charts.provider_rows] { + assert_eq!(rows.iter().map(|r| r.metrics.request_count).sum::(), 8); + let charges: f64 = rows + .iter() + .filter_map(|r| r.metrics.billable_amount.as_ref()) + .map(|v| v.parse::().unwrap()) + .sum(); + assert_eq!(charges, 7.25); + } + assert_eq!( + charts.provider_rows.len(), + 4, + "legacy provider names must not collapse into one null-ID group" + ); + // Retained older requests can have no contribution ledger entry. Mixing + // fallback rows with current cached rows must keep unknown pricing distinct + // from free usage and preserve the immutable composite settlement amount. + query("DELETE FROM dashboard_request_contributions WHERE request_id IN ('composite','free','invalid')") + .execute(&pool).await.unwrap(); + let mixed = repo + .query_usage_analytics(&UsageAnalyticsQuery { + from_unix_ms: start.timestamp_millis() as u64, + to_unix_ms: (start + Duration::days(1)).timestamp_millis() as u64, + timezone: summary_query.timezone.clone(), + view: UsageAnalyticsView::DashboardCharts, + limit: 10_000, + ..Default::default() + }) + .await + .unwrap(); + assert_eq!( + mixed.summary.billable_amount, + charts.summary.billable_amount + ); + assert_eq!(mixed.summary.total_tokens, charts.summary.total_tokens); + assert_eq!(mixed.summary.request_count, charts.summary.request_count); + assert_eq!( + mixed.summary.pricing_available_count, + charts.summary.pricing_available_count + ); + assert_eq!(mixed.rows.len(), charts.rows.len()); + for (mixed, cached) in mixed.rows.iter().zip(&charts.rows) { + assert_eq!(mixed.bucket_start, cached.bucket_start); + assert_eq!( + mixed.metrics.billable_amount, + cached.metrics.billable_amount + ); + assert_eq!(mixed.metrics.total_tokens, cached.metrics.total_tokens); + assert_eq!( + mixed.metrics.pricing_available_count, + cached.metrics.pricing_available_count + ); + } + let empty = repo + .query_usage_analytics(&UsageAnalyticsQuery { + from_unix_ms: (start - Duration::days(2)).timestamp_millis() as u64, + to_unix_ms: (start - Duration::days(1)).timestamp_millis() as u64, + timezone: summary_query.timezone, + view: UsageAnalyticsView::DashboardCharts, + limit: 10_000, + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(empty.summary.request_count, 0); + assert_eq!(empty.summary.billable_amount.as_deref(), Some("0.00000000")); + assert_eq!(empty.rows[0].metrics.unique_providers, Some(0)); + pool.close().await; +} + +#[tokio::test] +async fn customer_billing_parallel_fix_preserves_history_without_backfill() { + let Some(server) = ManagedPostgresServer::try_start().await.unwrap() else { + return; + }; + let mut connection = PgConnection::connect(server.database_url()).await.unwrap(); + connection.ensure_migrations_table().await.unwrap(); + for migration in POSTGRES_MIGRATOR + .iter() + .filter(|m| m.version < 20261008000000) + { + connection.apply(migration).await.unwrap(); + } + let pool = PgPool::connect(server.database_url()).await.unwrap(); + query("INSERT INTO stats_daily(id,date,total_requests,total_cost,actual_total_cost,is_complete) VALUES('untouched','2020-01-01',1,10,3,true)") + .execute(&pool).await.unwrap(); + query("INSERT INTO usage(id,request_id,model,provider_name,status,billing_status,total_cost_usd,actual_total_cost_usd,created_at,request_metadata) VALUES('untouched','untouched','m','p','completed','settled',10,3,'2020-01-01','{\"routing_group_billing_multiplier\":2}')") + .execute(&pool).await.unwrap(); + let before: (serde_json::Value,serde_json::Value) = sqlx::query_as("SELECT (SELECT to_jsonb(d) FROM stats_daily d WHERE id='untouched'), (SELECT to_jsonb(u) FROM usage u WHERE id='untouched')").fetch_one(&pool).await.unwrap(); + connection + .apply( + POSTGRES_MIGRATOR + .iter() + .find(|m| m.version == 20261008000000) + .unwrap(), + ) + .await + .unwrap(); + let after = sqlx::query_as("SELECT (SELECT to_jsonb(d) FROM stats_daily d WHERE id='untouched'), (SELECT to_jsonb(u) FROM usage u WHERE id='untouched')").fetch_one(&pool).await.unwrap(); + assert_eq!(before, after); + let parallel: String=query_scalar("SELECT proparallel::text FROM pg_proc WHERE oid='public.usage_customer_billable_amount(jsonb,numeric,numeric)'::regprocedure").fetch_one(&pool).await.unwrap(); + assert_eq!(parallel, "u"); + // Encourage a parallel scan; the exception-handling function must keep it serial. + let mut tx = pool.begin().await.unwrap(); + sqlx::raw_sql("CREATE TABLE billing_parallel_probe AS SELECT i::numeric AS cost FROM generate_series(1,10000) i; ALTER TABLE billing_parallel_probe SET (parallel_workers=2); ANALYZE billing_parallel_probe; SET LOCAL min_parallel_table_scan_size=0; SET LOCAL parallel_setup_cost=0; SET LOCAL parallel_tuple_cost=0;").execute(&mut *tx).await.unwrap(); + let charge:String=query_scalar("SELECT sum(public.usage_customer_billable_amount('{\"routing_group_billing_multiplier\":2}'::jsonb,cost,1))::text FROM billing_parallel_probe").fetch_one(&mut *tx).await.unwrap(); + assert_eq!(charge, "100010000.00000000"); + tx.rollback().await.unwrap(); + pool.close().await; +} diff --git a/crates/aether-data/runtime/src/lifecycle/migrate/tests/dashboard_chart_history.rs b/crates/aether-data/runtime/src/lifecycle/migrate/tests/dashboard_chart_history.rs new file mode 100644 index 000000000..8d60d86f4 --- /dev/null +++ b/crates/aether-data/runtime/src/lifecycle/migrate/tests/dashboard_chart_history.rs @@ -0,0 +1,144 @@ +use super::*; +use aether_data_contracts::repository::usage::{UsageAnalyticsQuery, UsageAnalyticsView}; +use chrono::{DateTime, Duration, Utc}; +use serde_json::json; + +#[tokio::test] +async fn dashboard_chart_history_coverage_matches_legacy_request_scope_without_raw_rescans() { + let Some(server) = ManagedPostgresServer::try_start().await.unwrap() else { + return; + }; + let pool = PgPool::connect(server.database_url()).await.unwrap(); + prepare_and_apply_clean_postgres_database(&pool).await; + let repo = aether_data_postgres::SqlxUsageReadRepository::new(pool.clone()); + let start = "2020-01-01T00:00:00Z".parse::>().unwrap(); + query("INSERT INTO stats_summary(id,cutoff_date) VALUES('history',$1)") + .bind(start + Duration::days(4)) + .execute(&pool) + .await + .unwrap(); + for (day, count, complete) in [ + (0, 2, true), + (1, 2, true), + (2, 1, true), + (3, 1, false), + (4, 1, true), + ] { + query("INSERT INTO stats_daily(id,date,total_requests,total_cost,actual_total_cost,is_complete) VALUES($1,$2,$3,9999,7777,$4)") + .bind(format!("day-{day}")) + .bind(start + Duration::days(day)) + .bind(count) + .bind(complete) + .execute(&pool).await.unwrap(); + } + for (id, day, status, provider, session) in [ + ("retained-request", 0, "completed", "provider", false), + ("retained-session", 0, "completed", "provider", true), + ("partial-request", 1, "completed", "provider", false), + ("partial-pending", 1, "pending", "provider", false), + ("partial-unknown", 1, "failed", "unknown", false), + ] { + query("INSERT INTO usage(id,request_id,model,provider_name,status,billing_status,total_cost_usd,actual_total_cost_usd,created_at,request_metadata) VALUES($1,$1,'model',$2,$3,'settled',2,1,$4,$5)") + .bind(id).bind(provider).bind(status) + .bind(start + Duration::days(day) + Duration::seconds(1)) + .bind(json!({"routing_group_billing_multiplier":2,"analytics_attribution":{"record_kind":if session { "session" } else { "request" }}})) + .execute(&pool).await.unwrap(); + } + query("UPDATE usage SET total_tokens=999,input_tokens=90,output_tokens=20,cache_creation_input_tokens=11,cache_read_input_tokens=13 WHERE id='retained-request'") + .execute(&pool).await.unwrap(); + query("INSERT INTO usage_settlement_snapshots(request_id,billing_status,billing_effective_input_tokens,billing_output_tokens,billing_cache_creation_tokens,billing_cache_read_tokens) VALUES('retained-request','settled',7,3,5,2)") + .execute(&pool).await.unwrap(); + let day_query = |day| UsageAnalyticsQuery { + from_unix_ms: (start + Duration::days(day)).timestamp_millis() as u64, + to_unix_ms: (start + Duration::days(day + 1)).timestamp_millis() as u64, + timezone: "UTC".into(), + view: UsageAnalyticsView::DashboardCharts, + limit: 10_000, + ..Default::default() + }; + + // A retained session belongs to the old rollup, but never to chart totals. + let complete = repo.query_usage_analytics(&day_query(0)).await.unwrap(); + assert_eq!(complete.unrecoverable_bucket_count, 0); + assert_eq!(complete.summary.request_count, 1); + assert_eq!(complete.summary.total_tokens, 17); + assert_eq!( + complete.summary.billable_amount.as_deref(), + Some("4.00000000") + ); + let mut canonical_query = day_query(0); + canonical_query.model = Some("model".into()); + let canonical = repo.query_usage_analytics(&canonical_query).await.unwrap(); + assert_eq!( + complete.summary.total_tokens, + canonical.summary.total_tokens + ); + assert_eq!( + complete.summary.billable_amount, + canonical.summary.billable_amount + ); + + // Pending and unknown-provider rows cannot disguise a missing legacy request. + let partial = repo.query_usage_analytics(&day_query(1)).await.unwrap(); + assert_eq!(partial.unrecoverable_bucket_count, 24); + assert_eq!(partial.summary.request_count, 3); + assert_eq!( + partial.summary.billable_amount.as_deref(), + Some("12.00000000") + ); + + let missing = repo.query_usage_analytics(&day_query(2)).await.unwrap(); + assert_eq!(missing.unrecoverable_bucket_count, 24); + assert_eq!(missing.summary.request_count, 0); + assert_eq!(missing.summary.billable_amount, None); + assert_eq!(missing.rows[0].metrics.billable_amount, None); + + // Incomplete rollups, unpublished days and filtered views cannot establish + // that request details are missing from global legacy daily totals. + for day in [3, 4] { + let result = repo.query_usage_analytics(&day_query(day)).await.unwrap(); + assert_eq!(result.unrecoverable_bucket_count, 0); + assert_eq!( + result.summary.billable_amount.as_deref(), + Some("0.00000000") + ); + } + let mut filtered_query = day_query(2); + filtered_query.model = Some("model".into()); + let filtered = repo.query_usage_analytics(&filtered_query).await.unwrap(); + assert_eq!(filtered.unrecoverable_bucket_count, 0); + let mut partial_day_query = day_query(2); + partial_day_query.from_unix_ms += 60 * 60 * 1000; + let partial_day = repo + .query_usage_analytics(&partial_day_query) + .await + .unwrap(); + assert_eq!(partial_day.unrecoverable_bucket_count, 23); + assert_eq!(partial_day.summary.billable_amount, None); + // A local day crosses two UTC archive days. Read both complete UTC days for + // coverage, while chart totals remain bounded to the original local range. + let local_day_query = UsageAnalyticsQuery { + from_unix_ms: (start + Duration::days(1) + Duration::hours(16)).timestamp_millis() as u64, + to_unix_ms: (start + Duration::days(2) + Duration::hours(16)).timestamp_millis() as u64, + timezone: "Asia/Shanghai".into(), + ..day_query(2) + }; + let local_day = repo.query_usage_analytics(&local_day_query).await.unwrap(); + assert_eq!(local_day.unrecoverable_bucket_count, 24); + assert_eq!(local_day.summary.request_count, 0); + assert_eq!(local_day.rows.len(), 1); + assert_eq!(local_day.rows[0].metrics.billable_amount, None); + let mut retained_partial = day_query(0); + retained_partial.from_unix_ms += 60 * 60 * 1000; + let retained_partial = repo.query_usage_analytics(&retained_partial).await.unwrap(); + assert_eq!(retained_partial.unrecoverable_bucket_count, 0); + assert_eq!(retained_partial.summary.request_count, 0); + + // Existing lost-hour evidence and the inferred day's coverage are one set. + query("INSERT INTO stats_overview_dirty_events(transaction_id,projection_version,granularity,bucket_start,unrecoverable) VALUES(txid_current(),'overview-v2','hour',$1,true)") + .bind(start + Duration::days(2) + Duration::hours(3)) + .execute(&pool).await.unwrap(); + let deduplicated = repo.query_usage_analytics(&day_query(2)).await.unwrap(); + assert_eq!(deduplicated.unrecoverable_bucket_count, 24); + pool.close().await; +} diff --git a/crates/aether-data/runtime/src/lifecycle/migrate/tests/legacy_overview_upgrade.rs b/crates/aether-data/runtime/src/lifecycle/migrate/tests/legacy_overview_upgrade.rs index 49fdf1ceb..ead8501da 100644 --- a/crates/aether-data/runtime/src/lifecycle/migrate/tests/legacy_overview_upgrade.rs +++ b/crates/aether-data/runtime/src/lifecycle/migrate/tests/legacy_overview_upgrade.rs @@ -116,6 +116,7 @@ WHERE version=20260919000000; 20261001000000, 20261004000000, 20261007000000, + 20261008000000, 20261009000000, ] ); diff --git a/crates/aether-data/runtime/src/repository/usage/memory/analytics.rs b/crates/aether-data/runtime/src/repository/usage/memory/analytics.rs index 51c004b1d..c7fa1e3b8 100644 --- a/crates/aether-data/runtime/src/repository/usage/memory/analytics.rs +++ b/crates/aether-data/runtime/src/repository/usage/memory/analytics.rs @@ -435,6 +435,20 @@ impl InMemoryUsageReadRepository { let metrics = |rows: &[&StoredRequestUsageAudit], slow| { let mut result = metrics(rows, slow, &keys); apply_allocations(&mut result, rows, &allocations); + if query.view == UsageAnalyticsView::DashboardCharts { + result.pricing_available_count = rows + .iter() + .filter(|row| { + row.billing_status == "settled" + && available(row, USAGE_PRICING_AVAILABLE_METADATA_KEY) + && row.billing_cost().is_some() + }) + .count() as u64; + if rows.is_empty() { + result.billable_amount = Some("0.00000000".into()); + result.rated_amount = Some("0.00000000".into()); + } + } result }; let mut summary = metrics(&filtered, query.slow_threshold_ms.unwrap_or(5000)); @@ -682,17 +696,39 @@ impl InMemoryUsageReadRepository { && query.group_by == UsageAnalyticsGroupBy::Provider; let mut grouped = groups .into_iter() - .map(|(id, rows)| UsageAnalyticsRow { - label: if provider_breakdown { - provider_display_label(&rows, id.as_deref()) - } else { - id.clone() - }, - bucket_start: (query.view != UsageAnalyticsView::Breakdown) - .then(|| id.clone()) - .flatten(), - id, - metrics: metrics(&rows, query.slow_threshold_ms.unwrap_or(5000)), + .map(|(id, rows)| { + let mut metrics = metrics(&rows, query.slow_threshold_ms.unwrap_or(5000)); + if query.view == UsageAnalyticsView::DashboardCharts { + metrics.unique_providers = Some( + rows.iter() + .filter_map(|row| { + row.provider_id + .as_deref() + .filter(|id| !id.is_empty()) + .or_else(|| { + (!matches!( + row.provider_name.as_str(), + "" | "unknown" | "pending" + )) + .then_some(row.provider_name.as_str()) + }) + }) + .collect::>() + .len() as u64, + ); + } + UsageAnalyticsRow { + label: if provider_breakdown { + provider_display_label(&rows, id.as_deref()) + } else { + id.clone() + }, + bucket_start: (query.view != UsageAnalyticsView::Breakdown) + .then(|| id.clone()) + .flatten(), + id, + metrics, + } }) .collect::>(); if query.view == UsageAnalyticsView::Breakdown { @@ -754,7 +790,14 @@ impl InMemoryUsageReadRepository { .today_start(at)? }; providers - .entry(row.provider_id.clone()) + .entry( + row.provider_id + .clone() + .filter(|id| !id.is_empty()) + .or_else(|| { + (!row.provider_name.is_empty()).then(|| row.provider_name.clone()) + }), + ) .or_default() .push(row); models diff --git a/crates/aether-usage/runtime/src/write.rs b/crates/aether-usage/runtime/src/write.rs index 010732fe8..bf7768666 100644 --- a/crates/aether-usage/runtime/src/write.rs +++ b/crates/aether-usage/runtime/src/write.rs @@ -3,9 +3,11 @@ use std::collections::BTreeMap; use aether_ai_formats::UPSTREAM_IS_STREAM_KEY; use aether_contracts::{ExecutionPlan, ExecutionTelemetry}; use aether_data_contracts::repository::usage::{ - UpsertUsageRecord, UsageBodyCaptureState, LIVE_SESSION_METADATA_KEY, - USAGE_AVAILABLE_METADATA_KEY, USAGE_PRICING_AVAILABLE_METADATA_KEY, - WEBSOCKET_MODE_METADATA_KEY, WEBSOCKET_TRANSPORT_METADATA_KEY, + UpsertUsageRecord, UsageBodyCaptureState, BILLING_MULTIPLIER_SNAPSHOT_METADATA_KEY, + LIVE_SESSION_METADATA_KEY, ROUTING_GROUP_BILLING_MULTIPLIER_METADATA_KEY, + ROUTING_GROUP_ID_METADATA_KEY, ROUTING_GROUP_NAME_METADATA_KEY, USAGE_AVAILABLE_METADATA_KEY, + USAGE_PRICING_AVAILABLE_METADATA_KEY, WEBSOCKET_MODE_METADATA_KEY, + WEBSOCKET_TRANSPORT_METADATA_KEY, }; use aether_data_contracts::DataLayerError; use serde_json::{json, Map, Value}; @@ -2214,6 +2216,23 @@ fn build_runtime_request_metadata_seed_from_parts( provider_request_body_base64: Option<&str>, ) -> Option { let mut metadata = Map::new(); + // Lifecycle writes need the same immutable routing and billing identity as terminal + // writes, without retaining the larger report-context payloads. + let routing_snapshot = Map::from_iter( + [ + ROUTING_GROUP_ID_METADATA_KEY, + ROUTING_GROUP_NAME_METADATA_KEY, + ROUTING_GROUP_BILLING_MULTIPLIER_METADATA_KEY, + BILLING_MULTIPLIER_SNAPSHOT_METADATA_KEY, + ] + .into_iter() + .filter_map(|key| context_value_ref(context, key).map(|value| (key.into(), value.clone()))), + ); + if let Some(Value::Object(snapshot)) = + sanitize_usage_request_metadata(Some(Value::Object(routing_snapshot))) + { + metadata.extend(snapshot); + } for key in ["analytics_attribution", "analytics_failure"] { if let Some(value) = context_value_ref(context, key) { metadata.insert(key.into(), value.clone()); @@ -3927,6 +3946,14 @@ mod tests { Some(&json!({ "candidate_id": "cand-pending-event-1", "candidate_index": 3, + "routing_group_id": "group-free", + "routing_group_name": "免费分组", + "routing_group_billing_multiplier": 0.0, + "billing_multiplier_snapshot": { + "version": 1, + "factors": {"routing_group": 0.0}, + "multiplier": 0.0 + }, "websocket_mode": true, "websocket_transport": "responses", "original_request_body": {"messages": [{"content": "omit me"}]}, @@ -3951,6 +3978,17 @@ mod tests { assert!(record.provider_request_body.is_none()); assert_eq!(record.candidate_id.as_deref(), Some("cand-pending-event-1")); assert_eq!(record.candidate_index, Some(3)); + let metadata = record.request_metadata.as_ref().expect("routing snapshot"); + assert_eq!(metadata["routing_group_id"], "group-free"); + assert_eq!(metadata["routing_group_name"], "免费分组"); + assert_eq!(metadata["routing_group_billing_multiplier"], 0.0); + assert_eq!( + aether_data_contracts::repository::usage::billing_multiplier_snapshot(Some(metadata)) + .unwrap() + .unwrap() + .multiplier(), + 0.0 + ); assert_eq!( record .request_metadata @@ -3999,6 +4037,14 @@ mod tests { Some(&json!({ "candidate_id": "cand-streaming-event-1", "candidate_index": 4, + "routing_group_id": "group-discount", + "routing_group_name": "折扣分组", + "routing_group_billing_multiplier": 0.5, + "billing_multiplier_snapshot": { + "version": 1, + "factors": {"routing_group": 0.5}, + "multiplier": 0.5 + }, "provider_request_body": {"input": "omit me"} })), ), @@ -4023,6 +4069,115 @@ mod tests { Some("cand-streaming-event-1") ); assert_eq!(record.candidate_index, Some(4)); + let metadata = record.request_metadata.as_ref().expect("routing snapshot"); + assert_eq!(metadata["routing_group_id"], "group-discount"); + assert_eq!(metadata["routing_group_name"], "折扣分组"); + assert_eq!(metadata["routing_group_billing_multiplier"], 0.5); + assert_eq!( + aether_data_contracts::repository::usage::billing_multiplier_snapshot(Some(metadata)) + .unwrap() + .unwrap() + .multiplier(), + 0.5 + ); + } + + #[test] + fn lifecycle_routing_snapshot_preserves_legacy_factors_and_invalid_markers() { + use aether_data_contracts::repository::usage::billing_multiplier_snapshot; + + let plan = ExecutionPlan { + request_id: "req-lifecycle-snapshot".to_string(), + candidate_id: None, + provider_name: Some("OpenAI".to_string()), + provider_id: "provider-1".to_string(), + endpoint_id: "endpoint-1".to_string(), + key_id: "key-1".to_string(), + method: "POST".to_string(), + url: "https://example.com/v1/responses".to_string(), + headers: BTreeMap::new(), + content_type: Some("application/json".to_string()), + content_encoding: None, + body: RequestBody::from_json(json!({"model": "gpt-5.4"})), + stream: true, + client_api_format: "openai:responses".to_string(), + provider_api_format: "openai:responses".to_string(), + model_name: Some("gpt-5.4".to_string()), + proxy: None, + transport_profile: None, + timeouts: None, + }; + + for (snapshot, expected_multiplier) in [ + (json!({"routing_group_billing_multiplier": 2.0}), Some(2.0)), + (json!({"routing_group_billing_multiplier": -1}), None), + ( + json!({ + "routing_group_billing_multiplier": 0.5, + "billing_multiplier_snapshot": null + }), + None, + ), + ( + json!({ + "routing_group_billing_multiplier": 0.5, + "billing_multiplier_snapshot": { + "version": 1, + "factors": {"routing_group": 2.0}, + "multiplier": 1.0 + } + }), + None, + ), + ] { + let mut context = snapshot; + context["routing_group_id"] = json!("group-1"); + context["routing_group_name"] = json!("请求时分组"); + context["original_request_body"] = json!({"secret": "do not capture"}); + context["billing_snapshot"] = json!({"payload": "x".repeat(32 * 1024)}); + let seed = super::build_lifecycle_usage_seed(&plan, Some(&context)); + let pending_event = + build_pending_usage_event_from_owned_seed(seed.clone(), 1_700_000_000).unwrap(); + let streaming_event = + build_streaming_usage_event_from_owned_seed(seed.clone(), 200, None, 1_700_000_001) + .unwrap(); + let records = [ + build_pending_usage_record(&plan, Some(&context), 1_700_000_000).unwrap(), + build_streaming_usage_record(&plan, Some(&context), 200, None, 1_700_000_001) + .unwrap(), + build_upsert_usage_record_from_event(&pending_event).unwrap(), + build_upsert_usage_record_from_event(&streaming_event).unwrap(), + ]; + + for metadata in std::iter::once(seed.request_metadata.as_ref()).chain( + records.iter().map(|record| { + assert!(record.request_body.is_none()); + assert!(record.provider_request_body.is_none()); + record.request_metadata.as_ref() + }), + ) { + let metadata = metadata.expect("lifecycle routing snapshot"); + assert_eq!(metadata["routing_group_id"], "group-1"); + assert_eq!(metadata["routing_group_name"], "请求时分组"); + assert!(metadata.get("original_request_body").is_none()); + assert!(metadata.get("billing_snapshot").is_none()); + if let Some(expected) = expected_multiplier { + assert_eq!( + billing_multiplier_snapshot(Some(metadata)) + .unwrap() + .unwrap() + .multiplier(), + expected + ); + } else { + assert_eq!( + metadata.get("billing_multiplier_snapshot"), + Some(&Value::Null) + ); + assert!(billing_multiplier_snapshot(Some(metadata)).is_err()); + } + } + } } #[test] diff --git a/frontend/src/api/overview.ts b/frontend/src/api/overview.ts index 5e2fe733d..f0d8cb4f4 100644 --- a/frontend/src/api/overview.ts +++ b/frontend/src/api/overview.ts @@ -153,13 +153,18 @@ export interface OverviewDashboardSummary { } } export interface OverviewPage { items: T[]; total: number; limit: number; offset: number } -export interface OverviewSeriesPoint extends OverviewMetrics { bucket_start: string } +export interface OverviewSeriesPoint extends OverviewMetrics { bucket_start: string; unique_providers?: number | null } export interface OverviewBreakdown extends OverviewMetrics { id: string | null; label: string | null } +export interface OverviewDashboardChartMetrics extends Omit { + usage_active_users: number | null + slow_request_count: number | null + unclassified_failure_count: number | null +} export interface OverviewDashboardCharts { - summary: OverviewMetrics - series: OverviewSeriesPoint[] - models: (OverviewBreakdown & { bucket_start: string })[] - providers: OverviewBreakdown[] + summary: OverviewDashboardChartMetrics + series: (OverviewDashboardChartMetrics & { bucket_start: string; unique_providers?: number | null })[] + models: (OverviewDashboardChartMetrics & { id: string | null; label: string | null; bucket_start: string })[] + providers: (OverviewDashboardChartMetrics & { id: string | null; label: string | null })[] } export interface OverviewEmployee extends OverviewMetrics { user_id: string diff --git a/frontend/src/features/overview/__tests__/dashboard.spec.ts b/frontend/src/features/overview/__tests__/dashboard.spec.ts index 1fb46fe9d..ebca1def0 100644 --- a/frontend/src/features/overview/__tests__/dashboard.spec.ts +++ b/frontend/src/features/overview/__tests__/dashboard.spec.ts @@ -3,9 +3,10 @@ import { createApp, nextTick, type App } from 'vue' import { createPinia, disposePinia, type Pinia } from 'pinia' import { createMemoryHistory, createRouter } from 'vue-router' import AdminDashboard from '@/views/admin/AdminDashboard.vue' -import type { OverviewDashboardCharts, OverviewMetrics } from '@/api/overview' +import type { OverviewDashboardCharts, OverviewDashboardChartMetrics } from '@/api/overview' +import type { DateRangeParams } from '@/features/usage/types' import { setI18nLocale } from '@/i18n' -import { dayRange, modelDatasets, providerSlices } from '../dashboard/charts' +import { dashboardChartRange, dayRange, modelDatasets, providerSlices } from '../dashboard/charts' import { dashboardSummary } from './fixtures/dashboardSummary' import { useAnnouncementStore } from '@/stores/announcements' @@ -13,19 +14,20 @@ const api = vi.hoisted(() => ({ dashboardSummary: vi.fn(), summary: vi.fn(), das const legacyApi = vi.hoisted(() => ({ getStats: vi.fn(), getDailyStats: vi.fn() })) const announcementApi = vi.hoisted(() => ({ getAnnouncements: vi.fn(), getUserAnnouncements: vi.fn(), markAsRead: vi.fn() })) const auth = vi.hoisted(() => ({ canAccessAdmin: true, isAdmin: true, isAuditAdmin: false })) +const picker = vi.hoisted(() => ({ next: null as DateRangeParams | null })) vi.mock('@/api/overview', () => ({ overviewApi: api })) vi.mock('@/api/dashboard', () => ({ dashboardApi: legacyApi })) vi.mock('@/features/usage/components/IntervalTimelineCard.vue', async () => { const { h, defineComponent } = await import('vue'); return { default: defineComponent({ props: { title: String, hours: Number, isAdmin: Boolean, refreshIntervalMs: Number }, setup: props => () => h('div', { 'data-timeline': '' }, props.title) }) } }) vi.mock('@/stores/auth', () => ({ useAuthStore: () => auth })) vi.mock('@/api/announcements', () => ({ announcementApi })) -vi.mock('@/components/charts/BarChart.vue', async () => { const { h } = await import('vue'); return { default: { render: () => h('div', { 'data-chart': 'bar' }) } } }) -vi.mock('@/components/charts/DoughnutChart.vue', async () => { const { h } = await import('vue'); return { default: { render: () => h('div', { 'data-chart': 'doughnut' }) } } }) +vi.mock('@/components/charts/BarChart.vue', async () => { const { h, defineComponent } = await import('vue'); return { default: defineComponent({ props: { data: Object }, setup: props => () => h('div', { 'data-chart': 'bar', 'data-values': JSON.stringify(props.data) }) }) } }) +vi.mock('@/components/charts/DoughnutChart.vue', async () => { const { h, defineComponent } = await import('vue'); return { default: defineComponent({ props: { data: Object }, setup: props => () => h('div', { 'data-chart': 'doughnut', 'data-values': JSON.stringify(props.data) }) }) } }) vi.mock('@/components/charts/LineChart.vue', async () => { const { h } = await import('vue'); return { default: { render: () => h('div', { 'data-chart': 'line' }) } } }) vi.mock('@/components/common', async () => { const { defineComponent, h } = await import('vue') return { TimeRangePicker: defineComponent({ props: { modelValue: { type: Object, default: () => ({}) } }, emits: ['update:modelValue'], - setup: (props, { emit }) => () => h('button', { 'data-period-control': '', onClick: () => emit('update:modelValue', { ...props.modelValue, preset: 'last30days' }) }, 'Last 30 days'), + setup: (props, { emit }) => () => h('button', { 'data-period-control': '', onClick: () => emit('update:modelValue', picker.next ?? { ...props.modelValue, preset: 'last30days' }) }, 'Last 30 days'), }) } }) vi.mock('@/components/ui', async () => { @@ -39,9 +41,9 @@ vi.mock('@/components/ui', async () => { }) const amount = (value: string | null, status = value === null ? 'unknown' : 'known') => ({ value, status, currency: 'USD', basis: 'billable' }) -const metrics = (request_count = 0): OverviewMetrics => ({ - request_count, successful_request_count: request_count, failed_request_count: 0, cancelled_request_count: 0, in_flight_request_count: 0, unclassified_failure_count: 0, - input_tokens: request_count * 10, output_tokens: request_count * 10, total_tokens: request_count * 20, usage_active_users: 3, enabled_users: 8, +const metrics = (request_count = 0): OverviewDashboardChartMetrics => ({ + request_count, successful_request_count: request_count, failed_request_count: 0, cancelled_request_count: 0, in_flight_request_count: 0, unclassified_failure_count: null, + input_tokens: null, output_tokens: null, total_tokens: request_count * 20, usage_active_users: null, slow_request_count: null, enabled_users: 8, success_rate: { value: request_count ? 1 : null, numerator: request_count, denominator: request_count }, latency_ms: { avg: 100, p50: 90, p95: 190, p99: 230, sample_count: request_count }, rated_amount: amount('10'), billable_amount: amount('5'), quota_covered_amount: amount('2'), wallet_consumed_amount: amount('3'), wallet_debit_amount: amount('3'), }) @@ -85,8 +87,10 @@ beforeEach(() => { vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() }))) setI18nLocale('zh-CN') vi.resetAllMocks() + picker.next = null Object.assign(auth, { canAccessAdmin: true, isAdmin: true, isAuditAdmin: false }) api.dashboardSummary.mockResolvedValue(dashboardSummary()) + api.dashboardCharts.mockResolvedValue({ data: charts() }) announcementApi.getUserAnnouncements.mockResolvedValue({ items: [], total: 0, unread_count: 0 }) legacyApi.getStats.mockResolvedValue({ stats: [{ name: 'Legacy request metric', value: '999999', icon: 'Activity' }], @@ -154,8 +158,10 @@ describe('administrator dashboard compact presentation', () => { expect(api.dashboardSummary).toHaveBeenCalledTimes(1) expect(api.summary).not.toHaveBeenCalled() expect(api.dashboardTotal).not.toHaveBeenCalled() - expect(api.dashboardCharts).not.toHaveBeenCalled() + expect(api.dashboardCharts).toHaveBeenCalledTimes(1) + expect(api.dashboardCharts).toHaveBeenCalledWith(expect.objectContaining({ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, from: expect.any(String), to: expect.any(String) }), expect.any(AbortSignal)) expect(legacyApi.getStats).not.toHaveBeenCalled() + expect(legacyApi.getDailyStats).not.toHaveBeenCalled() expect(announcementApi.getAnnouncements).not.toHaveBeenCalled() expect(announcementApi.getUserAnnouncements).toHaveBeenCalledExactlyOnceWith({ limit: 20, offset: 0 }) expect(root.querySelector('#announcements-section')?.textContent).toContain('系统公告') @@ -167,7 +173,9 @@ describe('administrator dashboard compact presentation', () => { root.querySelector('[data-period-control]')!.click() await new Promise(resolve => setTimeout(resolve, 150)) await settle() - expect(legacyApi.getDailyStats).toHaveBeenCalledTimes(2) + expect(api.dashboardCharts).toHaveBeenCalledTimes(2) + expect(api.dashboardCharts.mock.calls[1]![0].from).not.toBe(api.dashboardCharts.mock.calls[0]![0].from) + expect(legacyApi.getDailyStats).not.toHaveBeenCalled() expect(api.dashboardSummary).toHaveBeenCalledTimes(1) expect(card(root, '今日请求')?.textContent).toContain('12,345') }) @@ -231,9 +239,173 @@ describe('administrator dashboard compact presentation', () => { } expect(root.querySelector('[data-chart="bar"]')).not.toBeNull() }) + + it('uses customer billing and all request types for daily rows, totals, and both cost charts', async () => { + const snapshot = dashboardSummary() + Object.assign(snapshot.today, { request_count: 25018, stream_requests: 24989, standard_requests: 29, billable_amount: amount('10855.49') }) + api.dashboardSummary.mockResolvedValue(snapshot) + const metric = { ...metrics(25018), billable_amount: amount('10855.49'), rated_amount: amount('13580.42'), latency_ms: { ...metrics().latency_ms, avg: 1520 } } + const data: OverviewDashboardCharts = { + summary: metric, + series: [{ ...metric, bucket_start: '2026-09-19T00:00:00Z', unique_providers: 2 }], + models: [{ ...metric, id: 'model', label: 'Model', bucket_start: '2026-09-19T00:00:00+00:00' }], + providers: [{ ...metric, id: 'provider', label: 'Provider' }], + } + api.dashboardCharts.mockResolvedValue({ data }) + const { root } = await mount() + const row = root.querySelector('[data-daily-date]') + expect(row?.textContent).toContain('25,018') + expect(row?.textContent).toContain('$10855.4900') + expect(row?.textContent).toContain('1.52s') + expect(row?.lastElementChild?.textContent?.trim()).toBe('2') + expect(primaryValue(root, '今日消费')?.textContent).toContain('$10855.49') + expect(metricValue(root, 'stream')).toBe('24,989') + expect(metricValue(root, 'standard')).toBe('29') + expect(root.querySelector('[data-daily-total]')?.textContent).toContain('$10855.4900') + for (const kind of ['bar', 'doughnut']) { + const chart = JSON.parse(root.querySelector(`[data-chart="${kind}"]`)!.getAttribute('data-values')!) + expect(chart.datasets[0].data).toEqual([10855.49]) + } + expect(root.textContent).not.toContain('13580.42') + expect(legacyApi.getDailyStats).not.toHaveBeenCalled() + }) + + it.each([ + { value: null, status: 'unknown', display: '—' }, + { value: '123', status: 'unknown', display: '—' }, + { value: '0', status: 'known', display: '$0.0000' }, + { value: '0', status: 'known_subtotal', display: '$0.0000' }, + { value: '3.25', status: 'known_subtotal', display: '$3.2500' }, + ])('preserves daily amount $status/$value without inventing zero', async ({ value, status, display }) => { + const data = charts() + const billable = amount(value, status) + for (const metric of [data.summary, ...data.series, ...data.models, ...data.providers]) metric.billable_amount = billable + data.series[0]!.total_tokens = null + data.series[0]!.latency_ms.avg = null + api.dashboardCharts.mockResolvedValue({ data }) + const { root } = await mount() + const cells = root.querySelector('[data-daily-date]')!.querySelectorAll('td') + expect(cells[2]!.textContent?.trim()).toBe('—') + expect(cells[3]!.textContent).toContain(display) + expect(cells[4]!.textContent?.trim()).toBe('—') + expect(cells[6]!.textContent?.trim()).toBe('—') + expect(root.querySelector('[data-daily-total]')?.textContent).toContain(display) + if (status === 'unknown') { + expect(cells[3]!.textContent).not.toContain('$') + expect(root.querySelector('[data-chart="bar"]')).toBeNull() + expect(root.querySelector('[data-chart="doughnut"]')).toBeNull() + expect(root.textContent).toContain('费用尚未确认') + } + if (status === 'known_subtotal') { + expect(cells[3]!.textContent).toContain('已知小计') + expect(root.querySelector('[data-daily-total]')?.textContent).toContain('已知小计') + expect(root.textContent).toContain('分布占比按已知金额计算') + if (value === '0') { + expect(root.textContent).toContain('费用尚未确认') + expect(root.textContent).not.toContain('此周期暂无计费费用') + } + } + }) + + it('takes period totals and latency from the canonical summary instead of averaging daily values', async () => { + const data = charts() + data.summary = { ...metrics(101), billable_amount: amount('12', 'known_subtotal'), latency_ms: { ...metrics().latency_ms, avg: 500 } } + data.series = [ + { ...metrics(100), bucket_start: '2026-09-05T00:00:00Z', latency_ms: { ...metrics().latency_ms, avg: 100, sample_count: 1 } }, + { ...metrics(1), bucket_start: '2026-09-06T00:00:00Z', latency_ms: { ...metrics().latency_ms, avg: 900, sample_count: 1 } }, + ] + api.dashboardCharts.mockResolvedValue({ data }) + const { root } = await mount() + const total = root.querySelector('[data-daily-total]') + expect(total?.textContent).toContain('$12.0000') + expect(total?.textContent).toContain('500ms') + expect(total?.textContent).toContain('已知小计') + }) + + it('does not describe a positive summary with missing chart details as zero cost', async () => { + const data = charts() + data.models = [] + data.providers = [] + api.dashboardCharts.mockResolvedValue({ data }) + const { root } = await mount() + expect(root.querySelector('[data-daily-date]')?.textContent).toContain('$5.0000') + expect(root.textContent).toContain('暂无费用明细') + expect(root.textContent).not.toContain('此周期暂无计费费用') + }) + + it('hides the previous period total while loading the selected range', async () => { + const { root } = await mount() + expect(root.querySelector('[data-daily-total]')).not.toBeNull() + api.dashboardCharts.mockReturnValue(new Promise(() => {})) + root.querySelector('[data-period-control]')!.click() + await nextTick() + expect(root.querySelector('[data-daily-total]')).toBeNull() + await new Promise(resolve => setTimeout(resolve, 150)) + await settle() + expect(root.querySelector('[data-daily-total]')).toBeNull() + }) + + it('cancels a previous chart range and ignores its late response after the new range has loaded', async () => { + let resolveOld!: (value: { data: OverviewDashboardCharts }) => void + api.dashboardCharts.mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve })) + const { root } = await mount() + const oldSignal = api.dashboardCharts.mock.calls[0]![1] as AbortSignal + const current = charts() + current.series[0]!.billable_amount = amount('42') + api.dashboardCharts.mockResolvedValue({ data: current }) + root.querySelector('[data-period-control]')!.click() + await nextTick() + expect(oldSignal.aborted).toBe(true) + await new Promise(resolve => setTimeout(resolve, 150)) + await settle() + expect(root.querySelector('[data-daily-date]')?.textContent).toContain('$42.0000') + resolveOld({ data: charts() }) + await settle() + expect(root.querySelector('[data-daily-date]')?.textContent).toContain('$42.0000') + expect(api.dashboardCharts).toHaveBeenCalledTimes(2) + }) + + it('shows a retryable daily load error without replacing the summary or inventing empty statistics', async () => { + api.dashboardCharts.mockRejectedValueOnce(new Error('timeout')) + const { root } = await mount() + const alert = root.querySelector('[role="alert"]') + expect(alert?.textContent).toContain('统计加载失败,请重试') + expect(primaryValue(root, '今日消费')?.textContent).toContain('$9.87') + expect(root.querySelector('[data-daily-date]')).toBeNull() + expect(root.querySelector('[data-daily-total]')).toBeNull() + expect(root.textContent).not.toContain('暂无数据') + alert!.querySelector('button')!.click() + await settle() + expect(root.querySelector('[role="alert"]')).toBeNull() + expect(root.querySelector('[data-daily-date]')?.textContent).toContain('$5.0000') + expect(api.dashboardCharts).toHaveBeenCalledTimes(2) + expect(api.dashboardSummary).toHaveBeenCalledTimes(1) + }) + + it('uses the selected calendar timezone for chart requests and daily date labels', async () => { + const { root } = await mount() + picker.next = { start_date: '2026-03-08', end_date: '2026-03-08', timezone: 'America/New_York' } + const data = charts() + data.series[0]!.bucket_start = '2026-03-08T05:00:00Z' + api.dashboardCharts.mockResolvedValue({ data }) + root.querySelector('[data-period-control]')!.click() + await new Promise(resolve => setTimeout(resolve, 150)) + await settle() + expect(api.dashboardCharts).toHaveBeenLastCalledWith({ from: '2026-03-08T05:00:00.000Z', to: '2026-03-09T04:00:00.000Z', timezone: 'America/New_York' }, expect.any(AbortSignal)) + expect(api.dashboardSummary).toHaveBeenLastCalledWith('America/New_York', expect.any(AbortSignal)) + expect(root.querySelector('[data-daily-date]')?.firstElementChild?.textContent).toContain('03/08') + }) }) describe('dashboard chart accounting', () => { + it('preserves inclusive calendar periods and explicit instants in the selected timezone', () => { + const now = new Date('2026-10-08T04:34:56Z') + expect(dashboardChartRange({ preset: 'last7days', timezone: 'Asia/Shanghai' }, now)).toEqual({ from: '2026-10-01T16:00:00.000Z', to: '2026-10-08T16:00:00.000Z', timezone: 'Asia/Shanghai' }) + expect(dashboardChartRange({ preset: 'yesterday', timezone: 'Asia/Shanghai' }, now)).toEqual({ from: '2026-10-06T16:00:00.000Z', to: '2026-10-07T16:00:00.000Z', timezone: 'Asia/Shanghai' }) + expect(dashboardChartRange({ start_date: '2026-03-08', end_date: '2026-03-08', timezone: 'America/New_York' }, now)).toEqual({ from: '2026-03-08T05:00:00.000Z', to: '2026-03-09T04:00:00.000Z', timezone: 'America/New_York' }) + expect(dashboardChartRange({ start_date: '2026-09-06', end_date: '2026-09-06', timezone: 'America/Santiago' }, now)).toEqual({ from: '2026-09-06T04:00:00.000Z', to: '2026-09-07T03:00:00.000Z', timezone: 'America/Santiago' }) + expect(dashboardChartRange({ from: '2026-10-07T16:12:34Z', to: '2026-10-08T16:56:00Z', timezone: 'Asia/Shanghai' }, now)).toEqual({ from: '2026-10-07T16:12:34.000Z', to: '2026-10-08T16:56:00.000Z', timezone: 'Asia/Shanghai' }) + }) it('preserves the full provider sum when collapsing additional groups and excludes unknown amounts', () => { const rows = Array.from({ length: 105 }, (_, i) => ({ ...metrics(1), id: String(i), label: `Provider ${i}`, billable_amount: amount(String(i + 1)) })) rows.push({ ...metrics(1), id: 'unknown', label: 'Unknown cost', billable_amount: amount(null) }) @@ -253,6 +425,13 @@ describe('dashboard chart accounting', () => { expect(modelDatasets(data, 'Unknown model', 'Others')[0]).toMatchObject({ label: 'gpt-example', data: [3, null] }) }) + it.each(['unknown', 'known_subtotal', 'estimated_subtotal'])('keeps absent model rows unknown in an incomplete %s day', status => { + const data = charts() + data.series.push({ ...metrics(0), billable_amount: amount(status === 'unknown' ? null : '0', status), bucket_start: '2026-09-06T00:00:00Z' }) + data.series.push({ ...metrics(0), billable_amount: amount('0'), bucket_start: '2026-09-07T00:00:00Z' }) + expect(modelDatasets(data, 'Unknown model', 'Others')[0]!.data).toEqual([5, null, 0]) + }) + it('uses a 23-hour local day during daylight saving and clips it to report boundaries', () => { const dstRange = { from: '2026-03-07T05:00:00Z', to: '2026-03-10T04:00:00Z', timezone: 'America/New_York' } expect(dayRange('2026-03-08T05:00:00Z', dstRange)).toEqual({ from: '2026-03-08T05:00:00.000Z', to: '2026-03-09T04:00:00.000Z', timezone: dstRange.timezone }) diff --git a/frontend/src/features/overview/dashboard/charts.ts b/frontend/src/features/overview/dashboard/charts.ts index 469aaf0dc..347eea11d 100644 --- a/frontend/src/features/overview/dashboard/charts.ts +++ b/frontend/src/features/overview/dashboard/charts.ts @@ -1,22 +1,23 @@ -import type { OverviewBreakdown, OverviewDashboardCharts, OverviewRange } from '@/api/overview' +import type { OverviewDashboardChartMetrics, OverviewDashboardCharts, OverviewRange } from '@/api/overview' +import type { DateRangeParams } from '@/features/usage/types' import { getI18nLocale } from '@/i18n' -import { zonedInput, zonedInstant } from '../query' +import { browserTimezone, zonedInput, zonedInstant } from '../query' import { amountValue } from './amount' export const chartColors = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4', '#94a3b8'] -type ModelRow = OverviewDashboardCharts['models'][number] +type ModelRow = Pick export function chartDate(value: string, timezone: string): string { return new Intl.DateTimeFormat(getI18nLocale(), { timeZone: timezone, month: 'short', day: 'numeric' }).format(new Date(value)) } -function knownSum(rows: { billable_amount: OverviewBreakdown['billable_amount'] }[]): number | null { +function knownSum(rows: Pick[]): number | null { if (!rows.length) return 0 const values = rows.map(row => amountValue(row.billable_amount)).filter((value): value is number => value !== null) return values.length ? values.reduce((sum, value) => sum + value, 0) : null } -export function modelDatasets(data: OverviewDashboardCharts, unknownLabel: string, otherLabel: string) { +export function modelDatasets(data: { series: Pick[]; models: ModelRow[] }, unknownLabel: string, otherLabel: string) { const groups = new Map() for (const row of data.models) { const rows = groups.get(row.id) @@ -28,13 +29,18 @@ export function modelDatasets(data: OverviewDashboardCharts, unknownLabel: strin if (sorted.length > 6) visible.push({ id: '__other__', label: otherLabel, rows: sorted.slice(6).flatMap(([, rows]) => rows) }) return visible.map((group, index) => ({ label: group.label, - data: data.series.map(day => knownSum(group.rows.filter(row => Date.parse(row.bucket_start) === Date.parse(day.bucket_start)))), + data: data.series.map(day => { + const rows = group.rows.filter(row => Date.parse(row.bucket_start) === Date.parse(day.bucket_start)) + const incomplete = amountValue(day.billable_amount) === null + || day.billable_amount.status === 'known_subtotal' || day.billable_amount.status === 'estimated_subtotal' + return !rows.length && incomplete ? null : knownSum(rows) + }), backgroundColor: chartColors[index % chartColors.length], borderRadius: 2, stack: 'models', barPercentage: 0.6, categoryPercentage: 0.7, })) } -export function providerSlices(rows: OverviewBreakdown[], unknownLabel: string, otherLabel: string) { +export function providerSlices(rows: Pick[], unknownLabel: string, otherLabel: string) { const known = rows.map(row => ({ label: row.label || unknownLabel, value: amountValue(row.billable_amount) })) .filter((row): row is { label: string; value: number } => row.value !== null && row.value > 0) .sort((a, b) => b.value - a.value) @@ -55,6 +61,25 @@ export function dayRange(bucket: string, range: OverviewRange): OverviewRange { } } +/** The dashboard picker uses inclusive calendar dates, not rolling 24-hour periods. */ +export function dashboardChartRange(params: DateRangeParams, now = new Date()): OverviewRange { + const timezone = params.timezone || browserTimezone() + if (params.from && params.to) { + if (!Number.isFinite(Date.parse(params.from)) || Date.parse(params.from) >= Date.parse(params.to)) { + throw new Error('Invalid dashboard time range') + } + return { from: new Date(params.from).toISOString(), to: new Date(params.to).toISOString(), timezone } + } + const shiftDate = (date: string, days: number) => new Date(Date.parse(`${date}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10) + const today = zonedInput(now, timezone).slice(0, 10) + const days = params.preset === 'last90days' ? 90 : params.preset === 'last30days' ? 30 + : params.preset === 'today' || params.preset === 'yesterday' ? 1 : 7 + const endDate = params.end_date || shiftDate(today, params.preset === 'yesterday' ? -1 : 0) + const startDate = params.start_date || shiftDate(endDate, 1 - days) + if (startDate > endDate) throw new Error('Invalid dashboard time range') + return { from: localDayStart(startDate, timezone), to: localDayStart(shiftDate(endDate, 1), timezone), timezone } +} + function localDayStart(date: string, timezone: string): string { const midnight = Date.parse(`${date}T00:00:00Z`) // Some timezone transitions skip midnight; start at the first existing local minute. diff --git a/frontend/src/views/shared/Dashboard.vue b/frontend/src/views/shared/Dashboard.vue index 36c61272c..bdc2300b6 100644 --- a/frontend/src/views/shared/Dashboard.vue +++ b/frontend/src/views/shared/Dashboard.vue @@ -356,12 +356,31 @@ + + -
+
- + - 每日模型成本 + 每日模型费用
- 暂无数据 + {{ dailyCostEmptyLabel }}
- + - 提供商成本分布 + 提供商费用分布
- 暂无数据 + {{ dailyCostEmptyLabel }}
- + - 每日模型成本 + 每日模型费用
+

+ {{ t('统计为已知数据小计,分布占比按已知金额计算', 'Statistics are known subtotals; distribution shares use known amounts') }} +

+ - +
@@ -528,7 +557,7 @@ 加载中...
暂无数据 @@ -538,19 +567,20 @@ class="divide-y divide-border/60" >
{{ - formatDate(stat.date) + formatDailyDate(stat.date) }} - ${{ stat.cost.toFixed(4) }} + {{ formatDailyCost(stat.cost) }} + {{ amountStatus(stat.billableAmount, t) }}
@@ -560,7 +590,7 @@
Tokens - {{ formatTokens(stat.tokens) }} + {{ compactTokens(stat.tokens) }}
响应 @@ -617,7 +647,7 @@
- + @@ -676,8 +708,9 @@
@@ -693,7 +726,7 @@ 总Tokens
- {{ formatTokens(totalStats.tokens) }} + {{ compactTokens(totalStats.tokens) }}
@@ -701,7 +734,8 @@ 总费用
- ${{ totalStats.cost.toFixed(4) }} + {{ formatDailyCost(totalStats.cost) }} + {{ amountStatus(adminDailyCharts.summary.billable_amount, t) }}
@@ -741,8 +775,10 @@ import { type DailyStat, type ProviderSummary, } from "@/api/dashboard"; -import { overviewApi, type OverviewDashboardSummary } from "@/api/overview"; -import { amountValue } from "@/features/overview/dashboard/amount"; +import { overviewApi, type OverviewAmount, type OverviewDashboardCharts, type OverviewDashboardSummary, type OverviewRange } from "@/api/overview"; +import { amountStatus, amountValue } from "@/features/overview/dashboard/amount"; +import { chartDate, dashboardChartRange, modelDatasets, providerSlices } from "@/features/overview/dashboard/charts"; +import { zonedInput } from "@/features/overview/query"; import { count, percent, timestamp } from "@/features/overview/format"; import DashboardActivity from "@/features/overview/dashboard/DashboardActivity.vue"; import DashboardAnnouncements from "@/features/overview/dashboard/DashboardAnnouncements.vue"; @@ -926,19 +962,43 @@ const tokenBreakdown = ref<{ const dailyStats = ref([]); const providerSummary = ref([]); +const adminDailyCharts = ref(null); +const adminDailyRange = ref(null); +type DisplayDailyStat = Omit & { + tokens: number | null; + cost: number | null; + avg_response_time: number | null; + unique_providers?: number | null; + billableAmount?: OverviewAmount; +}; +const displayDailyStats = computed(() => { + if (!isAdmin.value || (import.meta.env.DEV && isDemo.value)) return dailyStats.value; + const charts = adminDailyCharts.value; + if (!charts) return []; + return charts.series.map(day => ({ + date: day.bucket_start, + requests: day.request_count, + tokens: day.total_tokens, + cost: amountValue(day.billable_amount), + billableAmount: day.billable_amount, + avg_response_time: day.latency_ms.avg === null ? null : day.latency_ms.avg / 1000, + unique_models: new Set(charts.models.filter(model => Date.parse(model.bucket_start) === Date.parse(day.bucket_start) && model.id !== null).map(model => model.id)).size, + unique_providers: day.unique_providers, + })); +}); const dailyTimeRange = ref( - getDateRangeFromPeriod("last7days"), + { ...getDateRangeFromPeriod("last7days"), granularity: 'day' }, ); // 统计周期 const loadingDaily = ref(false); +const dailyError = ref(''); const loading = ref(false); const dashboardError = ref(""); let dashboardRequestId = 0; let dashboardController: AbortController | null = null; let dashboardTimezone: string | null = null; let dailyStatsRequestId = 0; -let dailyStatsLoadPromise: Promise | null = null; -let hasPendingDailyStatsLoad = false; +let dailyStatsController: AbortController | null = null; let dailyStatsDebounceTimer: ReturnType | null = null; @@ -975,6 +1035,15 @@ const emptyStatPlaceholders = computed(() => { const statSkeletonCount = computed(() => emptyStatPlaceholders.value.length); const totalStats = computed(() => { + if (isAdmin.value && adminDailyCharts.value) { + const summary = adminDailyCharts.value.summary; + return { + requests: summary.request_count, + tokens: summary.total_tokens, + cost: amountValue(summary.billable_amount), + avgResponseTime: summary.latency_ms.avg === null ? null : summary.latency_ms.avg / 1000, + }; + } if (dailyStats.value.length === 0) { return { requests: 0, tokens: 0, cost: 0, avgResponseTime: 0 }; } @@ -997,7 +1066,7 @@ const totalStats = computed(() => { }; }); -// 每日模型成本(堆叠柱状图) +// 每日模型费用(堆叠柱状图) const MODEL_COLORS = [ "rgba(59, 130, 246, 0.8)", // blue "rgba(239, 68, 68, 0.8)", // red @@ -1010,6 +1079,13 @@ const MODEL_COLORS = [ ]; const dailyModelCostChartData = computed>(() => { + if (isAdmin.value && adminDailyCharts.value && adminDailyRange.value) { + const timezone = adminDailyRange.value.timezone; + return { + labels: adminDailyCharts.value.series.map(day => chartDate(day.bucket_start, timezone)), + datasets: modelDatasets(adminDailyCharts.value, t('未知模型', 'Unknown model'), t('其他模型', 'Other models')), + }; + } if (dailyStats.value.length === 0) { return { labels: [], datasets: [] }; } @@ -1057,6 +1133,23 @@ const dailyModelCostChartData = computed>(() => { }; }); +const hasDailyModelCost = computed(() => dailyModelCostChartData.value.datasets.some(dataset => dataset.data.some(value => typeof value === 'number' && value !== 0))); +const dailyCostsPartial = computed(() => { + const data = adminDailyCharts.value; + if (!data) return false; + const amounts = [data.summary, ...data.series, ...data.models, ...data.providers].map(row => row.billable_amount); + return amounts.some(amount => amountValue(amount) !== null) + && amounts.some(amount => amountValue(amount) === null || amount.status === 'known_subtotal' || amount.status === 'estimated_subtotal'); +}); +const dailyCostEmptyLabel = computed(() => { + const data = adminDailyCharts.value; + if (!data) return t('暂无数据', 'No data'); + const amount = amountValue(data.summary.billable_amount); + if (amount === null || dailyCostsPartial.value) return t('费用尚未确认', 'Cost not yet known'); + if (amount > 0) return t('暂无费用明细', 'No cost breakdown available'); + return data.summary.request_count === 0 ? t('暂无数据', 'No data') : t('此周期暂无计费费用', 'No billable cost in this period'); +}); + const dailyModelCostChartOptions = computed>(() => ({ responsive: true, maintainAspectRatio: false, @@ -1106,7 +1199,7 @@ const dailyModelCostChartOptions = computed>(() => ({ }, })); -// 提供商成本分布(环形图) +// 提供商费用分布(环形图) const PROVIDER_COLORS = [ "rgba(59, 130, 246, 0.8)", // blue "rgba(239, 68, 68, 0.8)", // red @@ -1119,6 +1212,13 @@ const PROVIDER_COLORS = [ ]; const providerCostChartData = computed>(() => { + if (isAdmin.value && adminDailyCharts.value) { + const slices = providerSlices(adminDailyCharts.value.providers, t('未知提供商', 'Unknown provider'), t('其他提供商', 'Other providers')); + return { + labels: slices.map(slice => slice.label), + datasets: [{ data: slices.map(slice => slice.value), backgroundColor: slices.map((_, i) => PROVIDER_COLORS[i % PROVIDER_COLORS.length]), borderWidth: 2, borderColor: 'rgba(255, 255, 255, 0.1)' }], + }; + } if (providerSummary.value.length === 0) { return { labels: [], datasets: [] }; } @@ -1275,9 +1375,9 @@ onBeforeUnmount(() => { clearTimeout(dailyStatsDebounceTimer); dailyStatsDebounceTimer = null; } - hasPendingDailyStatsLoad = false; - dailyStatsLoadPromise = null; dailyStatsRequestId += 1; + dailyStatsController?.abort(); + dailyStatsController = null; dashboardRequestId += 1; dashboardController?.abort(); dashboardController = null; @@ -1380,40 +1480,50 @@ function adminStatCards(snapshot: OverviewDashboardSummary): DashboardStatCard[] } async function loadDailyStats() { - if (dailyStatsLoadPromise) { - hasPendingDailyStatsLoad = true; - return dailyStatsLoadPromise; - } + dailyStatsController?.abort(); + const controller = new AbortController(); + dailyStatsController = controller; const requestId = ++dailyStatsRequestId; loadingDaily.value = true; - dailyStatsLoadPromise = (async () => { - try { + dailyError.value = ''; + try { + if (isAdmin.value && !(import.meta.env.DEV && isDemo.value)) { + const range = dashboardChartRange(dailyTimeRange.value); + const response = await overviewApi.dashboardCharts(range, controller.signal); + if (requestId !== dailyStatsRequestId || controller.signal.aborted) return; + adminDailyCharts.value = response.data; + adminDailyRange.value = range; + dailyStats.value = []; + providerSummary.value = []; + } else { const response = import.meta.env.DEV && isDemo.value ? (await import('@/features/overview/dashboard/demo')).createDashboardDailyDemo(dailyTimeRange.value) : await dashboardApi.getDailyStats(dailyTimeRange.value); - if (requestId !== dailyStatsRequestId) return; + if (requestId !== dailyStatsRequestId || controller.signal.aborted) return; + adminDailyCharts.value = null; + adminDailyRange.value = null; dailyStats.value = response.daily_stats; providerSummary.value = response.provider_summary || []; - } catch { - if (requestId !== dailyStatsRequestId) return; - dailyStats.value = []; - providerSummary.value = []; - } finally { - if (requestId === dailyStatsRequestId) { - loadingDaily.value = false; - } } - })().finally(() => { - dailyStatsLoadPromise = null; - if (hasPendingDailyStatsLoad) { - hasPendingDailyStatsLoad = false; - void loadDailyStats(); + } catch { + if (requestId !== dailyStatsRequestId || controller.signal.aborted) return; + if (isAdmin.value) dailyError.value = t('统计加载失败,请重试', 'Statistics could not be loaded. Please retry.'); + dailyStats.value = []; + providerSummary.value = []; + adminDailyCharts.value = null; + adminDailyRange.value = null; + } finally { + if (requestId === dailyStatsRequestId) { + loadingDaily.value = false; + dailyStatsController = null; } - }); - return dailyStatsLoadPromise; + } } function scheduleDailyStatsLoad() { + dailyStatsController?.abort(); + dailyStatsRequestId += 1; + loadingDaily.value = true; if (dailyStatsDebounceTimer) { clearTimeout(dailyStatsDebounceTimer); } @@ -1423,15 +1533,24 @@ function scheduleDailyStatsLoad() { }, 120); } -watch(dailyTimeRange, scheduleDailyStatsLoad, { deep: true }); +watch(() => JSON.stringify([ + dailyTimeRange.value.from, dailyTimeRange.value.to, + dailyTimeRange.value.start_date, dailyTimeRange.value.end_date, + dailyTimeRange.value.preset, dailyTimeRange.value.granularity, + dailyTimeRange.value.timezone, dailyTimeRange.value.tz_offset_minutes, +]), scheduleDailyStatsLoad); watch(isDemo, () => { dashboardController?.abort(); dashboardController = null; dashboardSnapshot.value = null; demoTimeline.value = null; dailyStatsRequestId += 1; + dailyStatsController?.abort(); dailyStats.value = []; providerSummary.value = []; + adminDailyCharts.value = null; + adminDailyRange.value = null; + dailyError.value = ''; void loadDashboardData(); void loadDailyStats(); }); @@ -1463,7 +1582,25 @@ function formatDateForChart(dateString: string): string { return date.toLocaleDateString(getI18nLocale(), { month: "numeric", day: "numeric" }); } -function formatResponseTime(seconds: number): string { +function formatDailyCost(cost: number | null): string { + return cost === null ? '—' : `$${cost.toFixed(4)}`; +} + +function formatDailyDate(value: string): string { + if (isAdmin.value && adminDailyRange.value) { + const timezone = adminDailyRange.value.timezone; + const date = zonedInput(value, timezone).slice(0, 10); + const today = zonedInput(new Date(), timezone).slice(0, 10); + const yesterday = new Date(Date.parse(`${today}T00:00:00Z`) - 86_400_000).toISOString().slice(0, 10); + if (date === today) return formatRelativeTime(0, 'day'); + if (date === yesterday) return formatRelativeTime(-1, 'day'); + return parseDateLike(date).toLocaleDateString(getI18nLocale(), { month: '2-digit', day: '2-digit', weekday: 'short' }); + } + return formatDate(value); +} + +function formatResponseTime(seconds: number | null): string { + if (seconds === null) return '—'; if (seconds === 0) return "-"; if (seconds < 1) return `${(seconds * 1000).toFixed(0)}ms`; return `${seconds.toFixed(2)}s`; diff --git a/frontend/src/views/shared/__tests__/Dashboard.loading.spec.ts b/frontend/src/views/shared/__tests__/Dashboard.loading.spec.ts index 35d574fdd..89eead212 100644 --- a/frontend/src/views/shared/__tests__/Dashboard.loading.spec.ts +++ b/frontend/src/views/shared/__tests__/Dashboard.loading.spec.ts @@ -1,13 +1,14 @@ import { createApp, nextTick, type App } from 'vue' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { OverviewDashboardSummary } from '@/api/overview' +import type { OverviewDashboardCharts, OverviewDashboardSummary, OverviewDashboardChartMetrics, OverviewRange, OverviewResponse } from '@/api/overview' import { dashboardSummary } from '@/features/overview/__tests__/fixtures/dashboardSummary' import Dashboard from '../Dashboard.vue' -const api = vi.hoisted(() => ({ dashboardSummary: vi.fn(), summary: vi.fn(), dashboardTotal: vi.fn(), daily: vi.fn() })) +const api = vi.hoisted(() => ({ dashboardSummary: vi.fn(), summary: vi.fn(), dashboardTotal: vi.fn(), dashboardCharts: vi.fn() })) +const legacyDaily = vi.hoisted(() => vi.fn()) vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ canAccessAdmin: true, isAdmin: true, isAuditAdmin: false }) })) vi.mock('@/api/overview', () => ({ overviewApi: api })) -vi.mock('@/api/dashboard', () => ({ dashboardApi: { getDailyStats: api.daily } })) +vi.mock('@/api/dashboard', () => ({ dashboardApi: { getDailyStats: legacyDaily } })) vi.mock('@/features/overview/dashboard/DashboardActivity.vue', async () => { const { defineComponent, h } = await import('vue') return { @@ -32,6 +33,33 @@ vi.mock('@/components/charts/BarChart.vue', () => ({ default: { render: () => nu vi.mock('@/components/charts/DoughnutChart.vue', () => ({ default: { render: () => null } })) vi.mock('@/components/charts/LineChart.vue', () => ({ default: { render: () => null } })) +function dashboardCharts(range: OverviewRange = { + from: '2026-09-19T00:00:00Z', to: '2026-09-19T04:00:00Z', timezone: 'UTC', +}): OverviewResponse { + const amount = { value: '0', currency: 'USD', basis: 'billable', status: 'known' } + const summary: OverviewDashboardChartMetrics = { + request_count: 0, successful_request_count: 0, failed_request_count: 0, + cancelled_request_count: 0, in_flight_request_count: 0, unclassified_failure_count: null, + input_tokens: null, output_tokens: null, total_tokens: 0, usage_active_users: null, slow_request_count: null, + success_rate: { value: null, numerator: 0, denominator: 0 }, + latency_ms: { avg: null, p50: null, p95: null, p99: null, sample_count: 0 }, + rated_amount: amount, billable_amount: amount, quota_covered_amount: amount, + wallet_consumed_amount: amount, wallet_debit_amount: amount, + } + return { + meta: { + schema_version: 1, metric_version: 'test', scope: { kind: 'admin' }, + range: { ...range, time_basis: 'request_started_at' }, + generated_at: range.to, data_through: range.to, read_revision: 'test', + coverage: { + status: 'complete', request_count: 0, usage_available_count: 0, + pricing_available_count: 0, settled_count: 0, + }, + }, + data: { summary, series: [], models: [], providers: [] }, + } +} + function deferred() { let resolve!: (value: T) => void let reject!: (reason: Error) => void @@ -52,13 +80,13 @@ beforeEach(() => { vi.useFakeTimers() vi.resetAllMocks() api.dashboardSummary.mockResolvedValue(dashboardSummary()) - api.daily.mockResolvedValue({ daily_stats: [], provider_summary: [] }) + api.dashboardCharts.mockImplementation((range: OverviewRange) => Promise.resolve(dashboardCharts(range))) }) afterEach(() => { app?.unmount(); app = undefined; vi.useRealTimers() }) describe('dashboard snapshot loading', () => { it('shows today and total from one compact snapshot independently of pending charts', async () => { - api.daily.mockReturnValue(new Promise(() => {})) + api.dashboardCharts.mockReturnValue(new Promise(() => {})) const root = mount() await settle() expect(root.textContent).toContain('总请求 12,345') @@ -68,9 +96,14 @@ describe('dashboard snapshot loading', () => { expect(api.dashboardSummary).toHaveBeenCalledWith(Intl.DateTimeFormat().resolvedOptions().timeZone, expect.any(AbortSignal)) expect(api.summary).not.toHaveBeenCalled() expect(api.dashboardTotal).not.toHaveBeenCalled() + expect(api.dashboardCharts).toHaveBeenCalledWith({ + from: expect.any(String), to: expect.any(String), + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }, expect.any(AbortSignal)) + expect(legacyDaily).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(240_000) expect(api.dashboardSummary).toHaveBeenCalledTimes(1) - expect(api.daily).toHaveBeenCalledTimes(1) + expect(api.dashboardCharts).toHaveBeenCalledTimes(1) }) it('labels restored history with its UTC activity dates independently of the dashboard timezone', async () => { @@ -181,7 +214,29 @@ describe('dashboard snapshot loading', () => { expect(root.querySelector('[data-request-metric="stream"]')?.textContent).toContain('—') await vi.advanceTimersByTimeAsync(61_000) expect(api.dashboardSummary).toHaveBeenCalledTimes(1) - expect(api.daily.mock.calls.length).toBeLessThanOrEqual(2) + expect(api.dashboardCharts.mock.calls.length).toBeLessThanOrEqual(2) + expect(legacyDaily).not.toHaveBeenCalled() + }) + + it('aborts the chart request when unmounted and ignores late success', async () => { + const charts = deferred>() + api.dashboardCharts.mockReturnValue(charts.promise) + const root = mount() + await settle() + const [range, signal] = api.dashboardCharts.mock.calls[0] as [OverviewRange, AbortSignal] + expect(signal.aborted).toBe(false) + expect(root.textContent).toContain('总请求 12,345') + app?.unmount() + app = undefined + expect(signal.aborted).toBe(true) + const response = dashboardCharts(range) + response.data.series = [{ ...response.data.summary, bucket_start: range.from, request_count: 987654 }] + charts.resolve(response) + await settle() + expect(root.textContent).toBe('') + await vi.advanceTimersByTimeAsync(240_000) + expect(api.dashboardCharts).toHaveBeenCalledTimes(1) + expect(legacyDaily).not.toHaveBeenCalled() }) it('aborts the compact snapshot request when unmounted and ignores late success', async () => { diff --git a/frontend/src/views/shared/__tests__/Dashboard.spec.ts b/frontend/src/views/shared/__tests__/Dashboard.spec.ts index ae397159a..1593abad0 100644 --- a/frontend/src/views/shared/__tests__/Dashboard.spec.ts +++ b/frontend/src/views/shared/__tests__/Dashboard.spec.ts @@ -7,7 +7,7 @@ const dashboardApiMocks = vi.hoisted(() => ({ getStats: vi.fn(), getDailyStats: vi.fn(), })) -const overviewApiMocks = vi.hoisted(() => ({ dashboard: vi.fn(), dashboardTotal: vi.fn(), summary: vi.fn(), dashboardSummary: vi.fn() })) +const overviewApiMocks = vi.hoisted(() => ({ dashboard: vi.fn(), dashboardTotal: vi.fn(), summary: vi.fn(), dashboardSummary: vi.fn(), dashboardCharts: vi.fn() })) const announcementApiMocks = vi.hoisted(() => ({ getAnnouncements: vi.fn(), markAsRead: vi.fn() })) vi.mock('@/api/overview', () => ({ overviewApi: overviewApiMocks })) @@ -130,6 +130,7 @@ beforeEach(() => { overviewApiMocks.dashboardTotal.mockReset() overviewApiMocks.summary.mockReset() overviewApiMocks.dashboardSummary.mockReset() + overviewApiMocks.dashboardCharts.mockReset() dashboardApiMocks.getStats.mockReset() dashboardApiMocks.getDailyStats.mockReset() dashboardApiMocks.getDailyStats.mockResolvedValue({ @@ -176,6 +177,7 @@ describe('Dashboard ordinary user wallet card', () => { expect(overviewApiMocks.dashboardTotal).not.toHaveBeenCalled() expect(overviewApiMocks.summary).not.toHaveBeenCalled() expect(overviewApiMocks.dashboardSummary).not.toHaveBeenCalled() + expect(overviewApiMocks.dashboardCharts).not.toHaveBeenCalled() expect(root.querySelector('#announcements-section')).not.toBeNull() expect(root.querySelector('[role="dialog"]')).toBeNull() expect(root.textContent).toContain('系统公告') @@ -216,6 +218,7 @@ describe('Dashboard refresh controls', () => { expect(root.textContent).not.toContain('自动刷新') expect(dashboardApiMocks.getStats).toHaveBeenCalledTimes(1) expect(dashboardApiMocks.getDailyStats).toHaveBeenCalledTimes(1) + expect(overviewApiMocks.dashboardCharts).not.toHaveBeenCalled() expect(announcementApiMocks.getAnnouncements).not.toHaveBeenCalled() expect(overviewApiMocks.dashboard).not.toHaveBeenCalled()