mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-06 01:17:46 +08:00
fix: restore dashboard history and complete activity calendar
Combine retained daily aggregates with recent usage without replaying historical records or changing the activation boundary. Complete activation-day metrics and expose the UTC calendar used by legacy history. Render all 365 calendar dates, size heatmap cells responsively, and soften cell corners. Add regression coverage for history cutoffs, retention, sparse dates, and timezone boundaries.
This commit is contained in:
@@ -9,6 +9,7 @@ pub fn dashboard_summary_value(snapshot: &StoredDashboardSummary) -> Value {
|
||||
"stats_since": snapshot.stats_since,
|
||||
"generated_at": snapshot.generated_at,
|
||||
"timezone": snapshot.timezone,
|
||||
"activity_timezone": snapshot.activity_timezone,
|
||||
"today_from": snapshot.today_from,
|
||||
"window_seconds": snapshot.window_seconds,
|
||||
"today": metrics_value(&snapshot.today),
|
||||
@@ -46,7 +47,8 @@ fn metrics_value(metrics: &DashboardSummaryMetrics) -> Value {
|
||||
"active_users": metrics.active_users,
|
||||
"cache_read_tokens": tokens_available.then_some(metrics.cache_read_tokens),
|
||||
"cache_creation_tokens": tokens_available.then_some(metrics.cache_creation_tokens),
|
||||
"cache_input_tokens": tokens_available.then_some(metrics.cache_input_tokens),
|
||||
"cache_input_tokens": (tokens_available && metrics.cache_input_tokens >= metrics.cache_read_tokens)
|
||||
.then_some(metrics.cache_input_tokens),
|
||||
"avg_first_byte_ms": average(metrics.first_byte_sum_ms, metrics.first_byte_sample_count),
|
||||
"avg_response_ms": average(metrics.response_sum_ms, metrics.response_sample_count),
|
||||
"stream_requests": metrics.stream_requests,
|
||||
@@ -99,6 +101,9 @@ mod tests {
|
||||
assert_eq!(value["total"]["cache_input_tokens"], 400);
|
||||
assert!(value["today"].get("latency_p95_ms").is_none());
|
||||
|
||||
snapshot.total.cache_input_tokens = 100;
|
||||
assert!(dashboard_summary_value(&snapshot)["total"]["cache_input_tokens"].is_null());
|
||||
|
||||
snapshot.total.usage_available_count = 0;
|
||||
let unknown = dashboard_summary_value(&snapshot);
|
||||
assert!(unknown["total"].get("cache_read_tokens").unwrap().is_null());
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Compatibility reads for installations whose retained daily totals predate
|
||||
//! the incremental dashboard. Never replay or mutate the activation boundary.
|
||||
use crate::error::SqlxResultExt;
|
||||
use aether_data_contracts::{repository::usage::*, DataLayerError};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_json::Value;
|
||||
use sqlx::{Postgres, Row, Transaction};
|
||||
|
||||
pub(super) struct DashboardHistory {
|
||||
pub since: DateTime<Utc>,
|
||||
pub metrics: Value,
|
||||
pub days: Vec<DashboardActivityDay>,
|
||||
}
|
||||
|
||||
pub(super) async fn read_dashboard_history(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
activation: DateTime<Utc>,
|
||||
now: DateTime<Utc>,
|
||||
) -> Result<Option<DashboardHistory>, DataLayerError> {
|
||||
// Both timestamp and integer dates exist in supported imported schemas.
|
||||
let state = sqlx::query(
|
||||
r#"
|
||||
WITH summary 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
|
||||
), daily 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
|
||||
FROM stats_daily WHERE total_requests>0
|
||||
) SELECT cutoff, (SELECT min(day) FROM daily WHERE day<LEAST(cutoff,$1)) AS since
|
||||
FROM summary WHERE cutoff<=$2"#,
|
||||
)
|
||||
.bind(activation)
|
||||
.bind(now)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
let Some(state) = state else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(since) = state
|
||||
.try_get::<Option<DateTime<Utc>>, _>("since")
|
||||
.map_postgres_err()?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let cutoff: DateTime<Utc> = state.try_get("cutoff").map_postgres_err()?;
|
||||
// Canonical fact views expand several joins. JIT compilation can cost more
|
||||
// than the small recent range itself on upgraded installations.
|
||||
sqlx::query("SET LOCAL jit=off")
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
// The published summary cutoff is advanced atomically with daily rollups.
|
||||
// Read that same snapshot and use disjoint [history, cutoff), [cutoff, now)
|
||||
// ranges. This keeps working after raw history is pruned or the cutoff moves.
|
||||
let row = sqlx::query(include_str!("queries/dashboard_history.sql"))
|
||||
.bind(cutoff)
|
||||
.bind(now)
|
||||
.fetch_one(&mut **tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
let days: Value = row.try_get("days").map_postgres_err()?;
|
||||
Ok(Some(DashboardHistory {
|
||||
since,
|
||||
metrics: row.try_get("metrics").map_postgres_err()?,
|
||||
days: serde_json::from_value(days)
|
||||
.map_err(|error| DataLayerError::UnexpectedValue(error.to_string()))?,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn complete_activation_day(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
today: &DashboardSummaryMetrics,
|
||||
today_start: DateTime<Utc>,
|
||||
activation: DateTime<Utc>,
|
||||
now: DateTime<Utc>,
|
||||
) -> Result<Value, DataLayerError> {
|
||||
// Match dashboard_request_fact exactly for the missing part of today.
|
||||
// The actor UNION deduplicates users present on both sides of activation.
|
||||
let row = sqlx::query(include_str!("queries/dashboard_activation_day.sql"))
|
||||
.bind(today_start)
|
||||
.bind(activation)
|
||||
.bind(now)
|
||||
.bind(
|
||||
serde_json::to_value(today)
|
||||
.map_err(|error| DataLayerError::UnexpectedValue(error.to_string()))?,
|
||||
)
|
||||
.fetch_one(&mut **tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
row.try_get("metrics").map_postgres_err()
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
use super::SqlxUsageReadRepository;
|
||||
use aether_data_contracts::repository::usage::*;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use futures_util::FutureExt;
|
||||
use std::{collections::BTreeMap, panic::AssertUnwindSafe};
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires local AETHER_TEST_DATABASE_URL with temporary database creation"]
|
||||
async fn live_dashboard_restores_legacy_history_without_replaying_or_double_counting() {
|
||||
let options = std::env::var("AETHER_TEST_DATABASE_URL")
|
||||
.unwrap()
|
||||
.parse::<sqlx::postgres::PgConnectOptions>()
|
||||
.unwrap();
|
||||
let admin = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let database = format!("dashboard_history_{}", uuid::Uuid::new_v4().simple());
|
||||
sqlx::query(&format!("CREATE DATABASE {database}"))
|
||||
.execute(&admin)
|
||||
.await
|
||||
.unwrap();
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(4)
|
||||
.connect_with(options.database(&database))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let outcome = AssertUnwindSafe(async {
|
||||
crate::POSTGRES_MIGRATOR.run(&pool).await.unwrap();
|
||||
let repo = SqlxUsageReadRepository::new(pool.clone());
|
||||
let query = UsageDashboardAnalyticsQuery {
|
||||
timezone: "Asia/Kathmandu".into(),
|
||||
};
|
||||
let now: DateTime<Utc> = sqlx::query_scalar("SELECT CURRENT_TIMESTAMP")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let utc_today = now.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc();
|
||||
let local_today = query.today_start(now).unwrap();
|
||||
let activation = local_today
|
||||
+ Duration::microseconds((now - local_today).num_microseconds().unwrap() / 2);
|
||||
let before_activation = local_today
|
||||
+ Duration::microseconds((activation - local_today).num_microseconds().unwrap() / 2);
|
||||
let after_activation = activation
|
||||
+ Duration::microseconds((now - activation).num_microseconds().unwrap() / 2);
|
||||
let oldest = utc_today - Duration::days(400);
|
||||
let recent_history = utc_today - Duration::days(3);
|
||||
let cutoff = utc_today - Duration::days(2);
|
||||
sqlx::query("UPDATE dashboard_stats_state SET stats_since=$1")
|
||||
.bind(activation)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO users(id,username,email_verified,is_deleted) VALUES ('shared','shared',false,false),('before-only','before-only',false,false),('deleted','deleted',false,true)")
|
||||
.execute(&pool).await.unwrap();
|
||||
sqlx::query("INSERT INTO stats_summary(id,cutoff_date) VALUES('legacy',$1)")
|
||||
.bind(cutoff).execute(&pool).await.unwrap();
|
||||
|
||||
// The oldest day's request rows have already expired. Its exact NUMERIC
|
||||
// cost and lifetime activity must survive outside the visible heatmap.
|
||||
for (id, date, requests, input, output, creation, read, cost) in [
|
||||
("expired", oldest, 10, 1000_i64, 200_i64, 30_i64, 40_i64, "123456789.12345678"),
|
||||
("recent", recent_history, 3, 300, 60, 9, 12, "0.30000003"),
|
||||
("at-cutoff", cutoff, 1, 100, 20, 3, 4, "0.10000001"),
|
||||
] {
|
||||
sqlx::query("INSERT INTO stats_daily(id,date,total_requests,input_tokens,output_tokens,cache_creation_tokens,cache_read_tokens,actual_total_cost) VALUES($1,$2,$3,$4,$5,$6,$7,$8::text::numeric)")
|
||||
.bind(id).bind(date).bind(requests).bind(input).bind(output)
|
||||
.bind(creation).bind(read).bind(cost).execute(&pool).await.unwrap();
|
||||
}
|
||||
// Retained detail below the cutoff must not be counted a second time;
|
||||
// a request exactly at the cutoff must still be included once.
|
||||
sqlx::query("INSERT INTO usage(id,request_id,model,provider_name,status,billing_status,input_tokens,output_tokens,total_tokens,cache_creation_input_tokens,cache_read_input_tokens,actual_total_cost_usd,created_at) VALUES('overlap','overlap','m','p','completed','settled',999,999,1998,0,0,999,$1),('boundary','boundary','m','p','completed','settled',100,20,127,3,4,0.10000001,$2)")
|
||||
.bind(recent_history + Duration::hours(12)).bind(cutoff)
|
||||
.execute(&pool).await.unwrap();
|
||||
sqlx::query("INSERT INTO usage(id,request_id,model,provider_name,status,billing_status,input_tokens,output_tokens,total_tokens,actual_total_cost_usd,created_at) VALUES('pending','pending','m','p','pending','pending',999,999,1998,999,$1),('unknown','unknown','m','unknown','completed','settled',999,999,1998,999,$1)")
|
||||
.bind(cutoff + Duration::hours(12)).execute(&pool).await.unwrap();
|
||||
for (id, user, at) in [
|
||||
("before-shared", "shared", before_activation),
|
||||
("before-unique", "before-only", before_activation),
|
||||
("before-deleted", "deleted", before_activation),
|
||||
("after-shared", "shared", after_activation),
|
||||
] {
|
||||
sqlx::query("INSERT INTO usage(id,request_id,user_id,model,provider_name,status,billing_status,input_tokens,output_tokens,total_tokens,actual_total_cost_usd,total_cost_usd,created_at,first_byte_time_ms,response_time_ms,upstream_is_stream) VALUES($1,$1,$2,'m','p','completed','settled',100,20,120,0.25,0.25,$3,100,800,true)")
|
||||
.bind(id).bind(user).bind(at).execute(&pool).await.unwrap();
|
||||
}
|
||||
// Today's missing prefix must use final settlement facts, just like
|
||||
// the already-active projection, instead of stale capture counters.
|
||||
sqlx::query("INSERT INTO usage_settlement_snapshots(request_id,billing_status,billing_input_tokens,billing_effective_input_tokens,billing_output_tokens,billing_cache_read_tokens,billing_cache_creation_tokens,billing_total_input_context,billing_total_cost_usd,billing_actual_total_cost_usd) VALUES('before-shared','settled',100,100,30,40,10,150,0.12345678,0.12345678)")
|
||||
.execute(&pool).await.unwrap();
|
||||
|
||||
let restored = repo.query_dashboard_summary(&query).await.unwrap();
|
||||
assert_eq!(restored.total.request_count, 18);
|
||||
assert_eq!(restored.total.total_tokens, 2318);
|
||||
assert_eq!(restored.total.billable_amount.as_deref(), Some("123456790.39691360"));
|
||||
assert_eq!(restored.stats_since, oldest.to_rfc3339());
|
||||
assert_eq!(restored.timezone, query.timezone);
|
||||
assert_eq!(restored.activity_timezone, "UTC");
|
||||
assert_eq!(restored.today_from, local_today.to_rfc3339());
|
||||
assert_eq!(restored.today.request_count, 4);
|
||||
assert_eq!(restored.today.total_tokens, 540);
|
||||
assert_eq!(restored.today.billable_amount.as_deref(), Some("0.87345678"));
|
||||
assert_eq!(restored.today.active_users, 2, "users on both sides of activation count once; deleted users do not count");
|
||||
assert_eq!(restored.today.first_byte_sample_count, 4);
|
||||
assert_eq!(restored.today.response_sample_count, 4);
|
||||
assert_eq!(restored.today.response_sum_ms, 3200.0);
|
||||
assert_eq!(restored.today.stream_requests, 4);
|
||||
|
||||
let mut expected_days = BTreeMap::from([
|
||||
(recent_history.date_naive().to_string(), 3_u64),
|
||||
(cutoff.date_naive().to_string(), 1),
|
||||
]);
|
||||
*expected_days.entry(before_activation.date_naive().to_string()).or_default() += 3;
|
||||
*expected_days.entry(after_activation.date_naive().to_string()).or_default() += 1;
|
||||
assert_eq!(restored.active_days, expected_days.len() as u64 + 1);
|
||||
assert_eq!(restored.activity_days.iter().map(|day| (day.date.clone(), day.requests)).collect::<BTreeMap<_, _>>(), expected_days);
|
||||
assert_eq!(sqlx::query_scalar::<_, DateTime<Utc>>("SELECT stats_since FROM dashboard_stats_state WHERE singleton")
|
||||
.fetch_one(&pool).await.unwrap(), activation, "reading history must not change the projection activation boundary");
|
||||
assert_eq!(sqlx::query_scalar::<_, i64>("SELECT count(*) FROM dashboard_request_contributions")
|
||||
.fetch_one(&pool).await.unwrap(), 1, "old requests are read, not replayed into projection ledgers");
|
||||
|
||||
// The daily aggregator advances its exclusive boundary over the same
|
||||
// request. Existing daily rows above the old cutoff were not duplicates.
|
||||
sqlx::query("UPDATE stats_summary SET cutoff_date=$1 WHERE id='legacy'")
|
||||
.bind(cutoff + Duration::days(1)).execute(&pool).await.unwrap();
|
||||
sqlx::query("DELETE FROM usage WHERE request_id='boundary'")
|
||||
.execute(&pool).await.unwrap();
|
||||
let advanced = repo.query_dashboard_summary(&query).await.unwrap();
|
||||
assert_eq!(advanced.total.request_count, restored.total.request_count);
|
||||
assert_eq!(advanced.total.total_tokens, restored.total.total_tokens);
|
||||
assert_eq!(advanced.total.billable_amount, restored.total.billable_amount);
|
||||
assert_eq!(advanced.today, restored.today);
|
||||
assert_eq!(advanced.activity_days, restored.activity_days);
|
||||
assert_eq!(advanced.active_days, restored.active_days);
|
||||
|
||||
// A summary cutoff without legacy daily history must leave the normal
|
||||
// future-only projection and its requested calendar unchanged.
|
||||
sqlx::query("DELETE FROM stats_daily").execute(&pool).await.unwrap();
|
||||
let future_only = repo.query_dashboard_summary(&query).await.unwrap();
|
||||
assert_eq!(future_only.stats_since, activation.to_rfc3339());
|
||||
assert_eq!(future_only.activity_timezone, query.timezone);
|
||||
assert_eq!(future_only.total.request_count, 1);
|
||||
assert_eq!(future_only.today.request_count, 1);
|
||||
assert_eq!(future_only.today_from, activation.to_rfc3339());
|
||||
})
|
||||
.catch_unwind()
|
||||
.await;
|
||||
|
||||
pool.close().await;
|
||||
sqlx::query(&format!("DROP DATABASE {database} WITH (FORCE)"))
|
||||
.execute(&admin)
|
||||
.await
|
||||
.unwrap();
|
||||
admin.close().await;
|
||||
if let Err(error) = outcome {
|
||||
std::panic::resume_unwind(error);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::dashboard_history::{complete_activation_day, read_dashboard_history};
|
||||
use super::SqlxUsageReadRepository;
|
||||
use crate::error::SqlxResultExt;
|
||||
use aether_data_contracts::{repository::usage::*, DataLayerError};
|
||||
@@ -46,8 +47,14 @@ impl SqlxUsageReadRepository {
|
||||
let since: DateTime<Utc> = state.try_get("stats_since").map_postgres_err()?;
|
||||
let now: DateTime<Utc> = state.try_get("now").map_postgres_err()?;
|
||||
let today_start = query.today_start(now)?;
|
||||
let today_from = today_start.max(since);
|
||||
let minute_from = today_from
|
||||
let history = read_dashboard_history(&mut tx, since, now).await?;
|
||||
let today_from = if history.is_some() {
|
||||
today_start
|
||||
} else {
|
||||
today_start.max(since)
|
||||
};
|
||||
let minute_from = today_start
|
||||
.max(since)
|
||||
.with_second(0)
|
||||
.and_then(|v| v.with_nanosecond(0))
|
||||
.expect("valid minute");
|
||||
@@ -78,10 +85,21 @@ impl SqlxUsageReadRepository {
|
||||
}
|
||||
today.active_users = sqlx::query_scalar::<_,i64>("SELECT count(DISTINCT a.actor_user_id) FROM dashboard_actor_minute a JOIN users u ON u.id=a.actor_user_id AND NOT u.is_deleted WHERE a.bucket_start >= $1 AND a.bucket_start <= $2 AND a.request_count > 0")
|
||||
.bind(minute_from).bind(now).fetch_one(&mut *tx).await.map_postgres_err()? as u64;
|
||||
if history.is_some() && since > today_start {
|
||||
today = decode_metrics(
|
||||
complete_activation_day(&mut tx, &today, today_start, since, now).await?,
|
||||
)?;
|
||||
}
|
||||
// Most UTC hours belong to one local day. Only hours straddling a local
|
||||
// midnight (e.g. Kathmandu) need their minute counts read. Lifetime
|
||||
// activity therefore scans narrow hourly counters, not metric JSON.
|
||||
let days = sqlx::query(r#"WITH hours AS MATERIALIZED (
|
||||
let activity_timezone = if history.is_some() {
|
||||
"UTC"
|
||||
} else {
|
||||
query.timezone.as_str()
|
||||
};
|
||||
let mut days = if history.is_none() {
|
||||
sqlx::query(r#"WITH hours AS MATERIALIZED (
|
||||
SELECT bucket_start,shard,request_count,
|
||||
(bucket_start AT TIME ZONE $1)::date AS day,
|
||||
((bucket_start+INTERVAL '1 hour'-INTERVAL '1 microsecond') AT TIME ZONE $1)::date AS last_day
|
||||
@@ -105,11 +123,23 @@ impl SqlxUsageReadRepository {
|
||||
) m ON TRUE WHERE h.day<>h.last_day
|
||||
) SELECT day AS date, sum(requests)::bigint AS requests FROM daily
|
||||
GROUP BY day HAVING sum(requests)>0 ORDER BY day"#)
|
||||
.bind(&query.timezone).bind(now).fetch_all(&mut *tx).await.map_postgres_err()?;
|
||||
.bind(activity_timezone).bind(now).fetch_all(&mut *tx).await.map_postgres_err()?
|
||||
.into_iter().map(|row| Ok(DashboardActivityDay {
|
||||
date: row.try_get::<NaiveDate, _>("date").map_postgres_err()?.to_string(),
|
||||
requests: row.try_get::<i64, _>("requests").map_postgres_err()?.max(0) as u64,
|
||||
})).collect::<Result<Vec<_>, DataLayerError>>()?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut display_since = since;
|
||||
if let Some(history) = history {
|
||||
display_since = history.since;
|
||||
total = decode_metrics(history.metrics)?;
|
||||
days = history.days;
|
||||
}
|
||||
let local_today = now
|
||||
.with_timezone(
|
||||
&query
|
||||
.timezone
|
||||
&activity_timezone
|
||||
.parse::<chrono_tz::Tz>()
|
||||
.map_err(|_| DataLayerError::InvalidInput("invalid timezone".into()))?,
|
||||
)
|
||||
@@ -118,7 +148,10 @@ impl SqlxUsageReadRepository {
|
||||
let active_days = days.len() as u64;
|
||||
let dates: Vec<NaiveDate> = days
|
||||
.iter()
|
||||
.map(|row| row.try_get("date").map_postgres_err())
|
||||
.map(|row| {
|
||||
NaiveDate::parse_from_str(&row.date, "%Y-%m-%d")
|
||||
.map_err(|error| DataLayerError::UnexpectedValue(error.to_string()))
|
||||
})
|
||||
.collect::<Result<_, _>>()?;
|
||||
let consecutive_active_days =
|
||||
dashboard_consecutive_active_days(dates.iter().copied(), local_today);
|
||||
@@ -127,16 +160,17 @@ impl SqlxUsageReadRepository {
|
||||
if date >= first_heatmap_day {
|
||||
activity_days.push(DashboardActivityDay {
|
||||
date: date.to_string(),
|
||||
requests: row.try_get::<i64, _>("requests").map_postgres_err()?.max(0) as u64,
|
||||
requests: row.requests,
|
||||
});
|
||||
}
|
||||
}
|
||||
let events = sqlx::query("SELECT COALESCE(sum(created_count),0)::bigint AS created, COALESCE(sum(deleted_count),0)::bigint AS deleted FROM dashboard_user_events_minute WHERE bucket_start >= $1 AND bucket_start <= $2")
|
||||
.bind(minute_from).bind(now).fetch_one(&mut *tx).await.map_postgres_err()?;
|
||||
let summary = StoredDashboardSummary {
|
||||
stats_since: since.to_rfc3339(),
|
||||
stats_since: display_since.to_rfc3339(),
|
||||
generated_at: now.to_rfc3339(),
|
||||
timezone: query.timezone.clone(),
|
||||
activity_timezone: activity_timezone.to_string(),
|
||||
today_from: today_from.to_rfc3339(),
|
||||
window_seconds: (now - today_from).num_milliseconds().max(0) as f64 / 1000.0,
|
||||
today,
|
||||
|
||||
@@ -63,6 +63,9 @@ mod analytics_tests;
|
||||
mod attribution;
|
||||
pub mod cleanup;
|
||||
mod dashboard;
|
||||
mod dashboard_history;
|
||||
#[cfg(test)]
|
||||
mod dashboard_history_tests;
|
||||
mod dashboard_retention;
|
||||
mod dashboard_summary;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
WITH missing AS MATERIALIZED (
|
||||
SELECT f.actor_user_id, f.input_tokens, f.output_tokens, f.total_tokens,
|
||||
f.usage_available, f.billable_amount, f.cache_read_input_tokens,
|
||||
f.cache_creation_input_tokens, f.first_byte_time_ms, f.response_time_ms,
|
||||
f.upstream_is_stream, b.total_input_context
|
||||
FROM usage_analytics_facts_v1 f
|
||||
JOIN usage_billing_facts b ON b.request_id=f.request_id
|
||||
-- Bound both view inputs so the planner cannot scan historical billing rows.
|
||||
AND b.created_at >= $1 AND b.created_at < $2
|
||||
WHERE f.created_at >= $1 AND f.created_at < $2 AND f.record_kind <> 'session'
|
||||
), metrics AS (
|
||||
SELECT jsonb_build_object(
|
||||
'request_count', count(*),
|
||||
'input_tokens', COALESCE(sum(input_tokens),0),
|
||||
'output_tokens', COALESCE(sum(output_tokens),0),
|
||||
'total_tokens', COALESCE(sum(total_tokens),0),
|
||||
'usage_available_count', count(*) FILTER (WHERE usage_available),
|
||||
'pricing_available_count', count(billable_amount),
|
||||
'billable_amount', COALESCE(sum(billable_amount),0),
|
||||
'cache_read_tokens', COALESCE(sum(cache_read_input_tokens),0),
|
||||
'cache_creation_tokens', COALESCE(sum(cache_creation_input_tokens),0),
|
||||
'cache_input_tokens', COALESCE(sum(total_input_context) FILTER (WHERE usage_available),0),
|
||||
'first_byte_sum_ms', COALESCE(sum(first_byte_time_ms),0),
|
||||
'first_byte_sample_count', count(first_byte_time_ms),
|
||||
'response_sum_ms', COALESCE(sum(response_time_ms),0),
|
||||
'response_sample_count', count(response_time_ms),
|
||||
'stream_requests', count(*) FILTER (WHERE COALESCE(upstream_is_stream,false)),
|
||||
'standard_requests', count(*) FILTER (WHERE NOT COALESCE(upstream_is_stream,false))
|
||||
) AS value FROM missing
|
||||
), actors AS (
|
||||
SELECT actor_user_id FROM missing WHERE actor_user_id IS NOT NULL
|
||||
UNION
|
||||
SELECT actor_user_id FROM dashboard_actor_minute
|
||||
WHERE bucket_start >= date_trunc('minute',$2::timestamptz)
|
||||
AND bucket_start <= $3 AND request_count>0
|
||||
), combined AS (
|
||||
SELECT dashboard_metrics_add($4::jsonb, value) AS value FROM metrics
|
||||
)
|
||||
SELECT value || jsonb_build_object(
|
||||
'billable_amount', round((value->>'billable_amount')::numeric,8)::text,
|
||||
'active_users', (SELECT count(*) FROM actors a JOIN users u
|
||||
ON u.id=a.actor_user_id AND NOT u.is_deleted)
|
||||
) AS metrics FROM combined
|
||||
@@ -0,0 +1,51 @@
|
||||
WITH daily 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 AS request_count, input_tokens, output_tokens,
|
||||
CASE WHEN effective_input_tokens=0 AND total_input_context=0 AND input_tokens>0
|
||||
THEN input_tokens ELSE effective_input_tokens END
|
||||
+ output_tokens + cache_creation_tokens + cache_read_tokens AS total_tokens,
|
||||
cache_creation_tokens, cache_read_tokens,
|
||||
CASE WHEN total_input_context=0 THEN
|
||||
CASE WHEN effective_input_tokens=0 AND input_tokens>0 THEN input_tokens
|
||||
ELSE effective_input_tokens END + cache_creation_tokens + cache_read_tokens
|
||||
ELSE total_input_context END AS cache_input_tokens,
|
||||
actual_total_cost::numeric AS billable_amount
|
||||
FROM stats_daily
|
||||
), facts AS MATERIALIZED (
|
||||
SELECT (day AT TIME ZONE 'UTC')::date AS day, request_count,
|
||||
input_tokens, output_tokens, total_tokens, cache_creation_tokens,
|
||||
cache_read_tokens, cache_input_tokens, billable_amount
|
||||
FROM daily WHERE day<$1
|
||||
UNION ALL
|
||||
SELECT (b.created_at AT TIME ZONE 'UTC')::date, 1::bigint,
|
||||
b.input_tokens, b.output_tokens, b.total_tokens, b.cache_creation_input_tokens,
|
||||
b.cache_read_input_tokens, b.total_input_context,
|
||||
COALESCE(s.billing_actual_total_cost_usd::numeric,u.actual_total_cost_usd::numeric)
|
||||
FROM usage_billing_facts b
|
||||
JOIN usage u USING (request_id)
|
||||
LEFT JOIN usage_settlement_snapshots s USING (request_id)
|
||||
WHERE b.created_at >= $1 AND b.created_at < $2
|
||||
AND u.created_at >= $1 AND u.created_at < $2
|
||||
AND b.status NOT IN ('pending','streaming')
|
||||
AND b.provider_name NOT IN ('unknown','pending')
|
||||
), activity AS (
|
||||
SELECT day, sum(request_count)::bigint AS requests FROM facts
|
||||
GROUP BY day HAVING sum(request_count)>0
|
||||
)
|
||||
SELECT jsonb_build_object(
|
||||
'request_count', COALESCE(sum(request_count),0)::bigint,
|
||||
'input_tokens', COALESCE(sum(input_tokens),0)::bigint,
|
||||
'output_tokens', COALESCE(sum(output_tokens),0)::bigint,
|
||||
'total_tokens', COALESCE(sum(total_tokens),0)::bigint,
|
||||
'cache_creation_tokens', COALESCE(sum(cache_creation_tokens),0)::bigint,
|
||||
'cache_read_tokens', COALESCE(sum(cache_read_tokens),0)::bigint,
|
||||
'cache_input_tokens', COALESCE(sum(cache_input_tokens),0)::bigint,
|
||||
'usage_available_count', COALESCE(sum(request_count),0)::bigint,
|
||||
'pricing_available_count', COALESCE(sum(request_count) FILTER (WHERE billable_amount IS NOT NULL),0)::bigint,
|
||||
'billable_amount', round(COALESCE(sum(billable_amount),0),8)::text
|
||||
) AS metrics,
|
||||
(SELECT COALESCE(jsonb_agg(jsonb_build_object('date',day::text,'requests',requests)
|
||||
ORDER BY day),'[]'::jsonb) FROM activity) AS days
|
||||
FROM facts
|
||||
@@ -42,6 +42,9 @@ pub struct StoredDashboardSummary {
|
||||
pub stats_since: String,
|
||||
pub generated_at: String,
|
||||
pub timezone: String,
|
||||
/// Historical daily imports retain their original UTC calendar boundaries.
|
||||
#[serde(default)]
|
||||
pub activity_timezone: String,
|
||||
pub today_from: String,
|
||||
pub window_seconds: f64,
|
||||
pub today: DashboardSummaryMetrics,
|
||||
|
||||
@@ -157,6 +157,22 @@ The statistics schema migrations above do not embed a historical data rebuild.
|
||||
The new dashboard's activation boundary is not moved by legacy backfills, so
|
||||
they do not restore pre-activation dashboard totals.
|
||||
|
||||
For upgraded installations that retain historical `stats_daily` rows and a
|
||||
`stats_summary` cutoff, the homepage has a read-only compatibility path. Lifetime
|
||||
totals and activity combine daily aggregates strictly before that cutoff with
|
||||
recent usage at or after it, in one database snapshot. This preserves history
|
||||
whose individual requests have already expired and avoids double counting when
|
||||
daily aggregation advances the cutoff. These activity dates use UTC, matching
|
||||
the historical aggregates; the API reports `activity_timezone` separately from
|
||||
the timezone used for today's metrics. On the activation day, today's metrics
|
||||
also include requests before activation, with active users deduplicated across
|
||||
the boundary. The stored activation timestamp and source records are unchanged.
|
||||
|
||||
This compatibility path retains the legacy daily-aggregation retention contract:
|
||||
unaggregated recent usage must remain available until the daily cutoff advances.
|
||||
Installations without historical daily aggregates continue to read the durable
|
||||
incremental dashboard projection.
|
||||
|
||||
Deleted users are excluded from dashboard active-user reads even when an older
|
||||
version left orphan activity rows. The anonymization upgrade installs rules for
|
||||
future deletions without cleaning old rows during migration; those old activity
|
||||
|
||||
@@ -212,6 +212,7 @@ impl InMemoryUsageReadRepository {
|
||||
stats_since: projection.since.to_rfc3339(),
|
||||
generated_at: now.to_rfc3339(),
|
||||
timezone: query.timezone.clone(),
|
||||
activity_timezone: query.timezone.clone(),
|
||||
today_from: today_from.to_rfc3339(),
|
||||
window_seconds: (now - today_from).num_milliseconds().max(0) as f64 / 1000.0,
|
||||
today,
|
||||
|
||||
@@ -114,6 +114,7 @@ export interface OverviewDashboardSummary {
|
||||
stats_since: string
|
||||
generated_at: string
|
||||
timezone: string
|
||||
activity_timezone?: string
|
||||
today_from: string
|
||||
window_seconds: number
|
||||
today: {
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
>
|
||||
<div
|
||||
v-if="day"
|
||||
class="cursor-pointer cell-emerge rounded-[4px]"
|
||||
class="cursor-pointer cell-emerge rounded-[6px]"
|
||||
:class="compact
|
||||
? 'transition-[filter] duration-150 hover:brightness-90'
|
||||
: 'transition-all duration-200 hover:shadow-lg'"
|
||||
@@ -144,7 +144,7 @@
|
||||
<div
|
||||
v-else
|
||||
:style="cellStyle"
|
||||
class="bg-transparent rounded-[4px]"
|
||||
class="bg-transparent rounded-[6px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -198,7 +198,8 @@ const heatmapScroller = ref<HTMLElement | null>(null)
|
||||
const heatmapWrapper = ref<HTMLElement | null>(null)
|
||||
const heatmapWidth = ref(0)
|
||||
const cellSize = ref(10)
|
||||
const cellGap = ref(props.compact ? 2 : 4)
|
||||
const wideScreen = ref(true)
|
||||
const cellGap = computed(() => !props.compact && wideScreen.value ? 4 : 2)
|
||||
const tooltipRef = ref<HTMLElement | null>(null)
|
||||
const tooltip = ref<{ day: ActivityHeatmapDay | null; x: number; y: number; visible: boolean; below: boolean }>({
|
||||
day: null,
|
||||
@@ -236,16 +237,20 @@ const verticalGapStyle = computed(() => ({
|
||||
}))
|
||||
|
||||
const weekColumns = computed(() => {
|
||||
if (!props.data || !props.data.days || props.data.days.length === 0) {
|
||||
if (!props.data) {
|
||||
return []
|
||||
}
|
||||
|
||||
const dayEntries: DayWithMeta[] = props.data.days.map(day => ({
|
||||
const dayEntries: DayWithMeta[] = [...props.data.days].sort((a, b) => a.date.localeCompare(b.date)).map(day => ({
|
||||
...day,
|
||||
dateObj: new Date(`${day.date}T00:00:00Z`)
|
||||
}))
|
||||
|
||||
const firstDay = dayEntries[0]?.dateObj
|
||||
const firstDay = new Date(`${props.data.start_date}T00:00:00Z`)
|
||||
const lastDay = new Date(`${props.data.end_date}T00:00:00Z`)
|
||||
if (!Number.isFinite(firstDay.getTime()) || !Number.isFinite(lastDay.getTime()) || firstDay > lastDay) {
|
||||
return []
|
||||
}
|
||||
const padding: (DayWithMeta | null)[] = []
|
||||
if (firstDay) {
|
||||
const weekday = firstDay.getUTCDay() // 周日=0, 周一=1, ..., 周六=6
|
||||
@@ -254,7 +259,18 @@ const weekColumns = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
const paddedDays: (DayWithMeta | null)[] = [...padding, ...dayEntries]
|
||||
// Keep every date in the declared range, including inactive dates before the
|
||||
// first request and after the latest one.
|
||||
const daysByDate = new Map(dayEntries.map(day => [day.date, day]))
|
||||
const paddedDays: (DayWithMeta | null)[] = [...padding]
|
||||
if (firstDay && lastDay) {
|
||||
const cursor = new Date(firstDay)
|
||||
while (cursor <= lastDay) {
|
||||
const date = cursor.toISOString().slice(0, 10)
|
||||
paddedDays.push(daysByDate.get(date) ?? { date, requests: 0, dateObj: new Date(cursor) })
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1)
|
||||
}
|
||||
}
|
||||
const remainder = paddedDays.length % 7
|
||||
if (remainder !== 0) {
|
||||
for (let i = remainder; i < 7; i++) {
|
||||
@@ -356,12 +372,15 @@ const recalcCellSize = () => {
|
||||
const totalGap = Math.max(columnCount - 1, 0) * cellGap.value
|
||||
const availableSpace = Math.max(heatmapWidth.value - totalGap, 0)
|
||||
const rawSize = availableSpace / columnCount
|
||||
// 自适应尺寸,最小 6px
|
||||
cellSize.value = Math.max(6, rawSize)
|
||||
// Let a complete year fill the card, while short histories never grow cells
|
||||
// beyond the size they would have in a full calendar.
|
||||
const yearCellSize = (heatmapWidth.value - 52 * cellGap.value) / 53
|
||||
const maxSize = Math.max(props.compact ? 12 : 16, yearCellSize)
|
||||
cellSize.value = Math.min(maxSize, Math.max(6, rawSize))
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => heatmapWidth.value, () => weekColumns.value.length, () => cellGap.value],
|
||||
[heatmapWidth, () => weekColumns.value.length, cellGap, () => props.compact],
|
||||
() => {
|
||||
recalcCellSize()
|
||||
},
|
||||
@@ -394,8 +413,7 @@ onMounted(() => {
|
||||
}
|
||||
mediaQuery = window.matchMedia('(min-width: 640px)')
|
||||
const updateGap = () => {
|
||||
cellGap.value = !props.compact && mediaQuery && mediaQuery.matches ? 4 : 2
|
||||
recalcCellSize()
|
||||
wideScreen.value = mediaQuery?.matches ?? false
|
||||
}
|
||||
mediaQueryHandler = () => updateGap()
|
||||
updateGap()
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
|
||||
import ActivityHeatmap from '../ActivityHeatmap.vue'
|
||||
import type { ActivityHeatmap as HeatmapData } from '@/types/activity'
|
||||
|
||||
const mounted: Array<{ app: App; root: HTMLElement }> = []
|
||||
const observers: ResizeObserverStub[] = []
|
||||
|
||||
class ResizeObserverStub {
|
||||
target: Element | null = null
|
||||
constructor(private callback: ResizeObserverCallback) { observers.push(this) }
|
||||
observe(target: Element) { this.target = target }
|
||||
unobserve() {}
|
||||
disconnect() { this.target = null }
|
||||
resize(width: number) {
|
||||
if (!this.target) throw new Error('Heatmap has not registered its resize observer')
|
||||
this.callback([{ target: this.target, contentRect: { width } } as ResizeObserverEntry], this as unknown as ResizeObserver)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
observers.length = 0
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, root } of mounted.splice(0)) { app.unmount(); root.remove() }
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function heatmap(startDate: string, length: number): HeatmapData {
|
||||
const start = new Date(`${startDate}T00:00:00Z`).getTime()
|
||||
const days = Array.from({ length }, (_, index) => ({
|
||||
date: new Date(start + index * 86400000).toISOString().slice(0, 10),
|
||||
requests: index === length - 1 ? 250 : 0,
|
||||
}))
|
||||
return { start_date: startDate, end_date: days[days.length - 1]!.date, total_days: length, max_requests: 250, days }
|
||||
}
|
||||
|
||||
async function settle() { await nextTick(); await nextTick() }
|
||||
|
||||
async function mount(data: HeatmapData, compact = true) {
|
||||
const model = ref(data)
|
||||
const isCompact = ref(compact)
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(defineComponent({
|
||||
setup: () => () => h(ActivityHeatmap, { data: model.value, compact: isCompact.value, showHeader: false }),
|
||||
}))
|
||||
mounted.push({ app, root })
|
||||
app.mount(root)
|
||||
await settle()
|
||||
return { root, model, isCompact, observer: observers[observers.length - 1]! }
|
||||
}
|
||||
|
||||
function cells(root: HTMLElement) {
|
||||
return Array.from(root.querySelectorAll<HTMLElement>('.cell-emerge'))
|
||||
}
|
||||
|
||||
function size(root: HTMLElement) { return Number.parseFloat(cells(root)[0]!.style.width) }
|
||||
|
||||
describe('activity heatmap layout', () => {
|
||||
it.each([
|
||||
{ compact: true, length: 1, maximum: 18 },
|
||||
{ compact: false, length: 1, maximum: 16 },
|
||||
{ compact: true, length: 31, maximum: 18 },
|
||||
{ compact: false, length: 31, maximum: 16 },
|
||||
])('keeps $length days within $maximum px cells (compact: $compact)', async ({ compact, length, maximum }) => {
|
||||
const { root, observer } = await mount(heatmap('2026-10-05', length), compact)
|
||||
observer.resize(1040)
|
||||
await settle()
|
||||
expect(cells(root)).toHaveLength(length)
|
||||
for (const cell of cells(root)) {
|
||||
expect(Number.parseFloat(cell.style.width)).toBeLessThanOrEqual(maximum)
|
||||
expect(Number.parseFloat(cell.style.width)).toBeGreaterThanOrEqual(6)
|
||||
expect(cell.style.height).toBe(cell.style.width)
|
||||
}
|
||||
const firstWeek = cells(root)[0]!.parentElement!.parentElement!
|
||||
expect(firstWeek.children).toHaveLength(7)
|
||||
expect(firstWeek.children[0]!.querySelector('.cell-emerge')).toBeNull()
|
||||
expect(firstWeek.children[1]!.querySelector('.cell-emerge')).toBe(cells(root)[0])
|
||||
})
|
||||
|
||||
it('keeps a full year legible in a narrow container and resizes when its width or data changes', async () => {
|
||||
const { root, observer, model } = await mount(heatmap('2025-10-06', 365))
|
||||
observer.resize(280)
|
||||
await settle()
|
||||
expect(cells(root)).toHaveLength(365)
|
||||
expect(size(root)).toBe(6)
|
||||
observer.resize(640)
|
||||
await settle()
|
||||
expect(size(root)).toBeGreaterThan(6)
|
||||
expect(size(root)).toBeLessThanOrEqual(12)
|
||||
const yearCellSize = size(root)
|
||||
model.value = heatmap('2026-10-05', 1)
|
||||
await settle()
|
||||
expect(cells(root)).toHaveLength(1)
|
||||
expect(size(root)).toBeGreaterThan(yearCellSize)
|
||||
expect(size(root)).toBeLessThanOrEqual(12)
|
||||
model.value = heatmap('2025-10-06', 365)
|
||||
await settle()
|
||||
expect(size(root)).toBe(yearCellSize)
|
||||
observer.resize(1400)
|
||||
await settle()
|
||||
expect(size(root) * 53 + 52 * 2).toBeCloseTo(1400)
|
||||
expect(cells(root)[0]!.style.height).toBe(cells(root)[0]!.style.width)
|
||||
})
|
||||
|
||||
it('updates the cell size and spacing when compact mode changes', async () => {
|
||||
const { root, observer, isCompact } = await mount(heatmap('2026-10-05', 1), false)
|
||||
observer.resize(1040)
|
||||
await settle()
|
||||
expect(size(root)).toBeLessThanOrEqual(16)
|
||||
expect(cells(root)[0]!.parentElement!.parentElement!.style.rowGap).toBe('4px')
|
||||
isCompact.value = true
|
||||
await settle()
|
||||
expect(size(root)).toBeLessThanOrEqual(18)
|
||||
expect(cells(root)[0]!.parentElement!.parentElement!.style.rowGap).toBe('2px')
|
||||
})
|
||||
|
||||
it('preserves calendar gaps between activity dates without inventing token or cost data', async () => {
|
||||
const data = heatmap('2026-10-05', 4)
|
||||
data.days = [
|
||||
{ date: '2026-10-05', requests: 250, total_tokens: 1000, total_cost: 1.25 },
|
||||
{ date: '2026-10-08', requests: 650 },
|
||||
]
|
||||
data.max_requests = 650
|
||||
const { root } = await mount(data)
|
||||
const days = cells(root)
|
||||
expect(days).toHaveLength(4)
|
||||
expect(days[0]!.title).toContain('250')
|
||||
expect(days[0]!.title).toContain('$1.25')
|
||||
expect(days[3]!.title).toContain('650')
|
||||
for (const [index, date] of ['2026-10-06', '2026-10-07'].entries()) {
|
||||
const cell = days[index + 1]!
|
||||
expect(cell.title).toContain(new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeZone: 'UTC' }).format(new Date(`${date}T00:00:00Z`)))
|
||||
expect(cell.title.split(' · ')[1]).toMatch(/^0\D/)
|
||||
expect(cell.title).not.toContain('tokens')
|
||||
expect(cell.title).not.toContain('$')
|
||||
}
|
||||
const week = days[0]!.parentElement!.parentElement!
|
||||
expect(week.children[1]!.querySelector('.cell-emerge')).toBe(days[0])
|
||||
expect(week.children[4]!.querySelector('.cell-emerge')).toBe(days[3])
|
||||
})
|
||||
|
||||
it.each([false, true])('renders the full declared year with sparse or empty activity (empty: %s)', async empty => {
|
||||
const data = heatmap('2025-10-06', 365)
|
||||
data.days = empty ? [] : [{ date: '2026-05-17', requests: 250 }]
|
||||
const { root, observer } = await mount(data)
|
||||
observer.resize(1040)
|
||||
await settle()
|
||||
const days = cells(root)
|
||||
expect(days).toHaveLength(365)
|
||||
expect(days[0]!.title).toContain('2025年10月6日')
|
||||
expect(days[364]!.title).toContain('2026年10月5日')
|
||||
expect(days[0]!.title.split(' · ')[1]).toMatch(/^0\D/)
|
||||
expect(days[364]!.title.split(' · ')[1]).toMatch(/^0\D/)
|
||||
expect(days.filter(day => day.title.split(' · ')[1]?.startsWith('250'))).toHaveLength(empty ? 0 : 1)
|
||||
expect(size(root) * 53 + 52 * 2).toBeCloseTo(1040)
|
||||
})
|
||||
})
|
||||
@@ -341,7 +341,7 @@
|
||||
:data="activityHeatmap"
|
||||
:consecutive-active-days="dashboardSnapshot?.consecutive_active_days ?? null"
|
||||
:active-days="dashboardSnapshot?.active_days ?? null"
|
||||
:scope-hint="statisticsScope + ' · ' + t('展示近365天', 'Last 365 days shown')"
|
||||
:scope-hint="activityScope"
|
||||
:loading="loading"
|
||||
:error="false"
|
||||
:timeline-data="isDemo ? demoTimeline : undefined"
|
||||
@@ -843,14 +843,29 @@ const todayStats = ref<{
|
||||
const dashboardSnapshot = ref<OverviewDashboardSummary | null>(null);
|
||||
const statsSince = computed(() => timestamp(dashboardSnapshot.value?.stats_since, dashboardSnapshot.value?.timezone));
|
||||
const statisticsScope = computed(() => `${t('统计自', 'Statistics since')} ${statsSince.value} · ${t('更新于', 'Updated at')} ${timestamp(dashboardSnapshot.value?.generated_at, dashboardSnapshot.value?.timezone)}`);
|
||||
const activityScope = computed(() => [
|
||||
statisticsScope.value,
|
||||
t('展示近365天', 'Last 365 days shown'),
|
||||
dashboardSnapshot.value?.activity_timezone ?? dashboardSnapshot.value?.timezone,
|
||||
].filter(Boolean).join(' · '));
|
||||
const activityHeatmap = computed<ActivityHeatmap | null>(() => {
|
||||
const snapshot = dashboardSnapshot.value;
|
||||
if (!snapshot) return null;
|
||||
const days = snapshot.activity_days;
|
||||
const dateParts = new Intl.DateTimeFormat('en', {
|
||||
timeZone: snapshot.activity_timezone ?? snapshot.timezone,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
}).formatToParts(new Date(snapshot.generated_at));
|
||||
const datePart = (type: Intl.DateTimeFormatPartTypes) => Number(dateParts.find(part => part.type === type)?.value);
|
||||
const today = Date.UTC(datePart('year'), datePart('month') - 1, datePart('day'));
|
||||
const requestsByDate = new Map(snapshot.activity_days.map(day => [day.date, day.requests]));
|
||||
const days = Array.from({ length: 365 }, (_, index) => {
|
||||
const date = new Date(today - (364 - index) * 86_400_000).toISOString().slice(0, 10);
|
||||
return { date, requests: requestsByDate.get(date) ?? 0 };
|
||||
});
|
||||
return {
|
||||
start_date: days[0]?.date ?? snapshot.stats_since.slice(0, 10),
|
||||
end_date: days[days.length - 1]?.date ?? snapshot.generated_at.slice(0, 10),
|
||||
total_days: days.length,
|
||||
start_date: new Date(today - 364 * 86_400_000).toISOString().slice(0, 10),
|
||||
end_date: new Date(today).toISOString().slice(0, 10),
|
||||
total_days: 365,
|
||||
max_requests: days.reduce((max, day) => Math.max(max, day.requests), 0),
|
||||
days,
|
||||
};
|
||||
|
||||
@@ -8,7 +8,25 @@ const api = vi.hoisted(() => ({ dashboardSummary: vi.fn(), summary: vi.fn(), das
|
||||
vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ canAccessAdmin: true, isAdmin: true, isAuditAdmin: false }) }))
|
||||
vi.mock('@/api/overview', () => ({ overviewApi: api }))
|
||||
vi.mock('@/api/dashboard', () => ({ dashboardApi: { getDailyStats: api.daily } }))
|
||||
vi.mock('@/features/overview/dashboard/DashboardActivity.vue', () => ({ default: { render: () => null } }))
|
||||
vi.mock('@/features/overview/dashboard/DashboardActivity.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return {
|
||||
default: defineComponent({
|
||||
props: { data: Object, scopeHint: String, consecutiveActiveDays: Number, activeDays: Number },
|
||||
setup: props => () => h('section', {
|
||||
'data-dashboard-activity': '',
|
||||
title: props.scopeHint,
|
||||
'data-start-date': props.data?.start_date,
|
||||
'data-end-date': props.data?.end_date,
|
||||
'data-total-days': props.data?.total_days,
|
||||
}, [
|
||||
h('span', `${props.consecutiveActiveDays} / ${props.activeDays}`),
|
||||
...((props.data?.days ?? []) as { date: string; requests: number }[]).map(day =>
|
||||
h('span', { 'data-activity-date': day.date }, String(day.requests))),
|
||||
]),
|
||||
}),
|
||||
}
|
||||
})
|
||||
vi.mock('@/features/overview/dashboard/DashboardAnnouncements.vue', () => ({ default: { render: () => null } }))
|
||||
vi.mock('@/components/charts/BarChart.vue', () => ({ default: { render: () => null } }))
|
||||
vi.mock('@/components/charts/DoughnutChart.vue', () => ({ default: { render: () => null } }))
|
||||
@@ -55,6 +73,99 @@ describe('dashboard snapshot loading', () => {
|
||||
expect(api.daily).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('labels restored history with its UTC activity dates independently of the dashboard timezone', async () => {
|
||||
const snapshot = dashboardSummary()
|
||||
snapshot.timezone = 'Asia/Shanghai'
|
||||
snapshot.activity_timezone = 'UTC'
|
||||
snapshot.stats_since = '2026-05-20T00:00:00Z'
|
||||
snapshot.activity_days = [{ date: '2026-05-20', requests: 1234 }, ...snapshot.activity_days]
|
||||
api.dashboardSummary.mockResolvedValue(snapshot)
|
||||
const root = mount()
|
||||
await settle()
|
||||
|
||||
const activity = root.querySelector('[data-dashboard-activity]')
|
||||
expect(activity?.getAttribute('title')).toContain('展示近365天 · UTC')
|
||||
expect(activity?.getAttribute('title')).toContain('统计自 2026/5/20 08:00')
|
||||
expect(activity?.textContent).toContain('2 / 413')
|
||||
expect(activity?.querySelector('[data-activity-date="2026-05-20"]')?.textContent).toBe('1234')
|
||||
})
|
||||
|
||||
it('uses the dashboard timezone for activity when the optional activity timezone is absent', async () => {
|
||||
const snapshot = dashboardSummary()
|
||||
snapshot.timezone = 'Asia/Shanghai'
|
||||
api.dashboardSummary.mockResolvedValue(snapshot)
|
||||
const root = mount()
|
||||
await settle()
|
||||
|
||||
expect(root.querySelector('[data-dashboard-activity]')?.getAttribute('title'))
|
||||
.toContain('展示近365天 · Asia/Shanghai')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ activityTimezone: 'UTC', start: '2025-09-20', end: '2026-09-19' },
|
||||
{ activityTimezone: 'Asia/Shanghai', start: '2025-09-21', end: '2026-09-20' },
|
||||
{ activityTimezone: undefined, start: '2025-09-21', end: '2026-09-20' },
|
||||
])('ends the 365-day calendar on $end for activity timezone $activityTimezone', async ({ activityTimezone, start, end }) => {
|
||||
const snapshot = dashboardSummary()
|
||||
snapshot.timezone = 'Asia/Shanghai'
|
||||
snapshot.activity_timezone = activityTimezone
|
||||
snapshot.generated_at = '2026-09-19T16:30:00Z'
|
||||
api.dashboardSummary.mockResolvedValue(snapshot)
|
||||
const root = mount()
|
||||
await settle()
|
||||
|
||||
const activity = root.querySelector('[data-dashboard-activity]')
|
||||
const days = activity?.querySelectorAll('[data-activity-date]')
|
||||
expect(activity?.getAttribute('data-start-date')).toBe(start)
|
||||
expect(activity?.getAttribute('data-end-date')).toBe(end)
|
||||
expect(activity?.getAttribute('data-total-days')).toBe('365')
|
||||
expect(days).toHaveLength(365)
|
||||
expect(days?.[0]?.getAttribute('data-activity-date')).toBe(start)
|
||||
expect(days?.[364]?.getAttribute('data-activity-date')).toBe(end)
|
||||
})
|
||||
|
||||
it('includes empty days at both ends and between requests without changing lifetime active counts', async () => {
|
||||
const snapshot = dashboardSummary()
|
||||
snapshot.activity_days = [
|
||||
{ date: '2025-09-19', requests: 9999 },
|
||||
{ date: '2026-09-16', requests: 50 },
|
||||
{ date: '2026-09-18', requests: 10 },
|
||||
{ date: '2026-09-20', requests: 9999 },
|
||||
]
|
||||
api.dashboardSummary.mockResolvedValue(snapshot)
|
||||
const root = mount()
|
||||
await settle()
|
||||
|
||||
const activity = root.querySelector('[data-dashboard-activity]')
|
||||
expect(activity?.querySelectorAll('[data-activity-date]')).toHaveLength(365)
|
||||
for (const date of ['2025-09-20', '2026-09-17', '2026-09-19']) {
|
||||
expect(activity?.querySelector(`[data-activity-date="${date}"]`)?.textContent).toBe('0')
|
||||
}
|
||||
expect(activity?.querySelector('[data-activity-date="2026-09-16"]')?.textContent).toBe('50')
|
||||
expect(activity?.querySelector('[data-activity-date="2026-09-18"]')?.textContent).toBe('10')
|
||||
expect(activity?.querySelector('[data-activity-date="2025-09-19"]')).toBeNull()
|
||||
expect(activity?.querySelector('[data-activity-date="2026-09-20"]')).toBeNull()
|
||||
expect(activity?.textContent).toContain('2 / 413')
|
||||
})
|
||||
|
||||
it('shows all 365 zero-request days when no usage history exists', async () => {
|
||||
const snapshot = dashboardSummary()
|
||||
snapshot.activity_days = []
|
||||
snapshot.active_days = 0
|
||||
snapshot.consecutive_active_days = 0
|
||||
api.dashboardSummary.mockResolvedValue(snapshot)
|
||||
const root = mount()
|
||||
await settle()
|
||||
|
||||
const activity = root.querySelector('[data-dashboard-activity]')
|
||||
const days = activity?.querySelectorAll('[data-activity-date]')
|
||||
expect(days).toHaveLength(365)
|
||||
expect(Array.from(days ?? []).every(day => day.textContent === '0')).toBe(true)
|
||||
expect(activity?.getAttribute('data-start-date')).toBe('2025-09-20')
|
||||
expect(activity?.getAttribute('data-end-date')).toBe('2026-09-19')
|
||||
expect(activity?.textContent).toContain('0 / 0')
|
||||
})
|
||||
|
||||
it('does not loop requests with the real time picker when summary fails', async () => {
|
||||
const snapshot = deferred<OverviewDashboardSummary>()
|
||||
api.dashboardSummary.mockReturnValue(snapshot.promise)
|
||||
|
||||
Reference in New Issue
Block a user