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
+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]);
}