diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index 0a178159c..d968bbdc5 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -14,10 +14,16 @@ pub fn get_usage_summary( start_date: Option, end_date: Option, app_type: Option, + provider_name: Option, + model: Option, ) -> Result { - state - .db - .get_usage_summary(start_date, end_date, app_type.as_deref()) + state.db.get_usage_summary( + start_date, + end_date, + app_type.as_deref(), + provider_name.as_deref(), + model.as_deref(), + ) } /// 获取按 app_type 拆分的使用量汇总 @@ -26,8 +32,15 @@ pub fn get_usage_summary_by_app( state: State<'_, AppState>, start_date: Option, end_date: Option, + provider_name: Option, + model: Option, ) -> Result, AppError> { - state.db.get_usage_summary_by_app(start_date, end_date) + state.db.get_usage_summary_by_app( + start_date, + end_date, + provider_name.as_deref(), + model.as_deref(), + ) } /// 获取每日趋势 @@ -37,10 +50,16 @@ pub fn get_usage_trends( start_date: Option, end_date: Option, app_type: Option, + provider_name: Option, + model: Option, ) -> Result, AppError> { - state - .db - .get_daily_trends(start_date, end_date, app_type.as_deref()) + state.db.get_daily_trends( + start_date, + end_date, + app_type.as_deref(), + provider_name.as_deref(), + model.as_deref(), + ) } /// 获取 Provider 统计 @@ -50,10 +69,16 @@ pub fn get_provider_stats( start_date: Option, end_date: Option, app_type: Option, + provider_name: Option, + model: Option, ) -> Result, AppError> { - state - .db - .get_provider_stats(start_date, end_date, app_type.as_deref()) + state.db.get_provider_stats( + start_date, + end_date, + app_type.as_deref(), + provider_name.as_deref(), + model.as_deref(), + ) } /// 获取模型统计 @@ -63,10 +88,16 @@ pub fn get_model_stats( start_date: Option, end_date: Option, app_type: Option, + provider_name: Option, + model: Option, ) -> Result, AppError> { - state - .db - .get_model_stats(start_date, end_date, app_type.as_deref()) + state.db.get_model_stats( + start_date, + end_date, + app_type.as_deref(), + provider_name.as_deref(), + model.as_deref(), + ) } /// 获取请求日志列表 diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index a624408b1..ea175f297 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -242,6 +242,52 @@ fn folded_app_type_sql(column: &str) -> String { format!("CASE WHEN {column} = 'claude-desktop' THEN 'claude' ELSE {column} END") } +/// SQL 片段:把日志/汇总行 LEFT JOIN 到 providers 表以取得供应商名称。 +/// `proxy_request_logs` 与 `usage_daily_rollups` 的 (provider_id, app_type) +/// 形状相同,两者皆可作为 `log_alias`。providers 主键即 (id, app_type), +/// 连接至多 1:1,不会放大行数。 +fn providers_join(log_alias: &str, provider_alias: &str) -> String { + format!( + "LEFT JOIN providers {provider_alias} \ + ON {log_alias}.provider_id = {provider_alias}.id \ + AND {log_alias}.app_type = {provider_alias}.app_type" + ) +} + +/// SQL 标量表达式:行的「有效计价模型」—— pricing_model 非空优先,NULL/'' 回落 +/// model。这是 `get_model_stats` 的分组键,也是 Dashboard 模型筛选的匹配口径: +/// 筛选值来自模型统计列表,两边必须用同一表达式才能选得中。 +fn effective_model_sql(alias: &str) -> String { + format!("COALESCE(NULLIF({alias}.pricing_model, ''), {alias}.model)") +} + +/// 把 Dashboard 顶部的 Provider/模型筛选追加到查询条件。 +/// +/// Provider 按展示名精确匹配(复用 [`provider_name_coalesce`],会话占位行的 +/// 可读名如 "Claude (Session)" 也能选中);模型按 [`effective_model_sql`] 匹配。 +/// 注意:传入 `provider_name` 时调用方必须把 [`providers_join`] 拼进 FROM, +/// 否则 `{provider_alias}.name` 无法解析。 +fn push_provider_model_filters( + conditions: &mut Vec, + params: &mut Vec>, + log_alias: &str, + provider_alias: &str, + provider_name: Option<&str>, + model: Option<&str>, +) { + if let Some(name) = provider_name { + conditions.push(format!( + "{} = ?", + provider_name_coalesce(log_alias, provider_alias) + )); + params.push(Box::new(name.to_string())); + } + if let Some(m) = model { + conditions.push(format!("{} = ?", effective_model_sql(log_alias))); + params.push(Box::new(m.to_string())); + } +} + pub(crate) fn effective_usage_log_filter(log_alias: &str) -> String { let data_source = data_source_expr(log_alias); let proxy_data_source = data_source_expr("proxy_dedup"); @@ -461,6 +507,8 @@ impl Database { start_date: Option, end_date: Option, app_type: Option<&str>, + provider_name: Option<&str>, + model: Option<&str>, ) -> Result { let conn = lock_conn!(self.conn); @@ -480,12 +528,25 @@ impl Database { conditions.push(format!("{} = ?", folded_app_type_sql("l.app_type"))); params_vec.push(Box::new(at.to_string())); } + push_provider_model_filters( + &mut conditions, + &mut params_vec, + "l", + "p", + provider_name, + model, + ); let where_clause = if conditions.is_empty() { String::new() } else { format!("WHERE {}", conditions.join(" AND ")) }; + let detail_join = if provider_name.is_some() { + providers_join("l", "p") + } else { + String::new() + }; // Only include rolled-up rows for full local days that are fully covered by the range. let mut rollup_conditions: Vec = Vec::new(); @@ -495,22 +556,35 @@ impl Database { push_rollup_date_filters( &mut rollup_conditions, &mut rollup_params, - "date", + "r.date", &rollup_bounds, ); if let Some(at) = app_type { - rollup_conditions.push(format!("{} = ?", folded_app_type_sql("app_type"))); + rollup_conditions.push(format!("{} = ?", folded_app_type_sql("r.app_type"))); rollup_params.push(Box::new(at.to_string())); } + push_provider_model_filters( + &mut rollup_conditions, + &mut rollup_params, + "r", + "p2", + provider_name, + model, + ); let rollup_where = if rollup_conditions.is_empty() { String::new() } else { format!("WHERE {}", rollup_conditions.join(" AND ")) }; + let rollup_join = if provider_name.is_some() { + providers_join("r", "p2") + } else { + String::new() + }; let fresh_input_detail = fresh_input_sql("l"); - let fresh_input_rollup = fresh_input_sql(""); + let fresh_input_rollup = fresh_input_sql("r"); let sql = format!( "SELECT COALESCE(d.total_requests, 0) + COALESCE(r.total_requests, 0), @@ -529,16 +603,16 @@ impl Database { COALESCE(SUM(l.cache_creation_tokens), 0) as total_cache_creation_tokens, COALESCE(SUM(l.cache_read_tokens), 0) as total_cache_read_tokens, COALESCE(SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END), 0) as success_count - FROM proxy_request_logs l {where_clause}) d, + FROM proxy_request_logs l {detail_join} {where_clause}) d, (SELECT - COALESCE(SUM(request_count), 0) as total_requests, - COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost, + COALESCE(SUM(r.request_count), 0) as total_requests, + COALESCE(SUM(CAST(r.total_cost_usd AS REAL)), 0) as total_cost, COALESCE(SUM({fresh_input_rollup}), 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, - COALESCE(SUM(success_count), 0) as success_count - FROM usage_daily_rollups {rollup_where}) r" + COALESCE(SUM(r.output_tokens), 0) as total_output_tokens, + COALESCE(SUM(r.cache_creation_tokens), 0) as total_cache_creation_tokens, + COALESCE(SUM(r.cache_read_tokens), 0) as total_cache_read_tokens, + COALESCE(SUM(r.success_count), 0) as success_count + FROM usage_daily_rollups r {rollup_join} {rollup_where}) r" ); // Combine params: detail params first, then rollup params @@ -593,6 +667,8 @@ impl Database { &self, start_date: Option, end_date: Option, + provider_name: Option<&str>, + model: Option<&str>, ) -> Result, AppError> { let conn = lock_conn!(self.conn); @@ -606,7 +682,20 @@ impl Database { detail_conditions.push("l.created_at <= ?".to_string()); detail_params.push(Box::new(end)); } + push_provider_model_filters( + &mut detail_conditions, + &mut detail_params, + "l", + "p", + provider_name, + model, + ); let detail_where = format!("WHERE {}", detail_conditions.join(" AND ")); + let detail_join = if provider_name.is_some() { + providers_join("l", "p") + } else { + String::new() + }; let rollup_bounds = compute_rollup_date_bounds(start_date, end_date)?; let mut rollup_conditions: Vec = Vec::new(); @@ -614,20 +703,33 @@ impl Database { push_rollup_date_filters( &mut rollup_conditions, &mut rollup_params, - "date", + "r.date", &rollup_bounds, ); + push_provider_model_filters( + &mut rollup_conditions, + &mut rollup_params, + "r", + "p2", + provider_name, + model, + ); let rollup_where = if rollup_conditions.is_empty() { String::new() } else { format!("WHERE {}", rollup_conditions.join(" AND ")) }; + let rollup_join = if provider_name.is_some() { + providers_join("r", "p2") + } else { + String::new() + }; let fresh_input_detail = fresh_input_sql("l"); - let fresh_input_rollup = fresh_input_sql(""); + let fresh_input_rollup = fresh_input_sql("r"); // 折叠 claude-desktop → claude:内层投影成同一桶名,外层 GROUP BY 自然合并。 let detail_app_type = folded_app_type_sql("l.app_type"); - let rollup_app_type = folded_app_type_sql("app_type"); + let rollup_app_type = folded_app_type_sql("r.app_type"); let sql = format!( "SELECT app_type, @@ -647,19 +749,19 @@ impl Database { COALESCE(SUM(l.cache_creation_tokens), 0) as cache_create_t, COALESCE(SUM(l.cache_read_tokens), 0) as cache_read_t, COALESCE(SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END), 0) as success_count - FROM proxy_request_logs l {detail_where} + FROM proxy_request_logs l {detail_join} {detail_where} GROUP BY l.app_type UNION ALL SELECT {rollup_app_type} as app_type, - COALESCE(SUM(request_count), 0), - COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0), + COALESCE(SUM(r.request_count), 0), + COALESCE(SUM(CAST(r.total_cost_usd AS REAL)), 0), COALESCE(SUM({fresh_input_rollup}), 0), - COALESCE(SUM(output_tokens), 0), - COALESCE(SUM(cache_creation_tokens), 0), - COALESCE(SUM(cache_read_tokens), 0), - COALESCE(SUM(success_count), 0) - FROM usage_daily_rollups {rollup_where} - GROUP BY app_type + COALESCE(SUM(r.output_tokens), 0), + COALESCE(SUM(r.cache_creation_tokens), 0), + COALESCE(SUM(r.cache_read_tokens), 0), + COALESCE(SUM(r.success_count), 0) + FROM usage_daily_rollups r {rollup_join} {rollup_where} + GROUP BY r.app_type ) GROUP BY app_type" ); @@ -729,6 +831,8 @@ impl Database { start_date: Option, end_date: Option, app_type: Option<&str>, + provider_name: Option<&str>, + model: Option<&str>, ) -> Result, AppError> { let conn = lock_conn!(self.conn); @@ -752,8 +856,27 @@ impl Database { bucket_count = 1; } - let app_type_filter = if app_type.is_some() { - format!("AND {} = ?4", folded_app_type_sql("l.app_type")) + let mut extra_conditions: Vec = Vec::new(); + let mut extra_params: Vec> = Vec::new(); + if let Some(at) = app_type { + extra_conditions.push(format!("{} = ?", folded_app_type_sql("l.app_type"))); + extra_params.push(Box::new(at.to_string())); + } + push_provider_model_filters( + &mut extra_conditions, + &mut extra_params, + "l", + "p", + provider_name, + model, + ); + let extra_filter = extra_conditions + .iter() + .map(|c| format!("AND {c}")) + .collect::>() + .join(" "); + let detail_join = if provider_name.is_some() { + providers_join("l", "p") } else { String::new() }; @@ -770,9 +893,9 @@ impl Database { COALESCE(SUM(l.output_tokens), 0) as total_output_tokens, COALESCE(SUM(l.cache_creation_tokens), 0) as total_cache_creation_tokens, COALESCE(SUM(l.cache_read_tokens), 0) as total_cache_read_tokens - FROM proxy_request_logs l + FROM proxy_request_logs l {detail_join} WHERE l.created_at >= ?1 AND l.created_at <= ?2 - AND {effective_filter} {app_type_filter} + AND {effective_filter} {extra_filter} GROUP BY bucket_idx ORDER BY bucket_idx ASC" ); @@ -796,11 +919,15 @@ impl Database { 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)? - }; + let mut all_params: Vec> = vec![ + Box::new(start_ts), + Box::new(end_ts), + Box::new(bucket_seconds), + ]; + all_params.extend(extra_params); + let param_refs: Vec<&dyn rusqlite::ToSql> = + all_params.iter().map(|p| p.as_ref()).collect(); + let rows = stmt.query_map(param_refs.as_slice(), row_mapper)?; for row in rows { let (mut bucket_idx, stat) = row?; if bucket_idx < 0 { @@ -842,8 +969,27 @@ impl Database { 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() { - format!("AND {} = ?3", folded_app_type_sql("l.app_type")) + let mut extra_conditions: Vec = Vec::new(); + let mut extra_params: Vec> = Vec::new(); + if let Some(at) = app_type { + extra_conditions.push(format!("{} = ?", folded_app_type_sql("l.app_type"))); + extra_params.push(Box::new(at.to_string())); + } + push_provider_model_filters( + &mut extra_conditions, + &mut extra_params, + "l", + "p", + provider_name, + model, + ); + let extra_filter = extra_conditions + .iter() + .map(|c| format!("AND {c}")) + .collect::>() + .join(" "); + let detail_join = if provider_name.is_some() { + providers_join("l", "p") } else { String::new() }; @@ -860,9 +1006,9 @@ impl Database { COALESCE(SUM(l.output_tokens), 0) as total_output_tokens, COALESCE(SUM(l.cache_creation_tokens), 0) as total_cache_creation_tokens, COALESCE(SUM(l.cache_read_tokens), 0) as total_cache_read_tokens - FROM proxy_request_logs l + FROM proxy_request_logs l {detail_join} WHERE l.created_at >= ?1 AND l.created_at <= ?2 - AND {effective_filter} {app_type_filter} + AND {effective_filter} {extra_filter} GROUP BY bucket_date ORDER BY bucket_date ASC" ); @@ -885,11 +1031,12 @@ impl Database { }; 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)? - }; + let mut detail_all_params: Vec> = + vec![Box::new(start_ts), Box::new(end_ts)]; + detail_all_params.extend(extra_params); + let detail_param_refs: Vec<&dyn rusqlite::ToSql> = + detail_all_params.iter().map(|p| p.as_ref()).collect(); + let detail_rows = detail_stmt.query_map(detail_param_refs.as_slice(), detail_row_mapper)?; for row in detail_rows { let (bucket_date, stat) = row?; @@ -904,35 +1051,48 @@ impl Database { push_rollup_date_filters( &mut rollup_conditions, &mut rollup_params, - "date", + "r.date", &rollup_bounds, ); if let Some(at) = app_type { - rollup_conditions.push(format!("{} = ?", folded_app_type_sql("app_type"))); + rollup_conditions.push(format!("{} = ?", folded_app_type_sql("r.app_type"))); rollup_params.push(Box::new(at.to_string())); } + push_provider_model_filters( + &mut rollup_conditions, + &mut rollup_params, + "r", + "p2", + provider_name, + model, + ); let rollup_where = if rollup_conditions.is_empty() { String::new() } else { format!("WHERE {}", rollup_conditions.join(" AND ")) }; + let rollup_join = if provider_name.is_some() { + providers_join("r", "p2") + } else { + String::new() + }; - let fresh_input_rollup = fresh_input_sql(""); + let fresh_input_rollup = fresh_input_sql("r"); let rollup_sql = format!( "SELECT - date, - COALESCE(SUM(request_count), 0), - COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0), - COALESCE(SUM({fresh_input_rollup} + output_tokens), 0), + r.date, + COALESCE(SUM(r.request_count), 0), + COALESCE(SUM(CAST(r.total_cost_usd AS REAL)), 0), + COALESCE(SUM({fresh_input_rollup} + r.output_tokens), 0), COALESCE(SUM({fresh_input_rollup}), 0), - COALESCE(SUM(output_tokens), 0), - COALESCE(SUM(cache_creation_tokens), 0), - COALESCE(SUM(cache_read_tokens), 0) - FROM usage_daily_rollups + COALESCE(SUM(r.output_tokens), 0), + COALESCE(SUM(r.cache_creation_tokens), 0), + COALESCE(SUM(r.cache_read_tokens), 0) + FROM usage_daily_rollups r {rollup_join} {rollup_where} - GROUP BY date - ORDER BY date ASC" + GROUP BY r.date + ORDER BY r.date ASC" ); let mut rollup_stmt = conn.prepare(&rollup_sql)?; @@ -1011,6 +1171,8 @@ impl Database { start_date: Option, end_date: Option, app_type: Option<&str>, + provider_name: Option<&str>, + model: Option<&str>, ) -> Result, AppError> { let conn = lock_conn!(self.conn); @@ -1028,6 +1190,14 @@ impl Database { detail_conditions.push(format!("{} = ?", folded_app_type_sql("l.app_type"))); detail_params.push(Box::new(at.to_string())); } + push_provider_model_filters( + &mut detail_conditions, + &mut detail_params, + "l", + "p", + provider_name, + model, + ); let detail_where = if detail_conditions.is_empty() { String::new() } else { @@ -1047,6 +1217,14 @@ impl Database { rollup_conditions.push(format!("{} = ?", folded_app_type_sql("r.app_type"))); rollup_params.push(Box::new(at.to_string())); } + push_provider_model_filters( + &mut rollup_conditions, + &mut rollup_params, + "r", + "p2", + provider_name, + model, + ); let rollup_where = if rollup_conditions.is_empty() { String::new() } else { @@ -1137,6 +1315,8 @@ impl Database { start_date: Option, end_date: Option, app_type: Option<&str>, + provider_name: Option<&str>, + model: Option<&str>, ) -> Result, AppError> { let conn = lock_conn!(self.conn); @@ -1154,11 +1334,24 @@ impl Database { detail_conditions.push(format!("{} = ?", folded_app_type_sql("l.app_type"))); detail_params.push(Box::new(at.to_string())); } + push_provider_model_filters( + &mut detail_conditions, + &mut detail_params, + "l", + "p", + provider_name, + model, + ); let detail_where = if detail_conditions.is_empty() { String::new() } else { format!("WHERE {}", detail_conditions.join(" AND ")) }; + let detail_join = if provider_name.is_some() { + providers_join("l", "p") + } else { + String::new() + }; let mut rollup_conditions = Vec::new(); let mut rollup_params: Vec> = Vec::new(); @@ -1173,11 +1366,24 @@ impl Database { rollup_conditions.push(format!("{} = ?", folded_app_type_sql("r.app_type"))); rollup_params.push(Box::new(at.to_string())); } + push_provider_model_filters( + &mut rollup_conditions, + &mut rollup_params, + "r", + "p2", + provider_name, + model, + ); let rollup_where = if rollup_conditions.is_empty() { String::new() } else { format!("WHERE {}", rollup_conditions.join(" AND ")) }; + let rollup_join = if provider_name.is_some() { + providers_join("r", "p2") + } else { + String::new() + }; // UNION detail logs + rollup data // @@ -1187,6 +1393,8 @@ impl Database { // 基准名下,而不是上游回显/客户端别名名下。 let fresh_input_detail = fresh_input_sql("l"); let fresh_input_rollup = fresh_input_sql("r"); + let detail_model = effective_model_sql("l"); + let rollup_model = effective_model_sql("r"); let sql = format!( "SELECT model, @@ -1194,21 +1402,23 @@ impl Database { SUM(total_tokens) as total_tokens, SUM(total_cost) as total_cost FROM ( - SELECT COALESCE(NULLIF(l.pricing_model, ''), l.model) as model, + SELECT {detail_model} as model, COUNT(*) as request_count, COALESCE(SUM({fresh_input_detail} + 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_join} {detail_where} - GROUP BY COALESCE(NULLIF(l.pricing_model, ''), l.model) + GROUP BY {detail_model} UNION ALL - SELECT COALESCE(NULLIF(r.pricing_model, ''), r.model), + SELECT {rollup_model}, COALESCE(SUM(r.request_count), 0), COALESCE(SUM({fresh_input_rollup} + r.output_tokens), 0), COALESCE(SUM(CAST(r.total_cost_usd AS REAL)), 0) FROM usage_daily_rollups r + {rollup_join} {rollup_where} - GROUP BY COALESCE(NULLIF(r.pricing_model, ''), r.model) + GROUP BY {rollup_model} ) GROUP BY model ORDER BY total_cost DESC" @@ -1264,14 +1474,16 @@ impl Database { conditions.push(format!("{} = ?", folded_app_type_sql("l.app_type"))); params.push(Box::new(app_type.clone())); } - if let Some(ref provider_name) = filters.provider_name { - conditions.push("p.name LIKE ?".to_string()); - params.push(Box::new(format!("%{provider_name}%"))); - } - if let Some(ref model) = filters.model { - conditions.push("l.model LIKE ?".to_string()); - params.push(Box::new(format!("%{model}%"))); - } + // 与 Dashboard 顶部下拉筛选同口径:Provider 按展示名精确匹配(会话占位 + // 行如 "Claude (Session)" 也能命中),模型按有效计价模型匹配。 + push_provider_model_filters( + &mut conditions, + &mut params, + "l", + "p", + filters.provider_name.as_deref(), + filters.model.as_deref(), + ); if let Some(status) = filters.status_code { conditions.push("l.status_code = ?".to_string()); params.push(Box::new(status as i64)); @@ -2169,7 +2381,7 @@ mod tests { } // ① 分应用汇总:desktop 折叠进 claude,不再单列 claude-desktop 桶。 - let by_app = db.get_usage_summary_by_app(None, None)?; + let by_app = db.get_usage_summary_by_app(None, None, None, None)?; assert_eq!(by_app.len(), 1, "应只剩一个合并后的 claude 桶"); assert_eq!(by_app[0].app_type, "claude"); assert_eq!(by_app[0].summary.total_requests, 2, "两条行都计入 claude"); @@ -2179,7 +2391,7 @@ mod tests { ); // ② 选中 claude 过滤:汇总应同时覆盖 desktop 行。 - let claude_summary = db.get_usage_summary(None, None, Some("claude"))?; + let claude_summary = db.get_usage_summary(None, None, Some("claude"), None, None)?; assert_eq!(claude_summary.total_requests, 2); // ③ 请求日志按 claude 过滤返回两行,且 desktop 行投影仍是原始 app_type。 @@ -2198,7 +2410,7 @@ mod tests { ); // ④ 折叠不外溢:codex 过滤为空。 - let codex_summary = db.get_usage_summary(None, None, Some("codex"))?; + let codex_summary = db.get_usage_summary(None, None, Some("codex"), None, None)?; assert_eq!(codex_summary.total_requests, 0); Ok(()) @@ -2504,7 +2716,7 @@ mod tests { )?; } - let summary = db.get_usage_summary(None, None, None)?; + let summary = db.get_usage_summary(None, None, None, None, None)?; assert_eq!(summary.total_requests, 2); assert_eq!(summary.success_rate, 100.0); @@ -2584,7 +2796,7 @@ mod tests { )?; } - let summary = db.get_usage_summary(Some(start), Some(end), Some("claude"))?; + let summary = db.get_usage_summary(Some(start), Some(end), Some("claude"), None, None)?; assert_eq!(summary.total_requests, 20); assert_eq!(summary.total_input_tokens, 2000); assert_eq!(summary.total_output_tokens, 1000); @@ -2592,6 +2804,174 @@ mod tests { Ok(()) } + #[test] + fn test_provider_and_model_filters_cover_detail_and_rollup() -> Result<(), AppError> { + let db = Database::memory()?; + let detail_ts = local_ts(2026, 6, 10, 12, 0, 0); + + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO providers (id, app_type, name, settings_config) VALUES + ('prov-a', 'claude', 'Packy', '{}'), + ('prov-b', 'claude', 'DeepSeek', '{}')", + [], + )?; + + insert_usage_log( + &conn, + "a-1", + "claude", + "prov-a", + "claude-sonnet-4-6", + "proxy", + detail_ts, + 100, + 10, + 0, + 0, + 200, + "1.0", + )?; + insert_usage_log( + &conn, + "b-1", + "claude", + "prov-b", + "deepseek-v3", + "proxy", + detail_ts, + 200, + 20, + 0, + 0, + 200, + "2.0", + )?; + // 会话占位行:providers 表无此 id,展示名走 CASE 映射。 + insert_usage_log( + &conn, + "s-1", + "claude", + "_session", + "claude-sonnet-4-6", + "session_log", + detail_ts, + 999, + 99, + 0, + 0, + 200, + "0.5", + )?; + // 计价模型与请求模型不同的行:模型筛选必须按有效计价模型命中。 + insert_usage_log( + &conn, + "a-2", + "claude", + "prov-a", + "alias-model", + "proxy", + detail_ts, + 50, + 5, + 0, + 0, + 200, + "0.3", + )?; + conn.execute( + "UPDATE proxy_request_logs SET pricing_model = 'real-model' WHERE request_id = 'a-2'", + [], + )?; + + // rollup 历史日行:无范围过滤时全部计入。 + 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 + ('2026-06-08', 'claude', 'prov-a', 'claude-sonnet-4-6', 5, 5, 500, 50, 0, 0, '5.0', 100), + ('2026-06-08', 'claude', 'prov-b', 'deepseek-v3', 7, 7, 700, 70, 0, 0, '7.0', 100)", + [], + )?; + } + + // ① 汇总按 Provider 展示名过滤:明细 + rollup 都命中。 + let packy = db.get_usage_summary(None, None, None, Some("Packy"), None)?; + assert_eq!(packy.total_requests, 7, "a-1 + a-2 + rollup 5"); + + // ② 汇总按模型过滤(有效计价模型口径)。 + let deepseek = db.get_usage_summary(None, None, None, None, Some("deepseek-v3"))?; + assert_eq!(deepseek.total_requests, 8, "b-1 + rollup 7"); + + // ③ pricing_model 优先于 model:alias-model 查不到,real-model 查得到。 + let by_alias = db.get_usage_summary(None, None, None, None, Some("alias-model"))?; + assert_eq!(by_alias.total_requests, 0); + let by_real = db.get_usage_summary(None, None, None, None, Some("real-model"))?; + assert_eq!(by_real.total_requests, 1); + + // ④ 会话占位行可按可读名选中。 + let session = db.get_usage_summary(None, None, None, Some("Claude (Session)"), None)?; + assert_eq!(session.total_requests, 1); + + // ⑤ Provider 统计 + 模型过滤:只剩 DeepSeek 一行。 + let provider_stats = db.get_provider_stats(None, None, None, None, Some("deepseek-v3"))?; + assert_eq!(provider_stats.len(), 1); + assert_eq!(provider_stats[0].provider_name, "DeepSeek"); + assert_eq!(provider_stats[0].request_count, 8); + + // ⑥ 模型统计 + Provider 过滤:只剩 Packy 名下的模型。 + let model_stats = db.get_model_stats(None, None, None, Some("Packy"), None)?; + let models: Vec<&str> = model_stats.iter().map(|m| m.model.as_str()).collect(); + assert!(models.contains(&"claude-sonnet-4-6")); + assert!(models.contains(&"real-model")); + assert!(!models.contains(&"deepseek-v3")); + + // ⑦ 分应用汇总(Hero 卡片数据源)同样受过滤影响。 + let by_app = db.get_usage_summary_by_app(None, None, Some("Packy"), None)?; + assert_eq!(by_app.len(), 1); + assert_eq!(by_app[0].app_type, "claude"); + assert_eq!(by_app[0].summary.total_requests, 7); + + // ⑧ 趋势(>24h 走天分桶 + rollup 分支)。 + let t_start = local_ts(2026, 6, 8, 0, 0, 0); + let t_end = local_ts(2026, 6, 10, 23, 59, 0); + let trends = db.get_daily_trends(Some(t_start), Some(t_end), None, Some("Packy"), None)?; + let total_req: u64 = trends.iter().map(|d| d.request_count).sum(); + assert_eq!(total_req, 7, "明细 2 + rollup 5"); + + // ⑨ 趋势 ≤24h 走小时分桶分支(?1/?2/?3 编号参数与追加过滤混用的路径), + // 同时验证 Provider + 模型组合过滤。 + let h_start = local_ts(2026, 6, 10, 0, 0, 0); + let h_end = local_ts(2026, 6, 10, 20, 0, 0); + let hourly = db.get_daily_trends( + Some(h_start), + Some(h_end), + None, + Some("Packy"), + Some("claude-sonnet-4-6"), + )?; + let hourly_req: u64 = hourly.iter().map(|d| d.request_count).sum(); + assert_eq!(hourly_req, 1, "仅 a-1 命中(a-2 计价模型不同)"); + + // ⑩ 请求日志列表与下拉同口径:精确名 + 有效计价模型。 + let logs = db.get_request_logs( + &LogFilters { + provider_name: Some("Packy".to_string()), + model: Some("real-model".to_string()), + ..Default::default() + }, + 0, + 10, + )?; + assert_eq!(logs.total, 1); + assert_eq!(logs.data[0].request_id, "a-2"); + + Ok(()) + } + #[test] fn test_get_usage_summary_includes_end_day_rollup_for_minute_precision_end_time( ) -> Result<(), AppError> { @@ -2645,7 +3025,7 @@ mod tests { )?; } - let summary = db.get_usage_summary(Some(start), Some(end), Some("claude"))?; + let summary = db.get_usage_summary(Some(start), Some(end), Some("claude"), None, None)?; assert_eq!(summary.total_requests, 30); assert_eq!(summary.total_input_tokens, 3000); assert_eq!(summary.total_output_tokens, 1500); @@ -2766,7 +3146,7 @@ mod tests { )?; } - let summary = db.get_usage_summary(None, None, None)?; + let summary = db.get_usage_summary(None, None, None, None, None)?; assert_eq!(summary.total_requests, 4); // codex-proxy contributes 100-10=90; gemini-proxy contributes 200-30=170 // (both cache-inclusive providers). claude-proxy=300, codex-session-only=50. @@ -2781,10 +3161,10 @@ mod tests { let expected_hit_rate = 60.0_f64 / 682.0_f64; assert!((summary.cache_hit_rate - expected_hit_rate).abs() < 1e-9); - let trends = db.get_daily_trends(Some(0), Some(40_000), None)?; + let trends = db.get_daily_trends(Some(0), Some(40_000), None, None, None)?; assert_eq!(trends.iter().map(|stat| stat.request_count).sum::(), 4); - let provider_stats = db.get_provider_stats(None, None, None)?; + let provider_stats = db.get_provider_stats(None, None, None, None, None)?; assert_eq!( provider_stats .iter() @@ -2802,7 +3182,7 @@ mod tests { .iter() .any(|stat| stat.provider_id == "_session")); - let model_stats = db.get_model_stats(None, None, None)?; + let model_stats = db.get_model_stats(None, None, None, None, None)?; assert_eq!( model_stats .iter() @@ -2994,7 +3374,7 @@ mod tests { )?; } - let summary = db.get_usage_summary(None, None, None)?; + let summary = db.get_usage_summary(None, None, None, None, None)?; assert_eq!(summary.total_requests, 9); let logs = db.get_request_logs(&LogFilters::default(), 0, 10)?; @@ -3042,7 +3422,7 @@ mod tests { )?; } - let stats = db.get_model_stats(None, None, None)?; + let stats = db.get_model_stats(None, None, None, None, None)?; assert_eq!(stats.len(), 1); assert_eq!(stats[0].model, "claude-3-sonnet"); assert_eq!(stats[0].request_count, 1); @@ -3074,7 +3454,7 @@ mod tests { )?; } - let stats = db.get_provider_stats(Some(1500), Some(2500), Some("claude"))?; + let stats = db.get_provider_stats(Some(1500), Some(2500), Some("claude"), None, None)?; assert_eq!(stats.len(), 1); assert_eq!(stats[0].provider_id, "p1"); assert_eq!(stats[0].request_count, 1); @@ -3106,7 +3486,7 @@ mod tests { )?; } - let stats = db.get_provider_stats(None, None, Some("opencode"))?; + let stats = db.get_provider_stats(None, None, Some("opencode"), None, None)?; assert_eq!(stats.len(), 1); assert_eq!(stats[0].provider_id, "_opencode_session"); assert_eq!(stats[0].provider_name, "OpenCode (Session)"); @@ -3187,7 +3567,7 @@ mod tests { )?; } - let stats = db.get_provider_stats(Some(start), Some(end), Some("claude"))?; + let stats = db.get_provider_stats(Some(start), Some(end), Some("claude"), None, None)?; assert_eq!(stats.len(), 1); assert_eq!(stats[0].provider_id, "p-rollup"); assert_eq!(stats[0].request_count, 8); @@ -3223,7 +3603,7 @@ mod tests { )?; } - let stats = db.get_daily_trends(Some(0), Some(15 * 60 * 60), Some("claude"))?; + let stats = db.get_daily_trends(Some(0), Some(15 * 60 * 60), Some("claude"), None, None)?; assert_eq!(stats.len(), 15); assert_eq!(stats[3].request_count, 1); @@ -3300,7 +3680,7 @@ mod tests { )?; } - let stats = db.get_daily_trends(Some(start), Some(end), Some("claude"))?; + let stats = db.get_daily_trends(Some(start), Some(end), Some("claude"), None, None)?; assert_eq!(stats.len(), 3); assert_eq!(stats[0].request_count, 1); assert_eq!(stats[0].total_tokens, 150); @@ -3385,7 +3765,7 @@ mod tests { )?; } - let stats = db.get_model_stats(Some(start), Some(end), Some("claude"))?; + let stats = db.get_model_stats(Some(start), Some(end), Some("claude"), None, None)?; assert_eq!(stats.len(), 1); assert_eq!(stats[0].model, "claude-3-haiku"); assert_eq!(stats[0].request_count, 9); diff --git a/src/components/usage/ModelStatsTable.tsx b/src/components/usage/ModelStatsTable.tsx index b74b70f49..0d6ee3704 100644 --- a/src/components/usage/ModelStatsTable.tsx +++ b/src/components/usage/ModelStatsTable.tsx @@ -14,18 +14,26 @@ import type { UsageRangeSelection } from "@/types/usage"; interface ModelStatsTableProps { range: UsageRangeSelection; appType?: string; + providerName?: string; + model?: string; refreshIntervalMs: number; } export function ModelStatsTable({ range, appType, + providerName, + model, refreshIntervalMs, }: ModelStatsTableProps) { const { t } = useTranslation(); - const { data: stats, isLoading } = useModelStats(range, appType, { - refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, - }); + const { data: stats, isLoading } = useModelStats( + range, + { appType, providerName, model }, + { + refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, + }, + ); if (isLoading) { return
; diff --git a/src/components/usage/ProviderStatsTable.tsx b/src/components/usage/ProviderStatsTable.tsx index 64c18213f..9d1ac0b18 100644 --- a/src/components/usage/ProviderStatsTable.tsx +++ b/src/components/usage/ProviderStatsTable.tsx @@ -14,18 +14,26 @@ import type { UsageRangeSelection } from "@/types/usage"; interface ProviderStatsTableProps { range: UsageRangeSelection; appType?: string; + providerName?: string; + model?: string; refreshIntervalMs: number; } export function ProviderStatsTable({ range, appType, + providerName, + model, refreshIntervalMs, }: ProviderStatsTableProps) { const { t } = useTranslation(); - const { data: stats, isLoading } = useProviderStats(range, appType, { - refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, - }); + const { data: stats, isLoading } = useProviderStats( + range, + { appType, providerName, model }, + { + refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, + }, + ); if (isLoading) { return
; diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx index 2ea0204eb..dc7e3da75 100644 --- a/src/components/usage/RequestLogTable.tsx +++ b/src/components/usage/RequestLogTable.tsx @@ -19,13 +19,12 @@ import { } from "@/components/ui/select"; import { useRequestLogs } from "@/lib/query/usage"; import { - KNOWN_APP_TYPES, getFreshInputTokens, isUnpricedUsage, type LogFilters, type UsageRangeSelection, } from "@/types/usage"; -import { ChevronLeft, ChevronRight, Search, X } from "lucide-react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; import { UsageDateRangePicker } from "./UsageDateRangePicker"; import { fmtInt, @@ -38,6 +37,8 @@ interface RequestLogTableProps { range: UsageRangeSelection; rangeLabel: string; appType?: string; + providerName?: string; + model?: string; refreshIntervalMs: number; onRangeChange?: (range: UsageRangeSelection) => void; } @@ -46,21 +47,29 @@ export function RequestLogTable({ range, rangeLabel, appType: dashboardAppType, + providerName, + model, refreshIntervalMs, onRangeChange, }: RequestLogTableProps) { const { t, i18n } = useTranslation(); - const [appliedFilters, setAppliedFilters] = useState({}); - const [draftFilters, setDraftFilters] = useState({}); + // 应用/Provider/模型筛选已上移到 Dashboard 顶栏(全局生效); + // 这里只保留日志特有的状态码筛选。 + const [statusCode, setStatusCode] = useState(undefined); const [page, setPage] = useState(0); const [pageInput, setPageInput] = useState(""); const pageSize = 20; - const dashboardAppTypeActive = dashboardAppType && dashboardAppType !== "all"; - const effectiveFilters: LogFilters = dashboardAppTypeActive - ? { ...appliedFilters, appType: dashboardAppType } - : appliedFilters; + const effectiveFilters: LogFilters = { + appType: + dashboardAppType && dashboardAppType !== "all" + ? dashboardAppType + : undefined, + providerName, + model, + statusCode, + }; const { data: result, isLoading } = useRequestLogs({ filters: effectiveFilters, @@ -80,37 +89,13 @@ export function RequestLogTable({ setPage(0); }, [ dashboardAppType, + providerName, + model, range.customEndDate, range.customStartDate, range.preset, ]); - const handleSearch = () => { - setAppliedFilters(draftFilters); - setPage(0); - }; - - const handleReset = () => { - setDraftFilters({}); - setAppliedFilters({}); - setPage(0); - }; - - const applySelectFilter = ( - key: K, - value: LogFilters[K], - ) => { - setDraftFilters((prev) => ({ - ...prev, - [key]: value, - })); - setAppliedFilters((prev) => ({ - ...prev, - [key]: value, - })); - setPage(0); - }; - const handleGoToPage = () => { const trimmed = pageInput.trim(); if (!/^\d+$/.test(trimmed)) return; @@ -127,44 +112,16 @@ export function RequestLogTable({
- {/* App type */} - - {/* Status code */} - {/* Provider search */} -
- - - setDraftFilters({ - ...draftFilters, - providerName: e.target.value || undefined, - }) - } - onKeyDown={(e) => { - if (e.key === "Enter") handleSearch(); - }} - /> -
- - {/* Model search */} -
- - setDraftFilters({ - ...draftFilters, - model: e.target.value || undefined, - }) - } - onKeyDown={(e) => { - if (e.key === "Enter") handleSearch(); - }} - /> -
- {onRangeChange && ( )} - - {/* Search & Reset (icon-only) */} - -
diff --git a/src/components/usage/UsageDashboard.tsx b/src/components/usage/UsageDashboard.tsx index 57d7d6b58..d0aeb75cd 100644 --- a/src/components/usage/UsageDashboard.tsx +++ b/src/components/usage/UsageDashboard.tsx @@ -22,8 +22,15 @@ import { } from "lucide-react"; import { ProviderIcon } from "@/components/ProviderIcon"; import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { useQueryClient } from "@tanstack/react-query"; -import { usageKeys } from "@/lib/query/usage"; +import { usageKeys, useModelStats, useProviderStats } from "@/lib/query/usage"; import { useUsageEventBridge } from "@/hooks/useUsageEventBridge"; import { Accordion, @@ -48,13 +55,40 @@ const APP_FILTER_ICON: Record = { opencode: "opencode", }; +// Select 的 "all" 哨兵和用户自定义名称同处一个值域——真有来源/模型叫 "all" +// 就会撞名(重复 value、选中即清空筛选)。动态选项统一加前缀编码隔离值域。 +const DYNAMIC_OPTION_PREFIX = "v:"; +const encodeOptionValue = (name: string) => `${DYNAMIC_OPTION_PREFIX}${name}`; +const decodeOptionValue = (value: string) => + value === "all" ? undefined : value.slice(DYNAMIC_OPTION_PREFIX.length); + export function UsageDashboard() { const { t, i18n } = useTranslation(); const queryClient = useQueryClient(); const [range, setRange] = useState({ preset: "today" }); const [appType, setAppType] = useState("all"); + const [providerName, setProviderName] = useState( + undefined, + ); + const [model, setModel] = useState(undefined); const [refreshIntervalMs, setRefreshIntervalMs] = useState(30000); + // 切应用时清掉下游筛选,避免留下一个在新范围内查无数据的"幽灵"组合; + // 切 Provider 同理清掉模型(模型选项随 Provider 级联)。 + const changeAppType = (next: AppTypeFilter) => { + setAppType(next); + if (next !== appType) { + setProviderName(undefined); + setModel(undefined); + } + }; + const changeProviderName = (next: string | undefined) => { + setProviderName(next); + if (next !== providerName) { + setModel(undefined); + } + }; + // 后端写入新日志时 emit `usage-log-recorded`,本 hook 立刻 invalidate 所有 // usage 查询,实现实时刷新(仅在 Dashboard 挂载时生效,离开页面自动取消监听) useUsageEventBridge(); @@ -84,6 +118,45 @@ export function UsageDashboard() { ).toLocaleString(locale)}`; }, [locale, range, resolvedRange.endDate, resolvedRange.startDate, t]); + // 顶栏下拉的选项池:Provider 列表只跟应用/时间范围走(不受自身选中值影响), + // 模型列表随所选 Provider 级联。两者都只列当前范围内真实有数据的条目。 + // refetchInterval 必须跟随面板的刷新设置——未筛选时这两个查询与统计表共享 + // query key,落下的话会以默认 30s 拖着同 key 查询一起轮询,"--" 形同虚设。 + const optionsRefetch = { + refetchInterval: + refreshIntervalMs > 0 ? refreshIntervalMs : (false as const), + }; + const { data: providerOptionsData } = useProviderStats( + range, + { appType }, + optionsRefetch, + ); + const { data: modelOptionsData } = useModelStats( + range, + { appType, providerName }, + optionsRefetch, + ); + + const providerOptions = useMemo(() => { + const names = new Set(); + for (const stat of providerOptionsData ?? []) { + names.add(stat.providerName); + } + // 数据刷新后选中项可能掉出列表(如改了时间范围);补回去保证 Select + // 仍能渲染选中文案,用户看得见才能主动清除。 + if (providerName) names.add(providerName); + return Array.from(names); + }, [providerOptionsData, providerName]); + + const modelOptions = useMemo(() => { + const names = new Set(); + for (const stat of modelOptionsData ?? []) { + names.add(stat.model); + } + if (model) names.add(model); + return Array.from(names); + }, [modelOptionsData, model]); + return ( setAppType(type)} + onClick={() => changeAppType(type)} title={label} aria-label={label} className={cn( @@ -131,6 +204,58 @@ export function UsageDashboard() { })}
+ + + +