perf(dashboard): 用量统计从秒级降到感知不到 — SQLite 连接单例、配置读取缓存、聚合结果缓存、快照持久化 + 后台校验

功能与数据口径零变化(用量数字、字段、IPC 签名、导出格式均与 v0.8.1 一致)。

- db.rs:新增进程级连接单例(含迁移只跑一次),卡片墙场景由 200+ 次「开连接 + 7 条 DDL」降为 1 次;
  新增写路径代际计数器供各处读缓存判失效
- commands.rs:load_agent_config_command 结果缓存(db 代际 + config.toml 文件戳双重校验),
  N 张供应商卡片由 N 次全量加载(config.toml 解析 + SQLite 全量读 + 逐供应商 settings 读)降为 1 次
- dashboard.rs:
  * build_all_models 单遍轻量聚合,替换原先为取一张模型表而白算日/明细/供应商三份数据的整表遍历
  * SUMMARY_CACHE 按记录集 Arc 身份 + 日期 + 分钟桶缓存热力图/模型总览/各范围汇总,切范围来回近乎零成本
  * 归档会话列表与合成记录按代际 + Arc 身份缓存(合并后的 Arc 保持稳定,否则上层缓存永不命中)
  * 解析结果持久化到 ~/.kimi-switch/scan-cache-<hash>.json:重启后首次 get_summary 直读快照
    (实测 6.4 万条记录 / 1.03 GB:22~27s → 0.85s),随后后台线程全量校验,
    有变化时经 dashboard://records-updated 事件推送,前端无感刷新(先快后准);
    快照损坏/指纹不符静默降级为全量扫描,refresh=true 语义不变
- useDashboard.ts:stale-while-revalidate,切范围/重进页面先呈现旧数据再静默刷新,不再闪加载态;
  订阅上述事件做无感更新,保留原世代号防竞态

测试:Rust 193 项(+16)、前端 130 项全绿
This commit is contained in:
KimiSwitch Dev committed 2026-10-09 16:44:03 +08:00
1 parent 803c346854
commit ded166c4c8
4 files changed
+1591 -140

No files matched your search

+77 -14
View File
@@ -1,35 +1,68 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import type { SummaryResult } from "../types/dashboard";
export type DashboardRange = "today" | "yesterday" | "7d" | "30d" | "all";
const RANGE_STORAGE_KEY = "kimi-switch-dashboard-range";
export function useDashboard() {
const [range, setRange] = useState<DashboardRange>(() => {
try {
const stored = localStorage.getItem(RANGE_STORAGE_KEY) as DashboardRange | null;
if (stored === "today" || stored === "yesterday" || stored === "7d" || stored === "30d" || stored === "all") {
return stored;
}
} catch {
// ignore
}
return "30d";
});
/**
* Emitted by the backend when the background verification walk found records the
* fast (disk-cache) path did not have. The payload is empty — the hook only
* needs to know that a re-fetch is worth making.
*/
const RECORDS_UPDATED_EVENT = "dashboard://records-updated";
const [data, setData] = useState<SummaryResult | null>(null);
const RANGES: readonly DashboardRange[] = ["today", "yesterday", "7d", "30d", "all"];
function readStoredRange(): DashboardRange {
try {
const stored = localStorage.getItem(RANGE_STORAGE_KEY) as DashboardRange | null;
if (stored && (RANGES as readonly string[]).includes(stored)) {
return stored;
}
} catch {
// ignore
}
return "30d";
}
/**
* Last successful payload per range, kept across mounts so a range switch (and
* reopening the page) paints instantly from memory and revalidates in the
* background instead of showing a spinner over data that is already known.
* Bounded by the five ranges the dashboard offers.
*/
const summaryCache = new Map<DashboardRange, SummaryResult>();
export function useDashboard() {
const [range, setRange] = useState<DashboardRange>(readStoredRange);
const [data, setData] = useState<SummaryResult | null>(
() => summaryCache.get(readStoredRange()) ?? null,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
/** Round-trip timing for the last get_summary call (user-perceived lag). */
const [loadStats, setLoadStats] = useState<{ ms: number } | null>(null);
// Generation counter: stale responses (superseded range / unmounted) are dropped.
const genRef = useRef(0);
// The event handler must call the *current* refresh, not the one captured when
// the listener was installed, so it reads the callback through a ref.
const refreshRef = useRef<(force?: boolean) => Promise<void>>(async () => {});
const refresh = useCallback(async (force = false) => {
const gen = ++genRef.current;
setLoading(true);
// Stale-while-revalidate: hand the cached payload for this range to the
// view before the request goes out, so a range switch is instant and the
// response only refreshes numbers that are already on screen. `loading` is
// raised only when there is nothing to show (first load of a range) or when
// the user asked for a refresh — the button keeps its busy state, the silent
// revalidation behind a cached range does not.
const cached = summaryCache.get(range);
if (cached) setData(cached);
setLoading(force || !cached);
setError(null);
const start = performance.now();
try {
@@ -38,6 +71,7 @@ export function useDashboard() {
refresh: force,
});
if (genRef.current !== gen) return;
summaryCache.set(range, result);
setData(result);
setLoadStats({ ms: Math.round(performance.now() - start) });
} catch (err) {
@@ -49,10 +83,39 @@ export function useDashboard() {
}
}, [range]);
refreshRef.current = refresh;
useEffect(() => {
refresh();
}, [refresh]);
// The first load of a session answers from the on-disk snapshot, which can be
// behind a session written since the app last ran. The backend verifies in the
// background and signals here when the numbers moved; re-fetching silently
// (never raising `loading`) keeps the already-drawn dashboard on screen and
// just corrects it. A silent call still takes a fresh generation, so it cannot
// be interleaved with a range switch in flight.
useEffect(() => {
let unlisten: (() => void) | undefined;
let cancelled = false;
listen(RECORDS_UPDATED_EVENT, () => {
refreshRef.current();
})
.then((fn) => {
// The effect may have been torn down before `listen` resolved.
if (cancelled) fn();
else unlisten = fn;
})
.catch(() => {
// No event bridge (e.g. a browser build): the dashboard still works,
// it just will not be corrected mid-session.
});
return () => {
cancelled = true;
unlisten?.();
};
}, []);
// Ignore late responses after unmount.
useEffect(() => {
return () => {