Align usage stats to sliding windows

This commit is contained in:
YoVinchen
2025-12-31 17:00:53 +08:00
parent 52199f39c1
commit 6004084644
9 changed files with 141 additions and 177 deletions
+3 -2
View File
@@ -19,9 +19,10 @@ pub fn get_usage_summary(
#[tauri::command]
pub fn get_usage_trends(
state: State<'_, AppState>,
days: u32,
start_date: Option<i64>,
end_date: Option<i64>,
) -> Result<Vec<DailyStats>, AppError> {
state.db.get_daily_trends(days)
state.db.get_daily_trends(start_date, end_date)
}
/// 获取 Provider 统计
+105 -136
View File
@@ -4,7 +4,7 @@
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use chrono::{Duration, Local, TimeZone};
use chrono::{Local, TimeZone};
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -181,145 +181,114 @@ impl Database {
Ok(result)
}
/// 获取每日趋势
pub fn get_daily_trends(&self, days: u32) -> Result<Vec<DailyStats>, AppError> {
/// 获取每日趋势(滑动窗口,<=24h 按小时,>24h 按天,窗口与汇总一致)
pub fn get_daily_trends(
&self,
start_date: Option<i64>,
end_date: Option<i64>,
) -> Result<Vec<DailyStats>, AppError> {
let conn = lock_conn!(self.conn);
if days <= 1 {
let today = Local::now().date_naive();
let start_of_today = today.and_hms_opt(0, 0, 0).unwrap();
// 使用 earliest() 处理 DST 切换时的歧义时间,fallback 到当前时间减一天
let start_ts = Local
.from_local_datetime(&start_of_today)
.earliest()
.unwrap_or_else(|| Local::now() - Duration::days(1))
.timestamp();
let end_ts = end_date.unwrap_or_else(|| Local::now().timestamp());
let mut start_ts = start_date.unwrap_or_else(|| end_ts - 24 * 60 * 60);
let sql = "SELECT
strftime('%Y-%m-%dT%H:00:00', datetime(created_at, 'unixepoch', 'localtime')) as bucket,
COUNT(*) as request_count,
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens
FROM proxy_request_logs
WHERE created_at >= ?
GROUP BY bucket
ORDER BY bucket ASC";
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map([start_ts], |row| {
Ok(DailyStats {
date: row.get(0)?,
request_count: row.get::<_, i64>(1)? as u64,
total_cost: format!("{:.6}", row.get::<_, f64>(2)?),
total_tokens: row.get::<_, i64>(3)? as u64,
total_input_tokens: row.get::<_, i64>(4)? as u64,
total_output_tokens: row.get::<_, i64>(5)? as u64,
total_cache_creation_tokens: row.get::<_, i64>(6)? as u64,
total_cache_read_tokens: row.get::<_, i64>(7)? as u64,
})
})?;
let mut buckets: HashMap<String, DailyStats> = HashMap::new();
for row in rows {
let stat = row?;
buckets.insert(stat.date.clone(), stat);
}
let mut stats = Vec::new();
for hour in 0..24 {
let bucket = today
.and_hms_opt(hour, 0, 0)
.unwrap()
.format("%Y-%m-%dT%H:00:00")
.to_string();
if let Some(stat) = buckets.remove(&bucket) {
stats.push(stat);
} else {
stats.push(DailyStats {
date: bucket,
request_count: 0,
total_cost: "0.000000".to_string(),
total_tokens: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_creation_tokens: 0,
total_cache_read_tokens: 0,
});
}
}
Ok(stats)
} else {
let today = Local::now().date_naive();
let start_day = today - Duration::days((days.saturating_sub(1)) as i64);
let start_of_window = start_day.and_hms_opt(0, 0, 0).unwrap();
// 使用 earliest() 处理 DST 切换时的歧义时间,fallback 到当前时间减 days 天
let start_ts = Local
.from_local_datetime(&start_of_window)
.earliest()
.unwrap_or_else(|| Local::now() - Duration::days(days as i64))
.timestamp();
let sql = "SELECT
strftime('%Y-%m-%dT00:00:00', datetime(created_at, 'unixepoch', 'localtime')) as bucket,
COUNT(*) as request_count,
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens
FROM proxy_request_logs
WHERE created_at >= ?
GROUP BY bucket
ORDER BY bucket ASC";
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map([start_ts], |row| {
Ok(DailyStats {
date: row.get(0)?,
request_count: row.get::<_, i64>(1)? as u64,
total_cost: format!("{:.6}", row.get::<_, f64>(2)?),
total_tokens: row.get::<_, i64>(3)? as u64,
total_input_tokens: row.get::<_, i64>(4)? as u64,
total_output_tokens: row.get::<_, i64>(5)? as u64,
total_cache_creation_tokens: row.get::<_, i64>(6)? as u64,
total_cache_read_tokens: row.get::<_, i64>(7)? as u64,
})
})?;
let mut map = HashMap::new();
for row in rows {
let stat = row?;
map.insert(stat.date.clone(), stat);
}
let mut stats = Vec::new();
for i in 0..days {
let day = start_day + Duration::days(i as i64);
let key = day.format("%Y-%m-%dT00:00:00").to_string();
if let Some(stat) = map.remove(&key) {
stats.push(stat);
} else {
stats.push(DailyStats {
date: key,
request_count: 0,
total_cost: "0.000000".to_string(),
total_tokens: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_creation_tokens: 0,
total_cache_read_tokens: 0,
});
}
}
Ok(stats)
if start_ts >= end_ts {
start_ts = end_ts - 24 * 60 * 60;
}
let duration = end_ts - start_ts;
let bucket_seconds: i64 = if duration <= 24 * 60 * 60 {
60 * 60
} else {
24 * 60 * 60
};
let mut bucket_count: i64 = if duration <= 0 {
1
} else {
((duration as f64) / bucket_seconds as f64).ceil() as i64
};
// 固定 24 小时窗口为 24 个小时桶,避免浮点误差
if bucket_seconds == 60 * 60 {
bucket_count = 24;
}
if bucket_count < 1 {
bucket_count = 1;
}
let sql = "
SELECT
CAST((created_at - ?1) / ?3 AS INTEGER) as bucket_idx,
COUNT(*) as request_count,
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens
FROM proxy_request_logs
WHERE created_at >= ?1 AND created_at <= ?2
GROUP BY bucket_idx
ORDER BY bucket_idx ASC";
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map(params![start_ts, end_ts, bucket_seconds], |row| {
Ok((
row.get::<_, i64>(0)?,
DailyStats {
date: String::new(),
request_count: row.get::<_, i64>(1)? as u64,
total_cost: format!("{:.6}", row.get::<_, f64>(2)?),
total_tokens: row.get::<_, i64>(3)? as u64,
total_input_tokens: row.get::<_, i64>(4)? as u64,
total_output_tokens: row.get::<_, i64>(5)? as u64,
total_cache_creation_tokens: row.get::<_, i64>(6)? as u64,
total_cache_read_tokens: row.get::<_, i64>(7)? as u64,
},
))
})?;
let mut map: HashMap<i64, DailyStats> = HashMap::new();
for row in rows {
let (mut bucket_idx, stat) = row?;
if bucket_idx < 0 {
continue;
}
if bucket_idx >= bucket_count {
bucket_idx = bucket_count - 1;
}
map.insert(bucket_idx, stat);
}
let mut stats = Vec::with_capacity(bucket_count as usize);
for i in 0..bucket_count {
let bucket_start_ts = start_ts + i * bucket_seconds;
let bucket_start = Local
.timestamp_opt(bucket_start_ts, 0)
.single()
.unwrap_or_else(Local::now);
let date = bucket_start.format("%Y-%m-%dT%H:%M:%S").to_string();
if let Some(mut stat) = map.remove(&i) {
stat.date = date;
stats.push(stat);
} else {
stats.push(DailyStats {
date,
request_count: 0,
total_cost: "0.000000".to_string(),
total_tokens: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_creation_tokens: 0,
total_cache_read_tokens: 0,
});
}
}
Ok(stats)
}
/// 获取 Provider 统计
+1 -7
View File
@@ -12,13 +12,7 @@ interface UsageSummaryCardsProps {
export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
const { t } = useTranslation();
const { startDate, endDate } = useMemo(() => {
const end = Math.floor(Date.now() / 1000);
const start = end - days * 24 * 60 * 60;
return { startDate: start, endDate: end };
}, [days]);
const { data: summary, isLoading } = useUsageSummary(startDate, endDate);
const { data: summary, isLoading } = useUsageSummary(days);
const stats = useMemo(() => {
const totalRequests = summary?.totalRequests ?? 0;
+7 -21
View File
@@ -41,7 +41,12 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
return {
rawDate: stat.date,
label: isToday
? pointDate.toLocaleTimeString(dateLocale, { hour: "2-digit" })
? pointDate.toLocaleString(dateLocale, {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: pointDate.toLocaleDateString(dateLocale, {
month: "2-digit",
day: "2-digit",
@@ -55,26 +60,7 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
};
}) || [];
const hourlyData = (() => {
if (!isToday) return chartData;
const map = new Map<number, (typeof chartData)[number]>();
chartData.forEach((point) => {
map.set(point.hour ?? 0, point);
});
return Array.from({ length: 24 }, (_, hour) => {
const bucket = map.get(hour);
return {
label: `${hour.toString().padStart(2, "0")}:00`,
inputTokens: bucket?.inputTokens ?? 0,
outputTokens: bucket?.outputTokens ?? 0,
cacheCreationTokens: bucket?.cacheCreationTokens ?? 0,
cacheReadTokens: bucket?.cacheReadTokens ?? 0,
cost: bucket?.cost ?? 0,
};
});
})();
const displayData = isToday ? hourlyData : chartData;
const displayData = chartData;
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
+1 -1
View File
@@ -423,7 +423,7 @@
"cost": "Cost",
"perMillion": "(per million)",
"trends": "Usage Trends",
"rangeToday": "Today (hourly)",
"rangeToday": "Last 24 hours (hourly)",
"rangeLast7Days": "Last 7 days",
"rangeLast30Days": "Last 30 days",
"totalTokens": "Total Tokens",
+1 -1
View File
@@ -423,7 +423,7 @@
"cost": "コスト",
"perMillion": "(100万あたり)",
"trends": "利用トレンド",
"rangeToday": "今日 (時間別)",
"rangeToday": "直近24時間 (時間別)",
"rangeLast7Days": "過去7日間",
"rangeLast30Days": "過去30日間",
"totalTokens": "総トークン数",
+1 -1
View File
@@ -423,7 +423,7 @@
"cost": "成本",
"perMillion": "(每百万)",
"trends": "使用趋势",
"rangeToday": "今天 (按小时)",
"rangeToday": "过去 24 小时 (按小时)",
"rangeLast7Days": "过去 7 天",
"rangeLast30Days": "过去 30 天",
"totalTokens": "总 Token 数",
+5 -2
View File
@@ -49,8 +49,11 @@ export const usageApi = {
return invoke("get_usage_summary", { startDate, endDate });
},
getUsageTrends: async (days: number): Promise<DailyStats[]> => {
return invoke("get_usage_trends", { days });
getUsageTrends: async (
startDate?: number,
endDate?: number,
): Promise<DailyStats[]> => {
return invoke("get_usage_trends", { startDate, endDate });
},
getProviderStats: async (): Promise<ProviderStats[]> => {
+17 -6
View File
@@ -5,8 +5,7 @@ import type { LogFilters } from "@/types/usage";
// Query keys
export const usageKeys = {
all: ["usage"] as const,
summary: (startDate?: number, endDate?: number) =>
[...usageKeys.all, "summary", startDate, endDate] as const,
summary: (days: number) => [...usageKeys.all, "summary", days] as const,
trends: (days: number) => [...usageKeys.all, "trends", days] as const,
providerStats: () => [...usageKeys.all, "provider-stats"] as const,
modelStats: () => [...usageKeys.all, "model-stats"] as const,
@@ -19,11 +18,20 @@ export const usageKeys = {
[...usageKeys.all, "limits", providerId, appType] as const,
};
const getWindow = (days: number) => {
const endDate = Math.floor(Date.now() / 1000);
const startDate = endDate - days * 24 * 60 * 60;
return { startDate, endDate };
};
// Hooks
export function useUsageSummary(startDate?: number, endDate?: number) {
export function useUsageSummary(days: number) {
return useQuery({
queryKey: usageKeys.summary(startDate, endDate),
queryFn: () => usageApi.getUsageSummary(startDate, endDate),
queryKey: usageKeys.summary(days),
queryFn: () => {
const { startDate, endDate } = getWindow(days);
return usageApi.getUsageSummary(startDate, endDate);
},
refetchInterval: 30000, // 每30秒自动刷新
refetchIntervalInBackground: false, // 后台不刷新
});
@@ -32,7 +40,10 @@ export function useUsageSummary(startDate?: number, endDate?: number) {
export function useUsageTrends(days: number) {
return useQuery({
queryKey: usageKeys.trends(days),
queryFn: () => usageApi.getUsageTrends(days),
queryFn: () => {
const { startDate, endDate } = getWindow(days);
return usageApi.getUsageTrends(startDate, endDate);
},
refetchInterval: 30000, // 每30秒自动刷新
refetchIntervalInBackground: false,
});