From 35fd2e7b0ee452adc461375449e9ca8b2f056752 Mon Sep 17 00:00:00 2001 From: KimiSwitch Dev Date: Wed, 9 Sep 2026 13:51:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20=E7=94=A8=E9=87=8F=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E6=96=B0=E5=A2=9E=E3=80=8C=E6=98=A8=E5=A4=A9=E3=80=8D?= =?UTF-8?q?=E8=8C=83=E5=9B=B4=20+=20=E8=B7=A8=E4=BE=9B=E5=BA=94=E5=95=86?= =?UTF-8?q?=E3=80=8C=E6=A8=A1=E5=9E=8B=E7=94=A8=E9=87=8F=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E3=80=8D=E9=80=89=E9=A1=B9=E5=8D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 范围新增 yesterday:本地时区昨日 0 点 ~ 今日 0 点,range_end 引入上界过滤 - RangeStats 新增 modelsByName:按裸模型名聚合,同一模型跨供应商合并, 子代理记录归入 __secondary__ 单独成行,按 Token 降序 - 趋势选项卡第 4 个 tab「模型用量统计」:新增 ModelTotalsChart 横向条形图, 悬停显示请求数/Token/缓存命中/费用 - 概览卡网格扩展为 5 张;i18n 中英同步 - 顺带:pricing 测试基准从已下架的 moonshotai/kimi-k2.5 切换到 kimi-k3 --- src-tauri/src/dashboard.rs | 167 +++++++++++++++--- src/components/dashboard/DashboardPage.tsx | 31 +++- src/components/dashboard/ModelTotalsChart.tsx | 73 ++++++++ src/hooks/useDashboard.ts | 4 +- src/i18n/en.ts | 2 + src/i18n/zh.ts | 2 + src/types/dashboard.ts | 2 + 7 files changed, 252 insertions(+), 29 deletions(-) create mode 100644 src/components/dashboard/ModelTotalsChart.tsx diff --git a/src-tauri/src/dashboard.rs b/src-tauri/src/dashboard.rs index 522b6d4..6eec106 100644 --- a/src-tauri/src/dashboard.rs +++ b/src-tauri/src/dashboard.rs @@ -102,6 +102,9 @@ pub struct RangeStats { pub totals: TotalsRow, pub daily: Vec, pub models: Vec, + /// Model totals keyed by bare model name (provider prefix stripped), so the + /// same model served by multiple providers merges into one row. + pub models_by_name: Vec, pub recent: Vec, pub recent_total: usize, pub recent_limit: usize, @@ -976,6 +979,7 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats { totals: TotalsRow::default(), daily: vec![], models: vec![], + models_by_name: vec![], recent: vec![], recent_total: 0, recent_limit: 500, @@ -985,6 +989,7 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats { let mut totals = TotalsRow::default(); let mut by_day: HashMap, HashMap>)> = HashMap::new(); let mut by_model: HashMap = HashMap::new(); + let mut by_name: HashMap = HashMap::new(); for r in &filtered { totals.add(r); @@ -1017,6 +1022,28 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats { entry.1.add(r); entry.0.cost_estimated = entry.0.cost_estimated || r.cost_estimated; entry.0.is_secondary = entry.0.is_secondary || r.is_secondary; + + // Provider-agnostic bucket: secondary (subagent) records keep the + // stable "__secondary__" marker; everything else keys on the bare + // model name so the same model across providers merges into one row. + let nk = if r.is_secondary { "__secondary__".to_string() } else { r.model_resolved.clone() }; + let name_entry = by_name.entry(nk.clone()).or_insert_with(|| { + let m = ModelRow { + model: nk.clone(), + model_display: nk.clone(), + model_resolved: nk.clone(), + price_id: r.price_id.clone(), + cost_estimated: r.cost_estimated, + is_secondary: r.is_secondary, + requests: 0, input_other: 0, output: 0, + input_cache_read: 0, input_cache_creation: 0, + cost_usd: 0.0, total_tokens: 0, cache_hit_rate: 0.0, + }; + (m, TotalsRow::default()) + }); + name_entry.1.add(r); + name_entry.0.cost_estimated = name_entry.0.cost_estimated || r.cost_estimated; + name_entry.0.is_secondary = name_entry.0.is_secondary || r.is_secondary; } let total_input = totals.input_other + totals.input_cache_read + totals.input_cache_creation; @@ -1063,6 +1090,17 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats { }).collect(); models.sort_by(|a, b| b.total_tokens.cmp(&a.total_tokens)); + let mut models_by_name: Vec = by_name.into_iter().map(|(_, (mut m, t))| { + let ti = t.input_other + t.input_cache_read + t.input_cache_creation; + m.cache_hit_rate = if ti > 0 { t.input_cache_read as f64 / ti as f64 } else { 0.0 }; + m.requests = t.requests; + m.input_other = t.input_other; m.output = t.output; + m.input_cache_read = t.input_cache_read; m.input_cache_creation = t.input_cache_creation; + m.cost_usd = t.cost_usd; m.total_tokens = t.total_tokens; + m + }).collect(); + models_by_name.sort_by(|a, b| b.total_tokens.cmp(&a.total_tokens)); + let recent_total = filtered.len(); let recent_limit = 500; let recent: Vec = filtered.iter().take(recent_limit).map(|r| RecentRow { @@ -1081,6 +1119,7 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats { totals, daily, models, + models_by_name, recent, recent_total, recent_limit, @@ -1109,24 +1148,40 @@ impl TotalsRow { fn filter_by_range<'a>(records: &'a [UsageRecord], range: &str, now_ms: u64) -> Vec<&'a UsageRecord> { if range == "all" { return records.iter().collect(); } let start = range_start(range, now_ms); - records.iter().filter(|r| r.time >= start).collect() + match range_end(range, now_ms) { + Some(end) => records.iter().filter(|r| r.time >= start && r.time < end).collect(), + None => records.iter().filter(|r| r.time >= start).collect(), + } +} + +/// Upper bound (exclusive) for bounded ranges. Only "yesterday" has one — +/// it must not bleed into today. +fn range_end(range: &str, now_ms: u64) -> Option { + match range { + "yesterday" => Some(local_midnight_ms(now_ms, 0)), + _ => None, + } +} + +/// LOCAL midnight `days_ago` days before today, in epoch ms. Not UTC midnight: +/// without local-midnight anchoring, users east of UTC see day boundaries at +/// the wrong hour (e.g. UTC+8 users get cutoff at local 08:00). +fn local_midnight_ms(now_ms: u64, days_ago: i64) -> u64 { + let now_utc = DateTime::from_timestamp((now_ms / 1000) as i64, 0).unwrap_or_default(); + let now_local = now_utc.with_timezone(&Local); + let date = now_local.date_naive() - chrono::Duration::days(days_ago); + let day_start = date.and_hms_opt(0, 0, 0).unwrap_or_default(); + Local + .from_local_datetime(&day_start) + .single() + .map(|dt| dt.timestamp() as u64 * 1000) + .unwrap_or_else(|| day_start.and_utc().timestamp() as u64 * 1000) } fn range_start(range: &str, now_ms: u64) -> u64 { match range { - "today" => { - // LOCAL midnight today — not UTC midnight. Without this, users east of UTC see - // "today" begin at the wrong hour (e.g. UTC+8 users get cutoff at local 08:00). - let now_utc = DateTime::from_timestamp((now_ms / 1000) as i64, 0).unwrap_or_default(); - let now_local = now_utc.with_timezone(&Local); - let today_date = now_local.date_naive(); - let today_start = today_date.and_hms_opt(0, 0, 0).unwrap_or_default(); - Local - .from_local_datetime(&today_start) - .single() - .map(|dt| dt.timestamp() as u64 * 1000) - .unwrap_or_else(|| today_start.and_utc().timestamp() as u64 * 1000) - } + "today" => local_midnight_ms(now_ms, 0), + "yesterday" => local_midnight_ms(now_ms, 1), "7d" => now_ms - 7 * 24 * 3600 * 1000, "30d" => now_ms - 30 * 24 * 3600 * 1000, _ => now_ms - 30 * 24 * 3600 * 1000, @@ -1836,7 +1891,7 @@ pub fn get_summary(home_override: Option, range: Option, refresh let all_model_count = all_models.len(); let mut range_totals = HashMap::new(); - for r_k in ["today", "7d", "30d", "all"] { + for r_k in ["today", "yesterday", "7d", "30d", "all"] { let s = aggregate(&records, r_k, now_ms); range_totals.insert(r_k.to_string(), s.totals); } @@ -1977,10 +2032,10 @@ mod pricing_tests { let idx = models_dev_cost_index(); // The compiled-in snapshot must carry real models.dev prices. assert!(idx.len() > 1000, "expected >1000 priced models, got {}", idx.len()); - let kimi = idx.get("moonshotai/kimi-k2.5").copied().expect("kimi-k2.5 present"); - assert_eq!(kimi.input, 0.6); - assert_eq!(kimi.output, 3.0); - assert_eq!(kimi.cache_read, 0.1); + let kimi = idx.get("moonshotai/kimi-k3").copied().expect("kimi-k3 present"); + assert_eq!(kimi.input, 3.0); + assert_eq!(kimi.output, 15.0); + assert_eq!(kimi.cache_read, 0.3); } #[test] @@ -1992,9 +2047,9 @@ mod pricing_tests { assert_eq!(input, 0.6); assert_eq!(output, 2.2); - let (id, _, _, _, est) = match_price("kimi/k2.5"); + let (id, _, _, _, est) = match_price("kimi/k3"); assert!(!est); - assert_eq!(id, "moonshotai/kimi-k2.5"); + assert_eq!(id, "moonshotai/kimi-k3"); } #[test] @@ -2307,3 +2362,73 @@ mod session_tests { assert!(list_sessions_cmd(&home, "all", None).sessions.is_empty()); } } + +#[cfg(test)] +mod range_and_model_totals_tests { + use super::*; + + fn rec(time: u64, model: &str, resolved: &str, secondary: bool) -> UsageRecord { + UsageRecord { + time, + model: model.to_string(), + input_other: 100, + output: 50, + input_cache_read: 50, + input_cache_creation: 0, + cost_usd: 0.01, + cost_estimated: false, + price_id: String::new(), + model_resolved: resolved.to_string(), + model_display: model.to_string(), + provider: None, + from_env: false, + is_secondary: secondary, + } + } + + #[test] + fn yesterday_range_is_bounded_to_yesterday() { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64; + let today_start = local_midnight_ms(now, 0); + let yesterday_start = local_midnight_ms(now, 1); + assert!(yesterday_start < today_start, "yesterday midnight precedes today's"); + assert_eq!(range_end("yesterday", now), Some(today_start)); + assert_eq!(range_end("today", now), None); + assert_eq!(range_end("7d", now), None); + + let records = vec![ + rec(yesterday_start + 1, "a/m1", "m1", false), // yesterday → in + rec(today_start + 1, "a/m1", "m1", false), // today → out + rec(yesterday_start.saturating_sub(1), "a/m1", "m1", false), // before → out + ]; + let stats = aggregate(&records, "yesterday", now); + assert_eq!(stats.totals.requests, 1, "only yesterday's record counts"); + } + + #[test] + fn models_by_name_merges_providers_and_keeps_secondary() { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64; + let records = vec![ + rec(now, "openai/gpt-x", "gpt-x", false), + rec(now, "azure/gpt-x", "gpt-x", false), + rec(now, "__secondary__", "__secondary__", true), + ]; + let stats = aggregate(&records, "all", now); + + // by_model keeps provider-qualified rows separate + assert_eq!(stats.models.len(), 3); + // by_name merges the two gpt-x rows, subagent stays its own row + assert_eq!(stats.models_by_name.len(), 2); + let gptx = stats.models_by_name.iter().find(|m| m.model == "gpt-x").expect("merged gpt-x row"); + assert_eq!(gptx.requests, 2); + assert_eq!(gptx.total_tokens, 2 * 200); + let sub = stats.models_by_name.iter().find(|m| m.model == "__secondary__").expect("secondary row"); + assert!(sub.is_secondary); + // sorted by total_tokens desc — every record has equal tokens here, so + // just assert the set is complete and totals add up. + let total: u64 = stats.models_by_name.iter().map(|m| m.total_tokens).sum(); + assert_eq!(total, 3 * 200); + } +} diff --git a/src/components/dashboard/DashboardPage.tsx b/src/components/dashboard/DashboardPage.tsx index 40ffbbd..39a0818 100644 --- a/src/components/dashboard/DashboardPage.tsx +++ b/src/components/dashboard/DashboardPage.tsx @@ -8,10 +8,11 @@ import { fmtInt, fmtPct, fmtTime, fmtTokens } from "../../lib/dashboard-format"; import { DailyBars } from "./DailyBars"; import { DailyDetailModal } from "./DailyDetailModal"; import { Heatmap } from "./Heatmap"; +import { ModelTotalsChart } from "./ModelTotalsChart"; import { TrendLineChart } from "./TrendLineChart"; import type { DailyRow, HeatmapCell } from "../../types/dashboard"; -const RANGES: DashboardRange[] = ["today", "7d", "30d", "all"]; +const RANGES: DashboardRange[] = ["today", "yesterday", "7d", "30d", "all"]; /** Per-request cache hit rate: cached tokens / total input tokens. */ function cacheHitOf(r: { inputOther: number; inputCacheRead: number }): number { @@ -59,13 +60,14 @@ export function DashboardPage() { 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"); + const [trendTab, setTrendTab] = useState<"daily" | "model" | "provider" | "totals">("daily"); const [heatmapSelected, setHeatmapSelected] = useState(null); const daily = data?.stats.daily ?? []; const RANGE_LABELS: Record = { today: t("rangeToday"), + yesterday: t("rangeYesterday"), "7d": t("range7d"), "30d": t("range30d"), all: t("rangeAll"), @@ -192,7 +194,7 @@ export function DashboardPage() { {/* Range overview cards */} -
+
{RANGES.map((r) => { const tot = rangeTotals[r]; if (!tot) return null; @@ -272,13 +274,26 @@ export function DashboardPage() { > {t("tabProviderTrend")} +
{trendTab === "daily" ? t("trendLineSub", { n: trendDaily.length }) - : daily.length > 30 - ? t("cardDailyTrendSubCapped", { n: daily.length }) - : t("cardDailyTrendSub", { n: daily.length })} + : trendTab === "totals" + ? t("cardModelUsageSub", { n: data.stats.modelsByName.length }) + : daily.length > 30 + ? t("cardDailyTrendSubCapped", { n: daily.length }) + : t("cardDailyTrendSub", { n: daily.length })}
{/* Active tab content */} @@ -287,6 +302,10 @@ export function DashboardPage() {
+ ) : trendTab === "totals" ? ( +
+ +
) : (
+ {t("noData")} +
+ ); + } + + const max = Math.max(...rows.map((m) => m.totalTokens || 0), 1); + + return ( +
+ {rows.map((m, i) => { + const color = i < 10 ? modelColor(i) : "#6b7280"; + const name = m.isSecondary ? t("subagentModel") : m.modelDisplay || m.model; + const tooltip = [ + name, + `${t("kpiRequests")}: ${fmtInt(m.requests)}`, + `${t("totalTokens")}: ${fmtInt(m.totalTokens)}`, + `${t("colCacheHit")}: ${fmtPct(m.cacheHitRate)}`, + `${t("cost")}: ${money(m.costUsd)}`, + ].join("\n"); + + return ( +
+ + + {name} + {m.costEstimated && ( + ({t("estimate")}) + )} + +
+
+
+ + {fmtTokens(m.totalTokens)} + +
+ ); + })} +
+ ); +} diff --git a/src/hooks/useDashboard.ts b/src/hooks/useDashboard.ts index 545a2b2..122c630 100644 --- a/src/hooks/useDashboard.ts +++ b/src/hooks/useDashboard.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import type { SummaryResult } from "../types/dashboard"; -export type DashboardRange = "today" | "7d" | "30d" | "all"; +export type DashboardRange = "today" | "yesterday" | "7d" | "30d" | "all"; const RANGE_STORAGE_KEY = "kimi-switch-dashboard-range"; @@ -10,7 +10,7 @@ export function useDashboard() { const [range, setRange] = useState(() => { try { const stored = localStorage.getItem(RANGE_STORAGE_KEY) as DashboardRange | null; - if (stored === "today" || stored === "7d" || stored === "30d" || stored === "all") { + if (stored === "today" || stored === "yesterday" || stored === "7d" || stored === "30d" || stored === "all") { return stored; } } catch { diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 414eea9..a176791 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -288,6 +288,7 @@ export const enTranslations: Record = { // Dashboard — ranges / status rangeToday: "Today", + rangeYesterday: "Yesterday", range7d: "7d", range30d: "30d", rangeAll: "All", @@ -309,6 +310,7 @@ export const enTranslations: Record = { cardDailyTrendSubCapped: "Last 30 days · {n} days total", tabModelTrend: "Model usage trend", tabProviderTrend: "Provider–model usage trend", + tabModelTotals: "Model usage totals", tabDailyTrend: "Daily usage trend", providerUnknown: "Unknown provider", cardDailyTrendSub: "{n} days", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index c17fe83..b883aeb 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -283,6 +283,7 @@ export const zhTranslations = { // Dashboard — ranges / status rangeToday: "今天", + rangeYesterday: "昨天", range7d: "7 天", range30d: "30 天", rangeAll: "全部", @@ -304,6 +305,7 @@ export const zhTranslations = { cardDailyTrendSubCapped: "近 30 天 · 共 {n} 天", tabModelTrend: "模型用量趋势", tabProviderTrend: "供应商模型用量趋势", + tabModelTotals: "模型用量统计", tabDailyTrend: "每日用量趋势", providerUnknown: "未知供应商", cardDailyTrendSub: "共 {n} 天", diff --git a/src/types/dashboard.ts b/src/types/dashboard.ts index 222120b..0d69f69 100644 --- a/src/types/dashboard.ts +++ b/src/types/dashboard.ts @@ -52,6 +52,8 @@ export interface RangeStats { totals: TotalsRow; daily: DailyRow[]; models: ModelRow[]; + /** Model totals keyed by bare model name — the same model across providers merges into one row. */ + modelsByName: ModelRow[]; recent: RecentRow[]; recentTotal: number; recentLimit: number;