feat(dashboard): 最近请求缓存命中列 + 中文模式人民币计价 + 价格解析记忆化
- 最近请求表新增缓存命中率列(缓存读取/总输入) - useCurrency:中文界面费用按最新 USD→CNY 汇率显示 ¥,英文保持 $(open.er-api.com,缓存 12h,失败兜底 7.2) - match_price 记忆化缓存:O(记录数×5910) → O(模型种类×5910),扫描从秒级降到百毫秒级 - 仪表盘右上角显示加载耗时/数据量(get_summary 往返埋点);Rust 侧分段计时
This commit is contained in:
1 parent
de132ffb3a
commit
38a11666ac
9 files changed
+158
-13
No files matched your search
@@ -610,7 +610,26 @@ fn strip_context_suffix(model_name: &str) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Memoized price resolution. `match_price` is called once per usage record
|
||||
/// during the scan, and the models.dev suffix scan is O(5910) per miss — with
|
||||
/// thousands of records sharing a handful of model names, memoizing the result
|
||||
/// turns the whole scan from O(records × 5910) into O(unique_models × 5910).
|
||||
static MATCH_PRICE_MEMO: OnceLock<Mutex<HashMap<String, (String, f64, f64, f64, bool)>>> =
|
||||
OnceLock::new();
|
||||
|
||||
/// Resolve a model's price, caching per-model results (hits and misses).
|
||||
fn match_price(model_name: &str) -> (String, f64, f64, f64, bool) {
|
||||
let key = model_name.to_ascii_lowercase();
|
||||
let memo = MATCH_PRICE_MEMO.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
if let Some(hit) = memo.lock().unwrap().get(&key) {
|
||||
return hit.clone();
|
||||
}
|
||||
let result = match_price_inner(model_name);
|
||||
memo.lock().unwrap().insert(key, result.clone());
|
||||
result
|
||||
}
|
||||
|
||||
fn match_price_inner(model_name: &str) -> (String, f64, f64, f64, bool) {
|
||||
let bare = match model_name.rsplit_once('/') {
|
||||
Some((_, b)) => b,
|
||||
None => model_name,
|
||||
@@ -1537,6 +1556,7 @@ pub fn get_prices() -> PricesResult {
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_summary(home_override: Option<String>, range: Option<String>, refresh: Option<bool>) -> SummaryResult {
|
||||
let t0 = std::time::Instant::now();
|
||||
let refresh = refresh.unwrap_or(false);
|
||||
let home = resolve_kimi_home(home_override);
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
@@ -1544,6 +1564,7 @@ pub fn get_summary(home_override: Option<String>, range: Option<String>, refresh
|
||||
let r = range.unwrap_or_else(|| "30d".into());
|
||||
|
||||
let (records, meta) = scan_usage_cached(&home, refresh);
|
||||
let t1 = std::time::Instant::now();
|
||||
let stats = aggregate(&records, &r, now_ms);
|
||||
let all_stats = aggregate(&records, "all", now_ms);
|
||||
let heatmap = build_heatmap(&records, now_ms);
|
||||
@@ -1568,6 +1589,15 @@ pub fn get_summary(home_override: Option<String>, range: Option<String>, refresh
|
||||
name, provider: std::env::var("KIMI_MODEL_PROVIDER").ok(), model: std::env::var("KIMI_MODEL_ID").ok(),
|
||||
});
|
||||
|
||||
let t2 = std::time::Instant::now();
|
||||
let scan_ms = t1.duration_since(t0).as_secs_f64() * 1000.0;
|
||||
let aggregate_ms = t2.duration_since(t1).as_secs_f64() * 1000.0;
|
||||
let total_ms = t2.duration_since(t0).as_secs_f64() * 1000.0;
|
||||
eprintln!(
|
||||
"[dashboard] get_summary range={} records={} scan={:.0}ms aggregate={:.0}ms total={:.0}ms",
|
||||
r, records.len(), scan_ms, aggregate_ms, total_ms
|
||||
);
|
||||
|
||||
SummaryResult {
|
||||
home: home.to_string_lossy().to_string(),
|
||||
valid: is_kimi_home(&home),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "../../i18n";
|
||||
import { fmtPct, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
|
||||
import { useCurrency } from "../../hooks/useCurrency";
|
||||
import { fmtPct, fmtTokens } from "../../lib/dashboard-format";
|
||||
import type { DailyRow } from "../../types/dashboard";
|
||||
import { DailyDetailModal } from "./DailyDetailModal";
|
||||
|
||||
@@ -43,6 +44,7 @@ function shortModel(model: string): string {
|
||||
|
||||
export function DailyBars({ daily, dimension, names, unknownProviderLabel }: DailyBarsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { money } = useCurrency();
|
||||
const [selected, setSelected] = useState<DailyRow | null>(null);
|
||||
|
||||
const colorMap = useMemo(() => {
|
||||
@@ -103,7 +105,7 @@ export function DailyBars({ daily, dimension, names, unknownProviderLabel }: Dai
|
||||
|
||||
const tooltipLines = [
|
||||
d.date,
|
||||
`${t("totalTokens")}: ${fmtTokens(d.totalTokens)} · ${fmtUsd(d.costUsd)} · ${t("colCacheHit")} ${fmtPct(d.cacheHitRate)}`,
|
||||
`${t("totalTokens")}: ${fmtTokens(d.totalTokens)} · ${money(d.costUsd)} · ${t("colCacheHit")} ${fmtPct(d.cacheHitRate)}`,
|
||||
...segments.map((s) => `${displayName(s.key)}: ${fmtTokens(s.tokens)}`),
|
||||
"",
|
||||
t("doubleClickHint"),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "../../i18n";
|
||||
import { fmtPct, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
|
||||
import { useCurrency } from "../../hooks/useCurrency";
|
||||
import { fmtPct, fmtTokens } from "../../lib/dashboard-format";
|
||||
import type { DailyRow } from "../../types/dashboard";
|
||||
import { modelColor } from "./DailyBars";
|
||||
|
||||
@@ -22,6 +23,7 @@ function shortModel(model: string): string {
|
||||
|
||||
export function DailyDetailModal({ day, dimension, colorMap, unknownProviderLabel, onClose }: DailyDetailModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { money } = useCurrency();
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -106,7 +108,7 @@ export function DailyDetailModal({ day, dimension, colorMap, unknownProviderLabe
|
||||
{/* Day summary chips */}
|
||||
<div className="grid grid-cols-2 gap-2 px-5 py-3 sm:grid-cols-4">
|
||||
<SummaryChip label={t("totalTokens")} value={fmtTokens(day.totalTokens)} tone="default" />
|
||||
<SummaryChip label={t("cost")} value={fmtUsd(day.costUsd)} tone="orange" />
|
||||
<SummaryChip label={t("cost")} value={money(day.costUsd)} tone="orange" />
|
||||
<SummaryChip label={t("requests")} value={Math.round(day.requests).toLocaleString()} tone="blue" />
|
||||
<SummaryChip label={t("cacheHitRate")} value={fmtPct(day.cacheHitRate)} tone="green" />
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useDashboard, type DashboardRange } from "../../hooks/useDashboard";
|
||||
import { useCurrency } from "../../hooks/useCurrency";
|
||||
import { useTranslation } from "../../i18n";
|
||||
import { fmtInt, fmtPct, fmtTime, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
|
||||
import { fmtInt, fmtPct, fmtTime, fmtTokens } from "../../lib/dashboard-format";
|
||||
import { DailyBars } from "./DailyBars";
|
||||
import { Heatmap } from "./Heatmap";
|
||||
import { TrendLineChart } from "./TrendLineChart";
|
||||
|
||||
const RANGES: DashboardRange[] = ["today", "7d", "30d", "all"];
|
||||
|
||||
/** Per-request cache hit rate: cached tokens / total input tokens. */
|
||||
function cacheHitOf(r: { inputOther: number; inputCacheRead: number }): number {
|
||||
const denom = r.inputOther + r.inputCacheRead;
|
||||
return denom > 0 ? r.inputCacheRead / denom : 0;
|
||||
}
|
||||
|
||||
function Card({
|
||||
title,
|
||||
subtitle,
|
||||
@@ -44,7 +51,8 @@ function KpiCard({ label, value, sub }: { label: string; value: string; sub?: st
|
||||
|
||||
export function DashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
const { range, changeRange, data, loading, error, refresh } = useDashboard();
|
||||
const { money } = useCurrency();
|
||||
const { range, changeRange, data, loading, error, refresh, loadStats } = useDashboard();
|
||||
const [showAllModels, setShowAllModels] = useState(false);
|
||||
const [recentPage, setRecentPage] = useState(1);
|
||||
const [trendTab, setTrendTab] = useState<"daily" | "model" | "provider">("daily");
|
||||
@@ -120,13 +128,14 @@ export function DashboardPage() {
|
||||
{ label: t("kpiCacheCreation"), value: fmtTokens(totals.inputCacheCreation), sub: fmtInt(totals.inputCacheCreation) },
|
||||
{ label: t("kpiCacheHit"), value: fmtPct(totals.cacheHitRate) },
|
||||
{ label: t("totalTokens"), value: fmtTokens(totals.totalTokens), sub: fmtInt(totals.totalTokens) },
|
||||
{ label: t("kpiCost"), value: fmtUsd(totals.costUsd) },
|
||||
{ label: t("kpiCost"), value: money(totals.costUsd) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-4 space-y-4">
|
||||
{/* Range tabs + refresh */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center bg-input border border-border rounded p-0.5">
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
@@ -143,6 +152,12 @@ export function DashboardPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{loadStats && !loading && (
|
||||
<span className="hidden text-[10px] text-content-muted sm:inline">
|
||||
{t("loadStats", { ms: loadStats.ms, kb: loadStats.kb })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refresh(true)}
|
||||
@@ -175,7 +190,7 @@ export function DashboardPage() {
|
||||
{fmtTokens(tot.totalTokens)}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-content-muted">
|
||||
{fmtInt(tot.requests)} {t("unitTimes")} · {fmtUsd(tot.costUsd)} · {fmtPct(tot.cacheHitRate)}
|
||||
{fmtInt(tot.requests)} {t("unitTimes")} · {money(tot.costUsd)} · {fmtPct(tot.cacheHitRate)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -305,7 +320,7 @@ export function DashboardPage() {
|
||||
<td className="py-2 pr-3 text-right text-content-muted">{fmtInt(m.requests)}</td>
|
||||
<td className="py-2 pr-3 text-right text-content-muted">{fmtTokens(m.totalTokens)}</td>
|
||||
<td className="py-2 pr-3 text-right text-content-muted">{fmtPct(m.cacheHitRate)}</td>
|
||||
<td className="py-2 text-right text-orange-600 dark:text-orange-400">{fmtUsd(m.costUsd)}</td>
|
||||
<td className="py-2 text-right text-orange-600 dark:text-orange-400">{money(m.costUsd)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -346,6 +361,7 @@ export function DashboardPage() {
|
||||
<th className="pb-2 pr-4 font-normal text-right">{t("colInput")}</th>
|
||||
<th className="pb-2 pr-4 font-normal text-right">{t("colOutput")}</th>
|
||||
<th className="pb-2 pr-4 font-normal text-right">{t("colCacheRead")}</th>
|
||||
<th className="pb-2 pr-4 font-normal text-right">{t("colCacheHit")}</th>
|
||||
<th className="pb-2 font-normal text-right">{t("cost")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -359,7 +375,8 @@ export function DashboardPage() {
|
||||
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.inputOther)}</td>
|
||||
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.output)}</td>
|
||||
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.inputCacheRead)}</td>
|
||||
<td className="py-1.5 text-right text-orange-600 dark:text-orange-400">{fmtUsd(r.costUsd)}</td>
|
||||
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtPct(cacheHitOf(r))}</td>
|
||||
<td className="py-1.5 text-right text-orange-600 dark:text-orange-400">{money(r.costUsd)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "../../i18n";
|
||||
import { fmtPct, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
|
||||
import { useCurrency } from "../../hooks/useCurrency";
|
||||
import { fmtPct, fmtTokens } from "../../lib/dashboard-format";
|
||||
import type { HeatmapCell, HeatmapData } from "../../types/dashboard";
|
||||
|
||||
interface HeatmapProps {
|
||||
@@ -220,6 +221,7 @@ export function Heatmap({ heatmap }: HeatmapProps) {
|
||||
|
||||
function HeatmapTooltip({ state }: { state: HoverState }) {
|
||||
const { t } = useTranslation();
|
||||
const { money } = useCurrency();
|
||||
const { cell, x, y } = state;
|
||||
// Position above the cursor with a small offset; flip below if too close to top.
|
||||
const offset = 14;
|
||||
@@ -239,7 +241,7 @@ function HeatmapTooltip({ state }: { state: HoverState }) {
|
||||
>
|
||||
<div className="font-medium text-content-primary">{cell.date}</div>
|
||||
<div className="text-content-muted">
|
||||
{fmtTokens(cell.totalTokens)} · {fmtUsd(cell.costUsd)} ·{" "}
|
||||
{fmtTokens(cell.totalTokens)} · {money(cell.costUsd)} ·{" "}
|
||||
{fmtInt(cell.requests)} {t("requests")}
|
||||
</div>
|
||||
<div className="text-content-muted">
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "../i18n";
|
||||
import { fmtUsd } from "../lib/dashboard-format";
|
||||
|
||||
/**
|
||||
* USD → CNY exchange rate for the dashboard.
|
||||
* Chinese mode shows prices in CNY (latest available rate), English stays in
|
||||
* USD. The rate is fetched once and cached in localStorage for 12h; any
|
||||
* failure falls back to a conservative fixed rate so the UI never blocks.
|
||||
*/
|
||||
|
||||
const FALLBACK_RATE = 7.2;
|
||||
const CACHE_KEY = "kimi-switch-cny-rate";
|
||||
const CACHE_TTL = 12 * 60 * 60 * 1000; // 12 hours
|
||||
|
||||
function readCachedRate(): number | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(CACHE_KEY);
|
||||
if (!raw) return null;
|
||||
const { ts, rate } = JSON.parse(raw) as { ts: number; rate: number };
|
||||
if (Date.now() - ts > CACHE_TTL || !(rate > 1)) return null;
|
||||
return rate;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCachedRate(rate: number) {
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify({ ts: Date.now(), rate }));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function useCurrency() {
|
||||
const { lang } = useTranslation();
|
||||
const [rate, setRate] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const cached = readCachedRate();
|
||||
if (cached) {
|
||||
setRate(cached);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch("https://open.er-api.com/v6/latest/USD");
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { rates?: { CNY?: number } };
|
||||
const cny = Number(data.rates?.CNY);
|
||||
if (cny > 1) {
|
||||
if (!cancelled) setRate(cny);
|
||||
writeCachedRate(cny);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* network blocked → fallback */
|
||||
}
|
||||
if (!cancelled) setRate(FALLBACK_RATE);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
/** Format a USD cost for display: CNY in Chinese mode, USD otherwise. */
|
||||
const money = useCallback(
|
||||
(usd: number): string => {
|
||||
if (lang !== "zh") return fmtUsd(usd);
|
||||
const v = (Number(usd) || 0) * (rate ?? FALLBACK_RATE);
|
||||
if (v === 0) return "¥0.00";
|
||||
if (v < 0.1) return "¥" + v.toFixed(3);
|
||||
if (v < 1) return "¥" + v.toFixed(2);
|
||||
return "¥" + v.toFixed(2);
|
||||
},
|
||||
[lang, rate],
|
||||
);
|
||||
|
||||
return { money, cnyRate: rate ?? FALLBACK_RATE };
|
||||
}
|
||||
@@ -22,16 +22,23 @@ export function useDashboard() {
|
||||
const [data, setData] = useState<SummaryResult | null>(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; kb: number } | null>(null);
|
||||
|
||||
const refresh = useCallback(async (force = false) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const start = performance.now();
|
||||
try {
|
||||
const result = await invoke<SummaryResult>("get_summary", {
|
||||
range,
|
||||
refresh: force,
|
||||
});
|
||||
setData(result);
|
||||
setLoadStats({
|
||||
ms: Math.round(performance.now() - start),
|
||||
kb: Math.round(JSON.stringify(result).length / 1024),
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
@@ -53,5 +60,5 @@ export function useDashboard() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { range, changeRange, data, loading, error, refresh };
|
||||
return { range, changeRange, data, loading, error, refresh, loadStats };
|
||||
}
|
||||
@@ -197,6 +197,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
scanning: "Scanning…",
|
||||
refresh: "Refresh",
|
||||
refreshing: "Refreshing…",
|
||||
loadStats: "Loaded in {ms} ms · {kb} KB payload",
|
||||
refreshed: "Refreshed",
|
||||
cancel: "Cancel",
|
||||
confirmDeleteTitle: "Delete session forever?",
|
||||
|
||||
@@ -194,6 +194,7 @@ export const zhTranslations = {
|
||||
scanning: "扫描中…",
|
||||
refresh: "刷新",
|
||||
refreshing: "刷新中…",
|
||||
loadStats: "加载 {ms} ms · 数据 {kb} KB",
|
||||
refreshed: "已刷新",
|
||||
cancel: "取消",
|
||||
confirmDeleteTitle: "永久删除会话?",
|
||||
|
||||
Reference in new issue
Block a user