mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-21 04:40:18 +08:00
feat: add per-app usage filtering (Claude/Codex/Gemini)
Add dashboard-level app type filter to usage statistics, replacing the DataSourceBar with a more useful segmented control. All components (summary cards, trend chart, provider stats, model stats, request logs) now respond to the selected app filter. Backend: add optional app_type parameter to get_usage_summary, get_daily_trends, get_provider_stats, and get_model_stats queries. Frontend: new AppTypeFilter type, updated query keys with appType dimension for proper cache separation, and RequestLogTable local filter auto-locks when dashboard filter is active.
This commit is contained in:
@@ -11,8 +11,11 @@ pub fn get_usage_summary(
|
||||
state: State<'_, AppState>,
|
||||
start_date: Option<i64>,
|
||||
end_date: Option<i64>,
|
||||
app_type: Option<String>,
|
||||
) -> Result<UsageSummary, AppError> {
|
||||
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<i64>,
|
||||
end_date: Option<i64>,
|
||||
app_type: Option<String>,
|
||||
) -> Result<Vec<DailyStats>, 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<Vec<ProviderStats>, AppError> {
|
||||
state.db.get_provider_stats()
|
||||
pub fn get_provider_stats(
|
||||
state: State<'_, AppState>,
|
||||
app_type: Option<String>,
|
||||
) -> Result<Vec<ProviderStats>, AppError> {
|
||||
state.db.get_provider_stats(app_type.as_deref())
|
||||
}
|
||||
|
||||
/// 获取模型统计
|
||||
#[tauri::command]
|
||||
pub fn get_model_stats(state: State<'_, AppState>) -> Result<Vec<ModelStats>, AppError> {
|
||||
state.db.get_model_stats()
|
||||
pub fn get_model_stats(
|
||||
state: State<'_, AppState>,
|
||||
app_type: Option<String>,
|
||||
) -> Result<Vec<ModelStats>, AppError> {
|
||||
state.db.get_model_stats(app_type.as_deref())
|
||||
}
|
||||
|
||||
/// 获取请求日志列表
|
||||
|
||||
@@ -123,44 +123,54 @@ impl Database {
|
||||
&self,
|
||||
start_date: Option<i64>,
|
||||
end_date: Option<i64>,
|
||||
app_type: Option<&str>,
|
||||
) -> Result<UsageSummary, AppError> {
|
||||
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<Box<dyn rusqlite::ToSql>> = 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<String> = Vec::new();
|
||||
let mut params = Vec::new();
|
||||
// Build rollup WHERE clause using date strings
|
||||
let mut rollup_conditions: Vec<String> = Vec::new();
|
||||
let mut rollup_params: Vec<Box<dyn rusqlite::ToSql>> = 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<i64> = params_vec;
|
||||
let mut all_params: Vec<Box<dyn rusqlite::ToSql>> = 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<i64>,
|
||||
end_date: Option<i64>,
|
||||
app_type: Option<&str>,
|
||||
) -> Result<Vec<DailyStats>, 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<i64, DailyStats> = 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<Vec<ProviderStats>, AppError> {
|
||||
pub fn get_provider_stats(
|
||||
&self,
|
||||
app_type: Option<&str>,
|
||||
) -> Result<Vec<ProviderStats>, 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<Vec<ModelStats>, AppError> {
|
||||
pub fn get_model_stats(&self, app_type: Option<&str>) -> Result<Vec<ModelStats>, 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);
|
||||
|
||||
Reference in New Issue
Block a user