diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index 79a3280fa..8c9047310 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -35,18 +35,26 @@ pub fn get_usage_trends( #[tauri::command] pub fn get_provider_stats( state: State<'_, AppState>, + start_date: Option, + end_date: Option, app_type: Option, ) -> Result, AppError> { - state.db.get_provider_stats(app_type.as_deref()) + state + .db + .get_provider_stats(start_date, end_date, app_type.as_deref()) } /// 获取模型统计 #[tauri::command] pub fn get_model_stats( state: State<'_, AppState>, + start_date: Option, + end_date: Option, app_type: Option, ) -> Result, AppError> { - state.db.get_model_stats(app_type.as_deref()) + state + .db + .get_model_stats(start_date, end_date, app_type.as_deref()) } /// 获取请求日志列表 diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index b43ea714a..706fd9f09 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -4,7 +4,7 @@ use crate::database::{lock_conn, Database}; use crate::error::AppError; -use chrono::{Local, TimeZone}; +use chrono::{Local, NaiveDate, TimeZone, Timelike}; use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -130,6 +130,96 @@ fn provider_name_coalesce(log_alias: &str, provider_alias: &str) -> String { ) } +#[derive(Debug, Clone, Default)] +struct RollupDateBounds { + start: Option, + end: Option, + is_empty: bool, +} + +fn local_datetime_from_timestamp(ts: i64) -> Result, AppError> { + Local + .timestamp_opt(ts, 0) + .single() + .ok_or_else(|| AppError::Database(format!("无法解析本地时间戳: {ts}"))) +} + +fn compute_rollup_date_bounds( + start_ts: Option, + end_ts: Option, +) -> Result { + let start = match start_ts { + Some(ts) => { + let local = local_datetime_from_timestamp(ts)?; + let day = local.date_naive(); + if local.time().num_seconds_from_midnight() == 0 { + Some(day.format("%Y-%m-%d").to_string()) + } else { + day.succ_opt() + .map(|next| next.format("%Y-%m-%d").to_string()) + } + } + None => None, + }; + + let end = match end_ts { + Some(ts) => { + let local = local_datetime_from_timestamp(ts)?; + let day = local.date_naive(); + if local.time().hour() == 23 && local.time().minute() == 59 { + Some(day.format("%Y-%m-%d").to_string()) + } else { + day.pred_opt() + .map(|prev| prev.format("%Y-%m-%d").to_string()) + } + } + None => None, + }; + + let is_empty = matches!((&start, &end), (Some(start), Some(end)) if start > end); + + Ok(RollupDateBounds { + start, + end, + is_empty, + }) +} + +fn push_rollup_date_filters( + conditions: &mut Vec, + params: &mut Vec>, + column: &str, + bounds: &RollupDateBounds, +) { + if bounds.is_empty { + conditions.push("1 = 0".to_string()); + return; + } + + if let Some(start) = &bounds.start { + conditions.push(format!("{column} >= ?")); + params.push(Box::new(start.clone())); + } + + if let Some(end) = &bounds.end { + conditions.push(format!("{column} <= ?")); + params.push(Box::new(end.clone())); + } +} + +fn local_day_start_rfc3339(day: NaiveDate) -> String { + let local_midnight = day + .and_hms_opt(0, 0, 0) + .and_then(|naive| match Local.from_local_datetime(&naive) { + chrono::LocalResult::Single(dt) => Some(dt), + chrono::LocalResult::Ambiguous(earliest, _) => Some(earliest), + chrono::LocalResult::None => None, + }) + .unwrap_or_else(Local::now); + + local_midnight.to_rfc3339() +} + impl Database { /// 获取使用量汇总 pub fn get_usage_summary( @@ -163,18 +253,17 @@ impl Database { format!("WHERE {}", conditions.join(" AND ")) }; - // Build rollup WHERE clause using date strings + // Only include rolled-up rows for full local days that are fully covered by the range. let mut rollup_conditions: Vec = Vec::new(); let mut rollup_params: Vec> = Vec::new(); + let rollup_bounds = compute_rollup_date_bounds(start_date, end_date)?; - if let Some(start) = start_date { - rollup_conditions.push("date >= date(?, 'unixepoch', 'localtime')".to_string()); - rollup_params.push(Box::new(start)); - } - if let Some(end) = end_date { - rollup_conditions.push("date <= date(?, 'unixepoch', 'localtime')".to_string()); - rollup_params.push(Box::new(end)); - } + push_rollup_date_filters( + &mut rollup_conditions, + &mut rollup_params, + "date", + &rollup_bounds, + ); if let Some(at) = app_type { rollup_conditions.push("app_type = ?".to_string()); rollup_params.push(Box::new(at.to_string())); @@ -267,36 +356,114 @@ impl Database { } let duration = end_ts - start_ts; - let bucket_seconds: i64 = if duration <= 24 * 60 * 60 { - 60 * 60 - } else { - 24 * 60 * 60 - }; - let mut bucket_count: i64 = if duration <= 0 { - 1 - } else { - ((duration as f64) / bucket_seconds as f64).ceil() as i64 - }; + if duration <= 24 * 60 * 60 { + let bucket_seconds: i64 = 60 * 60; + let mut bucket_count: i64 = if duration <= 0 { + 1 + } else { + (duration + bucket_seconds - 1) / bucket_seconds + }; - // 固定 24 小时窗口为 24 个小时桶,避免浮点误差 - if bucket_seconds == 60 * 60 { - bucket_count = 24; - } + if bucket_count < 1 { + bucket_count = 1; + } + + let app_type_filter = if app_type.is_some() { + "AND app_type = ?4" + } else { + "" + }; + + let sql = format!( + "SELECT + CAST((created_at - ?1) / ?3 AS INTEGER) as bucket_idx, + COUNT(*) as request_count, + COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost, + COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, + COALESCE(SUM(input_tokens), 0) as total_input_tokens, + COALESCE(SUM(output_tokens), 0) as total_output_tokens, + COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens + FROM proxy_request_logs + WHERE created_at >= ?1 AND created_at <= ?2 {app_type_filter} + GROUP BY bucket_idx + ORDER BY bucket_idx ASC" + ); + + let mut stmt = conn.prepare(&sql)?; + let row_mapper = |row: &rusqlite::Row| { + Ok(( + row.get::<_, i64>(0)?, + DailyStats { + date: String::new(), + request_count: row.get::<_, i64>(1)? as u64, + total_cost: format!("{:.6}", row.get::<_, f64>(2)?), + total_tokens: row.get::<_, i64>(3)? as u64, + total_input_tokens: row.get::<_, i64>(4)? as u64, + total_output_tokens: row.get::<_, i64>(5)? as u64, + total_cache_creation_tokens: row.get::<_, i64>(6)? as u64, + total_cache_read_tokens: row.get::<_, i64>(7)? as u64, + }, + )) + }; + + let mut map: HashMap = HashMap::new(); + + let rows = if let Some(at) = app_type { + stmt.query_map(params![start_ts, end_ts, bucket_seconds, at], row_mapper)? + } else { + stmt.query_map(params![start_ts, end_ts, bucket_seconds], row_mapper)? + }; + for row in rows { + let (mut bucket_idx, stat) = row?; + if bucket_idx < 0 { + continue; + } + if bucket_idx >= bucket_count { + bucket_idx = bucket_count - 1; + } + map.insert(bucket_idx, stat); + } + + let mut stats = Vec::with_capacity(bucket_count as usize); + for i in 0..bucket_count { + let bucket_start_ts = start_ts + i * bucket_seconds; + let bucket_start = local_datetime_from_timestamp(bucket_start_ts)?; + let date = bucket_start.to_rfc3339(); + + if let Some(mut stat) = map.remove(&i) { + stat.date = date; + stats.push(stat); + } else { + stats.push(DailyStats { + date, + request_count: 0, + total_cost: "0.000000".to_string(), + total_tokens: 0, + total_input_tokens: 0, + total_output_tokens: 0, + total_cache_creation_tokens: 0, + total_cache_read_tokens: 0, + }); + } + } - if bucket_count < 1 { - bucket_count = 1; + return Ok(stats); } + let start_day = local_datetime_from_timestamp(start_ts)?.date_naive(); + let end_day = local_datetime_from_timestamp(end_ts)?.date_naive(); + let bucket_count = (end_day.signed_duration_since(start_day).num_days() + 1) as usize; + let app_type_filter = if app_type.is_some() { - "AND app_type = ?4" + "AND app_type = ?3" } else { "" }; - // Query detail logs - let sql = format!( + let detail_sql = format!( "SELECT - CAST((created_at - ?1) / ?3 AS INTEGER) as bucket_idx, + date(created_at, 'unixepoch', 'localtime') as bucket_date, COUNT(*) as request_count, COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost, COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, @@ -306,14 +473,14 @@ impl Database { COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens FROM proxy_request_logs WHERE created_at >= ?1 AND created_at <= ?2 {app_type_filter} - GROUP BY bucket_idx - ORDER BY bucket_idx ASC" + GROUP BY bucket_date + ORDER BY bucket_date ASC" ); - let mut stmt = conn.prepare(&sql)?; - let row_mapper = |row: &rusqlite::Row| { + let mut detail_stmt = conn.prepare(&detail_sql)?; + let detail_row_mapper = |row: &rusqlite::Row| { Ok(( - row.get::<_, i64>(0)?, + row.get::<_, String>(0)?, DailyStats { date: String::new(), request_count: row.get::<_, i64>(1)? as u64, @@ -327,110 +494,105 @@ impl Database { )) }; - let mut map: HashMap = HashMap::new(); + let mut map: HashMap = HashMap::new(); + let detail_rows = if let Some(at) = app_type { + detail_stmt.query_map(params![start_ts, end_ts, at], detail_row_mapper)? + } else { + detail_stmt.query_map(params![start_ts, end_ts], detail_row_mapper)? + }; - // Collect rows into map (need to handle both param variants) - { - let rows = if let Some(at) = app_type { - stmt.query_map(params![start_ts, end_ts, bucket_seconds, at], row_mapper)? - } else { - stmt.query_map(params![start_ts, end_ts, bucket_seconds], row_mapper)? - }; - for row in rows { - let (mut bucket_idx, stat) = row?; - if bucket_idx < 0 { - continue; - } - if bucket_idx >= bucket_count { - bucket_idx = bucket_count - 1; - } - map.insert(bucket_idx, stat); - } + for row in detail_rows { + let (bucket_date, stat) = row?; + let date = NaiveDate::parse_from_str(&bucket_date, "%Y-%m-%d") + .map_err(|err| AppError::Database(format!("解析趋势日期失败: {err}")))?; + map.insert(date, stat); } - // Also query rollup data (daily granularity, only useful for daily buckets) - if bucket_seconds >= 86400 { - let rollup_sql = format!( - "SELECT - CAST((CAST(strftime('%s', date) AS INTEGER) - ?1) / ?3 AS INTEGER) as bucket_idx, - COALESCE(SUM(request_count), 0), - COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0), - COALESCE(SUM(input_tokens + output_tokens), 0), - COALESCE(SUM(input_tokens), 0), - COALESCE(SUM(output_tokens), 0), - COALESCE(SUM(cache_creation_tokens), 0), - COALESCE(SUM(cache_read_tokens), 0) - FROM usage_daily_rollups - WHERE date >= date(?1, 'unixepoch', 'localtime') AND date <= date(?2, 'unixepoch', 'localtime') {app_type_filter} - GROUP BY bucket_idx - ORDER BY bucket_idx ASC" - ); + let rollup_bounds = compute_rollup_date_bounds(Some(start_ts), Some(end_ts))?; + let mut rollup_conditions = Vec::new(); + let mut rollup_params: Vec> = Vec::new(); + push_rollup_date_filters( + &mut rollup_conditions, + &mut rollup_params, + "date", + &rollup_bounds, + ); + if let Some(at) = app_type { + rollup_conditions.push("app_type = ?".to_string()); + rollup_params.push(Box::new(at.to_string())); + } - let rollup_row_mapper = |row: &rusqlite::Row| { - Ok(( - row.get::<_, i64>(0)?, - ( - row.get::<_, i64>(1)? as u64, - row.get::<_, f64>(2)?, - row.get::<_, i64>(3)? as u64, - row.get::<_, i64>(4)? as u64, - row.get::<_, i64>(5)? as u64, - row.get::<_, i64>(6)? as u64, - row.get::<_, i64>(7)? as u64, - ), - )) - }; + let rollup_where = if rollup_conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", rollup_conditions.join(" AND ")) + }; - let mut rstmt = conn.prepare(&rollup_sql)?; - let rrows = if let Some(at) = app_type { - rstmt.query_map( - params![start_ts, end_ts, bucket_seconds, at], - rollup_row_mapper, - )? - } else { - rstmt.query_map(params![start_ts, end_ts, bucket_seconds], rollup_row_mapper)? - }; + let rollup_sql = format!( + "SELECT + date, + COALESCE(SUM(request_count), 0), + COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0), + COALESCE(SUM(input_tokens + output_tokens), 0), + COALESCE(SUM(input_tokens), 0), + COALESCE(SUM(output_tokens), 0), + COALESCE(SUM(cache_creation_tokens), 0), + COALESCE(SUM(cache_read_tokens), 0) + FROM usage_daily_rollups + {rollup_where} + GROUP BY date + ORDER BY date ASC" + ); - for row in rrows { - let (mut bucket_idx, (req, cost, tok, inp, out, cc, cr)) = row?; - if bucket_idx < 0 { - continue; - } - if bucket_idx >= bucket_count { - bucket_idx = bucket_count - 1; - } - let entry = map.entry(bucket_idx).or_insert_with(|| DailyStats { - date: String::new(), - request_count: 0, - total_cost: "0.000000".to_string(), - total_tokens: 0, - total_input_tokens: 0, - total_output_tokens: 0, - total_cache_creation_tokens: 0, - total_cache_read_tokens: 0, - }); - entry.request_count += req; - let existing_cost: f64 = entry.total_cost.parse().unwrap_or(0.0); - entry.total_cost = format!("{:.6}", existing_cost + cost); - entry.total_tokens += tok; - entry.total_input_tokens += inp; - entry.total_output_tokens += out; - entry.total_cache_creation_tokens += cc; - entry.total_cache_read_tokens += cr; - } + let mut rollup_stmt = conn.prepare(&rollup_sql)?; + let rollup_row_mapper = |row: &rusqlite::Row| { + Ok(( + row.get::<_, String>(0)?, + ( + row.get::<_, i64>(1)? as u64, + row.get::<_, f64>(2)?, + row.get::<_, i64>(3)? as u64, + row.get::<_, i64>(4)? as u64, + row.get::<_, i64>(5)? as u64, + row.get::<_, i64>(6)? as u64, + row.get::<_, i64>(7)? as u64, + ), + )) + }; + let rollup_param_refs: Vec<&dyn rusqlite::ToSql> = + rollup_params.iter().map(|param| param.as_ref()).collect(); + let rollup_rows = rollup_stmt.query_map(rollup_param_refs.as_slice(), rollup_row_mapper)?; + + for row in rollup_rows { + let (bucket_date, (req, cost, tok, inp, out, cc, cr)) = row?; + let date = NaiveDate::parse_from_str(&bucket_date, "%Y-%m-%d") + .map_err(|err| AppError::Database(format!("解析 rollup 趋势日期失败: {err}")))?; + let entry = map.entry(date).or_insert_with(|| DailyStats { + date: String::new(), + request_count: 0, + total_cost: "0.000000".to_string(), + total_tokens: 0, + total_input_tokens: 0, + total_output_tokens: 0, + total_cache_creation_tokens: 0, + total_cache_read_tokens: 0, + }); + entry.request_count += req; + let existing_cost: f64 = entry.total_cost.parse().unwrap_or(0.0); + entry.total_cost = format!("{:.6}", existing_cost + cost); + entry.total_tokens += tok; + entry.total_input_tokens += inp; + entry.total_output_tokens += out; + entry.total_cache_creation_tokens += cc; + entry.total_cache_read_tokens += cr; } - let mut stats = Vec::with_capacity(bucket_count as usize); - for i in 0..bucket_count { - let bucket_start_ts = start_ts + i * bucket_seconds; - let bucket_start = Local - .timestamp_opt(bucket_start_ts, 0) - .single() - .unwrap_or_else(Local::now); + let mut stats = Vec::with_capacity(bucket_count); + let mut current_day = start_day; + for _ in 0..bucket_count { + let date = local_day_start_rfc3339(current_day); - let date = bucket_start.to_rfc3339(); - - if let Some(mut stat) = map.remove(&i) { + if let Some(mut stat) = map.remove(¤t_day) { stat.date = date; stats.push(stat); } else { @@ -445,6 +607,8 @@ impl Database { total_cache_read_tokens: 0, }); } + + current_day = current_day.succ_opt().unwrap_or(current_day); } Ok(stats) @@ -453,14 +617,49 @@ impl Database { /// 获取 Provider 统计 pub fn get_provider_stats( &self, + start_date: Option, + end_date: Option, app_type: Option<&str>, ) -> Result, AppError> { let conn = lock_conn!(self.conn); - let (detail_where, rollup_where) = if app_type.is_some() { - ("WHERE l.app_type = ?1", "WHERE r.app_type = ?2") + let mut detail_conditions = Vec::new(); + let mut detail_params: Vec> = Vec::new(); + if let Some(start) = start_date { + detail_conditions.push("l.created_at >= ?"); + detail_params.push(Box::new(start)); + } + if let Some(end) = end_date { + detail_conditions.push("l.created_at <= ?"); + detail_params.push(Box::new(end)); + } + if let Some(at) = app_type { + detail_conditions.push("l.app_type = ?"); + detail_params.push(Box::new(at.to_string())); + } + let detail_where = if detail_conditions.is_empty() { + String::new() } else { - ("", "") + format!("WHERE {}", detail_conditions.join(" AND ")) + }; + + let mut rollup_conditions = Vec::new(); + let mut rollup_params: Vec> = Vec::new(); + let rollup_bounds = compute_rollup_date_bounds(start_date, end_date)?; + push_rollup_date_filters( + &mut rollup_conditions, + &mut rollup_params, + "r.date", + &rollup_bounds, + ); + if let Some(at) = app_type { + rollup_conditions.push("r.app_type = ?".to_string()); + rollup_params.push(Box::new(at.to_string())); + } + let rollup_where = if rollup_conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", rollup_conditions.join(" AND ")) }; // UNION detail logs + rollup data, then aggregate @@ -506,6 +705,9 @@ impl Database { ); let mut stmt = conn.prepare(&sql)?; + let mut params: Vec> = detail_params; + params.extend(rollup_params); + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let row_mapper = |row: &rusqlite::Row| { let request_count: i64 = row.get(3)?; let success_count: i64 = row.get(6)?; @@ -526,11 +728,7 @@ impl Database { }) }; - let rows = if let Some(at) = app_type { - stmt.query_map(params![at, at], row_mapper)? - } else { - stmt.query_map([], row_mapper)? - }; + let rows = stmt.query_map(param_refs.as_slice(), row_mapper)?; let mut stats = Vec::new(); for row in rows { @@ -541,13 +739,51 @@ impl Database { } /// 获取模型统计 - pub fn get_model_stats(&self, app_type: Option<&str>) -> Result, AppError> { + pub fn get_model_stats( + &self, + start_date: Option, + end_date: Option, + app_type: Option<&str>, + ) -> Result, AppError> { let conn = lock_conn!(self.conn); - let (detail_where, rollup_where) = if app_type.is_some() { - ("WHERE app_type = ?1", "WHERE app_type = ?2") + let mut detail_conditions = Vec::new(); + let mut detail_params: Vec> = Vec::new(); + if let Some(start) = start_date { + detail_conditions.push("l.created_at >= ?"); + detail_params.push(Box::new(start)); + } + if let Some(end) = end_date { + detail_conditions.push("l.created_at <= ?"); + detail_params.push(Box::new(end)); + } + if let Some(at) = app_type { + detail_conditions.push("l.app_type = ?"); + detail_params.push(Box::new(at.to_string())); + } + let detail_where = if detail_conditions.is_empty() { + String::new() } else { - ("", "") + format!("WHERE {}", detail_conditions.join(" AND ")) + }; + + let mut rollup_conditions = Vec::new(); + let mut rollup_params: Vec> = Vec::new(); + let rollup_bounds = compute_rollup_date_bounds(start_date, end_date)?; + push_rollup_date_filters( + &mut rollup_conditions, + &mut rollup_params, + "r.date", + &rollup_bounds, + ); + if let Some(at) = app_type { + rollup_conditions.push("r.app_type = ?".to_string()); + rollup_params.push(Box::new(at.to_string())); + } + let rollup_where = if rollup_conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", rollup_conditions.join(" AND ")) }; // UNION detail logs + rollup data @@ -558,27 +794,30 @@ impl Database { SUM(total_tokens) as total_tokens, SUM(total_cost) as total_cost FROM ( - SELECT model, + SELECT l.model, COUNT(*) as request_count, - COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, - COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost - FROM proxy_request_logs + COALESCE(SUM(l.input_tokens + l.output_tokens), 0) as total_tokens, + COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost + FROM proxy_request_logs l {detail_where} - GROUP BY model + GROUP BY l.model UNION ALL - SELECT model, + SELECT r.model, COALESCE(SUM(request_count), 0), COALESCE(SUM(input_tokens + output_tokens), 0), COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) - FROM usage_daily_rollups + FROM usage_daily_rollups r {rollup_where} - GROUP BY model + GROUP BY r.model ) GROUP BY model ORDER BY total_cost DESC" ); let mut stmt = conn.prepare(&sql)?; + let mut params: Vec> = detail_params; + params.extend(rollup_params); + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let row_mapper = |row: &rusqlite::Row| { let request_count: i64 = row.get(1)?; let total_cost: f64 = row.get(3)?; @@ -597,11 +836,7 @@ impl Database { }) }; - let rows = if let Some(at) = app_type { - stmt.query_map(params![at, at], row_mapper)? - } else { - stmt.query_map([], row_mapper)? - }; + let rows = stmt.query_map(param_refs.as_slice(), row_mapper)?; let mut stats = Vec::new(); for row in rows { @@ -1108,6 +1343,14 @@ pub(crate) fn find_model_pricing_row( mod tests { use super::*; + fn local_ts(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> i64 { + match Local.with_ymd_and_hms(year, month, day, hour, minute, second) { + chrono::LocalResult::Single(dt) => dt.timestamp(), + chrono::LocalResult::Ambiguous(earliest, _) => earliest.timestamp(), + chrono::LocalResult::None => panic!("valid local datetime"), + } + } + #[test] fn test_get_usage_summary() -> Result<(), AppError> { let db = Database::memory()?; @@ -1140,6 +1383,148 @@ mod tests { Ok(()) } + #[test] + fn test_get_usage_summary_excludes_partial_rollup_boundary_days() -> Result<(), AppError> { + let db = Database::memory()?; + let start = local_ts(2024, 1, 1, 12, 0, 0); + let end = local_ts(2024, 1, 3, 12, 0, 0); + + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO usage_daily_rollups ( + date, app_type, provider_id, model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "2024-01-01", + "claude", + "p1", + "claude-3", + 10, + 10, + 1000, + 500, + 0, + 0, + "1.00", + 100 + ], + )?; + conn.execute( + "INSERT INTO usage_daily_rollups ( + date, app_type, provider_id, model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "2024-01-02", + "claude", + "p1", + "claude-3", + 20, + 19, + 2000, + 1000, + 0, + 0, + "2.00", + 120 + ], + )?; + conn.execute( + "INSERT INTO usage_daily_rollups ( + date, app_type, provider_id, model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "2024-01-03", + "claude", + "p1", + "claude-3", + 30, + 29, + 3000, + 1500, + 0, + 0, + "3.00", + 140 + ], + )?; + } + + let summary = db.get_usage_summary(Some(start), Some(end), Some("claude"))?; + assert_eq!(summary.total_requests, 20); + assert_eq!(summary.total_input_tokens, 2000); + assert_eq!(summary.total_output_tokens, 1000); + + Ok(()) + } + + #[test] + fn test_get_usage_summary_includes_end_day_rollup_for_minute_precision_end_time( + ) -> Result<(), AppError> { + let db = Database::memory()?; + let start = local_ts(2024, 1, 1, 0, 0, 0); + let end = local_ts(2024, 1, 2, 23, 59, 0); + + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO usage_daily_rollups ( + date, app_type, provider_id, model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "2024-01-01", + "claude", + "p1", + "claude-3", + 10, + 10, + 1000, + 500, + 0, + 0, + "1.00", + 100 + ], + )?; + conn.execute( + "INSERT INTO usage_daily_rollups ( + date, app_type, provider_id, model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "2024-01-02", + "claude", + "p1", + "claude-3", + 20, + 19, + 2000, + 1000, + 0, + 0, + "2.00", + 120 + ], + )?; + } + + let summary = db.get_usage_summary(Some(start), Some(end), Some("claude"))?; + assert_eq!(summary.total_requests, 30); + assert_eq!(summary.total_input_tokens, 3000); + assert_eq!(summary.total_output_tokens, 1500); + + Ok(()) + } + #[test] fn test_get_model_stats() -> Result<(), AppError> { let db = Database::memory()?; @@ -1168,7 +1553,7 @@ mod tests { )?; } - let stats = db.get_model_stats(None)?; + let stats = db.get_model_stats(None, None, None)?; assert_eq!(stats.len(), 1); assert_eq!(stats[0].model, "claude-3-sonnet"); assert_eq!(stats[0].request_count, 1); @@ -1176,6 +1561,319 @@ mod tests { Ok(()) } + #[test] + fn test_get_provider_stats_with_time_filter() -> Result<(), AppError> { + let db = Database::memory()?; + + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, + input_tokens, output_tokens, total_cost_usd, + latency_ms, status_code, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params!["old", "p1", "claude", "claude-3", 100, 50, "0.01", 100, 200, 1000], + )?; + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, + input_tokens, output_tokens, total_cost_usd, + latency_ms, status_code, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params!["new", "p1", "claude", "claude-3", 200, 75, "0.02", 120, 200, 2000], + )?; + } + + let stats = db.get_provider_stats(Some(1500), Some(2500), Some("claude"))?; + assert_eq!(stats.len(), 1); + assert_eq!(stats[0].provider_id, "p1"); + assert_eq!(stats[0].request_count, 1); + assert_eq!(stats[0].total_tokens, 275); + + Ok(()) + } + + #[test] + fn test_get_provider_stats_excludes_partial_rollup_boundary_days() -> Result<(), AppError> { + let db = Database::memory()?; + let start = local_ts(2024, 2, 1, 12, 0, 0); + let end = local_ts(2024, 2, 3, 12, 0, 0); + + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO usage_daily_rollups ( + date, app_type, provider_id, model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "2024-02-01", + "claude", + "p-rollup", + "claude-3", + 5, + 5, + 500, + 250, + 0, + 0, + "0.50", + 100 + ], + )?; + conn.execute( + "INSERT INTO usage_daily_rollups ( + date, app_type, provider_id, model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "2024-02-02", + "claude", + "p-rollup", + "claude-3", + 8, + 7, + 800, + 400, + 0, + 0, + "0.80", + 120 + ], + )?; + conn.execute( + "INSERT INTO usage_daily_rollups ( + date, app_type, provider_id, model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "2024-02-03", + "claude", + "p-rollup", + "claude-3", + 12, + 11, + 1200, + 600, + 0, + 0, + "1.20", + 140 + ], + )?; + } + + let stats = db.get_provider_stats(Some(start), Some(end), Some("claude"))?; + assert_eq!(stats.len(), 1); + assert_eq!(stats[0].provider_id, "p-rollup"); + assert_eq!(stats[0].request_count, 8); + assert_eq!(stats[0].total_tokens, 1200); + + Ok(()) + } + + #[test] + fn test_get_daily_trends_respects_shorter_than_24_hours() -> Result<(), AppError> { + let db = Database::memory()?; + + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, + input_tokens, output_tokens, total_cost_usd, + latency_ms, status_code, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "req-short", + "p1", + "claude", + "claude-3", + 100, + 50, + "0.01", + 100, + 200, + 10_800 + ], + )?; + } + + let stats = db.get_daily_trends(Some(0), Some(15 * 60 * 60), Some("claude"))?; + assert_eq!(stats.len(), 15); + assert_eq!(stats[3].request_count, 1); + + Ok(()) + } + + #[test] + fn test_get_daily_trends_groups_ranges_longer_than_24_hours_by_local_day( + ) -> Result<(), AppError> { + let db = Database::memory()?; + let start = local_ts(2024, 3, 1, 12, 0, 0); + let end = local_ts(2024, 3, 3, 12, 0, 0); + + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, + input_tokens, output_tokens, total_cost_usd, + latency_ms, status_code, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "day-1-detail", + "p1", + "claude", + "claude-3", + 100, + 50, + "0.01", + 100, + 200, + local_ts(2024, 3, 1, 13, 0, 0) + ], + )?; + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, + input_tokens, output_tokens, total_cost_usd, + latency_ms, status_code, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "day-3-detail", + "p1", + "claude", + "claude-3", + 200, + 75, + "0.02", + 110, + 200, + local_ts(2024, 3, 3, 10, 0, 0) + ], + )?; + conn.execute( + "INSERT INTO usage_daily_rollups ( + date, app_type, provider_id, model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "2024-03-02", + "claude", + "p1", + "claude-3", + 4, + 4, + 400, + 200, + 0, + 0, + "0.40", + 120 + ], + )?; + } + + let stats = db.get_daily_trends(Some(start), Some(end), Some("claude"))?; + assert_eq!(stats.len(), 3); + assert_eq!(stats[0].request_count, 1); + assert_eq!(stats[0].total_tokens, 150); + assert_eq!(stats[1].request_count, 4); + assert_eq!(stats[1].total_tokens, 600); + assert_eq!(stats[2].request_count, 1); + assert_eq!(stats[2].total_tokens, 275); + + Ok(()) + } + + #[test] + fn test_get_model_stats_excludes_partial_rollup_boundary_days() -> Result<(), AppError> { + let db = Database::memory()?; + let start = local_ts(2024, 4, 1, 12, 0, 0); + let end = local_ts(2024, 4, 3, 12, 0, 0); + + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO usage_daily_rollups ( + date, app_type, provider_id, model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "2024-04-01", + "claude", + "p1", + "claude-3-haiku", + 6, + 6, + 600, + 300, + 0, + 0, + "0.60", + 100 + ], + )?; + conn.execute( + "INSERT INTO usage_daily_rollups ( + date, app_type, provider_id, model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "2024-04-02", + "claude", + "p1", + "claude-3-haiku", + 9, + 8, + 900, + 450, + 0, + 0, + "0.90", + 110 + ], + )?; + conn.execute( + "INSERT INTO usage_daily_rollups ( + date, app_type, provider_id, model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "2024-04-03", + "claude", + "p1", + "claude-3-haiku", + 12, + 11, + 1200, + 600, + 0, + 0, + "1.20", + 130 + ], + )?; + } + + let stats = db.get_model_stats(Some(start), Some(end), Some("claude"))?; + assert_eq!(stats.len(), 1); + assert_eq!(stats[0].model, "claude-3-haiku"); + assert_eq!(stats[0].request_count, 9); + assert_eq!(stats[0].total_tokens, 1350); + + Ok(()) + } + #[test] fn test_model_pricing_matching() -> Result<(), AppError> { let db = Database::memory()?; diff --git a/src/components/usage/ModelStatsTable.tsx b/src/components/usage/ModelStatsTable.tsx index 9f629d70b..b74b70f49 100644 --- a/src/components/usage/ModelStatsTable.tsx +++ b/src/components/usage/ModelStatsTable.tsx @@ -9,18 +9,21 @@ import { } from "@/components/ui/table"; import { useModelStats } from "@/lib/query/usage"; import { fmtUsd } from "./format"; +import type { UsageRangeSelection } from "@/types/usage"; interface ModelStatsTableProps { + range: UsageRangeSelection; appType?: string; refreshIntervalMs: number; } export function ModelStatsTable({ + range, appType, refreshIntervalMs, }: ModelStatsTableProps) { const { t } = useTranslation(); - const { data: stats, isLoading } = useModelStats(appType, { + const { data: stats, isLoading } = useModelStats(range, appType, { refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, }); diff --git a/src/components/usage/ProviderStatsTable.tsx b/src/components/usage/ProviderStatsTable.tsx index 993a436ba..64c18213f 100644 --- a/src/components/usage/ProviderStatsTable.tsx +++ b/src/components/usage/ProviderStatsTable.tsx @@ -9,18 +9,21 @@ import { } from "@/components/ui/table"; import { useProviderStats } from "@/lib/query/usage"; import { fmtUsd } from "./format"; +import type { UsageRangeSelection } from "@/types/usage"; interface ProviderStatsTableProps { + range: UsageRangeSelection; appType?: string; refreshIntervalMs: number; } export function ProviderStatsTable({ + range, appType, refreshIntervalMs, }: ProviderStatsTableProps) { const { t } = useTranslation(); - const { data: stats, isLoading } = useProviderStats(appType, { + const { data: stats, isLoading } = useProviderStats(range, appType, { refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, }); diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx index fe485ec20..ef421a5d6 100644 --- a/src/components/usage/RequestLogTable.tsx +++ b/src/components/usage/RequestLogTable.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Table, @@ -17,10 +17,9 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { useRequestLogs, usageKeys } from "@/lib/query/usage"; -import { useQueryClient } from "@tanstack/react-query"; -import type { LogFilters } from "@/types/usage"; -import { ChevronLeft, ChevronRight, RefreshCw, Search, X } from "lucide-react"; +import { useRequestLogs } from "@/lib/query/usage"; +import type { LogFilters, UsageRangeSelection } from "@/types/usage"; +import { Calendar, ChevronLeft, ChevronRight, Search, X } from "lucide-react"; import { fmtInt, fmtUsd, @@ -29,38 +28,25 @@ import { } from "./format"; interface RequestLogTableProps { + range: UsageRangeSelection; + rangeLabel: string; appType?: string; refreshIntervalMs: number; } -const ONE_DAY_SECONDS = 24 * 60 * 60; -const MAX_FIXED_RANGE_SECONDS = 30 * ONE_DAY_SECONDS; - -type TimeMode = "rolling" | "fixed"; - export function RequestLogTable({ + range, + rangeLabel, appType: dashboardAppType, refreshIntervalMs, }: RequestLogTableProps) { const { t, i18n } = useTranslation(); - const queryClient = useQueryClient(); - - const getRollingRange = () => { - const now = Math.floor(Date.now() / 1000); - const oneDayAgo = now - ONE_DAY_SECONDS; - return { startDate: oneDayAgo, endDate: now }; - }; - - const [appliedTimeMode, setAppliedTimeMode] = useState("rolling"); - const [draftTimeMode, setDraftTimeMode] = useState("rolling"); const [appliedFilters, setAppliedFilters] = useState({}); const [draftFilters, setDraftFilters] = useState({}); const [page, setPage] = useState(0); const pageSize = 20; - const [validationError, setValidationError] = useState(null); - // When dashboard-level app filter is active (not "all"), override the local appType filter const dashboardAppTypeActive = dashboardAppType && dashboardAppType !== "all"; const effectiveFilters: LogFilters = dashboardAppTypeActive ? { ...appliedFilters, appType: dashboardAppType } @@ -68,8 +54,7 @@ export function RequestLogTable({ const { data: result, isLoading } = useRequestLogs({ filters: effectiveFilters, - timeMode: appliedTimeMode, - rollingWindowSeconds: ONE_DAY_SECONDS, + range, page, pageSize, options: { @@ -81,108 +66,50 @@ export function RequestLogTable({ const total = result?.total ?? 0; const totalPages = Math.ceil(total / pageSize); - const handleSearch = () => { - setValidationError(null); - - if (draftTimeMode === "fixed") { - const start = draftFilters.startDate; - const end = draftFilters.endDate; - - if (typeof start !== "number" || typeof end !== "number") { - setValidationError( - t("usage.invalidTimeRange", "请选择完整的开始/结束时间"), - ); - return; - } - - if (start > end) { - setValidationError( - t("usage.invalidTimeRangeOrder", "开始时间不能晚于结束时间"), - ); - return; - } - - if (end - start > MAX_FIXED_RANGE_SECONDS) { - setValidationError( - t("usage.timeRangeTooLarge", "时间范围过大,请缩小范围"), - ); - return; - } - } + useEffect(() => { + setPage(0); + }, [ + dashboardAppType, + range.customEndDate, + range.customStartDate, + range.preset, + ]); - setAppliedTimeMode(draftTimeMode); - setAppliedFilters((prev) => { - const next = { ...prev, ...draftFilters }; - if (draftTimeMode === "rolling") { - delete next.startDate; - delete next.endDate; - } - return next; - }); + const handleSearch = () => { + setAppliedFilters(draftFilters); setPage(0); }; const handleReset = () => { - setValidationError(null); - setAppliedTimeMode("rolling"); - setDraftTimeMode("rolling"); setDraftFilters({}); setAppliedFilters({}); setPage(0); }; - const handleRefresh = () => { - const key = { - timeMode: appliedTimeMode, - rollingWindowSeconds: - appliedTimeMode === "rolling" ? ONE_DAY_SECONDS : undefined, - appType: appliedFilters.appType, - providerName: appliedFilters.providerName, - model: appliedFilters.model, - statusCode: appliedFilters.statusCode, - startDate: - appliedTimeMode === "fixed" ? appliedFilters.startDate : undefined, - endDate: appliedTimeMode === "fixed" ? appliedFilters.endDate : undefined, - }; - - queryClient.invalidateQueries({ - queryKey: usageKeys.logs(key, page, pageSize), - }); - }; - - // 将 Unix 时间戳转换为本地时间的 datetime-local 格式 - const timestampToLocalDatetime = (timestamp: number): string => { - const date = new Date(timestamp * 1000); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - const hours = String(date.getHours()).padStart(2, "0"); - const minutes = String(date.getMinutes()).padStart(2, "0"); - return `${year}-${month}-${day}T${hours}:${minutes}`; - }; - - // 将 datetime-local 格式转换为 Unix 时间戳 - const localDatetimeToTimestamp = (datetime: string): number | undefined => { - if (!datetime) return undefined; - // 验证格式是否完整 (YYYY-MM-DDTHH:mm) - if (datetime.length < 16) return undefined; - const timestamp = new Date(datetime).getTime(); - // 验证是否为有效日期 - if (isNaN(timestamp)) return undefined; - return Math.floor(timestamp / 1000); + const applySelectFilter = ( + key: K, + value: LogFilters[K], + ) => { + setDraftFilters((prev) => ({ + ...prev, + [key]: value, + })); + setAppliedFilters((prev) => ({ + ...prev, + [key]: value, + })); + setPage(0); }; const language = i18n.resolvedLanguage || i18n.language || "en"; const locale = getLocaleFromLanguage(language); - const rollingRangeForDisplay = - draftTimeMode === "rolling" ? getRollingRange() : null; - return (
- {/* 筛选栏 */} -
-
+
+
+ {/* App type */} + {/* App type */} + {/* Status code */} -
-
- - - setDraftFilters({ - ...draftFilters, - providerName: e.target.value || undefined, - }) - } - /> -
+ {/* Provider search */} +
+ setDraftFilters({ ...draftFilters, - model: e.target.value || undefined, + providerName: e.target.value || undefined, }) } + onKeyDown={(e) => { + if (e.key === "Enter") handleSearch(); + }} />
-
-
-
- {t("usage.timeRange")}: + {/* Model search */} +
{ - const timestamp = localDatetimeToTimestamp(e.target.value); - setDraftTimeMode("fixed"); + placeholder={t("usage.searchModelPlaceholder")} + className="h-8 bg-background text-xs" + value={draftFilters.model || ""} + onChange={(e) => setDraftFilters({ ...draftFilters, - startDate: timestamp, - }); - }} - /> - - - { - const timestamp = localDatetimeToTimestamp(e.target.value); - setDraftTimeMode("fixed"); - setDraftFilters({ - ...draftFilters, - endDate: timestamp, - }); + onKeyDown={(e) => { + if (e.key === "Enter") handleSearch(); }} />
-
- - - + {/* Time range badge */} +
+ + + {rangeLabel} +
-
- {validationError && ( -
{validationError}
- )} + {/* Search & Reset (icon-only) */} + + +
{isLoading ? ( @@ -353,40 +234,31 @@ export function RequestLogTable({ - + {t("usage.time")} - + {t("usage.provider")} - + {t("usage.billingModel")} - + {t("usage.inputTokens")} - + {t("usage.outputTokens")} - - {t("usage.cacheReadTokens")} - - - {t("usage.cacheCreationTokens")} - - - {t("usage.multiplier")} - - + {t("usage.totalCost")} - + {t("usage.timingInfo")} - + {t("usage.status")} - + {t("usage.source", { defaultValue: "Source" })} @@ -395,7 +267,7 @@ export function RequestLogTable({ {logs.length === 0 ? ( {t("usage.noData")} @@ -404,140 +276,96 @@ export function RequestLogTable({ ) : ( logs.map((log) => ( - - {new Date(log.createdAt * 1000).toLocaleString(locale)} + + {new Date(log.createdAt * 1000).toLocaleString(locale, { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + })} - + {log.providerName || t("usage.unknownProvider")} - +
- {log.model} + {log.requestModel && + log.requestModel !== log.model ? ( + + {log.requestModel} + + {" → "} + {log.model} + + + ) : ( + log.model + )}
- {log.requestModel && log.requestModel !== log.model && ( -
- ← {log.requestModel} + + +
+ {fmtInt(log.inputTokens, locale)} +
+ {(log.cacheReadTokens > 0 || + log.cacheCreationTokens > 0) && ( +
+ {[ + log.cacheReadTokens > 0 && + `R${fmtInt(log.cacheReadTokens, locale)}`, + log.cacheCreationTokens > 0 && + `W${fmtInt(log.cacheCreationTokens, locale)}`, + ] + .filter(Boolean) + .join("·")}
)}
- - {fmtInt(log.inputTokens, locale)} - - + {fmtInt(log.outputTokens, locale)} - - {fmtInt(log.cacheReadTokens, locale)} - - - {fmtInt(log.cacheCreationTokens, locale)} + +
+ {fmtUsd(log.totalCostUsd, 4)} +
+ {parseFiniteNumber(log.costMultiplier) != null && + parseFiniteNumber(log.costMultiplier) !== 1 && ( +
+ × + {parseFiniteNumber(log.costMultiplier)?.toFixed( + 2, + )} +
+ )}
- - {(parseFiniteNumber(log.costMultiplier) ?? 1) !== 1 ? ( - - ×{log.costMultiplier} + + {(log.latencyMs / 1000).toFixed(1)}s + {log.firstTokenMs != null && ( + + /{(log.firstTokenMs / 1000).toFixed(1)}s - ) : ( - ×1 )} - - {fmtUsd(log.totalCostUsd, 6)} - - -
- {(() => { - const durationMs = - typeof log.durationMs === "number" - ? log.durationMs - : log.latencyMs; - const durationSec = durationMs / 1000; - const durationColor = Number.isFinite(durationSec) - ? durationSec <= 5 - ? "bg-green-100 text-green-800" - : durationSec <= 120 - ? "bg-orange-100 text-orange-800" - : "bg-red-200 text-red-900" - : "bg-gray-100 text-gray-700"; - return ( - - {Number.isFinite(durationSec) - ? `${Math.round(durationSec)}s` - : "--"} - - ); - })()} - {log.isStreaming && - log.firstTokenMs != null && - (() => { - const firstSec = log.firstTokenMs / 1000; - const firstColor = Number.isFinite(firstSec) - ? firstSec <= 5 - ? "bg-green-100 text-green-800" - : firstSec <= 120 - ? "bg-orange-100 text-orange-800" - : "bg-red-200 text-red-900" - : "bg-gray-100 text-gray-700"; - return ( - - {Number.isFinite(firstSec) - ? `${firstSec.toFixed(1)}s` - : "--"} - - ); - })()} - - {log.isStreaming - ? t("usage.stream") - : t("usage.nonStream")} - -
-
- + = 200 && log.statusCode < 300 - ? "bg-green-100 text-green-800" - : "bg-red-100 text-red-800" - }`} + ? "text-green-600" + : "text-red-600" + } > {log.statusCode} - - {log.dataSource && log.dataSource !== "proxy" ? ( - - {t(`usage.dataSource.${log.dataSource}`, { - defaultValue: log.dataSource, - })} - - ) : ( - - {t("usage.dataSource.proxy", { - defaultValue: "Proxy", - })} - - )} + + {log.dataSource || "proxy"} )) @@ -546,71 +374,36 @@ export function RequestLogTable({
- {/* 分页控件 */} - {total > 0 && ( -
- - {t("usage.totalRecords", { total })} +
+ {t("usage.totalRecords", { total })} +
+ + + {page + 1} / {Math.max(totalPages, 1)} -
- - {/* 页码按钮 */} - {(() => { - const pages: (number | string)[] = []; - if (totalPages <= 7) { - for (let i = 0; i < totalPages; i++) pages.push(i); - } else { - pages.push(0); - if (page > 2) pages.push("..."); - for ( - let i = Math.max(1, page - 1); - i <= Math.min(totalPages - 2, page + 1); - i++ - ) { - pages.push(i); - } - if (page < totalPages - 3) pages.push("..."); - pages.push(totalPages - 1); - } - return pages.map((p, idx) => - typeof p === "string" ? ( - - ... - - ) : ( - - ), - ); - })()} - -
+
- )} +
)}
diff --git a/src/components/usage/UsageDashboard.tsx b/src/components/usage/UsageDashboard.tsx index d0ad19a7e..14dd815c0 100644 --- a/src/components/usage/UsageDashboard.tsx +++ b/src/components/usage/UsageDashboard.tsx @@ -1,12 +1,15 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { UsageSummaryCards } from "./UsageSummaryCards"; import { UsageTrendChart } from "./UsageTrendChart"; import { RequestLogTable } from "./RequestLogTable"; import { ProviderStatsTable } from "./ProviderStatsTable"; import { ModelStatsTable } from "./ModelStatsTable"; -import type { AppTypeFilter, TimeRange } from "@/types/usage"; +import type { + AppTypeFilter, + UsageRangePreset, + UsageRangeSelection, +} from "@/types/usage"; import { useUsageSummary } from "@/lib/query/usage"; import { motion } from "framer-motion"; import { @@ -27,7 +30,10 @@ import { } from "@/components/ui/accordion"; import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel"; import { cn } from "@/lib/utils"; -import { fmtUsd, parseFiniteNumber } from "./format"; +import { fmtUsd, getLocaleFromLanguage, parseFiniteNumber } from "./format"; +import { resolveUsageRange } from "@/lib/usageRange"; +import { UsageDateRangePicker } from "./UsageDateRangePicker"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; const APP_FILTER_OPTIONS: AppTypeFilter[] = [ "all", @@ -36,10 +42,32 @@ const APP_FILTER_OPTIONS: AppTypeFilter[] = [ "gemini", ]; +const RANGE_PRESETS: UsageRangePreset[] = ["today", "1d", "7d", "14d", "30d"]; + +function getPresetLabel( + preset: UsageRangePreset, + t: (key: string, options?: { defaultValue?: string }) => string, +): string { + switch (preset) { + case "today": + return t("usage.presetToday", { defaultValue: "当天" }); + case "1d": + return t("usage.preset1d", { defaultValue: "1d" }); + case "7d": + return t("usage.preset7d", { defaultValue: "7d" }); + case "14d": + return t("usage.preset14d", { defaultValue: "14d" }); + case "30d": + return t("usage.preset30d", { defaultValue: "30d" }); + case "custom": + return t("usage.customRange", { defaultValue: "日历筛选" }); + } +} + export function UsageDashboard() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const queryClient = useQueryClient(); - const [timeRange, setTimeRange] = useState("1d"); + const [range, setRange] = useState({ preset: "today" }); const [appType, setAppType] = useState("all"); const [refreshIntervalMs, setRefreshIntervalMs] = useState(30000); @@ -48,17 +76,27 @@ export function UsageDashboard() { const currentIndex = refreshIntervalOptionsMs.indexOf( refreshIntervalMs as (typeof refreshIntervalOptionsMs)[number], ); - const safeIndex = currentIndex >= 0 ? currentIndex : 3; // default 30s + const safeIndex = currentIndex >= 0 ? currentIndex : 3; const nextIndex = (safeIndex + 1) % refreshIntervalOptionsMs.length; const next = refreshIntervalOptionsMs[nextIndex]; setRefreshIntervalMs(next); queryClient.invalidateQueries({ queryKey: usageKeys.all }); }; - const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30; + const language = i18n.resolvedLanguage || i18n.language || "en"; + const locale = getLocaleFromLanguage(language); + const resolvedRange = useMemo(() => resolveUsageRange(range), [range]); + const rangeLabel = useMemo(() => { + if (range.preset !== "custom") { + return getPresetLabel(range.preset, t); + } + + return `${new Date(resolvedRange.startDate * 1000).toLocaleString(locale)} - ${new Date( + resolvedRange.endDate * 1000, + ).toLocaleString(locale)}`; + }, [locale, range, resolvedRange.endDate, resolvedRange.startDate, t]); - // Summary data for the app filter bar - const { data: summaryData } = useUsageSummary(days, appType, { + const { data: summaryData } = useUsageSummary(range, appType, { refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, }); @@ -69,93 +107,94 @@ export function UsageDashboard() { transition={{ duration: 0.4 }} className="space-y-8 pb-8" > -
-
-

{t("usage.title")}

-

{t("usage.subtitle")}

+
+
+
+

{t("usage.title")}

+

+ {t("usage.subtitle")} +

+
- setTimeRange(v as TimeRange)} - className="w-full sm:w-auto" - > -
- - - - {t("usage.today")} - - - {t("usage.last7days")} - - +
+
+ {APP_FILTER_OPTIONS.map((type) => ( + + ))} +
+ +
+ + + {RANGE_PRESETS.map((preset) => ( + + ))} + + setRange(nextRange)} + /> +
- -
- {/* App type filter bar (replaces DataSourceBar) */} -
-
- {APP_FILTER_OPTIONS.map((type) => ( - - ))} -
-
- - {(summaryData?.totalRequests ?? 0).toLocaleString()}{" "} - {t("usage.requestsLabel")} - - | - - {fmtUsd(parseFiniteNumber(summaryData?.totalCost) ?? 0, 4)}{" "} - {t("usage.costLabel")} - +
+ {rangeLabel} + | + + {(summaryData?.totalRequests ?? 0).toLocaleString()}{" "} + {t("usage.requestsLabel")} + + | + + {fmtUsd(parseFiniteNumber(summaryData?.totalCost) ?? 0, 4)}{" "} + {t("usage.costLabel")} + +
@@ -186,6 +225,8 @@ export function UsageDashboard() { > @@ -193,6 +234,7 @@ export function UsageDashboard() { @@ -200,6 +242,7 @@ export function UsageDashboard() { @@ -208,7 +251,6 @@ export function UsageDashboard() {
- {/* Pricing Configuration */} void; + triggerLabel: string; +} + +/* ── helpers ── */ + +function startOfDay(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); +} + +function isSameDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +function toTs(d: Date): number { + return Math.floor(d.getTime() / 1000); +} + +function fromTs(ts: number): Date { + return new Date(ts * 1000); +} + +function fmtDate(ts: number): string { + const d = fromTs(ts); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} + +function fmtTime(ts: number): string { + const d = fromTs(ts); + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; +} + +function parseDateInput(ts: number, value: string): number { + const [y, m, d] = value.split("-").map(Number); + if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d)) + return ts; + const base = fromTs(ts); + return toTs(new Date(y, m - 1, d, base.getHours(), base.getMinutes())); +} + +function parseTimeInput(ts: number, value: string): number { + const [h, min] = value.split(":").map(Number); + if (!Number.isFinite(h) || !Number.isFinite(min)) return ts; + const base = fromTs(ts); + return toTs( + new Date(base.getFullYear(), base.getMonth(), base.getDate(), h, min), + ); +} + +function setDateKeepTime(ts: number, day: Date): number { + const base = fromTs(ts); + return toTs( + new Date( + day.getFullYear(), + day.getMonth(), + day.getDate(), + base.getHours(), + base.getMinutes(), + ), + ); +} + +function getCalendarDays(month: Date): Date[] { + const first = new Date(month.getFullYear(), month.getMonth(), 1); + const gridStart = new Date(first); + gridStart.setDate(first.getDate() - first.getDay()); + return Array.from({ length: 42 }, (_, i) => { + const d = new Date(gridStart); + d.setDate(gridStart.getDate() + i); + return d; + }); +} + +/* ── component ── */ + +export function UsageDateRangePicker({ + selection, + onApply, + triggerLabel, +}: UsageDateRangePickerProps) { + const { t, i18n } = useTranslation(); + const [open, setOpen] = useState(false); + const [activeField, setActiveField] = useState("start"); + const resolvedRange = useMemo( + () => resolveUsageRange(selection), + [selection], + ); + const [draftStart, setDraftStart] = useState(resolvedRange.startDate); + const [draftEnd, setDraftEnd] = useState(resolvedRange.endDate); + const [displayMonth, setDisplayMonth] = useState( + () => + new Date( + fromTs(resolvedRange.startDate).getFullYear(), + fromTs(resolvedRange.startDate).getMonth(), + 1, + ), + ); + const [error, setError] = useState(null); + + const language = i18n.resolvedLanguage || i18n.language || "en"; + const locale = getLocaleFromLanguage(language); + + // Reset draft when popover opens + useEffect(() => { + if (!open) return; + const r = resolveUsageRange(selection); + setDraftStart(r.startDate); + setDraftEnd(r.endDate); + setDisplayMonth( + new Date( + fromTs(r.startDate).getFullYear(), + fromTs(r.startDate).getMonth(), + 1, + ), + ); + setActiveField("start"); + setError(null); + }, [open, selection]); + + const calendarDays = useMemo( + () => getCalendarDays(displayMonth), + [displayMonth], + ); + + const weekdayLabels = useMemo( + () => + Array.from({ length: 7 }, (_, i) => + new Intl.DateTimeFormat(locale, { weekday: "narrow" }).format( + new Date(2024, 0, 7 + i), + ), + ), + [locale], + ); + + const startDay = fromTs(draftStart); + const endDay = fromTs(draftEnd); + const today = new Date(); + + /* Pick a date from the calendar */ + const handleDatePick = (day: Date) => { + setError(null); + const nextTs = setDateKeepTime( + activeField === "start" ? draftStart : draftEnd, + day, + ); + + if (activeField === "start") { + setDraftStart(nextTs); + // Auto-swap if start > end + if (nextTs > draftEnd) { + setDraftEnd(nextTs); + } + // Auto-advance to end field + setActiveField("end"); + } else { + // If picked end < start, treat as new start and auto-advance + if (nextTs < draftStart) { + setDraftStart(nextTs); + setActiveField("end"); + } else { + setDraftEnd(nextTs); + } + } + + // Navigate calendar if the day is outside the displayed month + if ( + day.getMonth() !== displayMonth.getMonth() || + day.getFullYear() !== displayMonth.getFullYear() + ) { + setDisplayMonth(new Date(day.getFullYear(), day.getMonth(), 1)); + } + }; + + const handleApply = () => { + setError(null); + if (draftStart > draftEnd) { + setError(t("usage.invalidTimeRangeOrder", "开始时间不能晚于结束时间")); + return; + } + onApply({ + preset: "custom", + customStartDate: draftStart, + customEndDate: draftEnd, + }); + setOpen(false); + }; + + const goToToday = () => { + setDisplayMonth(new Date(today.getFullYear(), today.getMonth(), 1)); + }; + + /* ── Field card (start / end) ── */ + const renderField = (field: DraftField) => { + const isActive = activeField === field; + const ts = field === "start" ? draftStart : draftEnd; + const setTs = field === "start" ? setDraftStart : setDraftEnd; + const label = + field === "start" + ? t("usage.startTime", "开始时间") + : t("usage.endTime", "结束时间"); + + return ( +
setActiveField(field)} + > +
+ {label} +
+
+ { + const next = parseDateInput(ts, e.target.value); + setTs(next); + const d = fromTs(next); + setDisplayMonth(new Date(d.getFullYear(), d.getMonth(), 1)); + setError(null); + }} + onFocus={() => setActiveField(field)} + /> + { + setTs(parseTimeInput(ts, e.target.value)); + setError(null); + }} + onFocus={() => setActiveField(field)} + /> +
+
+ ); + }; + + return ( + + + + + +
+ {/* Left: date fields */} +
+

+ {t("usage.customRangeHint", "支持日期与时间,最长 30 天")} +

+ {renderField("start")} + {renderField("end")} + + {error &&

{error}

} + +
+ + +
+
+ + {/* Right: calendar */} +
+ {/* Month navigation */} +
+ + + +
+ + {/* Weekday headers */} +
+ {weekdayLabels.map((label, i) => ( +
+ {label} +
+ ))} +
+ + {/* Day grid */} +
+ {calendarDays.map((day) => { + const isCurrentMonth = + day.getMonth() === displayMonth.getMonth(); + const isToday = isSameDay(day, today); + const isStart = isSameDay(day, startDay); + const isEnd = isSameDay(day, endDay); + const dayStart = startOfDay(day); + const inRange = + dayStart >= startOfDay(startDay) && + dayStart <= startOfDay(endDay); + const isEndpoint = isStart || isEnd; + + return ( + + ); + })} +
+
+
+
+
+ ); +} diff --git a/src/components/usage/UsageSummaryCards.tsx b/src/components/usage/UsageSummaryCards.tsx index 1e7d58f60..5542c8dad 100644 --- a/src/components/usage/UsageSummaryCards.tsx +++ b/src/components/usage/UsageSummaryCards.tsx @@ -5,21 +5,22 @@ import { useUsageSummary } from "@/lib/query/usage"; import { Activity, DollarSign, Layers, Database, Loader2 } from "lucide-react"; import { motion } from "framer-motion"; import { fmtUsd, parseFiniteNumber } from "./format"; +import type { UsageRangeSelection } from "@/types/usage"; interface UsageSummaryCardsProps { - days: number; + range: UsageRangeSelection; appType?: string; refreshIntervalMs: number; } export function UsageSummaryCards({ - days, + range, appType, refreshIntervalMs, }: UsageSummaryCardsProps) { const { t } = useTranslation(); - const { data: summary, isLoading } = useUsageSummary(days, appType, { + const { data: summary, isLoading } = useUsageSummary(range, appType, { refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, }); diff --git a/src/components/usage/UsageTrendChart.tsx b/src/components/usage/UsageTrendChart.tsx index 7344c62b7..976e3db28 100644 --- a/src/components/usage/UsageTrendChart.tsx +++ b/src/components/usage/UsageTrendChart.tsx @@ -17,20 +17,25 @@ import { getLocaleFromLanguage, parseFiniteNumber, } from "./format"; +import { resolveUsageRange } from "@/lib/usageRange"; +import type { UsageRangeSelection } from "@/types/usage"; interface UsageTrendChartProps { - days: number; + range: UsageRangeSelection; + rangeLabel: string; appType?: string; refreshIntervalMs: number; } export function UsageTrendChart({ - days, + range, + rangeLabel, appType, refreshIntervalMs, }: UsageTrendChartProps) { const { t, i18n } = useTranslation(); - const { data: trends, isLoading } = useUsageTrends(days, appType, { + const { startDate, endDate } = resolveUsageRange(range); + const { data: trends, isLoading } = useUsageTrends(range, appType, { refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, }); @@ -42,7 +47,8 @@ export function UsageTrendChart({ ); } - const isToday = days === 1; + const durationSeconds = Math.max(endDate - startDate, 0); + const isHourly = durationSeconds <= 24 * 60 * 60; const language = i18n.resolvedLanguage || i18n.language || "en"; const dateLocale = getLocaleFromLanguage(language); const chartData = @@ -51,7 +57,7 @@ export function UsageTrendChart({ const cost = parseFiniteNumber(stat.totalCost); return { rawDate: stat.date, - label: isToday + label: isHourly ? pointDate.toLocaleString(dateLocale, { month: "2-digit", day: "2-digit", @@ -108,13 +114,7 @@ export function UsageTrendChart({

{t("usage.trends", "使用趋势")}

-

- {isToday - ? t("usage.rangeToday", "今天 (按小时)") - : days === 7 - ? t("usage.rangeLast7Days", "过去 7 天") - : t("usage.rangeLast30Days", "过去 30 天")} -

+

{rangeLabel}

diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 6a4fc6215..2e674bb31 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1042,6 +1042,11 @@ "today": "24 Hours", "last7days": "7 Days", "last30days": "30 Days", + "presetToday": "Today", + "preset1d": "1d", + "preset7d": "7d", + "preset14d": "14d", + "preset30d": "30d", "totalRequests": "Total Requests", "totalCost": "Total Cost", "cost": "Cost", @@ -1062,6 +1067,8 @@ "outputTokens": "Output", "cacheReadTokens": "Cache Hit", "cacheCreationTokens": "Cache Creation", + "cacheReadShort": "Cache Read", + "cacheWriteShort": "Write", "timingInfo": "Duration/TTFT", "status": "Status", "multiplier": "Multiplier", @@ -1131,6 +1138,10 @@ "searchProviderPlaceholder": "Search provider...", "searchModelPlaceholder": "Search model...", "timeRange": "Time Range", + "customRange": "Calendar Filter", + "customRangeHint": "Supports both date and time, up to 30 days", + "startTime": "Start Time", + "endTime": "End Time", "input": "Input", "output": "Output", "cacheWrite": "Creation", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 65d0c4aaf..5d845cccd 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1042,6 +1042,11 @@ "today": "24時間", "last7days": "7日間", "last30days": "30日間", + "presetToday": "当日", + "preset1d": "1d", + "preset7d": "7d", + "preset14d": "14d", + "preset30d": "30d", "totalRequests": "総リクエスト数", "totalCost": "総コスト", "cost": "コスト", @@ -1062,6 +1067,8 @@ "outputTokens": "出力", "cacheReadTokens": "キャッシュヒット", "cacheCreationTokens": "キャッシュ作成", + "cacheReadShort": "キャッシュ読", + "cacheWriteShort": "書", "timingInfo": "応答時間/TTFT", "status": "ステータス", "multiplier": "倍率", @@ -1131,6 +1138,10 @@ "searchProviderPlaceholder": "プロバイダーを検索...", "searchModelPlaceholder": "モデルを検索...", "timeRange": "期間", + "customRange": "カレンダーフィルター", + "customRangeHint": "日付と時刻の両方に対応、最大 30 日", + "startTime": "開始時刻", + "endTime": "終了時刻", "input": "Input", "output": "Output", "cacheWrite": "作成", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 0bd66025e..b25d7494f 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1043,6 +1043,11 @@ "today": "24小时", "last7days": "7天", "last30days": "30天", + "presetToday": "当天", + "preset1d": "1d", + "preset7d": "7d", + "preset14d": "14d", + "preset30d": "30d", "totalRequests": "总请求数", "totalCost": "总成本", "cost": "成本", @@ -1063,6 +1068,8 @@ "outputTokens": "输出", "cacheReadTokens": "缓存命中", "cacheCreationTokens": "缓存创建", + "cacheReadShort": "缓存读", + "cacheWriteShort": "写", "timingInfo": "用时/首字", "status": "状态", "multiplier": "倍率", @@ -1132,6 +1139,10 @@ "searchProviderPlaceholder": "搜索供应商...", "searchModelPlaceholder": "搜索模型...", "timeRange": "时间范围", + "customRange": "日历筛选", + "customRangeHint": "支持日期与时间,最长 30 天", + "startTime": "开始时间", + "endTime": "结束时间", "input": "Input", "output": "Output", "cacheWrite": "创建", diff --git a/src/lib/api/usage.ts b/src/lib/api/usage.ts index 1ff974705..33ef3c9a0 100644 --- a/src/lib/api/usage.ts +++ b/src/lib/api/usage.ts @@ -63,12 +63,20 @@ export const usageApi = { return invoke("get_usage_trends", { startDate, endDate, appType }); }, - getProviderStats: async (appType?: string): Promise => { - return invoke("get_provider_stats", { appType }); + getProviderStats: async ( + startDate?: number, + endDate?: number, + appType?: string, + ): Promise => { + return invoke("get_provider_stats", { startDate, endDate, appType }); }, - getModelStats: async (appType?: string): Promise => { - return invoke("get_model_stats", { appType }); + getModelStats: async ( + startDate?: number, + endDate?: number, + appType?: string, + ): Promise => { + return invoke("get_model_stats", { startDate, endDate, appType }); }, getRequestLogs: async ( diff --git a/src/lib/query/usage.ts b/src/lib/query/usage.ts index 84babb1a3..ed565508d 100644 --- a/src/lib/query/usage.ts +++ b/src/lib/query/usage.ts @@ -1,6 +1,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { usageApi } from "@/lib/api/usage"; -import type { LogFilters } from "@/types/usage"; +import { resolveUsageRange } from "@/lib/usageRange"; +import type { LogFilters, UsageRangeSelection } from "@/types/usage"; const DEFAULT_REFETCH_INTERVAL_MS = 30000; @@ -9,51 +10,94 @@ type UsageQueryOptions = { refetchIntervalInBackground?: boolean; }; -type RequestLogsTimeMode = "rolling" | "fixed"; - type RequestLogsQueryArgs = { filters: LogFilters; - timeMode: RequestLogsTimeMode; + range: UsageRangeSelection; page?: number; pageSize?: number; - rollingWindowSeconds?: number; options?: UsageQueryOptions; }; type RequestLogsKey = { - timeMode: RequestLogsTimeMode; - rollingWindowSeconds?: number; + preset: UsageRangeSelection["preset"]; + customStartDate?: number; + customEndDate?: number; appType?: string; providerName?: string; model?: string; statusCode?: number; - startDate?: number; - endDate?: number; }; // Query keys export const usageKeys = { all: ["usage"] as const, - summary: (days: number, appType?: string) => - [...usageKeys.all, "summary", days, appType ?? "all"] as const, - trends: (days: number, appType?: string) => - [...usageKeys.all, "trends", days, appType ?? "all"] as const, - providerStats: (appType?: string) => - [...usageKeys.all, "provider-stats", appType ?? "all"] as const, - modelStats: (appType?: string) => - [...usageKeys.all, "model-stats", appType ?? "all"] as const, + summary: ( + preset: UsageRangeSelection["preset"], + customStartDate: number | undefined, + customEndDate: number | undefined, + appType?: string, + ) => + [ + ...usageKeys.all, + "summary", + preset, + customStartDate ?? 0, + customEndDate ?? 0, + appType ?? "all", + ] as const, + trends: ( + preset: UsageRangeSelection["preset"], + customStartDate: number | undefined, + customEndDate: number | undefined, + appType?: string, + ) => + [ + ...usageKeys.all, + "trends", + preset, + customStartDate ?? 0, + customEndDate ?? 0, + appType ?? "all", + ] as const, + providerStats: ( + preset: UsageRangeSelection["preset"], + customStartDate: number | undefined, + customEndDate: number | undefined, + appType?: string, + ) => + [ + ...usageKeys.all, + "provider-stats", + preset, + customStartDate ?? 0, + customEndDate ?? 0, + appType ?? "all", + ] as const, + modelStats: ( + preset: UsageRangeSelection["preset"], + customStartDate: number | undefined, + customEndDate: number | undefined, + appType?: string, + ) => + [ + ...usageKeys.all, + "model-stats", + preset, + customStartDate ?? 0, + customEndDate ?? 0, + appType ?? "all", + ] as const, logs: (key: RequestLogsKey, page: number, pageSize: number) => [ ...usageKeys.all, "logs", - key.timeMode, - key.rollingWindowSeconds ?? 0, + key.preset, + key.customStartDate ?? 0, + key.customEndDate ?? 0, key.appType ?? "", key.providerName ?? "", key.model ?? "", key.statusCode ?? -1, - key.startDate ?? 0, - key.endDate ?? 0, page, pageSize, ] as const, @@ -64,23 +108,22 @@ export const usageKeys = { [...usageKeys.all, "limits", providerId, appType] as const, }; -const getWindow = (days: number) => { - const endDate = Math.floor(Date.now() / 1000); - const startDate = endDate - days * 24 * 60 * 60; - return { startDate, endDate }; -}; - // Hooks export function useUsageSummary( - days: number, + range: UsageRangeSelection, appType?: string, options?: UsageQueryOptions, ) { const effectiveAppType = appType === "all" ? undefined : appType; return useQuery({ - queryKey: usageKeys.summary(days, appType), + queryKey: usageKeys.summary( + range.preset, + range.customStartDate, + range.customEndDate, + appType, + ), queryFn: () => { - const { startDate, endDate } = getWindow(days); + const { startDate, endDate } = resolveUsageRange(range); return usageApi.getUsageSummary(startDate, endDate, effectiveAppType); }, refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, @@ -89,15 +132,20 @@ export function useUsageSummary( } export function useUsageTrends( - days: number, + range: UsageRangeSelection, appType?: string, options?: UsageQueryOptions, ) { const effectiveAppType = appType === "all" ? undefined : appType; return useQuery({ - queryKey: usageKeys.trends(days, appType), + queryKey: usageKeys.trends( + range.preset, + range.customStartDate, + range.customEndDate, + appType, + ), queryFn: () => { - const { startDate, endDate } = getWindow(days); + const { startDate, endDate } = resolveUsageRange(range); return usageApi.getUsageTrends(startDate, endDate, effectiveAppType); }, refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, @@ -106,61 +154,70 @@ export function useUsageTrends( } export function useProviderStats( + range: UsageRangeSelection, appType?: string, options?: UsageQueryOptions, ) { const effectiveAppType = appType === "all" ? undefined : appType; return useQuery({ - queryKey: usageKeys.providerStats(appType), - queryFn: () => usageApi.getProviderStats(effectiveAppType), + queryKey: usageKeys.providerStats( + range.preset, + range.customStartDate, + range.customEndDate, + appType, + ), + queryFn: () => { + const { startDate, endDate } = resolveUsageRange(range); + return usageApi.getProviderStats(startDate, endDate, effectiveAppType); + }, refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false, }); } -export function useModelStats(appType?: string, options?: UsageQueryOptions) { +export function useModelStats( + range: UsageRangeSelection, + appType?: string, + options?: UsageQueryOptions, +) { const effectiveAppType = appType === "all" ? undefined : appType; return useQuery({ - queryKey: usageKeys.modelStats(appType), - queryFn: () => usageApi.getModelStats(effectiveAppType), + queryKey: usageKeys.modelStats( + range.preset, + range.customStartDate, + range.customEndDate, + appType, + ), + queryFn: () => { + const { startDate, endDate } = resolveUsageRange(range); + return usageApi.getModelStats(startDate, endDate, effectiveAppType); + }, refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false, }); } -const getRollingRange = (windowSeconds: number) => { - const endDate = Math.floor(Date.now() / 1000); - const startDate = endDate - windowSeconds; - return { startDate, endDate }; -}; - export function useRequestLogs({ filters, - timeMode, + range, page = 0, pageSize = 20, - rollingWindowSeconds = 24 * 60 * 60, options, }: RequestLogsQueryArgs) { const key: RequestLogsKey = { - timeMode, - rollingWindowSeconds: - timeMode === "rolling" ? rollingWindowSeconds : undefined, + preset: range.preset, + customStartDate: range.customStartDate, + customEndDate: range.customEndDate, appType: filters.appType, providerName: filters.providerName, model: filters.model, statusCode: filters.statusCode, - startDate: timeMode === "fixed" ? filters.startDate : undefined, - endDate: timeMode === "fixed" ? filters.endDate : undefined, }; return useQuery({ queryKey: usageKeys.logs(key, page, pageSize), queryFn: () => { - const effectiveFilters = - timeMode === "rolling" - ? { ...filters, ...getRollingRange(rollingWindowSeconds) } - : filters; + const effectiveFilters = { ...filters, ...resolveUsageRange(range) }; return usageApi.getRequestLogs(effectiveFilters, page, pageSize); }, refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新 diff --git a/src/lib/usageRange.ts b/src/lib/usageRange.ts new file mode 100644 index 000000000..1dcd613c3 --- /dev/null +++ b/src/lib/usageRange.ts @@ -0,0 +1,84 @@ +import type { UsageRangePreset, UsageRangeSelection } from "@/types/usage"; + +const DAY_SECONDS = 24 * 60 * 60; +const DAY_MS = DAY_SECONDS * 1000; + +export const MAX_CUSTOM_USAGE_RANGE_SECONDS = 30 * DAY_SECONDS; + +export interface ResolvedUsageRange { + startDate: number; + endDate: number; +} + +function getStartOfLocalDayDate(nowMs: number): Date { + const date = new Date(nowMs); + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +function getPresetLookbackStart( + preset: Exclude, + nowMs: number, +): number { + const dayCount = preset === "7d" ? 7 : preset === "14d" ? 14 : 30; + return Math.floor( + getStartOfLocalDayDate(nowMs - (dayCount - 1) * DAY_MS).getTime() / 1000, + ); +} + +export function resolveUsageRange( + selection: UsageRangeSelection, + nowMs: number = Date.now(), +): ResolvedUsageRange { + const endDate = Math.floor(nowMs / 1000); + + switch (selection.preset) { + case "today": + return { + startDate: Math.floor(getStartOfLocalDayDate(nowMs).getTime() / 1000), + endDate, + }; + case "1d": + return { + startDate: endDate - DAY_SECONDS, + endDate, + }; + case "7d": + case "14d": + case "30d": + return { + startDate: getPresetLookbackStart(selection.preset, nowMs), + endDate, + }; + case "custom": { + const startDate = selection.customStartDate ?? endDate - DAY_SECONDS; + const customEndDate = selection.customEndDate ?? endDate; + return { + startDate, + endDate: customEndDate, + }; + } + } +} + +export function timestampToLocalDatetime(timestamp: number): string { + const date = new Date(timestamp * 1000); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + return `${year}-${month}-${day}T${hours}:${minutes}`; +} + +export function localDatetimeToTimestamp(datetime: string): number | undefined { + if (!datetime || datetime.length < 16) { + return undefined; + } + + const timestamp = new Date(datetime).getTime(); + if (Number.isNaN(timestamp)) { + return undefined; + } + + return Math.floor(timestamp / 1000); +} diff --git a/src/types/usage.ts b/src/types/usage.ts index ca9b40a09..c595c1cf7 100644 --- a/src/types/usage.ts +++ b/src/types/usage.ts @@ -121,12 +121,18 @@ export interface ProviderLimitStatus { monthlyExceeded: boolean; } -export type TimeRange = "1d" | "7d" | "30d"; +export type UsageRangePreset = "today" | "1d" | "7d" | "14d" | "30d" | "custom"; + +export interface UsageRangeSelection { + preset: UsageRangePreset; + customStartDate?: number; + customEndDate?: number; +} export type AppTypeFilter = "all" | "claude" | "codex" | "gemini"; export interface StatsFilters { - timeRange: TimeRange; + timeRange: UsageRangePreset; providerId?: string; appType?: string; } diff --git a/tests/components/RequestLogTable.test.tsx b/tests/components/RequestLogTable.test.tsx new file mode 100644 index 000000000..bc523af2a --- /dev/null +++ b/tests/components/RequestLogTable.test.tsx @@ -0,0 +1,154 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { RequestLogTable } from "@/components/usage/RequestLogTable"; +import type { UsageRangeSelection } from "@/types/usage"; + +const useRequestLogsMock = vi.hoisted(() => vi.fn()); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: ( + key: string, + options?: { + defaultValue?: string; + }, + ) => options?.defaultValue ?? key, + i18n: { + resolvedLanguage: "en", + language: "en", + }, + }), +})); + +vi.mock("@/lib/query/usage", () => ({ + useRequestLogs: (args: unknown) => useRequestLogsMock(args), +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ children, ...props }: any) => ( + + ), +})); + +vi.mock("@/components/ui/input", () => ({ + Input: (props: any) => , +})); + +vi.mock("@/components/ui/select", () => ({ + Select: ({ children }: any) =>
{children}
, + SelectTrigger: ({ children, ...props }: any) => ( + + ), + SelectValue: ({ placeholder }: any) => {placeholder ?? null}, + SelectContent: () => null, + SelectItem: () => null, +})); + +vi.mock("@/components/ui/table", () => ({ + Table: ({ children }: any) => {children}
, + TableBody: ({ children }: any) => {children}, + TableCell: ({ children, ...props }: any) => {children}, + TableHead: ({ children, ...props }: any) => {children}, + TableHeader: ({ children }: any) => {children}, + TableRow: ({ children }: any) => {children}, +})); + +describe("RequestLogTable", () => { + beforeEach(() => { + useRequestLogsMock.mockReset(); + useRequestLogsMock.mockImplementation( + ({ page = 0, pageSize = 20 }: { page?: number; pageSize?: number }) => ({ + data: { + data: [], + total: 120, + page, + pageSize, + }, + isLoading: false, + }), + ); + }); + + it("resets pagination when the dashboard range changes", async () => { + const initialRange: UsageRangeSelection = { preset: "today" }; + const nextRange: UsageRangeSelection = { + preset: "custom", + customStartDate: 1_710_000_000, + customEndDate: 1_710_086_400, + }; + + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Next page" })); + + await waitFor(() => { + expect(screen.getByText("2 / 6")).toBeInTheDocument(); + }); + + rerender( + , + ); + + await waitFor(() => { + expect(screen.getByText("1 / 6")).toBeInTheDocument(); + }); + + expect(useRequestLogsMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + page: 0, + range: nextRange, + }), + ); + }); + + it("resets pagination when the dashboard app filter changes", async () => { + const range: UsageRangeSelection = { preset: "today" }; + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Next page" })); + + await waitFor(() => { + expect(screen.getByText("2 / 6")).toBeInTheDocument(); + }); + + rerender( + , + ); + + await waitFor(() => { + expect(screen.getByText("1 / 6")).toBeInTheDocument(); + }); + + expect(useRequestLogsMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + page: 0, + }), + ); + }); +});