mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-16 13:34:04 +08:00
feat(usage): lift provider/model filters to dashboard-wide scope
The provider/model filters only lived inside the request-log table, so there was no way to see "how much did app X spend on source Y" across the whole dashboard. Promote them to the top bar next to the app filter, applying globally to the hero summary, trend chart, request logs, and both stats tabs. Backend: the five stats queries (summary, summary-by-app, trends, provider stats, model stats) accept optional provider_name/model filters, applied to both the detail and daily-rollup branches (the rollup PK already carries provider_id/model/pricing_model). Sources match by exact display name via provider_name_coalesce, so session placeholder rows like "Claude (Session)" are selectable; models match by effective pricing model (pricing_model falling back to model), the same grouping key the model-stats tab uses. Request-log filtering switches from LIKE to these exact semantics. Frontend: two truncating dropdowns list only sources/models that have data in the current range, with the model list cascading from the selected source. Dynamic option values are prefix-encoded so a source literally named "all" cannot collide with the sentinel, query keys fall back to null instead of "all", and the option queries follow the dashboard refresh interval (otherwise their default 30s polling drags same-key stats queries along even with refresh disabled). The request-log filter bar keeps only the log-specific status-code select. Labels read "sources" rather than "providers" because direct-connect session buckets sit alongside real providers. i18n updated across zh/en/ja/zh-TW.
This commit is contained in:
@@ -14,10 +14,16 @@ pub fn get_usage_summary(
|
||||
start_date: Option<i64>,
|
||||
end_date: Option<i64>,
|
||||
app_type: Option<String>,
|
||||
provider_name: Option<String>,
|
||||
model: Option<String>,
|
||||
) -> Result<UsageSummary, AppError> {
|
||||
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<i64>,
|
||||
end_date: Option<i64>,
|
||||
provider_name: Option<String>,
|
||||
model: Option<String>,
|
||||
) -> Result<Vec<UsageSummaryByApp>, 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<i64>,
|
||||
end_date: Option<i64>,
|
||||
app_type: Option<String>,
|
||||
provider_name: Option<String>,
|
||||
model: Option<String>,
|
||||
) -> Result<Vec<DailyStats>, 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<i64>,
|
||||
end_date: Option<i64>,
|
||||
app_type: Option<String>,
|
||||
provider_name: Option<String>,
|
||||
model: Option<String>,
|
||||
) -> Result<Vec<ProviderStats>, 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<i64>,
|
||||
end_date: Option<i64>,
|
||||
app_type: Option<String>,
|
||||
provider_name: Option<String>,
|
||||
model: Option<String>,
|
||||
) -> Result<Vec<ModelStats>, 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(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取请求日志列表
|
||||
|
||||
@@ -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<String>,
|
||||
params: &mut Vec<Box<dyn rusqlite::ToSql>>,
|
||||
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<i64>,
|
||||
end_date: Option<i64>,
|
||||
app_type: Option<&str>,
|
||||
provider_name: Option<&str>,
|
||||
model: Option<&str>,
|
||||
) -> Result<UsageSummary, AppError> {
|
||||
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<String> = 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<i64>,
|
||||
end_date: Option<i64>,
|
||||
provider_name: Option<&str>,
|
||||
model: Option<&str>,
|
||||
) -> Result<Vec<UsageSummaryByApp>, 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<String> = 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<i64>,
|
||||
end_date: Option<i64>,
|
||||
app_type: Option<&str>,
|
||||
provider_name: Option<&str>,
|
||||
model: Option<&str>,
|
||||
) -> Result<Vec<DailyStats>, 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<String> = Vec::new();
|
||||
let mut extra_params: Vec<Box<dyn rusqlite::ToSql>> = 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::<Vec<_>>()
|
||||
.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<i64, DailyStats> = 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<Box<dyn rusqlite::ToSql>> = 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<String> = Vec::new();
|
||||
let mut extra_params: Vec<Box<dyn rusqlite::ToSql>> = 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::<Vec<_>>()
|
||||
.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<NaiveDate, DailyStats> = 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<Box<dyn rusqlite::ToSql>> =
|
||||
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<i64>,
|
||||
end_date: Option<i64>,
|
||||
app_type: Option<&str>,
|
||||
provider_name: Option<&str>,
|
||||
model: Option<&str>,
|
||||
) -> Result<Vec<ProviderStats>, 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<i64>,
|
||||
end_date: Option<i64>,
|
||||
app_type: Option<&str>,
|
||||
provider_name: Option<&str>,
|
||||
model: Option<&str>,
|
||||
) -> Result<Vec<ModelStats>, 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<Box<dyn rusqlite::ToSql>> = 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::<u64>(), 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);
|
||||
|
||||
@@ -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 <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
|
||||
|
||||
@@ -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 <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
|
||||
|
||||
@@ -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<LogFilters>({});
|
||||
const [draftFilters, setDraftFilters] = useState<LogFilters>({});
|
||||
// 应用/Provider/模型筛选已上移到 Dashboard 顶栏(全局生效);
|
||||
// 这里只保留日志特有的状态码筛选。
|
||||
const [statusCode, setStatusCode] = useState<number | undefined>(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 = <K extends keyof LogFilters>(
|
||||
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({
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border bg-card/50 p-2 backdrop-blur-sm">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{/* App type */}
|
||||
<Select
|
||||
value={
|
||||
dashboardAppTypeActive
|
||||
? dashboardAppType
|
||||
: draftFilters.appType || "all"
|
||||
}
|
||||
onValueChange={(v) =>
|
||||
applySelectFilter("appType", v === "all" ? undefined : v)
|
||||
}
|
||||
disabled={!!dashboardAppTypeActive}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[110px] bg-background text-xs">
|
||||
<SelectValue placeholder={t("usage.appType")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("usage.allApps")}</SelectItem>
|
||||
{KNOWN_APP_TYPES.map((at) => (
|
||||
<SelectItem key={at} value={at}>
|
||||
{t(`usage.appFilter.${at}`, { defaultValue: at })}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Status code */}
|
||||
<Select
|
||||
value={draftFilters.statusCode?.toString() || "all"}
|
||||
onValueChange={(v) =>
|
||||
applySelectFilter(
|
||||
"statusCode",
|
||||
v === "all"
|
||||
? undefined
|
||||
: Number.isFinite(Number.parseInt(v, 10))
|
||||
? Number.parseInt(v, 10)
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
value={statusCode?.toString() || "all"}
|
||||
onValueChange={(v) => {
|
||||
const parsed = Number.parseInt(v, 10);
|
||||
setStatusCode(
|
||||
v === "all" || !Number.isFinite(parsed) ? undefined : parsed,
|
||||
);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[100px] bg-background text-xs">
|
||||
<SelectValue placeholder={t("usage.statusCode")} />
|
||||
@@ -179,43 +136,6 @@ export function RequestLogTable({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Provider search */}
|
||||
<div className="relative min-w-[140px] flex-1">
|
||||
<Search className="absolute left-2 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("usage.searchProviderPlaceholder")}
|
||||
className="h-8 bg-background pl-7 text-xs"
|
||||
value={draftFilters.providerName || ""}
|
||||
onChange={(e) =>
|
||||
setDraftFilters({
|
||||
...draftFilters,
|
||||
providerName: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSearch();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Model search */}
|
||||
<div className="relative min-w-[120px] flex-1">
|
||||
<Input
|
||||
placeholder={t("usage.searchModelPlaceholder")}
|
||||
className="h-8 bg-background text-xs"
|
||||
value={draftFilters.model || ""}
|
||||
onChange={(e) =>
|
||||
setDraftFilters({
|
||||
...draftFilters,
|
||||
model: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSearch();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{onRangeChange && (
|
||||
<UsageDateRangePicker
|
||||
selection={range}
|
||||
@@ -223,26 +143,6 @@ export function RequestLogTable({
|
||||
onApply={onRangeChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Search & Reset (icon-only) */}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="default"
|
||||
onClick={handleSearch}
|
||||
className="h-8 w-8"
|
||||
title={t("common.search")}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
className="h-8 w-8"
|
||||
title={t("common.reset")}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<AppType, string> = {
|
||||
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<UsageRangeSelection>({ preset: "today" });
|
||||
const [appType, setAppType] = useState<AppTypeFilter>("all");
|
||||
const [providerName, setProviderName] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [model, setModel] = useState<string | undefined>(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<string>();
|
||||
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<string>();
|
||||
for (const stat of modelOptionsData ?? []) {
|
||||
names.add(stat.model);
|
||||
}
|
||||
if (model) names.add(model);
|
||||
return Array.from(names);
|
||||
}, [modelOptionsData, model]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
@@ -107,7 +180,7 @@ export function UsageDashboard() {
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setAppType(type)}
|
||||
onClick={() => changeAppType(type)}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
@@ -131,6 +204,58 @@ export function UsageDashboard() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={
|
||||
providerName != null ? encodeOptionValue(providerName) : "all"
|
||||
}
|
||||
onValueChange={(v) => changeProviderName(decodeOptionValue(v))}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-9 w-[110px] bg-background text-xs [&>span]:min-w-0 [&>span]:truncate"
|
||||
title={providerName ?? t("usage.filterBySource")}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-w-[280px]">
|
||||
<SelectItem value="all">{t("usage.allSources")}</SelectItem>
|
||||
{providerOptions.map((name) => (
|
||||
<SelectItem
|
||||
key={name}
|
||||
value={encodeOptionValue(name)}
|
||||
title={name}
|
||||
className="[&>span]:min-w-0 [&>span]:truncate"
|
||||
>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={model != null ? encodeOptionValue(model) : "all"}
|
||||
onValueChange={(v) => setModel(decodeOptionValue(v))}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-9 w-[120px] bg-background text-xs [&>span]:min-w-0 [&>span]:truncate"
|
||||
title={model ?? t("usage.filterByModel")}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-w-[280px]">
|
||||
<SelectItem value="all">{t("usage.allModels")}</SelectItem>
|
||||
{modelOptions.map((name) => (
|
||||
<SelectItem
|
||||
key={name}
|
||||
value={encodeOptionValue(name)}
|
||||
title={name}
|
||||
className="[&>span]:min-w-0 [&>span]:truncate"
|
||||
>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto lg:ml-0">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -156,6 +281,8 @@ export function UsageDashboard() {
|
||||
<UsageHero
|
||||
range={range}
|
||||
appType={appType === "all" ? undefined : appType}
|
||||
providerName={providerName}
|
||||
model={model}
|
||||
refreshIntervalMs={refreshIntervalMs}
|
||||
/>
|
||||
|
||||
@@ -163,6 +290,8 @@ export function UsageDashboard() {
|
||||
range={range}
|
||||
rangeLabel={rangeLabel}
|
||||
appType={appType}
|
||||
providerName={providerName}
|
||||
model={model}
|
||||
refreshIntervalMs={refreshIntervalMs}
|
||||
/>
|
||||
|
||||
@@ -195,6 +324,8 @@ export function UsageDashboard() {
|
||||
range={range}
|
||||
rangeLabel={rangeLabel}
|
||||
appType={appType}
|
||||
providerName={providerName}
|
||||
model={model}
|
||||
refreshIntervalMs={refreshIntervalMs}
|
||||
onRangeChange={setRange}
|
||||
/>
|
||||
@@ -204,6 +335,8 @@ export function UsageDashboard() {
|
||||
<ProviderStatsTable
|
||||
range={range}
|
||||
appType={appType}
|
||||
providerName={providerName}
|
||||
model={model}
|
||||
refreshIntervalMs={refreshIntervalMs}
|
||||
/>
|
||||
</TabsContent>
|
||||
@@ -212,6 +345,8 @@ export function UsageDashboard() {
|
||||
<ModelStatsTable
|
||||
range={range}
|
||||
appType={appType}
|
||||
providerName={providerName}
|
||||
model={model}
|
||||
refreshIntervalMs={refreshIntervalMs}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
interface UsageHeroProps {
|
||||
range: UsageRangeSelection;
|
||||
appType?: string;
|
||||
providerName?: string;
|
||||
model?: string;
|
||||
refreshIntervalMs: number;
|
||||
}
|
||||
|
||||
@@ -159,14 +161,20 @@ function AppGlyph({
|
||||
export function UsageHero({
|
||||
range,
|
||||
appType,
|
||||
providerName,
|
||||
model,
|
||||
refreshIntervalMs,
|
||||
}: UsageHeroProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = getResolvedLang(i18n);
|
||||
|
||||
const { data, isLoading } = useUsageSummaryByApp(range, {
|
||||
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||
});
|
||||
const { data, isLoading } = useUsageSummaryByApp(
|
||||
range,
|
||||
{ providerName, model },
|
||||
{
|
||||
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||
},
|
||||
);
|
||||
|
||||
// No client-side filtering: Hero's totals must match the Trend/Logs/Stats
|
||||
// below, which all go through the backend's full set of app_types. The
|
||||
|
||||
@@ -24,6 +24,8 @@ interface UsageTrendChartProps {
|
||||
range: UsageRangeSelection;
|
||||
rangeLabel: string;
|
||||
appType?: string;
|
||||
providerName?: string;
|
||||
model?: string;
|
||||
refreshIntervalMs: number;
|
||||
}
|
||||
|
||||
@@ -31,13 +33,19 @@ export function UsageTrendChart({
|
||||
range,
|
||||
rangeLabel,
|
||||
appType,
|
||||
providerName,
|
||||
model,
|
||||
refreshIntervalMs,
|
||||
}: UsageTrendChartProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { startDate, endDate } = resolveUsageRange(range);
|
||||
const { data: trends, isLoading } = useUsageTrends(range, appType, {
|
||||
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||
});
|
||||
const { data: trends, isLoading } = useUsageTrends(
|
||||
range,
|
||||
{ appType, providerName, model },
|
||||
{
|
||||
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||
},
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@@ -1441,10 +1441,11 @@
|
||||
"modelIdPlaceholder": "e.g., claude-3-5-sonnet-20241022",
|
||||
"displayNamePlaceholder": "e.g., Claude 3.5 Sonnet",
|
||||
"appType": "App Type",
|
||||
"allApps": "All Apps",
|
||||
"statusCode": "Status Code",
|
||||
"searchProviderPlaceholder": "Search provider...",
|
||||
"searchModelPlaceholder": "Search model...",
|
||||
"allSources": "All Sources",
|
||||
"allModels": "All Models",
|
||||
"filterBySource": "Filter by source",
|
||||
"filterByModel": "Filter by model",
|
||||
"timeRange": "Time Range",
|
||||
"customRange": "Calendar Filter",
|
||||
"customRangeHint": "Supports both date and time",
|
||||
|
||||
@@ -1441,10 +1441,11 @@
|
||||
"modelIdPlaceholder": "例: claude-3-5-sonnet-20241022",
|
||||
"displayNamePlaceholder": "例: Claude 3.5 Sonnet",
|
||||
"appType": "アプリ種別",
|
||||
"allApps": "すべてのアプリ",
|
||||
"statusCode": "ステータスコード",
|
||||
"searchProviderPlaceholder": "プロバイダーを検索...",
|
||||
"searchModelPlaceholder": "モデルを検索...",
|
||||
"allSources": "すべてのソース",
|
||||
"allModels": "すべてのモデル",
|
||||
"filterBySource": "ソースで絞り込む",
|
||||
"filterByModel": "モデルで絞り込む",
|
||||
"timeRange": "期間",
|
||||
"customRange": "カレンダーフィルター",
|
||||
"customRangeHint": "日付と時刻の両方に対応",
|
||||
|
||||
@@ -1413,10 +1413,11 @@
|
||||
"modelIdPlaceholder": "例如: claude-3-5-sonnet-20241022",
|
||||
"displayNamePlaceholder": "例如: Claude 3.5 Sonnet",
|
||||
"appType": "應用程式類型",
|
||||
"allApps": "全部應用程式",
|
||||
"statusCode": "狀態碼",
|
||||
"searchProviderPlaceholder": "搜尋供應商...",
|
||||
"searchModelPlaceholder": "搜尋模型...",
|
||||
"allSources": "全部來源",
|
||||
"allModels": "全部模型",
|
||||
"filterBySource": "依來源篩選",
|
||||
"filterByModel": "依模型篩選",
|
||||
"timeRange": "時間範圍",
|
||||
"customRange": "日曆篩選",
|
||||
"customRangeHint": "支援日期與時間",
|
||||
|
||||
@@ -1441,10 +1441,11 @@
|
||||
"modelIdPlaceholder": "例如: claude-3-5-sonnet-20241022",
|
||||
"displayNamePlaceholder": "例如: Claude 3.5 Sonnet",
|
||||
"appType": "应用类型",
|
||||
"allApps": "全部应用",
|
||||
"statusCode": "状态码",
|
||||
"searchProviderPlaceholder": "搜索供应商...",
|
||||
"searchModelPlaceholder": "搜索模型...",
|
||||
"allSources": "全部来源",
|
||||
"allModels": "全部模型",
|
||||
"filterBySource": "按来源筛选",
|
||||
"filterByModel": "按模型筛选",
|
||||
"timeRange": "时间范围",
|
||||
"customRange": "日历筛选",
|
||||
"customRangeHint": "支持日期与时间",
|
||||
|
||||
+44
-5
@@ -52,39 +52,78 @@ export const usageApi = {
|
||||
startDate?: number,
|
||||
endDate?: number,
|
||||
appType?: string,
|
||||
providerName?: string,
|
||||
model?: string,
|
||||
): Promise<UsageSummary> => {
|
||||
return invoke("get_usage_summary", { startDate, endDate, appType });
|
||||
return invoke("get_usage_summary", {
|
||||
startDate,
|
||||
endDate,
|
||||
appType,
|
||||
providerName,
|
||||
model,
|
||||
});
|
||||
},
|
||||
|
||||
getUsageSummaryByApp: async (
|
||||
startDate?: number,
|
||||
endDate?: number,
|
||||
providerName?: string,
|
||||
model?: string,
|
||||
): Promise<UsageSummaryByApp[]> => {
|
||||
return invoke("get_usage_summary_by_app", { startDate, endDate });
|
||||
return invoke("get_usage_summary_by_app", {
|
||||
startDate,
|
||||
endDate,
|
||||
providerName,
|
||||
model,
|
||||
});
|
||||
},
|
||||
|
||||
getUsageTrends: async (
|
||||
startDate?: number,
|
||||
endDate?: number,
|
||||
appType?: string,
|
||||
providerName?: string,
|
||||
model?: string,
|
||||
): Promise<DailyStats[]> => {
|
||||
return invoke("get_usage_trends", { startDate, endDate, appType });
|
||||
return invoke("get_usage_trends", {
|
||||
startDate,
|
||||
endDate,
|
||||
appType,
|
||||
providerName,
|
||||
model,
|
||||
});
|
||||
},
|
||||
|
||||
getProviderStats: async (
|
||||
startDate?: number,
|
||||
endDate?: number,
|
||||
appType?: string,
|
||||
providerName?: string,
|
||||
model?: string,
|
||||
): Promise<ProviderStats[]> => {
|
||||
return invoke("get_provider_stats", { startDate, endDate, appType });
|
||||
return invoke("get_provider_stats", {
|
||||
startDate,
|
||||
endDate,
|
||||
appType,
|
||||
providerName,
|
||||
model,
|
||||
});
|
||||
},
|
||||
|
||||
getModelStats: async (
|
||||
startDate?: number,
|
||||
endDate?: number,
|
||||
appType?: string,
|
||||
providerName?: string,
|
||||
model?: string,
|
||||
): Promise<ModelStats[]> => {
|
||||
return invoke("get_model_stats", { startDate, endDate, appType });
|
||||
return invoke("get_model_stats", {
|
||||
startDate,
|
||||
endDate,
|
||||
appType,
|
||||
providerName,
|
||||
model,
|
||||
});
|
||||
},
|
||||
|
||||
getRequestLogs: async (
|
||||
|
||||
+81
-26
@@ -1,7 +1,11 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { usageApi } from "@/lib/api/usage";
|
||||
import { resolveUsageRange } from "@/lib/usageRange";
|
||||
import type { LogFilters, UsageRangeSelection } from "@/types/usage";
|
||||
import type {
|
||||
LogFilters,
|
||||
UsageRangeSelection,
|
||||
UsageScopeFilters,
|
||||
} from "@/types/usage";
|
||||
|
||||
const DEFAULT_REFETCH_INTERVAL_MS = 30000;
|
||||
|
||||
@@ -35,7 +39,7 @@ export const usageKeys = {
|
||||
preset: UsageRangeSelection["preset"],
|
||||
customStartDate: number | undefined,
|
||||
customEndDate: number | undefined,
|
||||
appType?: string,
|
||||
filters?: UsageScopeFilters,
|
||||
) =>
|
||||
[
|
||||
...usageKeys.all,
|
||||
@@ -43,12 +47,15 @@ export const usageKeys = {
|
||||
preset,
|
||||
customStartDate ?? 0,
|
||||
customEndDate ?? 0,
|
||||
appType ?? "all",
|
||||
filters?.appType ?? null,
|
||||
filters?.providerName ?? null,
|
||||
filters?.model ?? null,
|
||||
] as const,
|
||||
summaryByApp: (
|
||||
preset: UsageRangeSelection["preset"],
|
||||
customStartDate: number | undefined,
|
||||
customEndDate: number | undefined,
|
||||
filters?: UsageScopeFilters,
|
||||
) =>
|
||||
[
|
||||
...usageKeys.all,
|
||||
@@ -56,12 +63,14 @@ export const usageKeys = {
|
||||
preset,
|
||||
customStartDate ?? 0,
|
||||
customEndDate ?? 0,
|
||||
filters?.providerName ?? null,
|
||||
filters?.model ?? null,
|
||||
] as const,
|
||||
trends: (
|
||||
preset: UsageRangeSelection["preset"],
|
||||
customStartDate: number | undefined,
|
||||
customEndDate: number | undefined,
|
||||
appType?: string,
|
||||
filters?: UsageScopeFilters,
|
||||
) =>
|
||||
[
|
||||
...usageKeys.all,
|
||||
@@ -69,13 +78,15 @@ export const usageKeys = {
|
||||
preset,
|
||||
customStartDate ?? 0,
|
||||
customEndDate ?? 0,
|
||||
appType ?? "all",
|
||||
filters?.appType ?? null,
|
||||
filters?.providerName ?? null,
|
||||
filters?.model ?? null,
|
||||
] as const,
|
||||
providerStats: (
|
||||
preset: UsageRangeSelection["preset"],
|
||||
customStartDate: number | undefined,
|
||||
customEndDate: number | undefined,
|
||||
appType?: string,
|
||||
filters?: UsageScopeFilters,
|
||||
) =>
|
||||
[
|
||||
...usageKeys.all,
|
||||
@@ -83,13 +94,15 @@ export const usageKeys = {
|
||||
preset,
|
||||
customStartDate ?? 0,
|
||||
customEndDate ?? 0,
|
||||
appType ?? "all",
|
||||
filters?.appType ?? null,
|
||||
filters?.providerName ?? null,
|
||||
filters?.model ?? null,
|
||||
] as const,
|
||||
modelStats: (
|
||||
preset: UsageRangeSelection["preset"],
|
||||
customStartDate: number | undefined,
|
||||
customEndDate: number | undefined,
|
||||
appType?: string,
|
||||
filters?: UsageScopeFilters,
|
||||
) =>
|
||||
[
|
||||
...usageKeys.all,
|
||||
@@ -97,7 +110,9 @@ export const usageKeys = {
|
||||
preset,
|
||||
customStartDate ?? 0,
|
||||
customEndDate ?? 0,
|
||||
appType ?? "all",
|
||||
filters?.appType ?? null,
|
||||
filters?.providerName ?? null,
|
||||
filters?.model ?? null,
|
||||
] as const,
|
||||
logs: (key: RequestLogsKey, page: number, pageSize: number) =>
|
||||
[
|
||||
@@ -122,23 +137,38 @@ export const usageKeys = {
|
||||
[...usageKeys.all, providerId, appType] as const,
|
||||
};
|
||||
|
||||
/** 把 UI 侧的 "all" 哨兵归一成 undefined(后端语义:不过滤)。 */
|
||||
function normalizeScopeFilters(filters?: UsageScopeFilters): UsageScopeFilters {
|
||||
return {
|
||||
appType: filters?.appType === "all" ? undefined : filters?.appType,
|
||||
providerName: filters?.providerName,
|
||||
model: filters?.model,
|
||||
};
|
||||
}
|
||||
|
||||
// Hooks
|
||||
export function useUsageSummary(
|
||||
range: UsageRangeSelection,
|
||||
appType?: string,
|
||||
filters?: UsageScopeFilters,
|
||||
options?: UsageQueryOptions,
|
||||
) {
|
||||
const effectiveAppType = appType === "all" ? undefined : appType;
|
||||
const effective = normalizeScopeFilters(filters);
|
||||
return useQuery({
|
||||
queryKey: usageKeys.summary(
|
||||
range.preset,
|
||||
range.customStartDate,
|
||||
range.customEndDate,
|
||||
appType,
|
||||
effective,
|
||||
),
|
||||
queryFn: () => {
|
||||
const { startDate, endDate } = resolveUsageRange(range);
|
||||
return usageApi.getUsageSummary(startDate, endDate, effectiveAppType);
|
||||
return usageApi.getUsageSummary(
|
||||
startDate,
|
||||
endDate,
|
||||
effective.appType,
|
||||
effective.providerName,
|
||||
effective.model,
|
||||
);
|
||||
},
|
||||
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
|
||||
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
|
||||
@@ -147,6 +177,7 @@ export function useUsageSummary(
|
||||
|
||||
export function useUsageSummaryByApp(
|
||||
range: UsageRangeSelection,
|
||||
filters?: Pick<UsageScopeFilters, "providerName" | "model">,
|
||||
options?: UsageQueryOptions,
|
||||
) {
|
||||
return useQuery({
|
||||
@@ -154,10 +185,16 @@ export function useUsageSummaryByApp(
|
||||
range.preset,
|
||||
range.customStartDate,
|
||||
range.customEndDate,
|
||||
filters,
|
||||
),
|
||||
queryFn: () => {
|
||||
const { startDate, endDate } = resolveUsageRange(range);
|
||||
return usageApi.getUsageSummaryByApp(startDate, endDate);
|
||||
return usageApi.getUsageSummaryByApp(
|
||||
startDate,
|
||||
endDate,
|
||||
filters?.providerName,
|
||||
filters?.model,
|
||||
);
|
||||
},
|
||||
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
|
||||
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
|
||||
@@ -166,20 +203,26 @@ export function useUsageSummaryByApp(
|
||||
|
||||
export function useUsageTrends(
|
||||
range: UsageRangeSelection,
|
||||
appType?: string,
|
||||
filters?: UsageScopeFilters,
|
||||
options?: UsageQueryOptions,
|
||||
) {
|
||||
const effectiveAppType = appType === "all" ? undefined : appType;
|
||||
const effective = normalizeScopeFilters(filters);
|
||||
return useQuery({
|
||||
queryKey: usageKeys.trends(
|
||||
range.preset,
|
||||
range.customStartDate,
|
||||
range.customEndDate,
|
||||
appType,
|
||||
effective,
|
||||
),
|
||||
queryFn: () => {
|
||||
const { startDate, endDate } = resolveUsageRange(range);
|
||||
return usageApi.getUsageTrends(startDate, endDate, effectiveAppType);
|
||||
return usageApi.getUsageTrends(
|
||||
startDate,
|
||||
endDate,
|
||||
effective.appType,
|
||||
effective.providerName,
|
||||
effective.model,
|
||||
);
|
||||
},
|
||||
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
|
||||
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
|
||||
@@ -188,20 +231,26 @@ export function useUsageTrends(
|
||||
|
||||
export function useProviderStats(
|
||||
range: UsageRangeSelection,
|
||||
appType?: string,
|
||||
filters?: UsageScopeFilters,
|
||||
options?: UsageQueryOptions,
|
||||
) {
|
||||
const effectiveAppType = appType === "all" ? undefined : appType;
|
||||
const effective = normalizeScopeFilters(filters);
|
||||
return useQuery({
|
||||
queryKey: usageKeys.providerStats(
|
||||
range.preset,
|
||||
range.customStartDate,
|
||||
range.customEndDate,
|
||||
appType,
|
||||
effective,
|
||||
),
|
||||
queryFn: () => {
|
||||
const { startDate, endDate } = resolveUsageRange(range);
|
||||
return usageApi.getProviderStats(startDate, endDate, effectiveAppType);
|
||||
return usageApi.getProviderStats(
|
||||
startDate,
|
||||
endDate,
|
||||
effective.appType,
|
||||
effective.providerName,
|
||||
effective.model,
|
||||
);
|
||||
},
|
||||
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
|
||||
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
|
||||
@@ -210,20 +259,26 @@ export function useProviderStats(
|
||||
|
||||
export function useModelStats(
|
||||
range: UsageRangeSelection,
|
||||
appType?: string,
|
||||
filters?: UsageScopeFilters,
|
||||
options?: UsageQueryOptions,
|
||||
) {
|
||||
const effectiveAppType = appType === "all" ? undefined : appType;
|
||||
const effective = normalizeScopeFilters(filters);
|
||||
return useQuery({
|
||||
queryKey: usageKeys.modelStats(
|
||||
range.preset,
|
||||
range.customStartDate,
|
||||
range.customEndDate,
|
||||
appType,
|
||||
effective,
|
||||
),
|
||||
queryFn: () => {
|
||||
const { startDate, endDate } = resolveUsageRange(range);
|
||||
return usageApi.getModelStats(startDate, endDate, effectiveAppType);
|
||||
return usageApi.getModelStats(
|
||||
startDate,
|
||||
endDate,
|
||||
effective.appType,
|
||||
effective.providerName,
|
||||
effective.model,
|
||||
);
|
||||
},
|
||||
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
|
||||
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
|
||||
|
||||
@@ -122,6 +122,20 @@ export interface LogFilters {
|
||||
endDate?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard 顶栏的全局筛选维度,作用于 Hero / 趋势图 / 三个统计 Tab。
|
||||
*
|
||||
* - `providerName` 按展示名精确匹配(与 Provider 统计列表同口径,含
|
||||
* "Claude (Session)" 等会话占位名);
|
||||
* - `model` 按「有效计价模型」匹配(pricing_model 优先、回落 model,
|
||||
* 与模型统计的分组口径一致)。
|
||||
*/
|
||||
export interface UsageScopeFilters {
|
||||
appType?: string;
|
||||
providerName?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface ProviderLimitStatus {
|
||||
providerId: string;
|
||||
dailyUsage: string;
|
||||
|
||||
Reference in New Issue
Block a user