diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index 9bf0bf08e..79a3280fa 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -11,8 +11,11 @@ pub fn get_usage_summary( state: State<'_, AppState>, start_date: Option, end_date: Option, + app_type: Option, ) -> Result { - state.db.get_usage_summary(start_date, end_date) + state + .db + .get_usage_summary(start_date, end_date, app_type.as_deref()) } /// 获取每日趋势 @@ -21,20 +24,29 @@ pub fn get_usage_trends( state: State<'_, AppState>, start_date: Option, end_date: Option, + app_type: Option, ) -> Result, AppError> { - state.db.get_daily_trends(start_date, end_date) + state + .db + .get_daily_trends(start_date, end_date, app_type.as_deref()) } /// 获取 Provider 统计 #[tauri::command] -pub fn get_provider_stats(state: State<'_, AppState>) -> Result, AppError> { - state.db.get_provider_stats() +pub fn get_provider_stats( + state: State<'_, AppState>, + app_type: Option, +) -> Result, AppError> { + state.db.get_provider_stats(app_type.as_deref()) } /// 获取模型统计 #[tauri::command] -pub fn get_model_stats(state: State<'_, AppState>) -> Result, AppError> { - state.db.get_model_stats() +pub fn get_model_stats( + state: State<'_, AppState>, + app_type: Option, +) -> Result, AppError> { + state.db.get_model_stats(app_type.as_deref()) } /// 获取请求日志列表 diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index d03eea482..6aaf3da42 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -123,44 +123,54 @@ impl Database { &self, start_date: Option, end_date: Option, + app_type: Option<&str>, ) -> Result { let conn = lock_conn!(self.conn); - let (where_clause, params_vec) = if start_date.is_some() || end_date.is_some() { - let mut conditions = Vec::new(); - let mut params = Vec::new(); + // Build detail WHERE clause + let mut conditions = Vec::new(); + let mut params_vec: Vec> = Vec::new(); - if let Some(start) = start_date { - conditions.push("created_at >= ?"); - params.push(start); - } - if let Some(end) = end_date { - conditions.push("created_at <= ?"); - params.push(end); - } + if let Some(start) = start_date { + conditions.push("created_at >= ?"); + params_vec.push(Box::new(start)); + } + if let Some(end) = end_date { + conditions.push("created_at <= ?"); + params_vec.push(Box::new(end)); + } + if let Some(at) = app_type { + conditions.push("app_type = ?"); + params_vec.push(Box::new(at.to_string())); + } - (format!("WHERE {}", conditions.join(" AND ")), params) + let where_clause = if conditions.is_empty() { + String::new() } else { - (String::new(), Vec::new()) + format!("WHERE {}", conditions.join(" AND ")) }; - // Build rollup WHERE clause using date strings (use ? for sequential binding) - let (rollup_where, rollup_params) = if start_date.is_some() || end_date.is_some() { - let mut conditions: Vec = Vec::new(); - let mut params = Vec::new(); + // Build rollup WHERE clause using date strings + let mut rollup_conditions: Vec = Vec::new(); + let mut rollup_params: Vec> = Vec::new(); - if let Some(start) = start_date { - conditions.push("date >= date(?, 'unixepoch', 'localtime')".to_string()); - params.push(start); - } - if let Some(end) = end_date { - conditions.push("date <= date(?, 'unixepoch', 'localtime')".to_string()); - params.push(end); - } + 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)); + } + if let Some(at) = app_type { + rollup_conditions.push("app_type = ?".to_string()); + rollup_params.push(Box::new(at.to_string())); + } - (format!("WHERE {}", conditions.join(" AND ")), params) + let rollup_where = if rollup_conditions.is_empty() { + String::new() } else { - (String::new(), Vec::new()) + format!("WHERE {}", rollup_conditions.join(" AND ")) }; let sql = format!( @@ -194,10 +204,11 @@ impl Database { ); // Combine params: detail params first, then rollup params - let mut all_params: Vec = params_vec; + let mut all_params: Vec> = params_vec; all_params.extend(rollup_params); + let param_refs: Vec<&dyn rusqlite::ToSql> = all_params.iter().map(|p| p.as_ref()).collect(); - let result = conn.query_row(&sql, rusqlite::params_from_iter(all_params), |row| { + let result = conn.query_row(&sql, param_refs.as_slice(), |row| { let total_requests: i64 = row.get(0)?; let total_cost: f64 = row.get(1)?; let total_input_tokens: i64 = row.get(2)?; @@ -231,6 +242,7 @@ impl Database { &self, start_date: Option, end_date: Option, + app_type: Option<&str>, ) -> Result, AppError> { let conn = lock_conn!(self.conn); @@ -262,9 +274,15 @@ impl Database { bucket_count = 1; } + let app_type_filter = if app_type.is_some() { + "AND app_type = ?4" + } else { + "" + }; + // Query detail logs - let sql = " - SELECT + 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, @@ -274,12 +292,13 @@ impl Database { 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 + WHERE created_at >= ?1 AND created_at <= ?2 {app_type_filter} GROUP BY bucket_idx - ORDER BY bucket_idx ASC"; + ORDER BY bucket_idx ASC" + ); - let mut stmt = conn.prepare(sql)?; - let rows = stmt.query_map(params![start_ts, end_ts, bucket_seconds], |row| { + let mut stmt = conn.prepare(&sql)?; + let row_mapper = |row: &rusqlite::Row| { Ok(( row.get::<_, i64>(0)?, DailyStats { @@ -293,24 +312,33 @@ impl Database { total_cache_read_tokens: row.get::<_, i64>(7)? as u64, }, )) - })?; + }; let mut map: HashMap = HashMap::new(); - for row in rows { - let (mut bucket_idx, stat) = row?; - if bucket_idx < 0 { - continue; + + // 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); } - if bucket_idx >= bucket_count { - bucket_idx = bucket_count - 1; - } - map.insert(bucket_idx, stat); } // Also query rollup data (daily granularity, only useful for daily buckets) if bucket_seconds >= 86400 { - let rollup_sql = " - SELECT + 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), @@ -320,12 +348,12 @@ impl Database { 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') + WHERE date >= date(?1, 'unixepoch', 'localtime') AND date <= date(?2, 'unixepoch', 'localtime') {app_type_filter} GROUP BY bucket_idx - ORDER BY bucket_idx ASC"; + ORDER BY bucket_idx ASC" + ); - let mut rstmt = conn.prepare(rollup_sql)?; - let rrows = rstmt.query_map(params![start_ts, end_ts, bucket_seconds], |row| { + let rollup_row_mapper = |row: &rusqlite::Row| { Ok(( row.get::<_, i64>(0)?, ( @@ -338,7 +366,14 @@ impl Database { row.get::<_, i64>(7)? as u64, ), )) - })?; + }; + + 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)? + }; for row in rrows { let (mut bucket_idx, (req, cost, tok, inp, out, cc, cr)) = row?; @@ -400,11 +435,21 @@ impl Database { } /// 获取 Provider 统计 - pub fn get_provider_stats(&self) -> Result, AppError> { + pub fn get_provider_stats( + &self, + 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") + } else { + ("", "") + }; + // UNION detail logs + rollup data, then aggregate - let sql = "SELECT + let sql = format!( + "SELECT provider_id, app_type, provider_name, SUM(request_count) as request_count, SUM(total_tokens) as total_tokens, @@ -423,6 +468,7 @@ impl Database { COALESCE(SUM(l.latency_ms), 0) as latency_sum FROM proxy_request_logs l LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type + {detail_where} GROUP BY l.provider_id, l.app_type UNION ALL SELECT r.provider_id, r.app_type, @@ -434,13 +480,15 @@ impl Database { COALESCE(SUM(r.avg_latency_ms * r.request_count), 0) FROM usage_daily_rollups r LEFT JOIN providers p2 ON r.provider_id = p2.id AND r.app_type = p2.app_type + {rollup_where} GROUP BY r.provider_id, r.app_type ) GROUP BY provider_id, app_type - ORDER BY total_cost DESC"; + ORDER BY total_cost DESC" + ); - let mut stmt = conn.prepare(sql)?; - let rows = stmt.query_map([], |row| { + let mut stmt = conn.prepare(&sql)?; + let row_mapper = |row: &rusqlite::Row| { let request_count: i64 = row.get(3)?; let success_count: i64 = row.get(6)?; let success_rate = if request_count > 0 { @@ -460,7 +508,13 @@ impl Database { success_rate, avg_latency_ms: row.get::<_, f64>(7)? as u64, }) - })?; + }; + + let rows = if let Some(at) = app_type { + stmt.query_map(params![at, at], row_mapper)? + } else { + stmt.query_map([], row_mapper)? + }; let mut stats = Vec::new(); for row in rows { @@ -471,11 +525,18 @@ impl Database { } /// 获取模型统计 - pub fn get_model_stats(&self) -> Result, AppError> { + pub fn get_model_stats(&self, 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") + } else { + ("", "") + }; + // UNION detail logs + rollup data - let sql = "SELECT + let sql = format!( + "SELECT model, SUM(request_count) as request_count, SUM(total_tokens) as total_tokens, @@ -486,6 +547,7 @@ impl Database { 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 + {detail_where} GROUP BY model UNION ALL SELECT model, @@ -493,13 +555,15 @@ impl Database { COALESCE(SUM(input_tokens + output_tokens), 0), COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) FROM usage_daily_rollups + {rollup_where} GROUP BY model ) GROUP BY model - ORDER BY total_cost DESC"; + ORDER BY total_cost DESC" + ); - let mut stmt = conn.prepare(sql)?; - let rows = stmt.query_map([], |row| { + let mut stmt = conn.prepare(&sql)?; + let row_mapper = |row: &rusqlite::Row| { let request_count: i64 = row.get(1)?; let total_cost: f64 = row.get(3)?; let avg_cost = if request_count > 0 { @@ -515,7 +579,13 @@ impl Database { total_cost: format!("{total_cost:.6}"), avg_cost_per_request: format!("{avg_cost:.6}"), }) - })?; + }; + + let rows = if let Some(at) = app_type { + stmt.query_map(params![at, at], row_mapper)? + } else { + stmt.query_map([], row_mapper)? + }; let mut stats = Vec::new(); for row in rows { @@ -1044,7 +1114,7 @@ mod tests { )?; } - let summary = db.get_usage_summary(None, None)?; + let summary = db.get_usage_summary(None, None, None)?; assert_eq!(summary.total_requests, 2); assert_eq!(summary.success_rate, 100.0); @@ -1079,7 +1149,7 @@ mod tests { )?; } - let stats = db.get_model_stats()?; + let stats = db.get_model_stats(None)?; assert_eq!(stats.len(), 1); assert_eq!(stats[0].model, "claude-3-sonnet"); assert_eq!(stats[0].request_count, 1); diff --git a/src/components/usage/ModelStatsTable.tsx b/src/components/usage/ModelStatsTable.tsx index 20f83e628..9f629d70b 100644 --- a/src/components/usage/ModelStatsTable.tsx +++ b/src/components/usage/ModelStatsTable.tsx @@ -11,12 +11,16 @@ import { useModelStats } from "@/lib/query/usage"; import { fmtUsd } from "./format"; interface ModelStatsTableProps { + appType?: string; refreshIntervalMs: number; } -export function ModelStatsTable({ refreshIntervalMs }: ModelStatsTableProps) { +export function ModelStatsTable({ + appType, + refreshIntervalMs, +}: ModelStatsTableProps) { const { t } = useTranslation(); - const { data: stats, isLoading } = useModelStats({ + const { data: stats, isLoading } = useModelStats(appType, { refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, }); diff --git a/src/components/usage/ProviderStatsTable.tsx b/src/components/usage/ProviderStatsTable.tsx index e21e1f6c0..993a436ba 100644 --- a/src/components/usage/ProviderStatsTable.tsx +++ b/src/components/usage/ProviderStatsTable.tsx @@ -11,14 +11,16 @@ import { useProviderStats } from "@/lib/query/usage"; import { fmtUsd } from "./format"; interface ProviderStatsTableProps { + appType?: string; refreshIntervalMs: number; } export function ProviderStatsTable({ + appType, refreshIntervalMs, }: ProviderStatsTableProps) { const { t } = useTranslation(); - const { data: stats, isLoading } = useProviderStats({ + const { data: stats, isLoading } = useProviderStats(appType, { refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, }); diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx index 5d6bd0c17..fe485ec20 100644 --- a/src/components/usage/RequestLogTable.tsx +++ b/src/components/usage/RequestLogTable.tsx @@ -29,6 +29,7 @@ import { } from "./format"; interface RequestLogTableProps { + appType?: string; refreshIntervalMs: number; } @@ -37,7 +38,10 @@ const MAX_FIXED_RANGE_SECONDS = 30 * ONE_DAY_SECONDS; type TimeMode = "rolling" | "fixed"; -export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) { +export function RequestLogTable({ + appType: dashboardAppType, + refreshIntervalMs, +}: RequestLogTableProps) { const { t, i18n } = useTranslation(); const queryClient = useQueryClient(); @@ -56,8 +60,14 @@ export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) { 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 } + : appliedFilters; + const { data: result, isLoading } = useRequestLogs({ - filters: appliedFilters, + filters: effectiveFilters, timeMode: appliedTimeMode, rollingWindowSeconds: ONE_DAY_SECONDS, page, @@ -174,13 +184,18 @@ export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) {