mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(usage): real-time stats refresh + fix codex sync panic on non-ASCII model names (#3027)
The usage dashboard previously only refreshed on app restart for users who don't route through the cc-switch proxy. Two issues were involved: 1. The session-sync background task panicked when a Codex model name contained non-ASCII characters (e.g. `【官】glm-5.1`), because `normalize_codex_model` sliced `&name[name.len() - 11..]` without verifying char boundaries. Once the task panicked, no session logs were imported until the app was restarted (where startup-time `rollup_and_prune` happened to flush pending data). 2. Even with sync working, the dashboard only polled every 30s and skipped polling when the window was unfocused, so freshly-imported data was invisible until the next poll or window refocus. Fixes ----- * `normalize_codex_model`: guard the 11-byte ISO-date suffix slice with `is_char_boundary` + `is_ascii` checks. ASCII-only suffix means the date-stripping logic is correct, and non-ASCII names (which can never be valid date suffixes anyway) now bypass the slice safely. * New `usage_events` module that emits `usage-log-recorded` to the frontend whenever `proxy_request_logs` actually gains a new row. Sources covered: proxy `log_request`, Claude/Codex/Gemini session sync, and startup `rollup_and_prune`. Notifications use a global `OnceLock<AppHandle>` so call sites that don't already hold an `AppHandle` (e.g. `UsageLogger`) can notify without signature churn. * 200ms debounce in `notify_log_recorded` collapses bursts (a single Codex sync importing 3000+ entries triggers ~2 emits, not 3000) so the frontend's `invalidateQueries` is never spammed. * Frontend `useUsageEventBridge` listens for the event and invalidates `usageKeys.all`. Hook is mounted only on `UsageDashboard`, so the listener is unsubscribed automatically when the user navigates away. Verification ------------ * `cargo check` passes (existing 25 dead-code warnings in `commands/misc.rs` are pre-existing and unrelated). * `tsc --noEmit` passes. * Manually verified end-to-end: a Codex sync run that imported 3145 entries produced 2 debounced emits, both logged as `emit usage-log-recorded 成功`, and the dashboard updated within ~200ms. Behaviour notes --------------- * `INSERT OR IGNORE` paths (Claude/Codex session sync) only notify when the row is actually inserted, so dedup-skipped writes don't trigger empty refreshes. * Gemini's `INSERT … ON CONFLICT … DO UPDATE` path reuses the existing `conn.changes() > 0` check and only notifies when token counts truly changed. * `rollup_and_prune` notifies once per pruning cycle (at most once per app start) so the dashboard reflects the new aggregate state. Co-authored-by: in30mn1a <in30mn1a@users.noreply.github.com>
This commit is contained in:
@@ -89,6 +89,9 @@ impl Database {
|
||||
log::info!(
|
||||
"Rolled up and pruned {deleted} proxy_request_logs (retain={retain_days}d)"
|
||||
);
|
||||
// 归档触发了表结构变化,前端 30 天前的统计可能跟着变,
|
||||
// 通知一次让 UsageDashboard 重拉数据
|
||||
crate::usage_events::notify_log_recorded();
|
||||
}
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ mod settings;
|
||||
mod store;
|
||||
|
||||
mod tray;
|
||||
mod usage_events;
|
||||
mod usage_script;
|
||||
|
||||
pub use app_config::{AppType, InstalledSkill, McpApps, McpServer, MultiAppConfig, SkillApps};
|
||||
@@ -336,6 +337,11 @@ pub fn run() {
|
||||
)?;
|
||||
}
|
||||
|
||||
// 注入 AppHandle 给 usage_events,让无 AppHandle 持有的写日志路径
|
||||
// 也能向前端推送 `usage-log-recorded`。
|
||||
// 放在日志系统初始化之后,确保 init 的日志能正常输出。
|
||||
usage_events::init(app.handle().clone());
|
||||
|
||||
// 初始化数据库
|
||||
let app_config_dir = crate::config::get_app_config_dir();
|
||||
let db_path = app_config_dir.join("cc-switch.db");
|
||||
|
||||
@@ -102,6 +102,9 @@ impl<'a> UsageLogger<'a> {
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("记录请求日志失败: {e}")))?;
|
||||
|
||||
// 通知前端使用统计有更新(200ms 防抖合并,不阻塞写入路径)
|
||||
crate::usage_events::notify_log_recorded();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -441,42 +441,48 @@ fn insert_session_log_entry(
|
||||
),
|
||||
};
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_request_logs (
|
||||
let inserted_rows = conn
|
||||
.execute(
|
||||
"INSERT OR IGNORE INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, request_model,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
|
||||
latency_ms, first_token_ms, status_code, error_message, session_id,
|
||||
provider_type, is_streaming, cost_multiplier, created_at, data_source
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)",
|
||||
rusqlite::params![
|
||||
request_id,
|
||||
"_session", // provider_id: 标记为会话来源
|
||||
"claude", // app_type
|
||||
msg.model,
|
||||
msg.model, // request_model = model
|
||||
msg.input_tokens,
|
||||
msg.output_tokens,
|
||||
msg.cache_read_tokens,
|
||||
msg.cache_creation_tokens,
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_read_cost,
|
||||
cache_creation_cost,
|
||||
total_cost,
|
||||
0i64, // latency_ms: 会话日志无此数据
|
||||
Option::<i64>::None, // first_token_ms
|
||||
200i64, // status_code: 有 stop_reason 说明请求成功
|
||||
Option::<String>::None, // error_message
|
||||
msg.session_id,
|
||||
Some("session_log"), // provider_type
|
||||
1i64, // is_streaming: Claude Code 通常使用流式
|
||||
"1.0", // cost_multiplier
|
||||
created_at,
|
||||
"session_log", // data_source
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("插入会话日志失败: {e}")))?;
|
||||
rusqlite::params![
|
||||
request_id,
|
||||
"_session", // provider_id: 标记为会话来源
|
||||
"claude", // app_type
|
||||
msg.model,
|
||||
msg.model, // request_model = model
|
||||
msg.input_tokens,
|
||||
msg.output_tokens,
|
||||
msg.cache_read_tokens,
|
||||
msg.cache_creation_tokens,
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_read_cost,
|
||||
cache_creation_cost,
|
||||
total_cost,
|
||||
0i64, // latency_ms: 会话日志无此数据
|
||||
Option::<i64>::None, // first_token_ms
|
||||
200i64, // status_code: 有 stop_reason 说明请求成功
|
||||
Option::<String>::None, // error_message
|
||||
msg.session_id,
|
||||
Some("session_log"), // provider_type
|
||||
1i64, // is_streaming: Claude Code 通常使用流式
|
||||
"1.0", // cost_multiplier
|
||||
created_at,
|
||||
"session_log", // data_source
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("插入会话日志失败: {e}")))?;
|
||||
|
||||
// 仅在确实写入新行时通知前端,避免 INSERT OR IGNORE 跳过时产生空刷新
|
||||
if inserted_rows > 0 {
|
||||
crate::usage_events::notify_log_recorded();
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -75,9 +75,10 @@ fn normalize_codex_model(raw: &str) -> String {
|
||||
}
|
||||
|
||||
// Step 3: 剥离 ISO 日期后缀 -YYYY-MM-DD(正好 11 字符)
|
||||
if name.len() > 11 {
|
||||
if name.len() > 11 && name.is_char_boundary(name.len() - 11) {
|
||||
let suffix = &name[name.len() - 11..];
|
||||
if suffix.as_bytes()[0] == b'-'
|
||||
if suffix.is_ascii()
|
||||
&& suffix.as_bytes()[0] == b'-'
|
||||
&& suffix[1..5].chars().all(|c| c.is_ascii_digit())
|
||||
&& suffix.as_bytes()[5] == b'-'
|
||||
&& suffix[6..8].chars().all(|c| c.is_ascii_digit())
|
||||
@@ -495,42 +496,47 @@ fn insert_codex_session_entry(
|
||||
),
|
||||
};
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_request_logs (
|
||||
let inserted_rows = conn
|
||||
.execute(
|
||||
"INSERT OR IGNORE INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, request_model,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
|
||||
latency_ms, first_token_ms, status_code, error_message, session_id,
|
||||
provider_type, is_streaming, cost_multiplier, created_at, data_source
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)",
|
||||
rusqlite::params![
|
||||
request_id,
|
||||
"_codex_session", // provider_id
|
||||
"codex", // app_type
|
||||
model,
|
||||
model, // request_model = model
|
||||
delta.input,
|
||||
delta.output,
|
||||
delta.cached_input,
|
||||
0i64, // cache_creation_tokens: Codex 日志无此数据
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_read_cost,
|
||||
cache_creation_cost,
|
||||
total_cost,
|
||||
0i64, // latency_ms
|
||||
Option::<i64>::None, // first_token_ms
|
||||
200i64, // status_code
|
||||
Option::<String>::None, // error_message
|
||||
session_id.map(|s| s.to_string()),
|
||||
Some("codex_session"), // provider_type
|
||||
1i64, // is_streaming
|
||||
"1.0", // cost_multiplier
|
||||
created_at,
|
||||
"codex_session", // data_source
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("插入 Codex 会话日志失败: {e}")))?;
|
||||
rusqlite::params![
|
||||
request_id,
|
||||
"_codex_session", // provider_id
|
||||
"codex", // app_type
|
||||
model,
|
||||
model, // request_model = model
|
||||
delta.input,
|
||||
delta.output,
|
||||
delta.cached_input,
|
||||
0i64, // cache_creation_tokens: Codex 日志无此数据
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_read_cost,
|
||||
cache_creation_cost,
|
||||
total_cost,
|
||||
0i64, // latency_ms
|
||||
Option::<i64>::None, // first_token_ms
|
||||
200i64, // status_code
|
||||
Option::<String>::None, // error_message
|
||||
session_id.map(|s| s.to_string()),
|
||||
Some("codex_session"), // provider_type
|
||||
1i64, // is_streaming
|
||||
"1.0", // cost_multiplier
|
||||
created_at,
|
||||
"codex_session", // data_source
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("插入 Codex 会话日志失败: {e}")))?;
|
||||
|
||||
if inserted_rows > 0 {
|
||||
crate::usage_events::notify_log_recorded();
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -350,7 +350,11 @@ fn insert_gemini_session_entry(
|
||||
.map_err(|e| AppError::Database(format!("插入 Gemini 会话日志失败: {e}")))?;
|
||||
|
||||
// changes() > 0 表示新插入或已更新,== 0 表示值完全相同(无实际变更)
|
||||
Ok(conn.changes() > 0)
|
||||
let changed = conn.changes() > 0;
|
||||
if changed {
|
||||
crate::usage_events::notify_log_recorded();
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// 查找 Gemini 模型定价
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//! 使用统计实时刷新事件模块
|
||||
//!
|
||||
//! 当 `proxy_request_logs` 表写入新数据时(代理日志、会话同步、归档等),
|
||||
//! 通过本模块向前端 emit `usage-log-recorded` 事件,让 UsageDashboard
|
||||
//! 立刻 invalidate 查询缓存而无需等待轮询周期。
|
||||
//!
|
||||
//! 设计要点:
|
||||
//! - 全局单例 AppHandle:写日志路径上不持有 AppHandle,用 OnceCell 共享。
|
||||
//! - 200ms 防抖合并:流式响应等场景在短时间内可能写入多条日志,
|
||||
//! 合并成一次事件可避免前端连续 invalidate。
|
||||
//! - 不阻塞写入:通知失败仅记录 warn 日志,不向上传播错误。
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
/// 前端监听的事件名
|
||||
pub const EVENT_USAGE_LOG_RECORDED: &str = "usage-log-recorded";
|
||||
|
||||
/// 防抖窗口:合并 200ms 内的多次通知。
|
||||
const DEBOUNCE_WINDOW: Duration = Duration::from_millis(200);
|
||||
|
||||
static APP_HANDLE: OnceLock<AppHandle> = OnceLock::new();
|
||||
|
||||
/// 防抖标记:true 表示已有调度任务在等待 emit,后续通知合并到该任务。
|
||||
static EMIT_SCHEDULED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// 在应用 setup 阶段调用一次,注入 AppHandle。
|
||||
///
|
||||
/// 重复调用是无害的(OnceLock 仅首次写入生效),但应用启动期只该被
|
||||
/// `lib.rs::run` 调一次。
|
||||
pub fn init(handle: AppHandle) {
|
||||
if APP_HANDLE.set(handle).is_err() {
|
||||
log::debug!("usage_events::init 重复调用,已忽略");
|
||||
} else {
|
||||
log::info!("[usage-event] AppHandle 已注入,事件推送启用");
|
||||
}
|
||||
}
|
||||
|
||||
/// 通知前端有新的使用日志写入。
|
||||
///
|
||||
/// 调用方**不**需要持有 AppHandle,可以从任意线程/任意写入路径调用。
|
||||
/// 内部 200ms 防抖合并,绝不阻塞调用线程。
|
||||
pub fn notify_log_recorded() {
|
||||
// AppHandle 未注入(典型出现在单元测试或 setup 之前):直接放弃。
|
||||
let Some(handle) = APP_HANDLE.get() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// 已有调度任务:本次通知被合并到既有任务里,无需再起线程。
|
||||
if EMIT_SCHEDULED.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
|
||||
let handle = handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(DEBOUNCE_WINDOW);
|
||||
// 必须先清标志再 emit:万一 emit 期间又有新通知进来,
|
||||
// 下一轮防抖窗口会重新调度,不会丢失。
|
||||
EMIT_SCHEDULED.store(false, Ordering::Release);
|
||||
|
||||
if let Err(e) = handle.emit(EVENT_USAGE_LOG_RECORDED, ()) {
|
||||
log::warn!("emit {EVENT_USAGE_LOG_RECORDED} 失败: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user