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:
in30mn1a
2026-05-29 22:47:51 +08:00
committed by GitHub
parent e71b90916b
commit bc1467db8d
9 changed files with 205 additions and 63 deletions
@@ -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)
}
+6
View File
@@ -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");
+3
View File
@@ -102,6 +102,9 @@ impl<'a> UsageLogger<'a> {
)
.map_err(|e| AppError::Database(format!("记录请求日志失败: {e}")))?;
// 通知前端使用统计有更新(200ms 防抖合并,不阻塞写入路径)
crate::usage_events::notify_log_recorded();
Ok(())
}
+7 -1
View File
@@ -441,7 +441,8 @@ fn insert_session_log_entry(
),
};
conn.execute(
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,
@@ -478,6 +479,11 @@ fn insert_session_log_entry(
)
.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,7 +496,8 @@ fn insert_codex_session_entry(
),
};
conn.execute(
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,
@@ -532,6 +534,10 @@ fn insert_codex_session_entry(
)
.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 模型定价
+68
View File
@@ -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}");
}
});
}
+5
View File
@@ -21,6 +21,7 @@ import {
import { Button } from "@/components/ui/button";
import { useQueryClient } from "@tanstack/react-query";
import { usageKeys } from "@/lib/query/usage";
import { useUsageEventBridge } from "@/hooks/useUsageEventBridge";
import {
Accordion,
AccordionContent,
@@ -43,6 +44,10 @@ export function UsageDashboard() {
const [appType, setAppType] = useState<AppTypeFilter>("all");
const [refreshIntervalMs, setRefreshIntervalMs] = useState(30000);
// 后端写入新日志时 emit `usage-log-recorded`,本 hook 立刻 invalidate 所有
// usage 查询,实现实时刷新(仅在 Dashboard 挂载时生效,离开页面自动取消监听)
useUsageEventBridge();
const refreshIntervalOptionsMs = [0, 5000, 10000, 30000, 60000] as const;
const changeRefreshInterval = () => {
const currentIndex = refreshIntervalOptionsMs.indexOf(
+41
View File
@@ -0,0 +1,41 @@
import { useEffect } from "react";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { useQueryClient } from "@tanstack/react-query";
import { usageKeys } from "@/lib/query/usage";
/**
* 监听后端 `usage-log-recorded` 事件,收到后立刻 invalidate 所有
* UsageDashboard 相关查询,让用户无需等待 30s 轮询周期。
*
* 后端在 `proxy_request_logs` 写入新行时会 emit 该事件(200ms 防抖合并),
* 来源覆盖代理日志、Claude/Codex/Gemini 会话同步、启动归档。
*
* 该 hook 只挂在 UsageDashboard 上,避免在主界面其他位置无意义触发。
*/
export function useUsageEventBridge() {
const queryClient = useQueryClient();
useEffect(() => {
let unlisten: UnlistenFn | undefined;
let disposed = false;
(async () => {
const off = await listen("usage-log-recorded", () => {
// invalidate 整个 usage 命名空间:summary / trends / providerStats /
// modelStats / logs 全部跟着重拉
queryClient.invalidateQueries({ queryKey: usageKeys.all });
});
if (disposed) {
off();
} else {
unlisten = off;
}
})();
return () => {
disposed = true;
unlisten?.();
};
}, [queryClient]);
}