feat(dashboard): 用量统计新增「昨天」范围 + 跨供应商「模型用量统计」选项卡
- 范围新增 yesterday:本地时区昨日 0 点 ~ 今日 0 点,range_end 引入上界过滤 - RangeStats 新增 modelsByName:按裸模型名聚合,同一模型跨供应商合并, 子代理记录归入 __secondary__ 单独成行,按 Token 降序 - 趋势选项卡第 4 个 tab「模型用量统计」:新增 ModelTotalsChart 横向条形图, 悬停显示请求数/Token/缓存命中/费用 - 概览卡网格扩展为 5 张;i18n 中英同步 - 顺带:pricing 测试基准从已下架的 moonshotai/kimi-k2.5 切换到 kimi-k3
This commit is contained in:
1 parent
86112ee90b
commit
35fd2e7b0e
7 files changed
+252
-29
No files matched your search
+146
-21
@@ -102,6 +102,9 @@ pub struct RangeStats {
|
|||||||
pub totals: TotalsRow,
|
pub totals: TotalsRow,
|
||||||
pub daily: Vec<DailyRow>,
|
pub daily: Vec<DailyRow>,
|
||||||
pub models: Vec<ModelRow>,
|
pub models: Vec<ModelRow>,
|
||||||
|
/// 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<ModelRow>,
|
||||||
pub recent: Vec<RecentRow>,
|
pub recent: Vec<RecentRow>,
|
||||||
pub recent_total: usize,
|
pub recent_total: usize,
|
||||||
pub recent_limit: usize,
|
pub recent_limit: usize,
|
||||||
@@ -976,6 +979,7 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
|
|||||||
totals: TotalsRow::default(),
|
totals: TotalsRow::default(),
|
||||||
daily: vec![],
|
daily: vec![],
|
||||||
models: vec![],
|
models: vec![],
|
||||||
|
models_by_name: vec![],
|
||||||
recent: vec![],
|
recent: vec![],
|
||||||
recent_total: 0,
|
recent_total: 0,
|
||||||
recent_limit: 500,
|
recent_limit: 500,
|
||||||
@@ -985,6 +989,7 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
|
|||||||
let mut totals = TotalsRow::default();
|
let mut totals = TotalsRow::default();
|
||||||
let mut by_day: HashMap<String, (TotalsRow, HashMap<String, TotalsRow>, HashMap<String, HashMap<String, u64>>)> = HashMap::new();
|
let mut by_day: HashMap<String, (TotalsRow, HashMap<String, TotalsRow>, HashMap<String, HashMap<String, u64>>)> = HashMap::new();
|
||||||
let mut by_model: HashMap<String, (ModelRow, TotalsRow)> = HashMap::new();
|
let mut by_model: HashMap<String, (ModelRow, TotalsRow)> = HashMap::new();
|
||||||
|
let mut by_name: HashMap<String, (ModelRow, TotalsRow)> = HashMap::new();
|
||||||
|
|
||||||
for r in &filtered {
|
for r in &filtered {
|
||||||
totals.add(r);
|
totals.add(r);
|
||||||
@@ -1017,6 +1022,28 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
|
|||||||
entry.1.add(r);
|
entry.1.add(r);
|
||||||
entry.0.cost_estimated = entry.0.cost_estimated || r.cost_estimated;
|
entry.0.cost_estimated = entry.0.cost_estimated || r.cost_estimated;
|
||||||
entry.0.is_secondary = entry.0.is_secondary || r.is_secondary;
|
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;
|
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();
|
}).collect();
|
||||||
models.sort_by(|a, b| b.total_tokens.cmp(&a.total_tokens));
|
models.sort_by(|a, b| b.total_tokens.cmp(&a.total_tokens));
|
||||||
|
|
||||||
|
let mut models_by_name: Vec<ModelRow> = 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_total = filtered.len();
|
||||||
let recent_limit = 500;
|
let recent_limit = 500;
|
||||||
let recent: Vec<RecentRow> = filtered.iter().take(recent_limit).map(|r| RecentRow {
|
let recent: Vec<RecentRow> = filtered.iter().take(recent_limit).map(|r| RecentRow {
|
||||||
@@ -1081,6 +1119,7 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
|
|||||||
totals,
|
totals,
|
||||||
daily,
|
daily,
|
||||||
models,
|
models,
|
||||||
|
models_by_name,
|
||||||
recent,
|
recent,
|
||||||
recent_total,
|
recent_total,
|
||||||
recent_limit,
|
recent_limit,
|
||||||
@@ -1109,24 +1148,40 @@ impl TotalsRow {
|
|||||||
fn filter_by_range<'a>(records: &'a [UsageRecord], range: &str, now_ms: u64) -> Vec<&'a UsageRecord> {
|
fn filter_by_range<'a>(records: &'a [UsageRecord], range: &str, now_ms: u64) -> Vec<&'a UsageRecord> {
|
||||||
if range == "all" { return records.iter().collect(); }
|
if range == "all" { return records.iter().collect(); }
|
||||||
let start = range_start(range, now_ms);
|
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<u64> {
|
||||||
|
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 {
|
fn range_start(range: &str, now_ms: u64) -> u64 {
|
||||||
match range {
|
match range {
|
||||||
"today" => {
|
"today" => local_midnight_ms(now_ms, 0),
|
||||||
// LOCAL midnight today — not UTC midnight. Without this, users east of UTC see
|
"yesterday" => local_midnight_ms(now_ms, 1),
|
||||||
// "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)
|
|
||||||
}
|
|
||||||
"7d" => now_ms - 7 * 24 * 3600 * 1000,
|
"7d" => now_ms - 7 * 24 * 3600 * 1000,
|
||||||
"30d" => now_ms - 30 * 24 * 3600 * 1000,
|
"30d" => now_ms - 30 * 24 * 3600 * 1000,
|
||||||
_ => now_ms - 30 * 24 * 3600 * 1000,
|
_ => now_ms - 30 * 24 * 3600 * 1000,
|
||||||
@@ -1836,7 +1891,7 @@ pub fn get_summary(home_override: Option<String>, range: Option<String>, refresh
|
|||||||
|
|
||||||
let all_model_count = all_models.len();
|
let all_model_count = all_models.len();
|
||||||
let mut range_totals = HashMap::new();
|
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);
|
let s = aggregate(&records, r_k, now_ms);
|
||||||
range_totals.insert(r_k.to_string(), s.totals);
|
range_totals.insert(r_k.to_string(), s.totals);
|
||||||
}
|
}
|
||||||
@@ -1977,10 +2032,10 @@ mod pricing_tests {
|
|||||||
let idx = models_dev_cost_index();
|
let idx = models_dev_cost_index();
|
||||||
// The compiled-in snapshot must carry real models.dev prices.
|
// The compiled-in snapshot must carry real models.dev prices.
|
||||||
assert!(idx.len() > 1000, "expected >1000 priced models, got {}", idx.len());
|
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");
|
let kimi = idx.get("moonshotai/kimi-k3").copied().expect("kimi-k3 present");
|
||||||
assert_eq!(kimi.input, 0.6);
|
assert_eq!(kimi.input, 3.0);
|
||||||
assert_eq!(kimi.output, 3.0);
|
assert_eq!(kimi.output, 15.0);
|
||||||
assert_eq!(kimi.cache_read, 0.1);
|
assert_eq!(kimi.cache_read, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1992,9 +2047,9 @@ mod pricing_tests {
|
|||||||
assert_eq!(input, 0.6);
|
assert_eq!(input, 0.6);
|
||||||
assert_eq!(output, 2.2);
|
assert_eq!(output, 2.2);
|
||||||
|
|
||||||
let (id, _, _, _, est) = match_price("kimi/k2.5");
|
let (id, _, _, _, est) = match_price("kimi/k3");
|
||||||
assert!(!est);
|
assert!(!est);
|
||||||
assert_eq!(id, "moonshotai/kimi-k2.5");
|
assert_eq!(id, "moonshotai/kimi-k3");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2307,3 +2362,73 @@ mod session_tests {
|
|||||||
assert!(list_sessions_cmd(&home, "all", None).sessions.is_empty());
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,10 +8,11 @@ import { fmtInt, fmtPct, fmtTime, fmtTokens } from "../../lib/dashboard-format";
|
|||||||
import { DailyBars } from "./DailyBars";
|
import { DailyBars } from "./DailyBars";
|
||||||
import { DailyDetailModal } from "./DailyDetailModal";
|
import { DailyDetailModal } from "./DailyDetailModal";
|
||||||
import { Heatmap } from "./Heatmap";
|
import { Heatmap } from "./Heatmap";
|
||||||
|
import { ModelTotalsChart } from "./ModelTotalsChart";
|
||||||
import { TrendLineChart } from "./TrendLineChart";
|
import { TrendLineChart } from "./TrendLineChart";
|
||||||
import type { DailyRow, HeatmapCell } from "../../types/dashboard";
|
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. */
|
/** Per-request cache hit rate: cached tokens / total input tokens. */
|
||||||
function cacheHitOf(r: { inputOther: number; inputCacheRead: number }): number {
|
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 { range, changeRange, data, loading, error, refresh, loadStats } = useDashboard();
|
||||||
const [showAllModels, setShowAllModels] = useState(false);
|
const [showAllModels, setShowAllModels] = useState(false);
|
||||||
const [recentPage, setRecentPage] = useState(1);
|
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<DailyRow | null>(null);
|
const [heatmapSelected, setHeatmapSelected] = useState<DailyRow | null>(null);
|
||||||
|
|
||||||
const daily = data?.stats.daily ?? [];
|
const daily = data?.stats.daily ?? [];
|
||||||
|
|
||||||
const RANGE_LABELS: Record<DashboardRange, string> = {
|
const RANGE_LABELS: Record<DashboardRange, string> = {
|
||||||
today: t("rangeToday"),
|
today: t("rangeToday"),
|
||||||
|
yesterday: t("rangeYesterday"),
|
||||||
"7d": t("range7d"),
|
"7d": t("range7d"),
|
||||||
"30d": t("range30d"),
|
"30d": t("range30d"),
|
||||||
all: t("rangeAll"),
|
all: t("rangeAll"),
|
||||||
@@ -192,7 +194,7 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Range overview cards */}
|
{/* Range overview cards */}
|
||||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
{RANGES.map((r) => {
|
{RANGES.map((r) => {
|
||||||
const tot = rangeTotals[r];
|
const tot = rangeTotals[r];
|
||||||
if (!tot) return null;
|
if (!tot) return null;
|
||||||
@@ -272,13 +274,26 @@ export function DashboardPage() {
|
|||||||
>
|
>
|
||||||
{t("tabProviderTrend")}
|
{t("tabProviderTrend")}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTrendTab("totals")}
|
||||||
|
className={`px-3 py-2 -mb-px border-b-2 text-sm font-medium transition-colors ${
|
||||||
|
trendTab === "totals"
|
||||||
|
? "border-blue-500 text-content-primary"
|
||||||
|
: "border-transparent text-content-muted hover:text-content-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("tabModelTotals")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span className="pb-2 text-xs text-content-muted">
|
<span className="pb-2 text-xs text-content-muted">
|
||||||
{trendTab === "daily"
|
{trendTab === "daily"
|
||||||
? t("trendLineSub", { n: trendDaily.length })
|
? t("trendLineSub", { n: trendDaily.length })
|
||||||
: daily.length > 30
|
: trendTab === "totals"
|
||||||
? t("cardDailyTrendSubCapped", { n: daily.length })
|
? t("cardModelUsageSub", { n: data.stats.modelsByName.length })
|
||||||
: t("cardDailyTrendSub", { n: daily.length })}
|
: daily.length > 30
|
||||||
|
? t("cardDailyTrendSubCapped", { n: daily.length })
|
||||||
|
: t("cardDailyTrendSub", { n: daily.length })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Active tab content */}
|
{/* Active tab content */}
|
||||||
@@ -287,6 +302,10 @@ export function DashboardPage() {
|
|||||||
<div className="flex-1 min-h-[240px]">
|
<div className="flex-1 min-h-[240px]">
|
||||||
<TrendLineChart daily={trendDaily} />
|
<TrendLineChart daily={trendDaily} />
|
||||||
</div>
|
</div>
|
||||||
|
) : trendTab === "totals" ? (
|
||||||
|
<div className="flex-1 min-h-[240px]">
|
||||||
|
<ModelTotalsChart rows={data.stats.modelsByName} />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 min-h-[240px]">
|
<div className="flex-1 min-h-[240px]">
|
||||||
<DailyBars
|
<DailyBars
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useTranslation } from "../../i18n";
|
||||||
|
import { useCurrency } from "../../hooks/useCurrency";
|
||||||
|
import { fmtInt, fmtPct, fmtTokens } from "../../lib/dashboard-format";
|
||||||
|
import { modelColor } from "./DailyBars";
|
||||||
|
import type { ModelRow } from "../../types/dashboard";
|
||||||
|
|
||||||
|
interface ModelTotalsChartProps {
|
||||||
|
/** Provider-agnostic model totals, pre-sorted by totalTokens desc. */
|
||||||
|
rows: ModelRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Horizontal ranked bars: one row per bare model name, providers merged. */
|
||||||
|
export function ModelTotalsChart({ rows }: ModelTotalsChartProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { money } = useCurrency();
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-48 items-center justify-center text-sm text-content-muted">
|
||||||
|
{t("noData")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const max = Math.max(...rows.map((m) => m.totalTokens || 0), 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-1 overflow-y-auto">
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={m.model}
|
||||||
|
title={tooltip}
|
||||||
|
className="group flex items-center gap-2 rounded px-1 py-1 cursor-help hover:bg-hover"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-block h-2.5 w-2.5 rounded-sm shrink-0"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
<span className="w-40 shrink-0 truncate text-xs text-content-primary">
|
||||||
|
{name}
|
||||||
|
{m.costEstimated && (
|
||||||
|
<span className="ml-1 text-[10px] text-yellow-600">({t("estimate")})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="flex h-4 flex-1 items-center overflow-hidden rounded-sm bg-hover-2/50">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-sm transition-[width] group-hover:opacity-85"
|
||||||
|
style={{
|
||||||
|
width: `${Math.max(2, (m.totalTokens / max) * 100)}%`,
|
||||||
|
backgroundColor: color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="w-16 shrink-0 text-right text-xs text-content-muted">
|
||||||
|
{fmtTokens(m.totalTokens)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type { SummaryResult } from "../types/dashboard";
|
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";
|
const RANGE_STORAGE_KEY = "kimi-switch-dashboard-range";
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ export function useDashboard() {
|
|||||||
const [range, setRange] = useState<DashboardRange>(() => {
|
const [range, setRange] = useState<DashboardRange>(() => {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(RANGE_STORAGE_KEY) as DashboardRange | null;
|
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;
|
return stored;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -288,6 +288,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
|||||||
|
|
||||||
// Dashboard — ranges / status
|
// Dashboard — ranges / status
|
||||||
rangeToday: "Today",
|
rangeToday: "Today",
|
||||||
|
rangeYesterday: "Yesterday",
|
||||||
range7d: "7d",
|
range7d: "7d",
|
||||||
range30d: "30d",
|
range30d: "30d",
|
||||||
rangeAll: "All",
|
rangeAll: "All",
|
||||||
@@ -309,6 +310,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
|||||||
cardDailyTrendSubCapped: "Last 30 days · {n} days total",
|
cardDailyTrendSubCapped: "Last 30 days · {n} days total",
|
||||||
tabModelTrend: "Model usage trend",
|
tabModelTrend: "Model usage trend",
|
||||||
tabProviderTrend: "Provider–model usage trend",
|
tabProviderTrend: "Provider–model usage trend",
|
||||||
|
tabModelTotals: "Model usage totals",
|
||||||
tabDailyTrend: "Daily usage trend",
|
tabDailyTrend: "Daily usage trend",
|
||||||
providerUnknown: "Unknown provider",
|
providerUnknown: "Unknown provider",
|
||||||
cardDailyTrendSub: "{n} days",
|
cardDailyTrendSub: "{n} days",
|
||||||
|
|||||||
@@ -283,6 +283,7 @@ export const zhTranslations = {
|
|||||||
|
|
||||||
// Dashboard — ranges / status
|
// Dashboard — ranges / status
|
||||||
rangeToday: "今天",
|
rangeToday: "今天",
|
||||||
|
rangeYesterday: "昨天",
|
||||||
range7d: "7 天",
|
range7d: "7 天",
|
||||||
range30d: "30 天",
|
range30d: "30 天",
|
||||||
rangeAll: "全部",
|
rangeAll: "全部",
|
||||||
@@ -304,6 +305,7 @@ export const zhTranslations = {
|
|||||||
cardDailyTrendSubCapped: "近 30 天 · 共 {n} 天",
|
cardDailyTrendSubCapped: "近 30 天 · 共 {n} 天",
|
||||||
tabModelTrend: "模型用量趋势",
|
tabModelTrend: "模型用量趋势",
|
||||||
tabProviderTrend: "供应商模型用量趋势",
|
tabProviderTrend: "供应商模型用量趋势",
|
||||||
|
tabModelTotals: "模型用量统计",
|
||||||
tabDailyTrend: "每日用量趋势",
|
tabDailyTrend: "每日用量趋势",
|
||||||
providerUnknown: "未知供应商",
|
providerUnknown: "未知供应商",
|
||||||
cardDailyTrendSub: "共 {n} 天",
|
cardDailyTrendSub: "共 {n} 天",
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export interface RangeStats {
|
|||||||
totals: TotalsRow;
|
totals: TotalsRow;
|
||||||
daily: DailyRow[];
|
daily: DailyRow[];
|
||||||
models: ModelRow[];
|
models: ModelRow[];
|
||||||
|
/** Model totals keyed by bare model name — the same model across providers merges into one row. */
|
||||||
|
modelsByName: ModelRow[];
|
||||||
recent: RecentRow[];
|
recent: RecentRow[];
|
||||||
recentTotal: number;
|
recentTotal: number;
|
||||||
recentLimit: number;
|
recentLimit: number;
|
||||||
|
|||||||
Reference in new issue
Block a user