fix: align dashboard charts with customer billing and history coverage

This commit is contained in:
elky
2026-10-09 21:53:43 +08:00
parent 63520c8c27
commit 6b6d5b3a85
19 changed files with 1561 additions and 153 deletions
@@ -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<OverviewRequest
const OVERVIEW_CHART_LIMIT: u32 = 10_000;
pub fn dashboard_charts_value(snapshot: &StoredUsageAnalytics) -> 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::<Vec<_>>()
};
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"
@@ -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;
@@ -312,6 +312,9 @@ impl SqlxUsageReadRepository {
query: &UsageAnalyticsQuery,
) -> Result<StoredUsageAnalytics, DataLayerError> {
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::<Postgres>::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::<Postgres>::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.
@@ -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<T: DeserializeOwned>(value: Value) -> Result<T, DataLayerError> {
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<StoredUsageAnalytics, DataLayerError> {
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::<Utc>::from_timestamp_millis(query.from_unix_ms as i64)
.expect("validated timestamp");
let to = DateTime::<Utc>::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::<Postgres>::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::<i64, _>("unrecoverable_bucket_count")
.map_postgres_err()? as u64,
read_revision: state.try_get("revision").map_postgres_err()?,
generated_at: state
.try_get::<DateTime<Utc>, _>("generated_at")
.map_postgres_err()?
.to_rfc3339(),
..Default::default()
};
result.summary.enabled_users = state
.try_get::<i64, _>("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)
}
}
@@ -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;
@@ -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<u64>,
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()
@@ -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;
@@ -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,
]
);
@@ -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::<u64>(), 8);
let charges: f64 = rows
.iter()
.filter_map(|r| r.metrics.billable_amount.as_ref())
.map(|v| v.parse::<f64>().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;
}
@@ -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::<DateTime<Utc>>().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;
}
@@ -116,6 +116,7 @@ WHERE version=20260919000000;
20261001000000,
20261004000000,
20261007000000,
20261008000000,
20261009000000,
]
);
@@ -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::<BTreeSet<_>>()
.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::<Vec<_>>();
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
+158 -3
View File
@@ -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<Value> {
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]
+10 -5
View File
@@ -153,13 +153,18 @@ export interface OverviewDashboardSummary {
}
}
export interface OverviewPage<T> { 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<OverviewMetrics, 'usage_active_users' | 'unclassified_failure_count'> {
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
@@ -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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('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<HTMLButtonElement>('[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 })
@@ -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<OverviewDashboardCharts['models'][number], 'id' | 'label' | 'bucket_start' | 'billable_amount'>
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<OverviewDashboardChartMetrics, 'billable_amount'>[]): 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<OverviewDashboardCharts['series'][number], 'bucket_start' | 'billable_amount'>[]; models: ModelRow[] }, unknownLabel: string, otherLabel: string) {
const groups = new Map<string | null, ModelRow[]>()
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<OverviewDashboardCharts['providers'][number], 'label' | 'billable_amount'>[], 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.
+196 -59
View File
@@ -356,12 +356,31 @@
</h3>
<TimeRangePicker
v-model="dailyTimeRange"
:allow-hourly="true"
:allow-hourly="!isAdmin"
:show-granularity="!isAdmin"
/>
</div>
<div
v-if="dailyError"
role="alert"
class="flex items-center justify-between gap-3 rounded-lg border border-border p-4 text-sm text-muted-foreground"
>
<span>{{ dailyError }}</span>
<Button
variant="outline"
size="sm"
@click="loadDailyStats"
>
{{ t('重试', 'Retry') }}
</Button>
</div>
<!-- 趋势图表区域 -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div
v-if="!dailyError"
class="grid grid-cols-1 gap-6 lg:grid-cols-2"
>
<!-- 每日使用趋势(折线图)- 普通用户可见 -->
<Card
v-if="!isAdmin"
@@ -399,7 +418,7 @@
</div>
</Card>
<!-- 每日模型成本(堆叠柱状图)- 仅管理员可见 -->
<!-- 每日模型费用(堆叠柱状图)- 仅管理员可见 -->
<Card
v-if="isAdmin"
class="p-5"
@@ -407,7 +426,7 @@
<h4
class="mb-3 text-xs font-semibold text-foreground uppercase tracking-wider"
>
每日模型成本
每日模型费用
</h4>
<div
v-if="loadingDaily"
@@ -422,7 +441,7 @@
<BarChart
v-if="
dailyModelCostChartData.labels &&
dailyModelCostChartData.labels.length > 0
dailyModelCostChartData.labels.length > 0 && hasDailyModelCost
"
:data="dailyModelCostChartData"
:options="dailyModelCostChartOptions"
@@ -431,12 +450,12 @@
v-else
class="flex h-full items-center justify-center text-xs text-muted-foreground"
>
暂无数据
{{ dailyCostEmptyLabel }}
</div>
</div>
</Card>
<!-- 提供商成本分布(环形图)- 仅管理员可见 -->
<!-- 提供商费用分布(环形图)- 仅管理员可见 -->
<Card
v-if="isAdmin"
class="p-5"
@@ -444,7 +463,7 @@
<h4
class="mb-3 text-xs font-semibold text-foreground uppercase tracking-wider"
>
提供商成本分布
提供商费用分布
</h4>
<div
v-if="loadingDaily"
@@ -468,12 +487,12 @@
v-else
class="flex h-full items-center justify-center text-xs text-muted-foreground"
>
暂无数据
{{ dailyCostEmptyLabel }}
</div>
</div>
</Card>
<!-- 每日模型成本(堆叠柱状图)- 普通用户可见 -->
<!-- 每日模型费用(堆叠柱状图)- 普通用户可见 -->
<Card
v-if="!isAdmin"
class="p-5"
@@ -481,7 +500,7 @@
<h4
class="mb-3 text-xs font-semibold text-foreground uppercase tracking-wider"
>
每日模型成本
每日模型费用
</h4>
<div
v-if="loadingDaily"
@@ -511,8 +530,18 @@
</Card>
</div>
<p
v-if="dailyCostsPartial && !loadingDaily"
class="mt-3 text-xs text-amber-700 dark:text-amber-400"
>
{{ t('统计为已知数据小计,分布占比按已知金额计算', 'Statistics are known subtotals; distribution shares use known amounts') }}
</p>
<!-- 每日统计 -->
<Card class="overflow-hidden mt-6">
<Card
v-if="!dailyError"
class="overflow-hidden mt-6"
>
<!-- 移动端:卡片列表 -->
<div class="sm:hidden">
<div class="px-4 py-3 border-b border-border/60">
@@ -528,7 +557,7 @@
<span class="ml-2 text-muted-foreground text-xs">加载中...</span>
</div>
<div
v-else-if="dailyStats.length === 0"
v-else-if="displayDailyStats.length === 0"
class="py-8 text-center text-muted-foreground text-xs"
>
暂无数据
@@ -538,19 +567,20 @@
class="divide-y divide-border/60"
>
<div
v-for="stat in dailyStats.slice().reverse()"
v-for="stat in displayDailyStats.slice().reverse()"
:key="stat.date"
class="p-4 space-y-2"
>
<div class="flex items-center justify-between">
<span class="font-medium text-sm">{{
formatDate(stat.date)
formatDailyDate(stat.date)
}}</span>
<Badge
variant="success"
class="text-[10px]"
>
${{ stat.cost.toFixed(4) }}
{{ formatDailyCost(stat.cost) }}
<span v-if="stat.billableAmount">{{ amountStatus(stat.billableAmount, t) }}</span>
</Badge>
</div>
<div class="grid grid-cols-2 gap-2 text-xs">
@@ -560,7 +590,7 @@
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Tokens</span>
<span>{{ formatTokens(stat.tokens) }}</span>
<span>{{ compactTokens(stat.tokens) }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">响应</span>
@@ -617,7 +647,7 @@
</div>
</TableCell>
</TableRow>
<TableRow v-else-if="dailyStats.length === 0">
<TableRow v-else-if="displayDailyStats.length === 0">
<TableCell
:colspan="isAdmin ? 7 : 6"
class="text-center py-8 text-muted-foreground text-xs"
@@ -627,11 +657,12 @@
</TableRow>
<template v-else>
<TableRow
v-for="stat in dailyStats.slice().reverse()"
v-for="stat in displayDailyStats.slice().reverse()"
:key="stat.date"
:data-daily-date="stat.date"
>
<TableCell class="font-medium text-xs">
{{ formatDate(stat.date) }}
{{ formatDailyDate(stat.date) }}
</TableCell>
<TableCell class="text-center text-xs">
{{ stat.requests.toLocaleString() }}
@@ -641,7 +672,7 @@
variant="secondary"
class="text-[10px]"
>
{{ formatTokens(stat.tokens) }}
{{ compactTokens(stat.tokens) }}
</Badge>
</TableCell>
<TableCell class="text-center">
@@ -649,7 +680,8 @@
variant="success"
class="text-[10px]"
>
${{ stat.cost.toFixed(4) }}
{{ formatDailyCost(stat.cost) }}
<span v-if="stat.billableAmount">{{ amountStatus(stat.billableAmount, t) }}</span>
</Badge>
</TableCell>
<TableCell class="text-center">
@@ -667,7 +699,7 @@
v-if="isAdmin"
class="text-center text-xs"
>
{{ stat.unique_providers }}
{{ stat.unique_providers ?? '—' }}
</TableCell>
</TableRow>
</template>
@@ -676,8 +708,9 @@
<!-- 汇总信息 -->
<div
v-if="dailyStats.length > 0"
v-if="displayDailyStats.length > 0 && !loadingDaily"
class="border-t border-border bg-muted/30 backdrop-blur-sm px-4 py-3 text-xs"
data-daily-total
>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div class="text-center">
@@ -693,7 +726,7 @@
总Tokens
</div>
<div class="font-semibold text-book-cloth dark:text-kraft">
{{ formatTokens(totalStats.tokens) }}
{{ compactTokens(totalStats.tokens) }}
</div>
</div>
<div class="text-center">
@@ -701,7 +734,8 @@
总费用
</div>
<div class="font-semibold text-amber-600 dark:text-amber-400">
${{ totalStats.cost.toFixed(4) }}
{{ formatDailyCost(totalStats.cost) }}
<span v-if="adminDailyCharts">{{ amountStatus(adminDailyCharts.summary.billable_amount, t) }}</span>
</div>
</div>
<div class="text-center">
@@ -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<DailyStat[]>([]);
const providerSummary = ref<ProviderSummary[]>([]);
const adminDailyCharts = ref<OverviewDashboardCharts | null>(null);
const adminDailyRange = ref<OverviewRange | null>(null);
type DisplayDailyStat = Omit<DailyStat, 'tokens' | 'cost' | 'avg_response_time' | 'model_breakdown' | 'unique_providers'> & {
tokens: number | null;
cost: number | null;
avg_response_time: number | null;
unique_providers?: number | null;
billableAmount?: OverviewAmount;
};
const displayDailyStats = computed<DisplayDailyStat[]>(() => {
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<DateRangeParams>(
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<void> | null = null;
let hasPendingDailyStatsLoad = false;
let dailyStatsController: AbortController | null = null;
let dailyStatsDebounceTimer: ReturnType<typeof setTimeout> | 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<ChartData<"bar">>(() => {
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<ChartData<"bar">>(() => {
};
});
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<ChartOptions<"bar">>(() => ({
responsive: true,
maintainAspectRatio: false,
@@ -1106,7 +1199,7 @@ const dailyModelCostChartOptions = computed<ChartOptions<"bar">>(() => ({
},
}));
// 提供商成本分布(环形图)
// 提供商费用分布(环形图)
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<ChartData<"doughnut">>(() => {
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`;
@@ -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<OverviewDashboardCharts> {
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<T>() {
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<OverviewResponse<OverviewDashboardCharts>>()
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 () => {
@@ -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()