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:
1 parent
803c346854
commit
ded166c4c8
4 files changed
+1591
-140
No files matched your search
+77
-14
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user