mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 03:09:50 +08:00
fix: align dashboard charts with customer billing and history coverage
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
use super::{envelope, metrics_value, parse_overview_query, OverviewRequest};
|
use super::{envelope, metrics_value, parse_overview_query, OverviewRequest};
|
||||||
use aether_data_contracts::repository::usage::{
|
use aether_data_contracts::repository::usage::{
|
||||||
StoredUsageAnalytics, StoredUsageDashboardAnalytics, UsageAnalyticsQuery, UsageAnalyticsRow,
|
StoredUsageAnalytics, StoredUsageDashboardAnalytics, UsageAnalyticsMetrics,
|
||||||
UsageAnalyticsView, UsageDashboardAnalyticsQuery,
|
UsageAnalyticsQuery, UsageAnalyticsRow, UsageAnalyticsView, UsageDashboardAnalyticsQuery,
|
||||||
};
|
};
|
||||||
use chrono::DateTime;
|
use chrono::DateTime;
|
||||||
use serde_json::{json, Value};
|
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;
|
const OVERVIEW_CHART_LIMIT: u32 = 10_000;
|
||||||
|
|
||||||
pub fn dashboard_charts_value(snapshot: &StoredUsageAnalytics) -> Value {
|
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]| {
|
let rows = |items: &[UsageAnalyticsRow]| {
|
||||||
items
|
items
|
||||||
.iter()
|
.iter()
|
||||||
.map(|row| {
|
.map(|row| {
|
||||||
let mut value = metrics_value(&row.metrics);
|
let mut value = chart_metrics(&row.metrics);
|
||||||
if snapshot.unrecoverable_bucket_count > 0 {
|
if snapshot.unrecoverable_bucket_count > 0 {
|
||||||
mark_incomplete_amounts(&mut value);
|
mark_incomplete_amounts(&mut value);
|
||||||
}
|
}
|
||||||
value["id"] = json!(row.id);
|
value["id"] = json!(row.id);
|
||||||
value["label"] = json!(row.label);
|
value["label"] = json!(row.label);
|
||||||
value["bucket_start"] = json!(row.bucket_start);
|
value["bucket_start"] = json!(row.bucket_start);
|
||||||
|
value["unique_providers"] = json!(row.metrics.unique_providers);
|
||||||
value
|
value
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
};
|
};
|
||||||
let mut summary = metrics_value(&snapshot.summary);
|
let mut summary = chart_metrics(&snapshot.summary);
|
||||||
if snapshot.unrecoverable_bucket_count > 0 {
|
if snapshot.unrecoverable_bucket_count > 0 {
|
||||||
mark_incomplete_amounts(&mut summary);
|
mark_incomplete_amounts(&mut summary);
|
||||||
}
|
}
|
||||||
@@ -293,6 +313,9 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
snapshot.summary.billable_amount = Some("2.00000000".into());
|
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 {
|
let row = UsageAnalyticsRow {
|
||||||
id: Some("model-1".into()),
|
id: Some("model-1".into()),
|
||||||
label: None,
|
label: None,
|
||||||
@@ -303,6 +326,25 @@ mod tests {
|
|||||||
snapshot.model_rows.push(row.clone());
|
snapshot.model_rows.push(row.clone());
|
||||||
snapshot.provider_rows.push(row);
|
snapshot.provider_rows.push(row);
|
||||||
let data = dashboard_charts_value(&snapshot);
|
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!(
|
assert_eq!(
|
||||||
data["summary"]["billable_amount"]["status"],
|
data["summary"]["billable_amount"]["status"],
|
||||||
"known_subtotal"
|
"known_subtotal"
|
||||||
|
|||||||
+3
@@ -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,
|
query: &UsageAnalyticsQuery,
|
||||||
) -> Result<StoredUsageAnalytics, DataLayerError> {
|
) -> Result<StoredUsageAnalytics, DataLayerError> {
|
||||||
query.validate()?;
|
query.validate()?;
|
||||||
|
if query.view == UsageAnalyticsView::DashboardCharts {
|
||||||
|
return self.query_dashboard_charts(query).await;
|
||||||
|
}
|
||||||
let mut tx = self.pool.begin().await.map_postgres_err()?;
|
let mut tx = self.pool.begin().await.map_postgres_err()?;
|
||||||
sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
|
sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
@@ -370,7 +373,6 @@ impl SqlxUsageReadRepository {
|
|||||||
match query.view {
|
match query.view {
|
||||||
UsageAnalyticsView::Timeseries
|
UsageAnalyticsView::Timeseries
|
||||||
| UsageAnalyticsView::Performance
|
| UsageAnalyticsView::Performance
|
||||||
| UsageAnalyticsView::DashboardCharts
|
|
||||||
| UsageAnalyticsView::Breakdown => {
|
| UsageAnalyticsView::Breakdown => {
|
||||||
let timeseries = query.view != UsageAnalyticsView::Breakdown;
|
let timeseries = query.view != UsageAnalyticsView::Breakdown;
|
||||||
// 提供商分组的行需要额外带出名称快照,并在最外层关联提供商目录解析展示名。
|
// 提供商分组的行需要额外带出名称快照,并在最外层关联提供商目录解析展示名。
|
||||||
@@ -531,29 +533,21 @@ impl SqlxUsageReadRepository {
|
|||||||
.map_postgres_err()?;
|
.map_postgres_err()?;
|
||||||
result.consumption = decode(row.try_get("items").map_postgres_err()?)?;
|
result.consumption = decode(row.try_get("items").map_postgres_err()?)?;
|
||||||
}
|
}
|
||||||
UsageAnalyticsView::Summary => unreachable!(),
|
UsageAnalyticsView::Summary | UsageAnalyticsView::DashboardCharts => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if matches!(
|
if matches!(
|
||||||
query.view,
|
query.view,
|
||||||
UsageAnalyticsView::Timeseries
|
UsageAnalyticsView::Timeseries | UsageAnalyticsView::Performance
|
||||||
| UsageAnalyticsView::Performance
|
|
||||||
| UsageAnalyticsView::DashboardCharts
|
|
||||||
) {
|
) {
|
||||||
fill_usage_analytics_timeseries(query, &mut result.rows);
|
fill_usage_analytics_timeseries(query, &mut result.rows);
|
||||||
result.total = result.rows.len() as u64;
|
result.total = result.rows.len() as u64;
|
||||||
}
|
}
|
||||||
if matches!(
|
if query.view == UsageAnalyticsView::Performance {
|
||||||
query.view,
|
|
||||||
UsageAnalyticsView::Performance | UsageAnalyticsView::DashboardCharts
|
|
||||||
) {
|
|
||||||
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, ");
|
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);
|
providers.push(&metrics_sql);
|
||||||
push_analytics_filter(&mut providers, query);
|
push_analytics_filter(&mut providers, query);
|
||||||
providers.push(" GROUP BY provider_id ORDER BY count(*) DESC,provider_id");
|
providers.push(" GROUP BY provider_id ORDER BY count(*) DESC,provider_id");
|
||||||
if query.view == UsageAnalyticsView::DashboardCharts {
|
|
||||||
providers.push(" LIMIT 10001");
|
|
||||||
}
|
|
||||||
providers.push(") m");
|
providers.push(") m");
|
||||||
let row = providers
|
let row = providers
|
||||||
.build()
|
.build()
|
||||||
@@ -561,38 +555,6 @@ impl SqlxUsageReadRepository {
|
|||||||
.await
|
.await
|
||||||
.map_postgres_err()?;
|
.map_postgres_err()?;
|
||||||
result.provider_rows = decode(row.try_get("items").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 {
|
if query.view == UsageAnalyticsView::Performance {
|
||||||
// Aggregate requested models across providers in the same read snapshot.
|
// 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;
|
mod attribution;
|
||||||
pub mod cleanup;
|
pub mod cleanup;
|
||||||
mod dashboard;
|
mod dashboard;
|
||||||
|
mod dashboard_charts;
|
||||||
mod dashboard_history;
|
mod dashboard_history;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod dashboard_history_tests;
|
mod dashboard_history_tests;
|
||||||
|
|||||||
@@ -196,6 +196,8 @@ impl UsageAnalyticsQuery {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct UsageAnalyticsMetrics {
|
pub struct UsageAnalyticsMetrics {
|
||||||
pub request_count: u64,
|
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 successful_request_count: u64,
|
||||||
pub failed_request_count: u64,
|
pub failed_request_count: u64,
|
||||||
pub cancelled_request_count: u64,
|
pub cancelled_request_count: u64,
|
||||||
@@ -429,6 +431,7 @@ pub fn fill_usage_analytics_timeseries(
|
|||||||
label: Some(start.clone()),
|
label: Some(start.clone()),
|
||||||
bucket_start: Some(start),
|
bucket_start: Some(start),
|
||||||
metrics: UsageAnalyticsMetrics {
|
metrics: UsageAnalyticsMetrics {
|
||||||
|
unique_providers: Some(0),
|
||||||
rated_amount: Some("0.00000000".into()),
|
rated_amount: Some("0.00000000".into()),
|
||||||
billable_amount: Some("0.00000000".into()),
|
billable_amount: Some("0.00000000".into()),
|
||||||
..Default::default()
|
..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.
|
-- procurement cost remains in actual_total_cost_usd for legacy reporting.
|
||||||
CREATE OR REPLACE FUNCTION public.usage_customer_billable_amount(
|
CREATE OR REPLACE FUNCTION public.usage_customer_billable_amount(
|
||||||
metadata jsonb, base_cost numeric, legacy_cost numeric
|
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;
|
DECLARE factor jsonb; multiplier numeric; amount numeric;
|
||||||
factor_name text; factor_value jsonb; factor_number double precision;
|
factor_name text; factor_value jsonb; factor_number double precision;
|
||||||
expected_multiplier double precision := 1.0; factor_count integer := 0;
|
expected_multiplier double precision := 1.0; factor_count integer := 0;
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ use crate::lifecycle::bootstrap::postgres::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
mod customer_billing_upgrade;
|
mod customer_billing_upgrade;
|
||||||
|
mod dashboard_chart_billing;
|
||||||
|
mod dashboard_chart_history;
|
||||||
mod dashboard_user_anonymization;
|
mod dashboard_user_anonymization;
|
||||||
mod legacy_overview_upgrade;
|
mod legacy_overview_upgrade;
|
||||||
mod migration_deadlines;
|
mod migration_deadlines;
|
||||||
@@ -1600,6 +1602,7 @@ fn pending_migrations_from_applied_skips_versions_already_applied() {
|
|||||||
20261001000000,
|
20261001000000,
|
||||||
20261004000000,
|
20261004000000,
|
||||||
20261007000000,
|
20261007000000,
|
||||||
|
20261008000000,
|
||||||
20261009000000,
|
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,
|
20261001000000,
|
||||||
20261004000000,
|
20261004000000,
|
||||||
20261007000000,
|
20261007000000,
|
||||||
|
20261008000000,
|
||||||
20261009000000,
|
20261009000000,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -435,6 +435,20 @@ impl InMemoryUsageReadRepository {
|
|||||||
let metrics = |rows: &[&StoredRequestUsageAudit], slow| {
|
let metrics = |rows: &[&StoredRequestUsageAudit], slow| {
|
||||||
let mut result = metrics(rows, slow, &keys);
|
let mut result = metrics(rows, slow, &keys);
|
||||||
apply_allocations(&mut result, rows, &allocations);
|
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
|
result
|
||||||
};
|
};
|
||||||
let mut summary = metrics(&filtered, query.slow_threshold_ms.unwrap_or(5000));
|
let mut summary = metrics(&filtered, query.slow_threshold_ms.unwrap_or(5000));
|
||||||
@@ -682,17 +696,39 @@ impl InMemoryUsageReadRepository {
|
|||||||
&& query.group_by == UsageAnalyticsGroupBy::Provider;
|
&& query.group_by == UsageAnalyticsGroupBy::Provider;
|
||||||
let mut grouped = groups
|
let mut grouped = groups
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(id, rows)| UsageAnalyticsRow {
|
.map(|(id, rows)| {
|
||||||
label: if provider_breakdown {
|
let mut metrics = metrics(&rows, query.slow_threshold_ms.unwrap_or(5000));
|
||||||
provider_display_label(&rows, id.as_deref())
|
if query.view == UsageAnalyticsView::DashboardCharts {
|
||||||
} else {
|
metrics.unique_providers = Some(
|
||||||
id.clone()
|
rows.iter()
|
||||||
},
|
.filter_map(|row| {
|
||||||
bucket_start: (query.view != UsageAnalyticsView::Breakdown)
|
row.provider_id
|
||||||
.then(|| id.clone())
|
.as_deref()
|
||||||
.flatten(),
|
.filter(|id| !id.is_empty())
|
||||||
id,
|
.or_else(|| {
|
||||||
metrics: metrics(&rows, query.slow_threshold_ms.unwrap_or(5000)),
|
(!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<_>>();
|
.collect::<Vec<_>>();
|
||||||
if query.view == UsageAnalyticsView::Breakdown {
|
if query.view == UsageAnalyticsView::Breakdown {
|
||||||
@@ -754,7 +790,14 @@ impl InMemoryUsageReadRepository {
|
|||||||
.today_start(at)?
|
.today_start(at)?
|
||||||
};
|
};
|
||||||
providers
|
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()
|
.or_default()
|
||||||
.push(row);
|
.push(row);
|
||||||
models
|
models
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ use std::collections::BTreeMap;
|
|||||||
use aether_ai_formats::UPSTREAM_IS_STREAM_KEY;
|
use aether_ai_formats::UPSTREAM_IS_STREAM_KEY;
|
||||||
use aether_contracts::{ExecutionPlan, ExecutionTelemetry};
|
use aether_contracts::{ExecutionPlan, ExecutionTelemetry};
|
||||||
use aether_data_contracts::repository::usage::{
|
use aether_data_contracts::repository::usage::{
|
||||||
UpsertUsageRecord, UsageBodyCaptureState, LIVE_SESSION_METADATA_KEY,
|
UpsertUsageRecord, UsageBodyCaptureState, BILLING_MULTIPLIER_SNAPSHOT_METADATA_KEY,
|
||||||
USAGE_AVAILABLE_METADATA_KEY, USAGE_PRICING_AVAILABLE_METADATA_KEY,
|
LIVE_SESSION_METADATA_KEY, ROUTING_GROUP_BILLING_MULTIPLIER_METADATA_KEY,
|
||||||
WEBSOCKET_MODE_METADATA_KEY, WEBSOCKET_TRANSPORT_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 aether_data_contracts::DataLayerError;
|
||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
@@ -2214,6 +2216,23 @@ fn build_runtime_request_metadata_seed_from_parts(
|
|||||||
provider_request_body_base64: Option<&str>,
|
provider_request_body_base64: Option<&str>,
|
||||||
) -> Option<Value> {
|
) -> Option<Value> {
|
||||||
let mut metadata = Map::new();
|
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"] {
|
for key in ["analytics_attribution", "analytics_failure"] {
|
||||||
if let Some(value) = context_value_ref(context, key) {
|
if let Some(value) = context_value_ref(context, key) {
|
||||||
metadata.insert(key.into(), value.clone());
|
metadata.insert(key.into(), value.clone());
|
||||||
@@ -3927,6 +3946,14 @@ mod tests {
|
|||||||
Some(&json!({
|
Some(&json!({
|
||||||
"candidate_id": "cand-pending-event-1",
|
"candidate_id": "cand-pending-event-1",
|
||||||
"candidate_index": 3,
|
"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_mode": true,
|
||||||
"websocket_transport": "responses",
|
"websocket_transport": "responses",
|
||||||
"original_request_body": {"messages": [{"content": "omit me"}]},
|
"original_request_body": {"messages": [{"content": "omit me"}]},
|
||||||
@@ -3951,6 +3978,17 @@ mod tests {
|
|||||||
assert!(record.provider_request_body.is_none());
|
assert!(record.provider_request_body.is_none());
|
||||||
assert_eq!(record.candidate_id.as_deref(), Some("cand-pending-event-1"));
|
assert_eq!(record.candidate_id.as_deref(), Some("cand-pending-event-1"));
|
||||||
assert_eq!(record.candidate_index, Some(3));
|
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!(
|
assert_eq!(
|
||||||
record
|
record
|
||||||
.request_metadata
|
.request_metadata
|
||||||
@@ -3999,6 +4037,14 @@ mod tests {
|
|||||||
Some(&json!({
|
Some(&json!({
|
||||||
"candidate_id": "cand-streaming-event-1",
|
"candidate_id": "cand-streaming-event-1",
|
||||||
"candidate_index": 4,
|
"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"}
|
"provider_request_body": {"input": "omit me"}
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
@@ -4023,6 +4069,115 @@ mod tests {
|
|||||||
Some("cand-streaming-event-1")
|
Some("cand-streaming-event-1")
|
||||||
);
|
);
|
||||||
assert_eq!(record.candidate_index, Some(4));
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -153,13 +153,18 @@ export interface OverviewDashboardSummary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
export interface OverviewPage<T> { items: T[]; total: number; limit: number; offset: number }
|
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 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 {
|
export interface OverviewDashboardCharts {
|
||||||
summary: OverviewMetrics
|
summary: OverviewDashboardChartMetrics
|
||||||
series: OverviewSeriesPoint[]
|
series: (OverviewDashboardChartMetrics & { bucket_start: string; unique_providers?: number | null })[]
|
||||||
models: (OverviewBreakdown & { bucket_start: string })[]
|
models: (OverviewDashboardChartMetrics & { id: string | null; label: string | null; bucket_start: string })[]
|
||||||
providers: OverviewBreakdown[]
|
providers: (OverviewDashboardChartMetrics & { id: string | null; label: string | null })[]
|
||||||
}
|
}
|
||||||
export interface OverviewEmployee extends OverviewMetrics {
|
export interface OverviewEmployee extends OverviewMetrics {
|
||||||
user_id: string
|
user_id: string
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { createApp, nextTick, type App } from 'vue'
|
|||||||
import { createPinia, disposePinia, type Pinia } from 'pinia'
|
import { createPinia, disposePinia, type Pinia } from 'pinia'
|
||||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
import AdminDashboard from '@/views/admin/AdminDashboard.vue'
|
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 { setI18nLocale } from '@/i18n'
|
||||||
import { dayRange, modelDatasets, providerSlices } from '../dashboard/charts'
|
import { dashboardChartRange, dayRange, modelDatasets, providerSlices } from '../dashboard/charts'
|
||||||
import { dashboardSummary } from './fixtures/dashboardSummary'
|
import { dashboardSummary } from './fixtures/dashboardSummary'
|
||||||
import { useAnnouncementStore } from '@/stores/announcements'
|
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 legacyApi = vi.hoisted(() => ({ getStats: vi.fn(), getDailyStats: vi.fn() }))
|
||||||
const announcementApi = vi.hoisted(() => ({ getAnnouncements: vi.fn(), getUserAnnouncements: vi.fn(), markAsRead: 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 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/overview', () => ({ overviewApi: api }))
|
||||||
vi.mock('@/api/dashboard', () => ({ dashboardApi: legacyApi }))
|
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('@/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('@/stores/auth', () => ({ useAuthStore: () => auth }))
|
||||||
vi.mock('@/api/announcements', () => ({ announcementApi }))
|
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/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 } = await import('vue'); return { default: { render: () => h('div', { 'data-chart': 'doughnut' }) } } })
|
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/charts/LineChart.vue', async () => { const { h } = await import('vue'); return { default: { render: () => h('div', { 'data-chart': 'line' }) } } })
|
||||||
vi.mock('@/components/common', async () => {
|
vi.mock('@/components/common', async () => {
|
||||||
const { defineComponent, h } = await import('vue')
|
const { defineComponent, h } = await import('vue')
|
||||||
return { TimeRangePicker: defineComponent({
|
return { TimeRangePicker: defineComponent({
|
||||||
props: { modelValue: { type: Object, default: () => ({}) } }, emits: ['update:modelValue'],
|
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 () => {
|
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 amount = (value: string | null, status = value === null ? 'unknown' : 'known') => ({ value, status, currency: 'USD', basis: 'billable' })
|
||||||
const metrics = (request_count = 0): OverviewMetrics => ({
|
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: 0,
|
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: request_count * 10, output_tokens: request_count * 10, total_tokens: request_count * 20, usage_active_users: 3, enabled_users: 8,
|
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 },
|
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'),
|
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() })))
|
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })))
|
||||||
setI18nLocale('zh-CN')
|
setI18nLocale('zh-CN')
|
||||||
vi.resetAllMocks()
|
vi.resetAllMocks()
|
||||||
|
picker.next = null
|
||||||
Object.assign(auth, { canAccessAdmin: true, isAdmin: true, isAuditAdmin: false })
|
Object.assign(auth, { canAccessAdmin: true, isAdmin: true, isAuditAdmin: false })
|
||||||
api.dashboardSummary.mockResolvedValue(dashboardSummary())
|
api.dashboardSummary.mockResolvedValue(dashboardSummary())
|
||||||
|
api.dashboardCharts.mockResolvedValue({ data: charts() })
|
||||||
announcementApi.getUserAnnouncements.mockResolvedValue({ items: [], total: 0, unread_count: 0 })
|
announcementApi.getUserAnnouncements.mockResolvedValue({ items: [], total: 0, unread_count: 0 })
|
||||||
legacyApi.getStats.mockResolvedValue({
|
legacyApi.getStats.mockResolvedValue({
|
||||||
stats: [{ name: 'Legacy request metric', value: '999999', icon: 'Activity' }],
|
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.dashboardSummary).toHaveBeenCalledTimes(1)
|
||||||
expect(api.summary).not.toHaveBeenCalled()
|
expect(api.summary).not.toHaveBeenCalled()
|
||||||
expect(api.dashboardTotal).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.getStats).not.toHaveBeenCalled()
|
||||||
|
expect(legacyApi.getDailyStats).not.toHaveBeenCalled()
|
||||||
expect(announcementApi.getAnnouncements).not.toHaveBeenCalled()
|
expect(announcementApi.getAnnouncements).not.toHaveBeenCalled()
|
||||||
expect(announcementApi.getUserAnnouncements).toHaveBeenCalledExactlyOnceWith({ limit: 20, offset: 0 })
|
expect(announcementApi.getUserAnnouncements).toHaveBeenCalledExactlyOnceWith({ limit: 20, offset: 0 })
|
||||||
expect(root.querySelector('#announcements-section')?.textContent).toContain('系统公告')
|
expect(root.querySelector('#announcements-section')?.textContent).toContain('系统公告')
|
||||||
@@ -167,7 +173,9 @@ describe('administrator dashboard compact presentation', () => {
|
|||||||
root.querySelector<HTMLButtonElement>('[data-period-control]')!.click()
|
root.querySelector<HTMLButtonElement>('[data-period-control]')!.click()
|
||||||
await new Promise(resolve => setTimeout(resolve, 150))
|
await new Promise(resolve => setTimeout(resolve, 150))
|
||||||
await settle()
|
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(api.dashboardSummary).toHaveBeenCalledTimes(1)
|
||||||
expect(card(root, '今日请求')?.textContent).toContain('12,345')
|
expect(card(root, '今日请求')?.textContent).toContain('12,345')
|
||||||
})
|
})
|
||||||
@@ -231,9 +239,173 @@ describe('administrator dashboard compact presentation', () => {
|
|||||||
}
|
}
|
||||||
expect(root.querySelector('[data-chart="bar"]')).not.toBeNull()
|
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', () => {
|
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', () => {
|
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)) }))
|
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) })
|
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] })
|
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', () => {
|
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' }
|
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 })
|
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 { getI18nLocale } from '@/i18n'
|
||||||
import { zonedInput, zonedInstant } from '../query'
|
import { browserTimezone, zonedInput, zonedInstant } from '../query'
|
||||||
import { amountValue } from './amount'
|
import { amountValue } from './amount'
|
||||||
|
|
||||||
export const chartColors = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4', '#94a3b8']
|
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 {
|
export function chartDate(value: string, timezone: string): string {
|
||||||
return new Intl.DateTimeFormat(getI18nLocale(), { timeZone: timezone, month: 'short', day: 'numeric' }).format(new Date(value))
|
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
|
if (!rows.length) return 0
|
||||||
const values = rows.map(row => amountValue(row.billable_amount)).filter((value): value is number => value !== null)
|
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
|
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[]>()
|
const groups = new Map<string | null, ModelRow[]>()
|
||||||
for (const row of data.models) {
|
for (const row of data.models) {
|
||||||
const rows = groups.get(row.id)
|
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) })
|
if (sorted.length > 6) visible.push({ id: '__other__', label: otherLabel, rows: sorted.slice(6).flatMap(([, rows]) => rows) })
|
||||||
return visible.map((group, index) => ({
|
return visible.map((group, index) => ({
|
||||||
label: group.label,
|
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],
|
backgroundColor: chartColors[index % chartColors.length],
|
||||||
borderRadius: 2, stack: 'models', barPercentage: 0.6, categoryPercentage: 0.7,
|
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) }))
|
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)
|
.filter((row): row is { label: string; value: number } => row.value !== null && row.value > 0)
|
||||||
.sort((a, b) => b.value - a.value)
|
.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 {
|
function localDayStart(date: string, timezone: string): string {
|
||||||
const midnight = Date.parse(`${date}T00:00:00Z`)
|
const midnight = Date.parse(`${date}T00:00:00Z`)
|
||||||
// Some timezone transitions skip midnight; start at the first existing local minute.
|
// Some timezone transitions skip midnight; start at the first existing local minute.
|
||||||
|
|||||||
@@ -356,12 +356,31 @@
|
|||||||
</h3>
|
</h3>
|
||||||
<TimeRangePicker
|
<TimeRangePicker
|
||||||
v-model="dailyTimeRange"
|
v-model="dailyTimeRange"
|
||||||
:allow-hourly="true"
|
:allow-hourly="!isAdmin"
|
||||||
|
:show-granularity="!isAdmin"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<Card
|
||||||
v-if="!isAdmin"
|
v-if="!isAdmin"
|
||||||
@@ -399,7 +418,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- 每日模型成本(堆叠柱状图)- 仅管理员可见 -->
|
<!-- 每日模型费用(堆叠柱状图)- 仅管理员可见 -->
|
||||||
<Card
|
<Card
|
||||||
v-if="isAdmin"
|
v-if="isAdmin"
|
||||||
class="p-5"
|
class="p-5"
|
||||||
@@ -407,7 +426,7 @@
|
|||||||
<h4
|
<h4
|
||||||
class="mb-3 text-xs font-semibold text-foreground uppercase tracking-wider"
|
class="mb-3 text-xs font-semibold text-foreground uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
每日模型成本
|
每日模型费用
|
||||||
</h4>
|
</h4>
|
||||||
<div
|
<div
|
||||||
v-if="loadingDaily"
|
v-if="loadingDaily"
|
||||||
@@ -422,7 +441,7 @@
|
|||||||
<BarChart
|
<BarChart
|
||||||
v-if="
|
v-if="
|
||||||
dailyModelCostChartData.labels &&
|
dailyModelCostChartData.labels &&
|
||||||
dailyModelCostChartData.labels.length > 0
|
dailyModelCostChartData.labels.length > 0 && hasDailyModelCost
|
||||||
"
|
"
|
||||||
:data="dailyModelCostChartData"
|
:data="dailyModelCostChartData"
|
||||||
:options="dailyModelCostChartOptions"
|
:options="dailyModelCostChartOptions"
|
||||||
@@ -431,12 +450,12 @@
|
|||||||
v-else
|
v-else
|
||||||
class="flex h-full items-center justify-center text-xs text-muted-foreground"
|
class="flex h-full items-center justify-center text-xs text-muted-foreground"
|
||||||
>
|
>
|
||||||
暂无数据
|
{{ dailyCostEmptyLabel }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- 提供商成本分布(环形图)- 仅管理员可见 -->
|
<!-- 提供商费用分布(环形图)- 仅管理员可见 -->
|
||||||
<Card
|
<Card
|
||||||
v-if="isAdmin"
|
v-if="isAdmin"
|
||||||
class="p-5"
|
class="p-5"
|
||||||
@@ -444,7 +463,7 @@
|
|||||||
<h4
|
<h4
|
||||||
class="mb-3 text-xs font-semibold text-foreground uppercase tracking-wider"
|
class="mb-3 text-xs font-semibold text-foreground uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
提供商成本分布
|
提供商费用分布
|
||||||
</h4>
|
</h4>
|
||||||
<div
|
<div
|
||||||
v-if="loadingDaily"
|
v-if="loadingDaily"
|
||||||
@@ -468,12 +487,12 @@
|
|||||||
v-else
|
v-else
|
||||||
class="flex h-full items-center justify-center text-xs text-muted-foreground"
|
class="flex h-full items-center justify-center text-xs text-muted-foreground"
|
||||||
>
|
>
|
||||||
暂无数据
|
{{ dailyCostEmptyLabel }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- 每日模型成本(堆叠柱状图)- 普通用户可见 -->
|
<!-- 每日模型费用(堆叠柱状图)- 普通用户可见 -->
|
||||||
<Card
|
<Card
|
||||||
v-if="!isAdmin"
|
v-if="!isAdmin"
|
||||||
class="p-5"
|
class="p-5"
|
||||||
@@ -481,7 +500,7 @@
|
|||||||
<h4
|
<h4
|
||||||
class="mb-3 text-xs font-semibold text-foreground uppercase tracking-wider"
|
class="mb-3 text-xs font-semibold text-foreground uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
每日模型成本
|
每日模型费用
|
||||||
</h4>
|
</h4>
|
||||||
<div
|
<div
|
||||||
v-if="loadingDaily"
|
v-if="loadingDaily"
|
||||||
@@ -511,8 +530,18 @@
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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="sm:hidden">
|
||||||
<div class="px-4 py-3 border-b border-border/60">
|
<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>
|
<span class="ml-2 text-muted-foreground text-xs">加载中...</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="dailyStats.length === 0"
|
v-else-if="displayDailyStats.length === 0"
|
||||||
class="py-8 text-center text-muted-foreground text-xs"
|
class="py-8 text-center text-muted-foreground text-xs"
|
||||||
>
|
>
|
||||||
暂无数据
|
暂无数据
|
||||||
@@ -538,19 +567,20 @@
|
|||||||
class="divide-y divide-border/60"
|
class="divide-y divide-border/60"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="stat in dailyStats.slice().reverse()"
|
v-for="stat in displayDailyStats.slice().reverse()"
|
||||||
:key="stat.date"
|
:key="stat.date"
|
||||||
class="p-4 space-y-2"
|
class="p-4 space-y-2"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-medium text-sm">{{
|
<span class="font-medium text-sm">{{
|
||||||
formatDate(stat.date)
|
formatDailyDate(stat.date)
|
||||||
}}</span>
|
}}</span>
|
||||||
<Badge
|
<Badge
|
||||||
variant="success"
|
variant="success"
|
||||||
class="text-[10px]"
|
class="text-[10px]"
|
||||||
>
|
>
|
||||||
${{ stat.cost.toFixed(4) }}
|
{{ formatDailyCost(stat.cost) }}
|
||||||
|
<span v-if="stat.billableAmount">{{ amountStatus(stat.billableAmount, t) }}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||||
@@ -560,7 +590,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-muted-foreground">Tokens</span>
|
<span class="text-muted-foreground">Tokens</span>
|
||||||
<span>{{ formatTokens(stat.tokens) }}</span>
|
<span>{{ compactTokens(stat.tokens) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-muted-foreground">响应</span>
|
<span class="text-muted-foreground">响应</span>
|
||||||
@@ -617,7 +647,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow v-else-if="dailyStats.length === 0">
|
<TableRow v-else-if="displayDailyStats.length === 0">
|
||||||
<TableCell
|
<TableCell
|
||||||
:colspan="isAdmin ? 7 : 6"
|
:colspan="isAdmin ? 7 : 6"
|
||||||
class="text-center py-8 text-muted-foreground text-xs"
|
class="text-center py-8 text-muted-foreground text-xs"
|
||||||
@@ -627,11 +657,12 @@
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<TableRow
|
<TableRow
|
||||||
v-for="stat in dailyStats.slice().reverse()"
|
v-for="stat in displayDailyStats.slice().reverse()"
|
||||||
:key="stat.date"
|
:key="stat.date"
|
||||||
|
:data-daily-date="stat.date"
|
||||||
>
|
>
|
||||||
<TableCell class="font-medium text-xs">
|
<TableCell class="font-medium text-xs">
|
||||||
{{ formatDate(stat.date) }}
|
{{ formatDailyDate(stat.date) }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="text-center text-xs">
|
<TableCell class="text-center text-xs">
|
||||||
{{ stat.requests.toLocaleString() }}
|
{{ stat.requests.toLocaleString() }}
|
||||||
@@ -641,7 +672,7 @@
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
class="text-[10px]"
|
class="text-[10px]"
|
||||||
>
|
>
|
||||||
{{ formatTokens(stat.tokens) }}
|
{{ compactTokens(stat.tokens) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="text-center">
|
<TableCell class="text-center">
|
||||||
@@ -649,7 +680,8 @@
|
|||||||
variant="success"
|
variant="success"
|
||||||
class="text-[10px]"
|
class="text-[10px]"
|
||||||
>
|
>
|
||||||
${{ stat.cost.toFixed(4) }}
|
{{ formatDailyCost(stat.cost) }}
|
||||||
|
<span v-if="stat.billableAmount">{{ amountStatus(stat.billableAmount, t) }}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="text-center">
|
<TableCell class="text-center">
|
||||||
@@ -667,7 +699,7 @@
|
|||||||
v-if="isAdmin"
|
v-if="isAdmin"
|
||||||
class="text-center text-xs"
|
class="text-center text-xs"
|
||||||
>
|
>
|
||||||
{{ stat.unique_providers }}
|
{{ stat.unique_providers ?? '—' }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</template>
|
</template>
|
||||||
@@ -676,8 +708,9 @@
|
|||||||
|
|
||||||
<!-- 汇总信息 -->
|
<!-- 汇总信息 -->
|
||||||
<div
|
<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"
|
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="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
@@ -693,7 +726,7 @@
|
|||||||
总Tokens
|
总Tokens
|
||||||
</div>
|
</div>
|
||||||
<div class="font-semibold text-book-cloth dark:text-kraft">
|
<div class="font-semibold text-book-cloth dark:text-kraft">
|
||||||
{{ formatTokens(totalStats.tokens) }}
|
{{ compactTokens(totalStats.tokens) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
@@ -701,7 +734,8 @@
|
|||||||
总费用
|
总费用
|
||||||
</div>
|
</div>
|
||||||
<div class="font-semibold text-amber-600 dark:text-amber-400">
|
<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>
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
@@ -741,8 +775,10 @@ import {
|
|||||||
type DailyStat,
|
type DailyStat,
|
||||||
type ProviderSummary,
|
type ProviderSummary,
|
||||||
} from "@/api/dashboard";
|
} from "@/api/dashboard";
|
||||||
import { overviewApi, type OverviewDashboardSummary } from "@/api/overview";
|
import { overviewApi, type OverviewAmount, type OverviewDashboardCharts, type OverviewDashboardSummary, type OverviewRange } from "@/api/overview";
|
||||||
import { amountValue } from "@/features/overview/dashboard/amount";
|
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 { count, percent, timestamp } from "@/features/overview/format";
|
||||||
import DashboardActivity from "@/features/overview/dashboard/DashboardActivity.vue";
|
import DashboardActivity from "@/features/overview/dashboard/DashboardActivity.vue";
|
||||||
import DashboardAnnouncements from "@/features/overview/dashboard/DashboardAnnouncements.vue";
|
import DashboardAnnouncements from "@/features/overview/dashboard/DashboardAnnouncements.vue";
|
||||||
@@ -926,19 +962,43 @@ const tokenBreakdown = ref<{
|
|||||||
|
|
||||||
const dailyStats = ref<DailyStat[]>([]);
|
const dailyStats = ref<DailyStat[]>([]);
|
||||||
const providerSummary = ref<ProviderSummary[]>([]);
|
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>(
|
const dailyTimeRange = ref<DateRangeParams>(
|
||||||
getDateRangeFromPeriod("last7days"),
|
{ ...getDateRangeFromPeriod("last7days"), granularity: 'day' },
|
||||||
);
|
);
|
||||||
// 统计周期
|
// 统计周期
|
||||||
const loadingDaily = ref(false);
|
const loadingDaily = ref(false);
|
||||||
|
const dailyError = ref('');
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const dashboardError = ref("");
|
const dashboardError = ref("");
|
||||||
let dashboardRequestId = 0;
|
let dashboardRequestId = 0;
|
||||||
let dashboardController: AbortController | null = null;
|
let dashboardController: AbortController | null = null;
|
||||||
let dashboardTimezone: string | null = null;
|
let dashboardTimezone: string | null = null;
|
||||||
let dailyStatsRequestId = 0;
|
let dailyStatsRequestId = 0;
|
||||||
let dailyStatsLoadPromise: Promise<void> | null = null;
|
let dailyStatsController: AbortController | null = null;
|
||||||
let hasPendingDailyStatsLoad = false;
|
|
||||||
let dailyStatsDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
let dailyStatsDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
|
||||||
@@ -975,6 +1035,15 @@ const emptyStatPlaceholders = computed(() => {
|
|||||||
const statSkeletonCount = computed(() => emptyStatPlaceholders.value.length);
|
const statSkeletonCount = computed(() => emptyStatPlaceholders.value.length);
|
||||||
|
|
||||||
const totalStats = computed(() => {
|
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) {
|
if (dailyStats.value.length === 0) {
|
||||||
return { requests: 0, tokens: 0, cost: 0, avgResponseTime: 0 };
|
return { requests: 0, tokens: 0, cost: 0, avgResponseTime: 0 };
|
||||||
}
|
}
|
||||||
@@ -997,7 +1066,7 @@ const totalStats = computed(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// 每日模型成本(堆叠柱状图)
|
// 每日模型费用(堆叠柱状图)
|
||||||
const MODEL_COLORS = [
|
const MODEL_COLORS = [
|
||||||
"rgba(59, 130, 246, 0.8)", // blue
|
"rgba(59, 130, 246, 0.8)", // blue
|
||||||
"rgba(239, 68, 68, 0.8)", // red
|
"rgba(239, 68, 68, 0.8)", // red
|
||||||
@@ -1010,6 +1079,13 @@ const MODEL_COLORS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const dailyModelCostChartData = computed<ChartData<"bar">>(() => {
|
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) {
|
if (dailyStats.value.length === 0) {
|
||||||
return { labels: [], datasets: [] };
|
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">>(() => ({
|
const dailyModelCostChartOptions = computed<ChartOptions<"bar">>(() => ({
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
@@ -1106,7 +1199,7 @@ const dailyModelCostChartOptions = computed<ChartOptions<"bar">>(() => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 提供商成本分布(环形图)
|
// 提供商费用分布(环形图)
|
||||||
const PROVIDER_COLORS = [
|
const PROVIDER_COLORS = [
|
||||||
"rgba(59, 130, 246, 0.8)", // blue
|
"rgba(59, 130, 246, 0.8)", // blue
|
||||||
"rgba(239, 68, 68, 0.8)", // red
|
"rgba(239, 68, 68, 0.8)", // red
|
||||||
@@ -1119,6 +1212,13 @@ const PROVIDER_COLORS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const providerCostChartData = computed<ChartData<"doughnut">>(() => {
|
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) {
|
if (providerSummary.value.length === 0) {
|
||||||
return { labels: [], datasets: [] };
|
return { labels: [], datasets: [] };
|
||||||
}
|
}
|
||||||
@@ -1275,9 +1375,9 @@ onBeforeUnmount(() => {
|
|||||||
clearTimeout(dailyStatsDebounceTimer);
|
clearTimeout(dailyStatsDebounceTimer);
|
||||||
dailyStatsDebounceTimer = null;
|
dailyStatsDebounceTimer = null;
|
||||||
}
|
}
|
||||||
hasPendingDailyStatsLoad = false;
|
|
||||||
dailyStatsLoadPromise = null;
|
|
||||||
dailyStatsRequestId += 1;
|
dailyStatsRequestId += 1;
|
||||||
|
dailyStatsController?.abort();
|
||||||
|
dailyStatsController = null;
|
||||||
dashboardRequestId += 1;
|
dashboardRequestId += 1;
|
||||||
dashboardController?.abort();
|
dashboardController?.abort();
|
||||||
dashboardController = null;
|
dashboardController = null;
|
||||||
@@ -1380,40 +1480,50 @@ function adminStatCards(snapshot: OverviewDashboardSummary): DashboardStatCard[]
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadDailyStats() {
|
async function loadDailyStats() {
|
||||||
if (dailyStatsLoadPromise) {
|
dailyStatsController?.abort();
|
||||||
hasPendingDailyStatsLoad = true;
|
const controller = new AbortController();
|
||||||
return dailyStatsLoadPromise;
|
dailyStatsController = controller;
|
||||||
}
|
|
||||||
const requestId = ++dailyStatsRequestId;
|
const requestId = ++dailyStatsRequestId;
|
||||||
loadingDaily.value = true;
|
loadingDaily.value = true;
|
||||||
dailyStatsLoadPromise = (async () => {
|
dailyError.value = '';
|
||||||
try {
|
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
|
const response = import.meta.env.DEV && isDemo.value
|
||||||
? (await import('@/features/overview/dashboard/demo')).createDashboardDailyDemo(dailyTimeRange.value)
|
? (await import('@/features/overview/dashboard/demo')).createDashboardDailyDemo(dailyTimeRange.value)
|
||||||
: await dashboardApi.getDailyStats(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;
|
dailyStats.value = response.daily_stats;
|
||||||
providerSummary.value = response.provider_summary || [];
|
providerSummary.value = response.provider_summary || [];
|
||||||
} catch {
|
|
||||||
if (requestId !== dailyStatsRequestId) return;
|
|
||||||
dailyStats.value = [];
|
|
||||||
providerSummary.value = [];
|
|
||||||
} finally {
|
|
||||||
if (requestId === dailyStatsRequestId) {
|
|
||||||
loadingDaily.value = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})().finally(() => {
|
} catch {
|
||||||
dailyStatsLoadPromise = null;
|
if (requestId !== dailyStatsRequestId || controller.signal.aborted) return;
|
||||||
if (hasPendingDailyStatsLoad) {
|
if (isAdmin.value) dailyError.value = t('统计加载失败,请重试', 'Statistics could not be loaded. Please retry.');
|
||||||
hasPendingDailyStatsLoad = false;
|
dailyStats.value = [];
|
||||||
void loadDailyStats();
|
providerSummary.value = [];
|
||||||
|
adminDailyCharts.value = null;
|
||||||
|
adminDailyRange.value = null;
|
||||||
|
} finally {
|
||||||
|
if (requestId === dailyStatsRequestId) {
|
||||||
|
loadingDaily.value = false;
|
||||||
|
dailyStatsController = null;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
return dailyStatsLoadPromise;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleDailyStatsLoad() {
|
function scheduleDailyStatsLoad() {
|
||||||
|
dailyStatsController?.abort();
|
||||||
|
dailyStatsRequestId += 1;
|
||||||
|
loadingDaily.value = true;
|
||||||
if (dailyStatsDebounceTimer) {
|
if (dailyStatsDebounceTimer) {
|
||||||
clearTimeout(dailyStatsDebounceTimer);
|
clearTimeout(dailyStatsDebounceTimer);
|
||||||
}
|
}
|
||||||
@@ -1423,15 +1533,24 @@ function scheduleDailyStatsLoad() {
|
|||||||
}, 120);
|
}, 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, () => {
|
watch(isDemo, () => {
|
||||||
dashboardController?.abort();
|
dashboardController?.abort();
|
||||||
dashboardController = null;
|
dashboardController = null;
|
||||||
dashboardSnapshot.value = null;
|
dashboardSnapshot.value = null;
|
||||||
demoTimeline.value = null;
|
demoTimeline.value = null;
|
||||||
dailyStatsRequestId += 1;
|
dailyStatsRequestId += 1;
|
||||||
|
dailyStatsController?.abort();
|
||||||
dailyStats.value = [];
|
dailyStats.value = [];
|
||||||
providerSummary.value = [];
|
providerSummary.value = [];
|
||||||
|
adminDailyCharts.value = null;
|
||||||
|
adminDailyRange.value = null;
|
||||||
|
dailyError.value = '';
|
||||||
void loadDashboardData();
|
void loadDashboardData();
|
||||||
void loadDailyStats();
|
void loadDailyStats();
|
||||||
});
|
});
|
||||||
@@ -1463,7 +1582,25 @@ function formatDateForChart(dateString: string): string {
|
|||||||
return date.toLocaleDateString(getI18nLocale(), { month: "numeric", day: "numeric" });
|
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 === 0) return "-";
|
||||||
if (seconds < 1) return `${(seconds * 1000).toFixed(0)}ms`;
|
if (seconds < 1) return `${(seconds * 1000).toFixed(0)}ms`;
|
||||||
return `${seconds.toFixed(2)}s`;
|
return `${seconds.toFixed(2)}s`;
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { createApp, nextTick, type App } from 'vue'
|
import { createApp, nextTick, type App } from 'vue'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
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 { dashboardSummary } from '@/features/overview/__tests__/fixtures/dashboardSummary'
|
||||||
import Dashboard from '../Dashboard.vue'
|
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('@/stores/auth', () => ({ useAuthStore: () => ({ canAccessAdmin: true, isAdmin: true, isAuditAdmin: false }) }))
|
||||||
vi.mock('@/api/overview', () => ({ overviewApi: api }))
|
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 () => {
|
vi.mock('@/features/overview/dashboard/DashboardActivity.vue', async () => {
|
||||||
const { defineComponent, h } = await import('vue')
|
const { defineComponent, h } = await import('vue')
|
||||||
return {
|
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/DoughnutChart.vue', () => ({ default: { render: () => null } }))
|
||||||
vi.mock('@/components/charts/LineChart.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>() {
|
function deferred<T>() {
|
||||||
let resolve!: (value: T) => void
|
let resolve!: (value: T) => void
|
||||||
let reject!: (reason: Error) => void
|
let reject!: (reason: Error) => void
|
||||||
@@ -52,13 +80,13 @@ beforeEach(() => {
|
|||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
vi.resetAllMocks()
|
vi.resetAllMocks()
|
||||||
api.dashboardSummary.mockResolvedValue(dashboardSummary())
|
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() })
|
afterEach(() => { app?.unmount(); app = undefined; vi.useRealTimers() })
|
||||||
|
|
||||||
describe('dashboard snapshot loading', () => {
|
describe('dashboard snapshot loading', () => {
|
||||||
it('shows today and total from one compact snapshot independently of pending charts', async () => {
|
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()
|
const root = mount()
|
||||||
await settle()
|
await settle()
|
||||||
expect(root.textContent).toContain('总请求 12,345')
|
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.dashboardSummary).toHaveBeenCalledWith(Intl.DateTimeFormat().resolvedOptions().timeZone, expect.any(AbortSignal))
|
||||||
expect(api.summary).not.toHaveBeenCalled()
|
expect(api.summary).not.toHaveBeenCalled()
|
||||||
expect(api.dashboardTotal).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)
|
await vi.advanceTimersByTimeAsync(240_000)
|
||||||
expect(api.dashboardSummary).toHaveBeenCalledTimes(1)
|
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 () => {
|
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('—')
|
expect(root.querySelector('[data-request-metric="stream"]')?.textContent).toContain('—')
|
||||||
await vi.advanceTimersByTimeAsync(61_000)
|
await vi.advanceTimersByTimeAsync(61_000)
|
||||||
expect(api.dashboardSummary).toHaveBeenCalledTimes(1)
|
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 () => {
|
it('aborts the compact snapshot request when unmounted and ignores late success', async () => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const dashboardApiMocks = vi.hoisted(() => ({
|
|||||||
getStats: vi.fn(),
|
getStats: vi.fn(),
|
||||||
getDailyStats: 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() }))
|
const announcementApiMocks = vi.hoisted(() => ({ getAnnouncements: vi.fn(), markAsRead: vi.fn() }))
|
||||||
|
|
||||||
vi.mock('@/api/overview', () => ({ overviewApi: overviewApiMocks }))
|
vi.mock('@/api/overview', () => ({ overviewApi: overviewApiMocks }))
|
||||||
@@ -130,6 +130,7 @@ beforeEach(() => {
|
|||||||
overviewApiMocks.dashboardTotal.mockReset()
|
overviewApiMocks.dashboardTotal.mockReset()
|
||||||
overviewApiMocks.summary.mockReset()
|
overviewApiMocks.summary.mockReset()
|
||||||
overviewApiMocks.dashboardSummary.mockReset()
|
overviewApiMocks.dashboardSummary.mockReset()
|
||||||
|
overviewApiMocks.dashboardCharts.mockReset()
|
||||||
dashboardApiMocks.getStats.mockReset()
|
dashboardApiMocks.getStats.mockReset()
|
||||||
dashboardApiMocks.getDailyStats.mockReset()
|
dashboardApiMocks.getDailyStats.mockReset()
|
||||||
dashboardApiMocks.getDailyStats.mockResolvedValue({
|
dashboardApiMocks.getDailyStats.mockResolvedValue({
|
||||||
@@ -176,6 +177,7 @@ describe('Dashboard ordinary user wallet card', () => {
|
|||||||
expect(overviewApiMocks.dashboardTotal).not.toHaveBeenCalled()
|
expect(overviewApiMocks.dashboardTotal).not.toHaveBeenCalled()
|
||||||
expect(overviewApiMocks.summary).not.toHaveBeenCalled()
|
expect(overviewApiMocks.summary).not.toHaveBeenCalled()
|
||||||
expect(overviewApiMocks.dashboardSummary).not.toHaveBeenCalled()
|
expect(overviewApiMocks.dashboardSummary).not.toHaveBeenCalled()
|
||||||
|
expect(overviewApiMocks.dashboardCharts).not.toHaveBeenCalled()
|
||||||
expect(root.querySelector('#announcements-section')).not.toBeNull()
|
expect(root.querySelector('#announcements-section')).not.toBeNull()
|
||||||
expect(root.querySelector('[role="dialog"]')).toBeNull()
|
expect(root.querySelector('[role="dialog"]')).toBeNull()
|
||||||
expect(root.textContent).toContain('系统公告')
|
expect(root.textContent).toContain('系统公告')
|
||||||
@@ -216,6 +218,7 @@ describe('Dashboard refresh controls', () => {
|
|||||||
expect(root.textContent).not.toContain('自动刷新')
|
expect(root.textContent).not.toContain('自动刷新')
|
||||||
expect(dashboardApiMocks.getStats).toHaveBeenCalledTimes(1)
|
expect(dashboardApiMocks.getStats).toHaveBeenCalledTimes(1)
|
||||||
expect(dashboardApiMocks.getDailyStats).toHaveBeenCalledTimes(1)
|
expect(dashboardApiMocks.getDailyStats).toHaveBeenCalledTimes(1)
|
||||||
|
expect(overviewApiMocks.dashboardCharts).not.toHaveBeenCalled()
|
||||||
expect(announcementApiMocks.getAnnouncements).not.toHaveBeenCalled()
|
expect(announcementApiMocks.getAnnouncements).not.toHaveBeenCalled()
|
||||||
expect(overviewApiMocks.dashboard).not.toHaveBeenCalled()
|
expect(overviewApiMocks.dashboard).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user