Compare commits

...

7 Commits

Author SHA1 Message Date
Jason e1292a82cd Merge remote-tracking branch 'origin/main' into feat/usage-dashboard-refinement 2026-04-15 11:36:52 +08:00
Jason 0c4226a271 Merge branch 'main' into feat/usage-dashboard-refinement
Resolve conflicts in usage dashboard components:

- RequestLogTable.tsx: keep PR's new `range: UsageRangeSelection` API
  (replaces the old timeMode/rollingWindowSeconds mechanism), while
  preserving main's advanced pagination (ellipsis pages + jump input)
  and page-reset effect. Removed obsolete handleRefresh and datetime
  converters that are no longer needed under the new range API.

- UsageDashboard.tsx: keep PR's redesigned filter panel with calendar
  picker and preset buttons. Drop the stale `timeRange={timeRange}`
  prop that was auto-preserved from main but references a variable
  that no longer exists in the PR's design.
2026-04-15 11:33:24 +08:00
YoVinchen 654e537746 feat(i18n): add cache short labels and usage stats translations for zh/en/ja 2026-04-11 15:38:38 +08:00
YoVinchen da416036e4 refactor(usage): compact request log table with merged cache/multiplier columns and centered layout 2026-04-11 15:38:00 +08:00
YoVinchen 7486023dde refactor(usage): streamline dashboard layout and stats components 2026-04-11 15:37:24 +08:00
YoVinchen 227fc79e7e feat(usage): redesign calendar date range picker with auto-switch and simplified layout 2026-04-11 15:36:44 +08:00
YoVinchen 7402f1ca05 feat(usage): enhance usage stats backend and query hooks 2026-04-11 15:36:06 +08:00
16 changed files with 1230 additions and 617 deletions
+10 -2
View File
@@ -35,18 +35,26 @@ pub fn get_usage_trends(
#[tauri::command] #[tauri::command]
pub fn get_provider_stats( pub fn get_provider_stats(
state: State<'_, AppState>, state: State<'_, AppState>,
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<String>, app_type: Option<String>,
) -> Result<Vec<ProviderStats>, AppError> { ) -> Result<Vec<ProviderStats>, AppError> {
state.db.get_provider_stats(app_type.as_deref()) state
.db
.get_provider_stats(start_date, end_date, app_type.as_deref())
} }
/// 获取模型统计 /// 获取模型统计
#[tauri::command] #[tauri::command]
pub fn get_model_stats( pub fn get_model_stats(
state: State<'_, AppState>, state: State<'_, AppState>,
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<String>, app_type: Option<String>,
) -> Result<Vec<ModelStats>, AppError> { ) -> Result<Vec<ModelStats>, AppError> {
state.db.get_model_stats(app_type.as_deref()) state
.db
.get_model_stats(start_date, end_date, app_type.as_deref())
} }
/// 获取请求日志列表 /// 获取请求日志列表
+167 -32
View File
@@ -275,14 +275,9 @@ impl Database {
let mut bucket_count: i64 = if duration <= 0 { let mut bucket_count: i64 = if duration <= 0 {
1 1
} else { } else {
((duration as f64) / bucket_seconds as f64).ceil() as i64 (duration + bucket_seconds - 1) / bucket_seconds
}; };
// 固定 24 小时窗口为 24 个小时桶,避免浮点误差
if bucket_seconds == 60 * 60 {
bucket_count = 24;
}
if bucket_count < 1 { if bucket_count < 1 {
bucket_count = 1; bucket_count = 1;
} }
@@ -453,14 +448,50 @@ impl Database {
/// 获取 Provider 统计 /// 获取 Provider 统计
pub fn get_provider_stats( pub fn get_provider_stats(
&self, &self,
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<&str>, app_type: Option<&str>,
) -> Result<Vec<ProviderStats>, AppError> { ) -> Result<Vec<ProviderStats>, AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
let (detail_where, rollup_where) = if app_type.is_some() { let mut detail_conditions = Vec::new();
("WHERE l.app_type = ?1", "WHERE r.app_type = ?2") let mut detail_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(start) = start_date {
detail_conditions.push("l.created_at >= ?");
detail_params.push(Box::new(start));
}
if let Some(end) = end_date {
detail_conditions.push("l.created_at <= ?");
detail_params.push(Box::new(end));
}
if let Some(at) = app_type {
detail_conditions.push("l.app_type = ?");
detail_params.push(Box::new(at.to_string()));
}
let detail_where = if detail_conditions.is_empty() {
String::new()
} else { } else {
("", "") format!("WHERE {}", detail_conditions.join(" AND "))
};
let mut rollup_conditions = Vec::new();
let mut rollup_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(start) = start_date {
rollup_conditions.push("r.date >= date(?, 'unixepoch', 'localtime')".to_string());
rollup_params.push(Box::new(start));
}
if let Some(end) = end_date {
rollup_conditions.push("r.date <= date(?, 'unixepoch', 'localtime')".to_string());
rollup_params.push(Box::new(end));
}
if let Some(at) = app_type {
rollup_conditions.push("r.app_type = ?".to_string());
rollup_params.push(Box::new(at.to_string()));
}
let rollup_where = if rollup_conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", rollup_conditions.join(" AND "))
}; };
// UNION detail logs + rollup data, then aggregate // UNION detail logs + rollup data, then aggregate
@@ -506,6 +537,9 @@ impl Database {
); );
let mut stmt = conn.prepare(&sql)?; let mut stmt = conn.prepare(&sql)?;
let mut params: Vec<Box<dyn rusqlite::ToSql>> = detail_params;
params.extend(rollup_params);
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let row_mapper = |row: &rusqlite::Row| { let row_mapper = |row: &rusqlite::Row| {
let request_count: i64 = row.get(3)?; let request_count: i64 = row.get(3)?;
let success_count: i64 = row.get(6)?; let success_count: i64 = row.get(6)?;
@@ -526,11 +560,7 @@ impl Database {
}) })
}; };
let rows = if let Some(at) = app_type { let rows = stmt.query_map(param_refs.as_slice(), row_mapper)?;
stmt.query_map(params![at, at], row_mapper)?
} else {
stmt.query_map([], row_mapper)?
};
let mut stats = Vec::new(); let mut stats = Vec::new();
for row in rows { for row in rows {
@@ -541,13 +571,52 @@ impl Database {
} }
/// 获取模型统计 /// 获取模型统计
pub fn get_model_stats(&self, app_type: Option<&str>) -> Result<Vec<ModelStats>, AppError> { pub fn get_model_stats(
&self,
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<&str>,
) -> Result<Vec<ModelStats>, AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
let (detail_where, rollup_where) = if app_type.is_some() { let mut detail_conditions = Vec::new();
("WHERE app_type = ?1", "WHERE app_type = ?2") let mut detail_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(start) = start_date {
detail_conditions.push("l.created_at >= ?");
detail_params.push(Box::new(start));
}
if let Some(end) = end_date {
detail_conditions.push("l.created_at <= ?");
detail_params.push(Box::new(end));
}
if let Some(at) = app_type {
detail_conditions.push("l.app_type = ?");
detail_params.push(Box::new(at.to_string()));
}
let detail_where = if detail_conditions.is_empty() {
String::new()
} else { } else {
("", "") format!("WHERE {}", detail_conditions.join(" AND "))
};
let mut rollup_conditions = Vec::new();
let mut rollup_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(start) = start_date {
rollup_conditions.push("r.date >= date(?, 'unixepoch', 'localtime')".to_string());
rollup_params.push(Box::new(start));
}
if let Some(end) = end_date {
rollup_conditions.push("r.date <= date(?, 'unixepoch', 'localtime')".to_string());
rollup_params.push(Box::new(end));
}
if let Some(at) = app_type {
rollup_conditions.push("r.app_type = ?".to_string());
rollup_params.push(Box::new(at.to_string()));
}
let rollup_where = if rollup_conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", rollup_conditions.join(" AND "))
}; };
// UNION detail logs + rollup data // UNION detail logs + rollup data
@@ -558,27 +627,30 @@ impl Database {
SUM(total_tokens) as total_tokens, SUM(total_tokens) as total_tokens,
SUM(total_cost) as total_cost SUM(total_cost) as total_cost
FROM ( FROM (
SELECT model, SELECT l.model,
COUNT(*) as request_count, COUNT(*) as request_count,
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, COALESCE(SUM(l.input_tokens + l.output_tokens), 0) as total_tokens,
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost
FROM proxy_request_logs FROM proxy_request_logs l
{detail_where} {detail_where}
GROUP BY model GROUP BY l.model
UNION ALL UNION ALL
SELECT model, SELECT r.model,
COALESCE(SUM(request_count), 0), COALESCE(SUM(request_count), 0),
COALESCE(SUM(input_tokens + output_tokens), 0), COALESCE(SUM(input_tokens + output_tokens), 0),
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0)
FROM usage_daily_rollups FROM usage_daily_rollups r
{rollup_where} {rollup_where}
GROUP BY model GROUP BY r.model
) )
GROUP BY model GROUP BY model
ORDER BY total_cost DESC" ORDER BY total_cost DESC"
); );
let mut stmt = conn.prepare(&sql)?; let mut stmt = conn.prepare(&sql)?;
let mut params: Vec<Box<dyn rusqlite::ToSql>> = detail_params;
params.extend(rollup_params);
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let row_mapper = |row: &rusqlite::Row| { let row_mapper = |row: &rusqlite::Row| {
let request_count: i64 = row.get(1)?; let request_count: i64 = row.get(1)?;
let total_cost: f64 = row.get(3)?; let total_cost: f64 = row.get(3)?;
@@ -597,11 +669,7 @@ impl Database {
}) })
}; };
let rows = if let Some(at) = app_type { let rows = stmt.query_map(param_refs.as_slice(), row_mapper)?;
stmt.query_map(params![at, at], row_mapper)?
} else {
stmt.query_map([], row_mapper)?
};
let mut stats = Vec::new(); let mut stats = Vec::new();
for row in rows { for row in rows {
@@ -1168,7 +1236,7 @@ mod tests {
)?; )?;
} }
let stats = db.get_model_stats(None)?; let stats = db.get_model_stats(None, None, None)?;
assert_eq!(stats.len(), 1); assert_eq!(stats.len(), 1);
assert_eq!(stats[0].model, "claude-3-sonnet"); assert_eq!(stats[0].model, "claude-3-sonnet");
assert_eq!(stats[0].request_count, 1); assert_eq!(stats[0].request_count, 1);
@@ -1176,6 +1244,73 @@ mod tests {
Ok(()) Ok(())
} }
#[test]
fn test_get_provider_stats_with_time_filter() -> Result<(), AppError> {
let db = Database::memory()?;
{
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model,
input_tokens, output_tokens, total_cost_usd,
latency_ms, status_code, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params!["old", "p1", "claude", "claude-3", 100, 50, "0.01", 100, 200, 1000],
)?;
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model,
input_tokens, output_tokens, total_cost_usd,
latency_ms, status_code, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params!["new", "p1", "claude", "claude-3", 200, 75, "0.02", 120, 200, 2000],
)?;
}
let stats = db.get_provider_stats(Some(1500), Some(2500), Some("claude"))?;
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].provider_id, "p1");
assert_eq!(stats[0].request_count, 1);
assert_eq!(stats[0].total_tokens, 275);
Ok(())
}
#[test]
fn test_get_daily_trends_respects_shorter_than_24_hours() -> Result<(), AppError> {
let db = Database::memory()?;
{
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model,
input_tokens, output_tokens, total_cost_usd,
latency_ms, status_code, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"req-short",
"p1",
"claude",
"claude-3",
100,
50,
"0.01",
100,
200,
10_800
],
)?;
}
let stats = db.get_daily_trends(Some(0), Some(15 * 60 * 60), Some("claude"))?;
assert_eq!(stats.len(), 15);
assert_eq!(stats[3].request_count, 1);
Ok(())
}
#[test] #[test]
fn test_model_pricing_matching() -> Result<(), AppError> { fn test_model_pricing_matching() -> Result<(), AppError> {
let db = Database::memory()?; let db = Database::memory()?;
+4 -1
View File
@@ -9,18 +9,21 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { useModelStats } from "@/lib/query/usage"; import { useModelStats } from "@/lib/query/usage";
import { fmtUsd } from "./format"; import { fmtUsd } from "./format";
import type { UsageRangeSelection } from "@/types/usage";
interface ModelStatsTableProps { interface ModelStatsTableProps {
range: UsageRangeSelection;
appType?: string; appType?: string;
refreshIntervalMs: number; refreshIntervalMs: number;
} }
export function ModelStatsTable({ export function ModelStatsTable({
range,
appType, appType,
refreshIntervalMs, refreshIntervalMs,
}: ModelStatsTableProps) { }: ModelStatsTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: stats, isLoading } = useModelStats(appType, { const { data: stats, isLoading } = useModelStats(range, appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
}); });
+4 -1
View File
@@ -9,18 +9,21 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { useProviderStats } from "@/lib/query/usage"; import { useProviderStats } from "@/lib/query/usage";
import { fmtUsd } from "./format"; import { fmtUsd } from "./format";
import type { UsageRangeSelection } from "@/types/usage";
interface ProviderStatsTableProps { interface ProviderStatsTableProps {
range: UsageRangeSelection;
appType?: string; appType?: string;
refreshIntervalMs: number; refreshIntervalMs: number;
} }
export function ProviderStatsTable({ export function ProviderStatsTable({
range,
appType, appType,
refreshIntervalMs, refreshIntervalMs,
}: ProviderStatsTableProps) { }: ProviderStatsTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: stats, isLoading } = useProviderStats(appType, { const { data: stats, isLoading } = useProviderStats(range, appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
}); });
+233 -432
View File
@@ -17,10 +17,9 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useRequestLogs, usageKeys } from "@/lib/query/usage"; import { useRequestLogs } from "@/lib/query/usage";
import { useQueryClient } from "@tanstack/react-query"; import type { LogFilters, UsageRangeSelection } from "@/types/usage";
import type { LogFilters } from "@/types/usage"; import { Calendar, ChevronLeft, ChevronRight, Search, X } from "lucide-react";
import { ChevronLeft, ChevronRight, RefreshCw, Search, X } from "lucide-react";
import { import {
fmtInt, fmtInt,
fmtUsd, fmtUsd,
@@ -29,51 +28,30 @@ import {
} from "./format"; } from "./format";
interface RequestLogTableProps { interface RequestLogTableProps {
range: UsageRangeSelection;
rangeLabel: string;
appType?: string; appType?: string;
refreshIntervalMs: number; refreshIntervalMs: number;
timeRange?: "1d" | "7d" | "30d";
} }
const ONE_DAY_SECONDS = 24 * 60 * 60;
const MAX_FIXED_RANGE_SECONDS = 30 * ONE_DAY_SECONDS;
const TIME_RANGE_SECONDS: Record<string, number> = {
"1d": ONE_DAY_SECONDS,
"7d": 7 * ONE_DAY_SECONDS,
"30d": 30 * ONE_DAY_SECONDS,
};
type TimeMode = "rolling" | "fixed";
export function RequestLogTable({ export function RequestLogTable({
range,
rangeLabel,
appType: dashboardAppType, appType: dashboardAppType,
refreshIntervalMs, refreshIntervalMs,
timeRange = "1d",
}: RequestLogTableProps) { }: RequestLogTableProps) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
const rollingWindowSeconds = TIME_RANGE_SECONDS[timeRange] ?? ONE_DAY_SECONDS;
const getRollingRange = () => {
const now = Math.floor(Date.now() / 1000);
return { startDate: now - rollingWindowSeconds, endDate: now };
};
const [appliedTimeMode, setAppliedTimeMode] = useState<TimeMode>("rolling");
const [draftTimeMode, setDraftTimeMode] = useState<TimeMode>("rolling");
const [appliedFilters, setAppliedFilters] = useState<LogFilters>({}); const [appliedFilters, setAppliedFilters] = useState<LogFilters>({});
const [draftFilters, setDraftFilters] = useState<LogFilters>({}); const [draftFilters, setDraftFilters] = useState<LogFilters>({});
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [pageInput, setPageInput] = useState(""); const [pageInput, setPageInput] = useState("");
const pageSize = 20; const pageSize = 20;
const [validationError, setValidationError] = useState<string | null>(null);
// Reset page when the dashboard time range changes // Reset page when the dashboard range changes
useEffect(() => { useEffect(() => {
setPage(0); setPage(0);
}, [timeRange]); }, [range.preset, range.customStartDate, range.customEndDate]);
// When dashboard-level app filter is active (not "all"), override the local appType filter // When dashboard-level app filter is active (not "all"), override the local appType filter
const dashboardAppTypeActive = dashboardAppType && dashboardAppType !== "all"; const dashboardAppTypeActive = dashboardAppType && dashboardAppType !== "all";
@@ -83,8 +61,7 @@ export function RequestLogTable({
const { data: result, isLoading } = useRequestLogs({ const { data: result, isLoading } = useRequestLogs({
filters: effectiveFilters, filters: effectiveFilters,
timeMode: appliedTimeMode, range,
rollingWindowSeconds,
page, page,
pageSize, pageSize,
options: { options: {
@@ -97,50 +74,11 @@ export function RequestLogTable({
const totalPages = Math.ceil(total / pageSize); const totalPages = Math.ceil(total / pageSize);
const handleSearch = () => { const handleSearch = () => {
setValidationError(null); setAppliedFilters(draftFilters);
if (draftTimeMode === "fixed") {
const start = draftFilters.startDate;
const end = draftFilters.endDate;
if (typeof start !== "number" || typeof end !== "number") {
setValidationError(
t("usage.invalidTimeRange", "请选择完整的开始/结束时间"),
);
return;
}
if (start > end) {
setValidationError(
t("usage.invalidTimeRangeOrder", "开始时间不能晚于结束时间"),
);
return;
}
if (end - start > MAX_FIXED_RANGE_SECONDS) {
setValidationError(
t("usage.timeRangeTooLarge", "时间范围过大,请缩小范围"),
);
return;
}
}
setAppliedTimeMode(draftTimeMode);
setAppliedFilters((prev) => {
const next = { ...prev, ...draftFilters };
if (draftTimeMode === "rolling") {
delete next.startDate;
delete next.endDate;
}
return next;
});
setPage(0); setPage(0);
}; };
const handleReset = () => { const handleReset = () => {
setValidationError(null);
setAppliedTimeMode("rolling");
setDraftTimeMode("rolling");
setDraftFilters({}); setDraftFilters({});
setAppliedFilters({}); setAppliedFilters({});
setPage(0); setPage(0);
@@ -155,73 +93,33 @@ export function RequestLogTable({
setPageInput(""); setPageInput("");
}; };
const handleRefresh = () => {
const key = {
timeMode: appliedTimeMode,
rollingWindowSeconds:
appliedTimeMode === "rolling" ? ONE_DAY_SECONDS : undefined,
appType: appliedFilters.appType,
providerName: appliedFilters.providerName,
model: appliedFilters.model,
statusCode: appliedFilters.statusCode,
startDate:
appliedTimeMode === "fixed" ? appliedFilters.startDate : undefined,
endDate: appliedTimeMode === "fixed" ? appliedFilters.endDate : undefined,
};
queryClient.invalidateQueries({
queryKey: usageKeys.logs(key, page, pageSize),
});
};
// 将 Unix 时间戳转换为本地时间的 datetime-local 格式
const timestampToLocalDatetime = (timestamp: number): string => {
const date = new Date(timestamp * 1000);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
// 将 datetime-local 格式转换为 Unix 时间戳
const localDatetimeToTimestamp = (datetime: string): number | undefined => {
if (!datetime) return undefined;
// 验证格式是否完整 (YYYY-MM-DDTHH:mm)
if (datetime.length < 16) return undefined;
const timestamp = new Date(datetime).getTime();
// 验证是否为有效日期
if (isNaN(timestamp)) return undefined;
return Math.floor(timestamp / 1000);
};
const language = i18n.resolvedLanguage || i18n.language || "en"; const language = i18n.resolvedLanguage || i18n.language || "en";
const locale = getLocaleFromLanguage(language); const locale = getLocaleFromLanguage(language);
const rollingRangeForDisplay =
draftTimeMode === "rolling" ? getRollingRange() : null;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* 筛选栏 */} <div className="rounded-lg border bg-card/50 p-2 backdrop-blur-sm">
<div className="flex flex-col gap-4 rounded-lg border bg-card/50 p-4 backdrop-blur-sm"> <div className="flex flex-wrap items-center gap-1.5">
<div className="flex flex-wrap items-center gap-3"> {/* App type */}
{/* App type */}
<Select <Select
value={ value={
dashboardAppTypeActive dashboardAppTypeActive
? dashboardAppType ? dashboardAppType
: draftFilters.appType || "all" : draftFilters.appType || "all"
} }
onValueChange={(v) => onValueChange={(v) => {
setDraftFilters({ const next = {
...draftFilters, ...draftFilters,
appType: v === "all" ? undefined : v, appType: v === "all" ? undefined : v,
}) };
} setDraftFilters(next);
setAppliedFilters(next);
setPage(0);
}}
disabled={!!dashboardAppTypeActive} disabled={!!dashboardAppTypeActive}
> >
<SelectTrigger className="w-[130px] bg-background"> <SelectTrigger className="h-8 w-[110px] bg-background text-xs">
<SelectValue placeholder={t("usage.appType")} /> <SelectValue placeholder={t("usage.appType")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -232,10 +130,11 @@ export function RequestLogTable({
</SelectContent> </SelectContent>
</Select> </Select>
{/* Status code */}
<Select <Select
value={draftFilters.statusCode?.toString() || "all"} value={draftFilters.statusCode?.toString() || "all"}
onValueChange={(v) => onValueChange={(v) => {
setDraftFilters({ const next = {
...draftFilters, ...draftFilters,
statusCode: statusCode:
v === "all" v === "all"
@@ -243,40 +142,49 @@ export function RequestLogTable({
: Number.isFinite(Number.parseInt(v, 10)) : Number.isFinite(Number.parseInt(v, 10))
? Number.parseInt(v, 10) ? Number.parseInt(v, 10)
: undefined, : undefined,
}) };
} setDraftFilters(next);
setAppliedFilters(next);
setPage(0);
}}
> >
<SelectTrigger className="w-[130px] bg-background"> <SelectTrigger className="h-8 w-[100px] bg-background text-xs">
<SelectValue placeholder={t("usage.statusCode")} /> <SelectValue placeholder={t("usage.statusCode")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">{t("common.all")}</SelectItem> <SelectItem value="all">{t("common.all")}</SelectItem>
<SelectItem value="200">200 OK</SelectItem> <SelectItem value="200">200 OK</SelectItem>
<SelectItem value="400">400 Bad Request</SelectItem> <SelectItem value="400">400</SelectItem>
<SelectItem value="401">401 Unauthorized</SelectItem> <SelectItem value="401">401</SelectItem>
<SelectItem value="429">429 Rate Limit</SelectItem> <SelectItem value="429">429</SelectItem>
<SelectItem value="500">500 Server Error</SelectItem> <SelectItem value="500">500</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<div className="flex items-center gap-2 flex-1 min-w-[300px]"> {/* Provider search */}
<div className="relative flex-1"> <div className="relative min-w-[140px] flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2 top-2 h-3.5 w-3.5 text-muted-foreground" />
<Input <Input
placeholder={t("usage.searchProviderPlaceholder")} placeholder={t("usage.searchProviderPlaceholder")}
className="pl-9 bg-background" className="h-8 bg-background pl-7 text-xs"
value={draftFilters.providerName || ""} value={draftFilters.providerName || ""}
onChange={(e) => onChange={(e) =>
setDraftFilters({ setDraftFilters({
...draftFilters, ...draftFilters,
providerName: e.target.value || undefined, providerName: e.target.value || undefined,
}) })
} }
/> onKeyDown={(e) => {
</div> if (e.key === "Enter") handleSearch();
}}
/>
</div>
{/* Model search */}
<div className="relative min-w-[120px] flex-1">
<Input <Input
placeholder={t("usage.searchModelPlaceholder")} placeholder={t("usage.searchModelPlaceholder")}
className="w-[180px] bg-background" className="h-8 bg-background text-xs"
value={draftFilters.model || ""} value={draftFilters.model || ""}
onChange={(e) => onChange={(e) =>
setDraftFilters({ setDraftFilters({
@@ -284,89 +192,40 @@ export function RequestLogTable({
model: e.target.value || undefined, model: e.target.value || undefined,
}) })
} }
/> onKeyDown={(e) => {
</div> if (e.key === "Enter") handleSearch();
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="whitespace-nowrap">{t("usage.timeRange")}:</span>
<Input
type="datetime-local"
className="h-8 w-[200px] bg-background"
value={
(rollingRangeForDisplay?.startDate ?? draftFilters.startDate)
? timestampToLocalDatetime(
(rollingRangeForDisplay?.startDate ??
draftFilters.startDate) as number,
)
: ""
}
onChange={(e) => {
const timestamp = localDatetimeToTimestamp(e.target.value);
setDraftTimeMode("fixed");
setDraftFilters({
...draftFilters,
startDate: timestamp,
});
}}
/>
<span>-</span>
<Input
type="datetime-local"
className="h-8 w-[200px] bg-background"
value={
(rollingRangeForDisplay?.endDate ?? draftFilters.endDate)
? timestampToLocalDatetime(
(rollingRangeForDisplay?.endDate ??
draftFilters.endDate) as number,
)
: ""
}
onChange={(e) => {
const timestamp = localDatetimeToTimestamp(e.target.value);
setDraftTimeMode("fixed");
setDraftFilters({
...draftFilters,
endDate: timestamp,
});
}} }}
/> />
</div> </div>
<div className="flex items-center gap-2 ml-auto"> {/* Time range badge */}
<Button <div className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border/60 bg-background px-2 text-xs text-muted-foreground">
size="sm" <Calendar className="h-3.5 w-3.5 shrink-0" />
variant="default" <span className="max-w-[180px] truncate text-foreground">
onClick={handleSearch} {rangeLabel}
className="h-8" </span>
>
<Search className="mr-2 h-3.5 w-3.5" />
{t("common.search")}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleReset}
className="h-8"
>
<X className="mr-2 h-3.5 w-3.5" />
{t("common.reset")}
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleRefresh}
className="h-8 px-2"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div> </div>
</div>
{validationError && ( {/* Search & Reset (icon-only) */}
<div className="text-sm text-red-600">{validationError}</div> <Button
)} size="icon"
variant="default"
onClick={handleSearch}
className="h-8 w-8"
title={t("common.search")}
>
<Search className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="outline"
onClick={handleReset}
className="h-8 w-8"
title={t("common.reset")}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div> </div>
{isLoading ? ( {isLoading ? (
@@ -377,40 +236,31 @@ export function RequestLogTable({
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="whitespace-nowrap"> <TableHead className="text-center whitespace-nowrap">
{t("usage.time")} {t("usage.time")}
</TableHead> </TableHead>
<TableHead className="whitespace-nowrap"> <TableHead className="text-center whitespace-nowrap">
{t("usage.provider")} {t("usage.provider")}
</TableHead> </TableHead>
<TableHead className="min-w-[200px] whitespace-nowrap"> <TableHead className="text-center whitespace-nowrap">
{t("usage.billingModel")} {t("usage.billingModel")}
</TableHead> </TableHead>
<TableHead className="text-right whitespace-nowrap"> <TableHead className="text-center whitespace-nowrap">
{t("usage.inputTokens")} {t("usage.inputTokens")}
</TableHead> </TableHead>
<TableHead className="text-right whitespace-nowrap"> <TableHead className="text-center whitespace-nowrap">
{t("usage.outputTokens")} {t("usage.outputTokens")}
</TableHead> </TableHead>
<TableHead className="text-right min-w-[90px] whitespace-nowrap"> <TableHead className="text-center whitespace-nowrap">
{t("usage.cacheReadTokens")}
</TableHead>
<TableHead className="text-right min-w-[90px] whitespace-nowrap">
{t("usage.cacheCreationTokens")}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("usage.multiplier")}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("usage.totalCost")} {t("usage.totalCost")}
</TableHead> </TableHead>
<TableHead className="text-center min-w-[140px] whitespace-nowrap"> <TableHead className="text-center whitespace-nowrap">
{t("usage.timingInfo")} {t("usage.timingInfo")}
</TableHead> </TableHead>
<TableHead className="whitespace-nowrap"> <TableHead className="text-center whitespace-nowrap">
{t("usage.status")} {t("usage.status")}
</TableHead> </TableHead>
<TableHead className="whitespace-nowrap"> <TableHead className="text-center whitespace-nowrap">
{t("usage.source", { defaultValue: "Source" })} {t("usage.source", { defaultValue: "Source" })}
</TableHead> </TableHead>
</TableRow> </TableRow>
@@ -419,7 +269,7 @@ export function RequestLogTable({
{logs.length === 0 ? ( {logs.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={12} colSpan={9}
className="text-center text-muted-foreground" className="text-center text-muted-foreground"
> >
{t("usage.noData")} {t("usage.noData")}
@@ -428,140 +278,96 @@ export function RequestLogTable({
) : ( ) : (
logs.map((log) => ( logs.map((log) => (
<TableRow key={log.requestId}> <TableRow key={log.requestId}>
<TableCell> <TableCell className="text-center whitespace-nowrap text-xs px-1.5">
{new Date(log.createdAt * 1000).toLocaleString(locale)} {new Date(log.createdAt * 1000).toLocaleString(locale, {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</TableCell> </TableCell>
<TableCell> <TableCell className="text-center">
{log.providerName || t("usage.unknownProvider")} {log.providerName || t("usage.unknownProvider")}
</TableCell> </TableCell>
<TableCell className="font-mono text-xs max-w-[200px]"> <TableCell className="text-center font-mono text-xs max-w-[200px]">
<div <div
className="truncate" className="truncate"
title={ title={
log.requestModel && log.requestModel !== log.model log.requestModel && log.requestModel !== log.model
? `${t("usage.requestModel")}: ${log.requestModel}\n${t("usage.responseModel")}: ${log.model}` ? `${log.requestModel} ${log.model}`
: log.model : log.model
} }
> >
{log.model} {log.requestModel &&
log.requestModel !== log.model ? (
<span>
{log.requestModel}
<span className="text-muted-foreground">
{" → "}
{log.model}
</span>
</span>
) : (
log.model
)}
</div> </div>
{log.requestModel && log.requestModel !== log.model && ( </TableCell>
<div <TableCell className="text-center px-1.5">
className="truncate text-muted-foreground text-[10px]" <div className="tabular-nums">
title={log.requestModel} {fmtInt(log.inputTokens, locale)}
> </div>
{log.requestModel} {(log.cacheReadTokens > 0 ||
log.cacheCreationTokens > 0) && (
<div className="text-[10px] text-muted-foreground whitespace-nowrap">
{[
log.cacheReadTokens > 0 &&
`R${fmtInt(log.cacheReadTokens, locale)}`,
log.cacheCreationTokens > 0 &&
`W${fmtInt(log.cacheCreationTokens, locale)}`,
]
.filter(Boolean)
.join("·")}
</div> </div>
)} )}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-center">
{fmtInt(log.inputTokens, locale)}
</TableCell>
<TableCell className="text-right">
{fmtInt(log.outputTokens, locale)} {fmtInt(log.outputTokens, locale)}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-center px-1.5">
{fmtInt(log.cacheReadTokens, locale)} <div className="font-medium tabular-nums">
{fmtUsd(log.totalCostUsd, 4)}
</div>
{parseFiniteNumber(log.costMultiplier) != null &&
parseFiniteNumber(log.costMultiplier) !== 1 && (
<div className="text-[11px] text-muted-foreground">
×
{parseFiniteNumber(log.costMultiplier)?.toFixed(
2,
)}
</div>
)}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-center whitespace-nowrap text-xs tabular-nums">
{fmtInt(log.cacheCreationTokens, locale)} {(log.latencyMs / 1000).toFixed(1)}s
</TableCell> {log.firstTokenMs != null && (
<TableCell className="text-right font-mono text-xs"> <span className="text-muted-foreground">
{(parseFiniteNumber(log.costMultiplier) ?? 1) !== 1 ? ( /{(log.firstTokenMs / 1000).toFixed(1)}s
<span className="text-orange-600">
×{log.costMultiplier}
</span> </span>
) : (
<span className="text-muted-foreground">×1</span>
)} )}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-center">
{fmtUsd(log.totalCostUsd, 6)}
</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1">
{(() => {
const durationMs =
typeof log.durationMs === "number"
? log.durationMs
: log.latencyMs;
const durationSec = durationMs / 1000;
const durationColor = Number.isFinite(durationSec)
? durationSec <= 5
? "bg-green-100 text-green-800"
: durationSec <= 120
? "bg-orange-100 text-orange-800"
: "bg-red-200 text-red-900"
: "bg-gray-100 text-gray-700";
return (
<span
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${durationColor}`}
>
{Number.isFinite(durationSec)
? `${Math.round(durationSec)}s`
: "--"}
</span>
);
})()}
{log.isStreaming &&
log.firstTokenMs != null &&
(() => {
const firstSec = log.firstTokenMs / 1000;
const firstColor = Number.isFinite(firstSec)
? firstSec <= 5
? "bg-green-100 text-green-800"
: firstSec <= 120
? "bg-orange-100 text-orange-800"
: "bg-red-200 text-red-900"
: "bg-gray-100 text-gray-700";
return (
<span
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${firstColor}`}
>
{Number.isFinite(firstSec)
? `${firstSec.toFixed(1)}s`
: "--"}
</span>
);
})()}
<span
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${
log.isStreaming
? "bg-blue-100 text-blue-800"
: "bg-purple-100 text-purple-800"
}`}
>
{log.isStreaming
? t("usage.stream")
: t("usage.nonStream")}
</span>
</div>
</TableCell>
<TableCell>
<span <span
className={`inline-flex rounded-full px-2 py-1 text-xs ${ className={
log.statusCode >= 200 && log.statusCode < 300 log.statusCode >= 200 && log.statusCode < 300
? "bg-green-100 text-green-800" ? "text-green-600"
: "bg-red-100 text-red-800" : "text-red-600"
}`} }
> >
{log.statusCode} {log.statusCode}
</span> </span>
</TableCell> </TableCell>
<TableCell> <TableCell className="text-center text-xs text-muted-foreground">
{log.dataSource && log.dataSource !== "proxy" ? ( {log.dataSource || "proxy"}
<span className="inline-flex rounded-full px-2 py-0.5 text-[10px] bg-indigo-100 text-indigo-800">
{t(`usage.dataSource.${log.dataSource}`, {
defaultValue: log.dataSource,
})}
</span>
) : (
<span className="inline-flex rounded-full px-2 py-0.5 text-[10px] bg-gray-100 text-gray-600">
{t("usage.dataSource.proxy", {
defaultValue: "Proxy",
})}
</span>
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
@@ -570,89 +376,84 @@ export function RequestLogTable({
</Table> </Table>
</div> </div>
{/* 分页控件 */} <div className="flex items-center justify-between text-sm text-muted-foreground">
{total > 0 && ( <span>{t("usage.totalRecords", { total })}</span>
<div className="flex items-center justify-between px-2"> <div className="flex items-center gap-1">
<span className="text-sm text-muted-foreground"> <Button
{t("usage.totalRecords", { total })} variant="outline"
</span> size="sm"
<div className="flex items-center gap-1"> onClick={() => setPage(Math.max(0, page - 1))}
<Button disabled={page === 0}
variant="outline" >
size="sm" <ChevronLeft className="h-4 w-4" />
onClick={() => setPage(Math.max(0, page - 1))} </Button>
disabled={page === 0} {(() => {
> const pages: (number | string)[] = [];
<ChevronLeft className="h-4 w-4" /> // 3 head + 3 tail + 3 neighborhood = 9 max distinct pages
</Button> if (totalPages <= 9) {
{(() => { for (let i = 0; i < totalPages; i++) pages.push(i);
const pages: (number | string)[] = []; } else {
// 3 head + 3 tail + 3 neighborhood = 9 max distinct pages const pageSet = new Set<number>();
if (totalPages <= 9) { for (let i = 0; i < 3; i++) pageSet.add(i);
for (let i = 0; i < totalPages; i++) pages.push(i); for (let i = totalPages - 3; i < totalPages; i++)
} else { pageSet.add(i);
const pageSet = new Set<number>(); for (
for (let i = 0; i < 3; i++) pageSet.add(i); let i = Math.max(0, page - 1);
for (let i = totalPages - 3; i < totalPages; i++) i <= Math.min(totalPages - 1, page + 1);
pageSet.add(i); i++
for ( )
let i = Math.max(0, page - 1); pageSet.add(i);
i <= Math.min(totalPages - 1, page + 1); const sorted = Array.from(pageSet).sort((a, b) => a - b);
i++ for (let i = 0; i < sorted.length; i++) {
) if (i > 0 && sorted[i] - sorted[i - 1] > 1) {
pageSet.add(i); pages.push(`ellipsis-${i}`);
const sorted = Array.from(pageSet).sort((a, b) => a - b);
for (let i = 0; i < sorted.length; i++) {
if (i > 0 && sorted[i] - sorted[i - 1] > 1) {
pages.push(`ellipsis-${i}`);
}
pages.push(sorted[i]);
} }
pages.push(sorted[i]);
} }
return pages.map((p) => }
typeof p === "string" ? ( return pages.map((p) =>
<span key={p} className="px-2 text-muted-foreground"> typeof p === "string" ? (
... <span key={p} className="px-2 text-muted-foreground">
</span> ...
) : ( </span>
<Button ) : (
key={p} <Button
variant={p === page ? "default" : "outline"} key={p}
size="sm" variant={p === page ? "default" : "outline"}
className="h-8 w-8 p-0" size="sm"
onClick={() => setPage(p)} className="h-8 w-8 p-0"
> onClick={() => setPage(p)}
{p + 1} >
</Button> {p + 1}
), </Button>
); ),
})()} );
<Button })()}
variant="outline" <Button
size="sm" variant="outline"
onClick={() => setPage(page + 1)} size="sm"
disabled={page >= totalPages - 1} onClick={() => setPage(page + 1)}
> disabled={page >= totalPages - 1}
<ChevronRight className="h-4 w-4" /> >
<ChevronRight className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1 ml-2">
<Input
type="text"
value={pageInput}
onChange={(e) => setPageInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleGoToPage();
}}
placeholder={t("usage.pageInputPlaceholder")}
className="h-8 w-16 text-center text-xs"
/>
<Button variant="outline" size="sm" onClick={handleGoToPage}>
{t("usage.goToPage")}
</Button> </Button>
<div className="flex items-center gap-1 ml-2">
<Input
type="text"
value={pageInput}
onChange={(e) => setPageInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleGoToPage();
}}
placeholder={t("usage.pageInputPlaceholder")}
className="h-8 w-16 text-center text-xs"
/>
<Button variant="outline" size="sm" onClick={handleGoToPage}>
{t("usage.goToPage")}
</Button>
</div>
</div> </div>
</div> </div>
)} </div>
</> </>
)} )}
</div> </div>
+132 -73
View File
@@ -1,12 +1,16 @@
import { useState } from "react"; import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { UsageSummaryCards } from "./UsageSummaryCards"; import { UsageSummaryCards } from "./UsageSummaryCards";
import { UsageTrendChart } from "./UsageTrendChart"; import { UsageTrendChart } from "./UsageTrendChart";
import { RequestLogTable } from "./RequestLogTable"; import { RequestLogTable } from "./RequestLogTable";
import { ProviderStatsTable } from "./ProviderStatsTable"; import { ProviderStatsTable } from "./ProviderStatsTable";
import { ModelStatsTable } from "./ModelStatsTable"; import { ModelStatsTable } from "./ModelStatsTable";
import type { AppTypeFilter, TimeRange } from "@/types/usage"; import type {
AppTypeFilter,
UsageRangePreset,
UsageRangeSelection,
} from "@/types/usage";
import { useUsageSummary } from "@/lib/query/usage";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { import {
BarChart3, BarChart3,
@@ -26,6 +30,10 @@ import {
} from "@/components/ui/accordion"; } from "@/components/ui/accordion";
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel"; import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { fmtUsd, getLocaleFromLanguage, parseFiniteNumber } from "./format";
import { resolveUsageRange } from "@/lib/usageRange";
import { UsageDateRangePicker } from "./UsageDateRangePicker";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const APP_FILTER_OPTIONS: AppTypeFilter[] = [ const APP_FILTER_OPTIONS: AppTypeFilter[] = [
"all", "all",
@@ -34,10 +42,32 @@ const APP_FILTER_OPTIONS: AppTypeFilter[] = [
"gemini", "gemini",
]; ];
const RANGE_PRESETS: UsageRangePreset[] = ["today", "1d", "7d", "14d", "30d"];
function getPresetLabel(
preset: UsageRangePreset,
t: (key: string, options?: { defaultValue?: string }) => string,
): string {
switch (preset) {
case "today":
return t("usage.presetToday", { defaultValue: "当天" });
case "1d":
return t("usage.preset1d", { defaultValue: "1d" });
case "7d":
return t("usage.preset7d", { defaultValue: "7d" });
case "14d":
return t("usage.preset14d", { defaultValue: "14d" });
case "30d":
return t("usage.preset30d", { defaultValue: "30d" });
case "custom":
return t("usage.customRange", { defaultValue: "日历筛选" });
}
}
export function UsageDashboard() { export function UsageDashboard() {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [timeRange, setTimeRange] = useState<TimeRange>("1d"); const [range, setRange] = useState<UsageRangeSelection>({ preset: "today" });
const [appType, setAppType] = useState<AppTypeFilter>("all"); const [appType, setAppType] = useState<AppTypeFilter>("all");
const [refreshIntervalMs, setRefreshIntervalMs] = useState(30000); const [refreshIntervalMs, setRefreshIntervalMs] = useState(30000);
@@ -46,14 +76,29 @@ export function UsageDashboard() {
const currentIndex = refreshIntervalOptionsMs.indexOf( const currentIndex = refreshIntervalOptionsMs.indexOf(
refreshIntervalMs as (typeof refreshIntervalOptionsMs)[number], refreshIntervalMs as (typeof refreshIntervalOptionsMs)[number],
); );
const safeIndex = currentIndex >= 0 ? currentIndex : 3; // default 30s const safeIndex = currentIndex >= 0 ? currentIndex : 3;
const nextIndex = (safeIndex + 1) % refreshIntervalOptionsMs.length; const nextIndex = (safeIndex + 1) % refreshIntervalOptionsMs.length;
const next = refreshIntervalOptionsMs[nextIndex]; const next = refreshIntervalOptionsMs[nextIndex];
setRefreshIntervalMs(next); setRefreshIntervalMs(next);
queryClient.invalidateQueries({ queryKey: usageKeys.all }); queryClient.invalidateQueries({ queryKey: usageKeys.all });
}; };
const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30; const language = i18n.resolvedLanguage || i18n.language || "en";
const locale = getLocaleFromLanguage(language);
const resolvedRange = useMemo(() => resolveUsageRange(range), [range]);
const rangeLabel = useMemo(() => {
if (range.preset !== "custom") {
return getPresetLabel(range.preset, t);
}
return `${new Date(resolvedRange.startDate * 1000).toLocaleString(locale)} - ${new Date(
resolvedRange.endDate * 1000,
).toLocaleString(locale)}`;
}, [locale, range, resolvedRange.endDate, resolvedRange.startDate, t]);
const { data: summaryData } = useUsageSummary(range, appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
return ( return (
<motion.div <motion.div
@@ -62,82 +107,94 @@ export function UsageDashboard() {
transition={{ duration: 0.4 }} transition={{ duration: 0.4 }}
className="space-y-8 pb-8" className="space-y-8 pb-8"
> >
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<h2 className="text-2xl font-bold">{t("usage.title")}</h2> <div className="flex flex-col gap-1">
<p className="text-sm text-muted-foreground">{t("usage.subtitle")}</p> <h2 className="text-2xl font-bold">{t("usage.title")}</h2>
<p className="text-sm text-muted-foreground">
{t("usage.subtitle")}
</p>
</div>
</div> </div>
<Tabs <div className="rounded-xl border border-border/50 bg-card/40 backdrop-blur-sm p-4 space-y-4">
value={timeRange} <div className="flex flex-wrap items-center gap-3">
onValueChange={(v) => setTimeRange(v as TimeRange)} <div className="flex flex-wrap items-center gap-1.5">
className="w-full sm:w-auto" {APP_FILTER_OPTIONS.map((type) => (
> <button
<div className="flex w-full sm:w-auto items-center gap-1"> key={type}
<Button type="button"
type="button" onClick={() => setAppType(type)}
variant="ghost" className={cn(
size="sm" "px-4 py-1.5 rounded-lg text-sm font-medium transition-all",
className="h-10 px-2 text-xs text-muted-foreground" appType === type
title={t("common.refresh", "刷新")} ? "bg-primary/10 text-primary shadow-sm border border-primary/20"
onClick={changeRefreshInterval} : "text-muted-foreground hover:text-primary hover:bg-muted/50 border border-transparent",
> )}
<RefreshCw className="mr-1 h-3.5 w-3.5" /> >
{refreshIntervalMs > 0 ? `${refreshIntervalMs / 1000}s` : "--"} {t(`usage.appFilter.${type}`)}
</Button> </button>
<TabsList className="flex w-full sm:w-auto bg-card/60 border border-border/50 backdrop-blur-sm shadow-sm h-10 p-1"> ))}
<TabsTrigger </div>
value="1d"
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
>
{t("usage.today")}
</TabsTrigger>
<TabsTrigger
value="7d"
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
>
{t("usage.last7days")}
</TabsTrigger>
<TabsTrigger
value="30d"
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
>
{t("usage.last30days")}
</TabsTrigger>
</TabsList>
</div>
</Tabs>
</div>
{/* App type filter bar (replaces DataSourceBar) */} <div className="ml-auto flex flex-wrap items-center justify-end gap-2">
<div className="rounded-xl border border-border/50 bg-card/40 backdrop-blur-sm p-4 space-y-3"> <Button
<div className="flex flex-wrap items-center gap-1.5"> type="button"
{APP_FILTER_OPTIONS.map((type) => ( variant="ghost"
<button size="sm"
key={type} className="h-9 px-2 text-xs text-muted-foreground"
type="button" title={t("common.refresh", "刷新")}
onClick={() => setAppType(type)} onClick={changeRefreshInterval}
className={cn( >
"px-4 py-1.5 rounded-lg text-sm font-medium transition-all", <RefreshCw className="mr-1 h-3.5 w-3.5" />
appType === type {refreshIntervalMs > 0 ? `${refreshIntervalMs / 1000}s` : "--"}
? "bg-primary/10 text-primary shadow-sm border border-primary/20" </Button>
: "text-muted-foreground hover:text-primary hover:bg-muted/50 border border-transparent",
)} {RANGE_PRESETS.map((preset) => (
> <Button
{t(`usage.appFilter.${type}`)} key={preset}
</button> type="button"
))} size="sm"
variant={range.preset === preset ? "default" : "outline"}
onClick={() => setRange({ preset })}
>
{getPresetLabel(preset, t)}
</Button>
))}
<UsageDateRangePicker
selection={range}
triggerLabel={rangeLabel}
onApply={(nextRange) => setRange(nextRange)}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<span className="font-medium text-foreground">{rangeLabel}</span>
<span className="text-border">|</span>
<span>
{(summaryData?.totalRequests ?? 0).toLocaleString()}{" "}
{t("usage.requestsLabel")}
</span>
<span className="text-border">|</span>
<span>
{fmtUsd(parseFiniteNumber(summaryData?.totalCost) ?? 0, 4)}{" "}
{t("usage.costLabel")}
</span>
</div>
</div> </div>
</div> </div>
<UsageSummaryCards <UsageSummaryCards
days={days} range={range}
appType={appType} appType={appType}
refreshIntervalMs={refreshIntervalMs} refreshIntervalMs={refreshIntervalMs}
/> />
<UsageTrendChart <UsageTrendChart
days={days} range={range}
rangeLabel={rangeLabel}
appType={appType} appType={appType}
refreshIntervalMs={refreshIntervalMs} refreshIntervalMs={refreshIntervalMs}
/> />
@@ -168,14 +225,16 @@ export function UsageDashboard() {
> >
<TabsContent value="logs" className="mt-0"> <TabsContent value="logs" className="mt-0">
<RequestLogTable <RequestLogTable
range={range}
rangeLabel={rangeLabel}
appType={appType} appType={appType}
refreshIntervalMs={refreshIntervalMs} refreshIntervalMs={refreshIntervalMs}
timeRange={timeRange}
/> />
</TabsContent> </TabsContent>
<TabsContent value="providers" className="mt-0"> <TabsContent value="providers" className="mt-0">
<ProviderStatsTable <ProviderStatsTable
range={range}
appType={appType} appType={appType}
refreshIntervalMs={refreshIntervalMs} refreshIntervalMs={refreshIntervalMs}
/> />
@@ -183,6 +242,7 @@ export function UsageDashboard() {
<TabsContent value="models" className="mt-0"> <TabsContent value="models" className="mt-0">
<ModelStatsTable <ModelStatsTable
range={range}
appType={appType} appType={appType}
refreshIntervalMs={refreshIntervalMs} refreshIntervalMs={refreshIntervalMs}
/> />
@@ -191,7 +251,6 @@ export function UsageDashboard() {
</Tabs> </Tabs>
</div> </div>
{/* Pricing Configuration */}
<Accordion type="multiple" defaultValue={[]} className="w-full space-y-4"> <Accordion type="multiple" defaultValue={[]} className="w-full space-y-4">
<AccordionItem <AccordionItem
value="pricing" value="pricing"
@@ -0,0 +1,415 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { resolveUsageRange } from "@/lib/usageRange";
import { getLocaleFromLanguage } from "./format";
import type { UsageRangeSelection } from "@/types/usage";
type DraftField = "start" | "end";
interface UsageDateRangePickerProps {
selection: UsageRangeSelection;
onApply: (selection: UsageRangeSelection) => void;
triggerLabel: string;
}
/* ── helpers ── */
function startOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
function toTs(d: Date): number {
return Math.floor(d.getTime() / 1000);
}
function fromTs(ts: number): Date {
return new Date(ts * 1000);
}
function fmtDate(ts: number): string {
const d = fromTs(ts);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function fmtTime(ts: number): string {
const d = fromTs(ts);
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
function parseDateInput(ts: number, value: string): number {
const [y, m, d] = value.split("-").map(Number);
if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d))
return ts;
const base = fromTs(ts);
return toTs(new Date(y, m - 1, d, base.getHours(), base.getMinutes()));
}
function parseTimeInput(ts: number, value: string): number {
const [h, min] = value.split(":").map(Number);
if (!Number.isFinite(h) || !Number.isFinite(min)) return ts;
const base = fromTs(ts);
return toTs(
new Date(base.getFullYear(), base.getMonth(), base.getDate(), h, min),
);
}
function setDateKeepTime(ts: number, day: Date): number {
const base = fromTs(ts);
return toTs(
new Date(
day.getFullYear(),
day.getMonth(),
day.getDate(),
base.getHours(),
base.getMinutes(),
),
);
}
function getCalendarDays(month: Date): Date[] {
const first = new Date(month.getFullYear(), month.getMonth(), 1);
const gridStart = new Date(first);
gridStart.setDate(first.getDate() - first.getDay());
return Array.from({ length: 42 }, (_, i) => {
const d = new Date(gridStart);
d.setDate(gridStart.getDate() + i);
return d;
});
}
/* ── component ── */
export function UsageDateRangePicker({
selection,
onApply,
triggerLabel,
}: UsageDateRangePickerProps) {
const { t, i18n } = useTranslation();
const [open, setOpen] = useState(false);
const [activeField, setActiveField] = useState<DraftField>("start");
const resolvedRange = useMemo(
() => resolveUsageRange(selection),
[selection],
);
const [draftStart, setDraftStart] = useState(resolvedRange.startDate);
const [draftEnd, setDraftEnd] = useState(resolvedRange.endDate);
const [displayMonth, setDisplayMonth] = useState(
() =>
new Date(
fromTs(resolvedRange.startDate).getFullYear(),
fromTs(resolvedRange.startDate).getMonth(),
1,
),
);
const [error, setError] = useState<string | null>(null);
const language = i18n.resolvedLanguage || i18n.language || "en";
const locale = getLocaleFromLanguage(language);
// Reset draft when popover opens
useEffect(() => {
if (!open) return;
const r = resolveUsageRange(selection);
setDraftStart(r.startDate);
setDraftEnd(r.endDate);
setDisplayMonth(
new Date(
fromTs(r.startDate).getFullYear(),
fromTs(r.startDate).getMonth(),
1,
),
);
setActiveField("start");
setError(null);
}, [open, selection]);
const calendarDays = useMemo(
() => getCalendarDays(displayMonth),
[displayMonth],
);
const weekdayLabels = useMemo(
() =>
Array.from({ length: 7 }, (_, i) =>
new Intl.DateTimeFormat(locale, { weekday: "narrow" }).format(
new Date(2024, 0, 7 + i),
),
),
[locale],
);
const startDay = fromTs(draftStart);
const endDay = fromTs(draftEnd);
const today = new Date();
/* Pick a date from the calendar */
const handleDatePick = (day: Date) => {
setError(null);
const nextTs = setDateKeepTime(
activeField === "start" ? draftStart : draftEnd,
day,
);
if (activeField === "start") {
setDraftStart(nextTs);
// Auto-swap if start > end
if (nextTs > draftEnd) {
setDraftEnd(nextTs);
}
// Auto-advance to end field
setActiveField("end");
} else {
// If picked end < start, treat as new start and auto-advance
if (nextTs < draftStart) {
setDraftStart(nextTs);
setActiveField("end");
} else {
setDraftEnd(nextTs);
}
}
// Navigate calendar if the day is outside the displayed month
if (
day.getMonth() !== displayMonth.getMonth() ||
day.getFullYear() !== displayMonth.getFullYear()
) {
setDisplayMonth(new Date(day.getFullYear(), day.getMonth(), 1));
}
};
const handleApply = () => {
setError(null);
if (draftStart > draftEnd) {
setError(t("usage.invalidTimeRangeOrder", "开始时间不能晚于结束时间"));
return;
}
onApply({
preset: "custom",
customStartDate: draftStart,
customEndDate: draftEnd,
});
setOpen(false);
};
const goToToday = () => {
setDisplayMonth(new Date(today.getFullYear(), today.getMonth(), 1));
};
/* ── Field card (start / end) ── */
const renderField = (field: DraftField) => {
const isActive = activeField === field;
const ts = field === "start" ? draftStart : draftEnd;
const setTs = field === "start" ? setDraftStart : setDraftEnd;
const label =
field === "start"
? t("usage.startTime", "开始时间")
: t("usage.endTime", "结束时间");
return (
<div
className={cn(
"rounded-lg border px-3 py-2 cursor-pointer transition-all",
isActive
? "border-primary ring-1 ring-primary/30 bg-primary/5"
: "border-border/50 hover:border-border",
)}
onClick={() => setActiveField(field)}
>
<div className="mb-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{label}
</div>
<div className="flex items-center gap-1.5">
<Input
type="date"
className="h-7 flex-1 border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0"
value={fmtDate(ts)}
onChange={(e) => {
const next = parseDateInput(ts, e.target.value);
setTs(next);
const d = fromTs(next);
setDisplayMonth(new Date(d.getFullYear(), d.getMonth(), 1));
setError(null);
}}
onFocus={() => setActiveField(field)}
/>
<Input
type="time"
step={60}
className="h-7 w-[90px] flex-none border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0"
value={fmtTime(ts)}
onChange={(e) => {
setTs(parseTimeInput(ts, e.target.value));
setError(null);
}}
onFocus={() => setActiveField(field)}
/>
</div>
</div>
);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant={selection.preset === "custom" ? "default" : "outline"}
className="justify-start gap-2"
>
<CalendarDays className="h-4 w-4" />
<span className="truncate">{triggerLabel}</span>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[340px] max-w-[calc(100vw-2rem)] p-3 sm:w-[620px]"
align="end"
>
<div className="flex flex-col gap-3 sm:flex-row">
{/* Left: date fields */}
<div className="space-y-2 sm:w-[250px] sm:flex-none">
<p className="text-xs text-muted-foreground">
{t("usage.customRangeHint", "支持日期与时间,最长 30 天")}
</p>
{renderField("start")}
{renderField("end")}
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex gap-2 pt-1">
<Button
type="button"
variant="ghost"
size="sm"
className="flex-1"
onClick={() => setOpen(false)}
>
{t("common.cancel")}
</Button>
<Button
type="button"
size="sm"
className="flex-1"
onClick={handleApply}
>
{t("common.confirm")}
</Button>
</div>
</div>
{/* Right: calendar */}
<div className="rounded-lg border border-border/50 bg-muted/30 p-2.5 sm:min-w-0 sm:flex-1">
{/* Month navigation */}
<div className="flex items-center justify-between mb-1.5">
<Button
type="button"
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() =>
setDisplayMonth(
new Date(
displayMonth.getFullYear(),
displayMonth.getMonth() - 1,
1,
),
)
}
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<button
type="button"
className="text-sm font-medium hover:text-primary transition-colors"
onClick={goToToday}
title={t("usage.presetToday", { defaultValue: "当天" })}
>
{displayMonth.toLocaleDateString(locale, {
year: "numeric",
month: "long",
})}
</button>
<Button
type="button"
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() =>
setDisplayMonth(
new Date(
displayMonth.getFullYear(),
displayMonth.getMonth() + 1,
1,
),
)
}
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
{/* Weekday headers */}
<div className="grid grid-cols-7 text-center text-[11px] text-muted-foreground mb-0.5">
{weekdayLabels.map((label, i) => (
<div key={i} className="py-0.5">
{label}
</div>
))}
</div>
{/* Day grid */}
<div className="grid grid-cols-7 gap-px">
{calendarDays.map((day) => {
const isCurrentMonth =
day.getMonth() === displayMonth.getMonth();
const isToday = isSameDay(day, today);
const isStart = isSameDay(day, startDay);
const isEnd = isSameDay(day, endDay);
const dayStart = startOfDay(day);
const inRange =
dayStart >= startOfDay(startDay) &&
dayStart <= startOfDay(endDay);
const isEndpoint = isStart || isEnd;
return (
<button
key={day.toISOString()}
type="button"
className={cn(
"relative h-7 rounded text-xs transition-colors",
!isCurrentMonth && "text-muted-foreground/30",
isCurrentMonth && !inRange && "hover:bg-muted",
inRange && !isEndpoint && "bg-primary/10 text-primary",
isEndpoint &&
"bg-primary text-primary-foreground font-medium",
isToday && !isEndpoint && "ring-1 ring-primary/40",
)}
onClick={() => handleDatePick(day)}
>
{day.getDate()}
</button>
);
})}
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
}
+4 -3
View File
@@ -5,21 +5,22 @@ import { useUsageSummary } from "@/lib/query/usage";
import { Activity, DollarSign, Layers, Database, Loader2 } from "lucide-react"; import { Activity, DollarSign, Layers, Database, Loader2 } from "lucide-react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { fmtUsd, parseFiniteNumber } from "./format"; import { fmtUsd, parseFiniteNumber } from "./format";
import type { UsageRangeSelection } from "@/types/usage";
interface UsageSummaryCardsProps { interface UsageSummaryCardsProps {
days: number; range: UsageRangeSelection;
appType?: string; appType?: string;
refreshIntervalMs: number; refreshIntervalMs: number;
} }
export function UsageSummaryCards({ export function UsageSummaryCards({
days, range,
appType, appType,
refreshIntervalMs, refreshIntervalMs,
}: UsageSummaryCardsProps) { }: UsageSummaryCardsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: summary, isLoading } = useUsageSummary(days, appType, { const { data: summary, isLoading } = useUsageSummary(range, appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
}); });
+12 -12
View File
@@ -17,20 +17,25 @@ import {
getLocaleFromLanguage, getLocaleFromLanguage,
parseFiniteNumber, parseFiniteNumber,
} from "./format"; } from "./format";
import { resolveUsageRange } from "@/lib/usageRange";
import type { UsageRangeSelection } from "@/types/usage";
interface UsageTrendChartProps { interface UsageTrendChartProps {
days: number; range: UsageRangeSelection;
rangeLabel: string;
appType?: string; appType?: string;
refreshIntervalMs: number; refreshIntervalMs: number;
} }
export function UsageTrendChart({ export function UsageTrendChart({
days, range,
rangeLabel,
appType, appType,
refreshIntervalMs, refreshIntervalMs,
}: UsageTrendChartProps) { }: UsageTrendChartProps) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { data: trends, isLoading } = useUsageTrends(days, appType, { const { startDate, endDate } = resolveUsageRange(range);
const { data: trends, isLoading } = useUsageTrends(range, appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
}); });
@@ -42,7 +47,8 @@ export function UsageTrendChart({
); );
} }
const isToday = days === 1; const durationSeconds = Math.max(endDate - startDate, 0);
const isHourly = durationSeconds <= 24 * 60 * 60;
const language = i18n.resolvedLanguage || i18n.language || "en"; const language = i18n.resolvedLanguage || i18n.language || "en";
const dateLocale = getLocaleFromLanguage(language); const dateLocale = getLocaleFromLanguage(language);
const chartData = const chartData =
@@ -51,7 +57,7 @@ export function UsageTrendChart({
const cost = parseFiniteNumber(stat.totalCost); const cost = parseFiniteNumber(stat.totalCost);
return { return {
rawDate: stat.date, rawDate: stat.date,
label: isToday label: isHourly
? pointDate.toLocaleString(dateLocale, { ? pointDate.toLocaleString(dateLocale, {
month: "2-digit", month: "2-digit",
day: "2-digit", day: "2-digit",
@@ -108,13 +114,7 @@ export function UsageTrendChart({
<h3 className="text-lg font-semibold"> <h3 className="text-lg font-semibold">
{t("usage.trends", "使用趋势")} {t("usage.trends", "使用趋势")}
</h3> </h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">{rangeLabel}</p>
{isToday
? t("usage.rangeToday", "今天 (按小时)")
: days === 7
? t("usage.rangeLast7Days", "过去 7 天")
: t("usage.rangeLast30Days", "过去 30 天")}
</p>
</div> </div>
<div className="h-[350px] w-full"> <div className="h-[350px] w-full">
+11
View File
@@ -1048,6 +1048,11 @@
"today": "24 Hours", "today": "24 Hours",
"last7days": "7 Days", "last7days": "7 Days",
"last30days": "30 Days", "last30days": "30 Days",
"presetToday": "Today",
"preset1d": "1d",
"preset7d": "7d",
"preset14d": "14d",
"preset30d": "30d",
"totalRequests": "Total Requests", "totalRequests": "Total Requests",
"totalCost": "Total Cost", "totalCost": "Total Cost",
"cost": "Cost", "cost": "Cost",
@@ -1068,6 +1073,8 @@
"outputTokens": "Output", "outputTokens": "Output",
"cacheReadTokens": "Cache Hit", "cacheReadTokens": "Cache Hit",
"cacheCreationTokens": "Cache Creation", "cacheCreationTokens": "Cache Creation",
"cacheReadShort": "Cache Read",
"cacheWriteShort": "Write",
"timingInfo": "Duration/TTFT", "timingInfo": "Duration/TTFT",
"status": "Status", "status": "Status",
"multiplier": "Multiplier", "multiplier": "Multiplier",
@@ -1139,6 +1146,10 @@
"searchProviderPlaceholder": "Search provider...", "searchProviderPlaceholder": "Search provider...",
"searchModelPlaceholder": "Search model...", "searchModelPlaceholder": "Search model...",
"timeRange": "Time Range", "timeRange": "Time Range",
"customRange": "Calendar Filter",
"customRangeHint": "Supports both date and time, up to 30 days",
"startTime": "Start Time",
"endTime": "End Time",
"input": "Input", "input": "Input",
"output": "Output", "output": "Output",
"cacheWrite": "Creation", "cacheWrite": "Creation",
+11
View File
@@ -1048,6 +1048,11 @@
"today": "24時間", "today": "24時間",
"last7days": "7日間", "last7days": "7日間",
"last30days": "30日間", "last30days": "30日間",
"presetToday": "当日",
"preset1d": "1d",
"preset7d": "7d",
"preset14d": "14d",
"preset30d": "30d",
"totalRequests": "総リクエスト数", "totalRequests": "総リクエスト数",
"totalCost": "総コスト", "totalCost": "総コスト",
"cost": "コスト", "cost": "コスト",
@@ -1068,6 +1073,8 @@
"outputTokens": "出力", "outputTokens": "出力",
"cacheReadTokens": "キャッシュヒット", "cacheReadTokens": "キャッシュヒット",
"cacheCreationTokens": "キャッシュ作成", "cacheCreationTokens": "キャッシュ作成",
"cacheReadShort": "キャッシュ読",
"cacheWriteShort": "書",
"timingInfo": "応答時間/TTFT", "timingInfo": "応答時間/TTFT",
"status": "ステータス", "status": "ステータス",
"multiplier": "倍率", "multiplier": "倍率",
@@ -1139,6 +1146,10 @@
"searchProviderPlaceholder": "プロバイダーを検索...", "searchProviderPlaceholder": "プロバイダーを検索...",
"searchModelPlaceholder": "モデルを検索...", "searchModelPlaceholder": "モデルを検索...",
"timeRange": "期間", "timeRange": "期間",
"customRange": "カレンダーフィルター",
"customRangeHint": "日付と時刻の両方に対応、最大 30 日",
"startTime": "開始時刻",
"endTime": "終了時刻",
"input": "Input", "input": "Input",
"output": "Output", "output": "Output",
"cacheWrite": "作成", "cacheWrite": "作成",
+11
View File
@@ -1049,6 +1049,11 @@
"today": "24小时", "today": "24小时",
"last7days": "7天", "last7days": "7天",
"last30days": "30天", "last30days": "30天",
"presetToday": "当天",
"preset1d": "1d",
"preset7d": "7d",
"preset14d": "14d",
"preset30d": "30d",
"totalRequests": "总请求数", "totalRequests": "总请求数",
"totalCost": "总成本", "totalCost": "总成本",
"cost": "成本", "cost": "成本",
@@ -1069,6 +1074,8 @@
"outputTokens": "输出", "outputTokens": "输出",
"cacheReadTokens": "缓存命中", "cacheReadTokens": "缓存命中",
"cacheCreationTokens": "缓存创建", "cacheCreationTokens": "缓存创建",
"cacheReadShort": "缓存读",
"cacheWriteShort": "写",
"timingInfo": "用时/首字", "timingInfo": "用时/首字",
"status": "状态", "status": "状态",
"multiplier": "倍率", "multiplier": "倍率",
@@ -1140,6 +1147,10 @@
"searchProviderPlaceholder": "搜索供应商...", "searchProviderPlaceholder": "搜索供应商...",
"searchModelPlaceholder": "搜索模型...", "searchModelPlaceholder": "搜索模型...",
"timeRange": "时间范围", "timeRange": "时间范围",
"customRange": "日历筛选",
"customRangeHint": "支持日期与时间,最长 30 天",
"startTime": "开始时间",
"endTime": "结束时间",
"input": "Input", "input": "Input",
"output": "Output", "output": "Output",
"cacheWrite": "创建", "cacheWrite": "创建",
+12 -4
View File
@@ -63,12 +63,20 @@ export const usageApi = {
return invoke("get_usage_trends", { startDate, endDate, appType }); return invoke("get_usage_trends", { startDate, endDate, appType });
}, },
getProviderStats: async (appType?: string): Promise<ProviderStats[]> => { getProviderStats: async (
return invoke("get_provider_stats", { appType }); startDate?: number,
endDate?: number,
appType?: string,
): Promise<ProviderStats[]> => {
return invoke("get_provider_stats", { startDate, endDate, appType });
}, },
getModelStats: async (appType?: string): Promise<ModelStats[]> => { getModelStats: async (
return invoke("get_model_stats", { appType }); startDate?: number,
endDate?: number,
appType?: string,
): Promise<ModelStats[]> => {
return invoke("get_model_stats", { startDate, endDate, appType });
}, },
getRequestLogs: async ( getRequestLogs: async (
+112 -55
View File
@@ -1,6 +1,7 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { usageApi } from "@/lib/api/usage"; import { usageApi } from "@/lib/api/usage";
import type { LogFilters } from "@/types/usage"; import { resolveUsageRange } from "@/lib/usageRange";
import type { LogFilters, UsageRangeSelection } from "@/types/usage";
const DEFAULT_REFETCH_INTERVAL_MS = 30000; const DEFAULT_REFETCH_INTERVAL_MS = 30000;
@@ -9,51 +10,94 @@ type UsageQueryOptions = {
refetchIntervalInBackground?: boolean; refetchIntervalInBackground?: boolean;
}; };
type RequestLogsTimeMode = "rolling" | "fixed";
type RequestLogsQueryArgs = { type RequestLogsQueryArgs = {
filters: LogFilters; filters: LogFilters;
timeMode: RequestLogsTimeMode; range: UsageRangeSelection;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
rollingWindowSeconds?: number;
options?: UsageQueryOptions; options?: UsageQueryOptions;
}; };
type RequestLogsKey = { type RequestLogsKey = {
timeMode: RequestLogsTimeMode; preset: UsageRangeSelection["preset"];
rollingWindowSeconds?: number; customStartDate?: number;
customEndDate?: number;
appType?: string; appType?: string;
providerName?: string; providerName?: string;
model?: string; model?: string;
statusCode?: number; statusCode?: number;
startDate?: number;
endDate?: number;
}; };
// Query keys // Query keys
export const usageKeys = { export const usageKeys = {
all: ["usage"] as const, all: ["usage"] as const,
summary: (days: number, appType?: string) => summary: (
[...usageKeys.all, "summary", days, appType ?? "all"] as const, preset: UsageRangeSelection["preset"],
trends: (days: number, appType?: string) => customStartDate: number | undefined,
[...usageKeys.all, "trends", days, appType ?? "all"] as const, customEndDate: number | undefined,
providerStats: (appType?: string) => appType?: string,
[...usageKeys.all, "provider-stats", appType ?? "all"] as const, ) =>
modelStats: (appType?: string) => [
[...usageKeys.all, "model-stats", appType ?? "all"] as const, ...usageKeys.all,
"summary",
preset,
customStartDate ?? 0,
customEndDate ?? 0,
appType ?? "all",
] as const,
trends: (
preset: UsageRangeSelection["preset"],
customStartDate: number | undefined,
customEndDate: number | undefined,
appType?: string,
) =>
[
...usageKeys.all,
"trends",
preset,
customStartDate ?? 0,
customEndDate ?? 0,
appType ?? "all",
] as const,
providerStats: (
preset: UsageRangeSelection["preset"],
customStartDate: number | undefined,
customEndDate: number | undefined,
appType?: string,
) =>
[
...usageKeys.all,
"provider-stats",
preset,
customStartDate ?? 0,
customEndDate ?? 0,
appType ?? "all",
] as const,
modelStats: (
preset: UsageRangeSelection["preset"],
customStartDate: number | undefined,
customEndDate: number | undefined,
appType?: string,
) =>
[
...usageKeys.all,
"model-stats",
preset,
customStartDate ?? 0,
customEndDate ?? 0,
appType ?? "all",
] as const,
logs: (key: RequestLogsKey, page: number, pageSize: number) => logs: (key: RequestLogsKey, page: number, pageSize: number) =>
[ [
...usageKeys.all, ...usageKeys.all,
"logs", "logs",
key.timeMode, key.preset,
key.rollingWindowSeconds ?? 0, key.customStartDate ?? 0,
key.customEndDate ?? 0,
key.appType ?? "", key.appType ?? "",
key.providerName ?? "", key.providerName ?? "",
key.model ?? "", key.model ?? "",
key.statusCode ?? -1, key.statusCode ?? -1,
key.startDate ?? 0,
key.endDate ?? 0,
page, page,
pageSize, pageSize,
] as const, ] as const,
@@ -64,23 +108,22 @@ export const usageKeys = {
[...usageKeys.all, "limits", providerId, appType] as const, [...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 // Hooks
export function useUsageSummary( export function useUsageSummary(
days: number, range: UsageRangeSelection,
appType?: string, appType?: string,
options?: UsageQueryOptions, options?: UsageQueryOptions,
) { ) {
const effectiveAppType = appType === "all" ? undefined : appType; const effectiveAppType = appType === "all" ? undefined : appType;
return useQuery({ return useQuery({
queryKey: usageKeys.summary(days, appType), queryKey: usageKeys.summary(
range.preset,
range.customStartDate,
range.customEndDate,
appType,
),
queryFn: () => { queryFn: () => {
const { startDate, endDate } = getWindow(days); const { startDate, endDate } = resolveUsageRange(range);
return usageApi.getUsageSummary(startDate, endDate, effectiveAppType); return usageApi.getUsageSummary(startDate, endDate, effectiveAppType);
}, },
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
@@ -89,15 +132,20 @@ export function useUsageSummary(
} }
export function useUsageTrends( export function useUsageTrends(
days: number, range: UsageRangeSelection,
appType?: string, appType?: string,
options?: UsageQueryOptions, options?: UsageQueryOptions,
) { ) {
const effectiveAppType = appType === "all" ? undefined : appType; const effectiveAppType = appType === "all" ? undefined : appType;
return useQuery({ return useQuery({
queryKey: usageKeys.trends(days, appType), queryKey: usageKeys.trends(
range.preset,
range.customStartDate,
range.customEndDate,
appType,
),
queryFn: () => { queryFn: () => {
const { startDate, endDate } = getWindow(days); const { startDate, endDate } = resolveUsageRange(range);
return usageApi.getUsageTrends(startDate, endDate, effectiveAppType); return usageApi.getUsageTrends(startDate, endDate, effectiveAppType);
}, },
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
@@ -106,61 +154,70 @@ export function useUsageTrends(
} }
export function useProviderStats( export function useProviderStats(
range: UsageRangeSelection,
appType?: string, appType?: string,
options?: UsageQueryOptions, options?: UsageQueryOptions,
) { ) {
const effectiveAppType = appType === "all" ? undefined : appType; const effectiveAppType = appType === "all" ? undefined : appType;
return useQuery({ return useQuery({
queryKey: usageKeys.providerStats(appType), queryKey: usageKeys.providerStats(
queryFn: () => usageApi.getProviderStats(effectiveAppType), range.preset,
range.customStartDate,
range.customEndDate,
appType,
),
queryFn: () => {
const { startDate, endDate } = resolveUsageRange(range);
return usageApi.getProviderStats(startDate, endDate, effectiveAppType);
},
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false, refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
}); });
} }
export function useModelStats(appType?: string, options?: UsageQueryOptions) { export function useModelStats(
range: UsageRangeSelection,
appType?: string,
options?: UsageQueryOptions,
) {
const effectiveAppType = appType === "all" ? undefined : appType; const effectiveAppType = appType === "all" ? undefined : appType;
return useQuery({ return useQuery({
queryKey: usageKeys.modelStats(appType), queryKey: usageKeys.modelStats(
queryFn: () => usageApi.getModelStats(effectiveAppType), range.preset,
range.customStartDate,
range.customEndDate,
appType,
),
queryFn: () => {
const { startDate, endDate } = resolveUsageRange(range);
return usageApi.getModelStats(startDate, endDate, effectiveAppType);
},
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false, refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
}); });
} }
const getRollingRange = (windowSeconds: number) => {
const endDate = Math.floor(Date.now() / 1000);
const startDate = endDate - windowSeconds;
return { startDate, endDate };
};
export function useRequestLogs({ export function useRequestLogs({
filters, filters,
timeMode, range,
page = 0, page = 0,
pageSize = 20, pageSize = 20,
rollingWindowSeconds = 24 * 60 * 60,
options, options,
}: RequestLogsQueryArgs) { }: RequestLogsQueryArgs) {
const key: RequestLogsKey = { const key: RequestLogsKey = {
timeMode, preset: range.preset,
rollingWindowSeconds: customStartDate: range.customStartDate,
timeMode === "rolling" ? rollingWindowSeconds : undefined, customEndDate: range.customEndDate,
appType: filters.appType, appType: filters.appType,
providerName: filters.providerName, providerName: filters.providerName,
model: filters.model, model: filters.model,
statusCode: filters.statusCode, statusCode: filters.statusCode,
startDate: timeMode === "fixed" ? filters.startDate : undefined,
endDate: timeMode === "fixed" ? filters.endDate : undefined,
}; };
return useQuery({ return useQuery({
queryKey: usageKeys.logs(key, page, pageSize), queryKey: usageKeys.logs(key, page, pageSize),
queryFn: () => { queryFn: () => {
const effectiveFilters = const effectiveFilters = { ...filters, ...resolveUsageRange(range) };
timeMode === "rolling"
? { ...filters, ...getRollingRange(rollingWindowSeconds) }
: filters;
return usageApi.getRequestLogs(effectiveFilters, page, pageSize); return usageApi.getRequestLogs(effectiveFilters, page, pageSize);
}, },
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新 refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
+84
View File
@@ -0,0 +1,84 @@
import type { UsageRangePreset, UsageRangeSelection } from "@/types/usage";
const DAY_SECONDS = 24 * 60 * 60;
const DAY_MS = DAY_SECONDS * 1000;
export const MAX_CUSTOM_USAGE_RANGE_SECONDS = 30 * DAY_SECONDS;
export interface ResolvedUsageRange {
startDate: number;
endDate: number;
}
function getStartOfLocalDayDate(nowMs: number): Date {
const date = new Date(nowMs);
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
function getPresetLookbackStart(
preset: Exclude<UsageRangePreset, "today" | "1d" | "custom">,
nowMs: number,
): number {
const dayCount = preset === "7d" ? 7 : preset === "14d" ? 14 : 30;
return Math.floor(
getStartOfLocalDayDate(nowMs - (dayCount - 1) * DAY_MS).getTime() / 1000,
);
}
export function resolveUsageRange(
selection: UsageRangeSelection,
nowMs: number = Date.now(),
): ResolvedUsageRange {
const endDate = Math.floor(nowMs / 1000);
switch (selection.preset) {
case "today":
return {
startDate: Math.floor(getStartOfLocalDayDate(nowMs).getTime() / 1000),
endDate,
};
case "1d":
return {
startDate: endDate - DAY_SECONDS,
endDate,
};
case "7d":
case "14d":
case "30d":
return {
startDate: getPresetLookbackStart(selection.preset, nowMs),
endDate,
};
case "custom": {
const startDate = selection.customStartDate ?? endDate - DAY_SECONDS;
const customEndDate = selection.customEndDate ?? endDate;
return {
startDate,
endDate: customEndDate,
};
}
}
}
export function timestampToLocalDatetime(timestamp: number): string {
const date = new Date(timestamp * 1000);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
export function localDatetimeToTimestamp(datetime: string): number | undefined {
if (!datetime || datetime.length < 16) {
return undefined;
}
const timestamp = new Date(datetime).getTime();
if (Number.isNaN(timestamp)) {
return undefined;
}
return Math.floor(timestamp / 1000);
}
+8 -2
View File
@@ -121,12 +121,18 @@ export interface ProviderLimitStatus {
monthlyExceeded: boolean; monthlyExceeded: boolean;
} }
export type TimeRange = "1d" | "7d" | "30d"; export type UsageRangePreset = "today" | "1d" | "7d" | "14d" | "30d" | "custom";
export interface UsageRangeSelection {
preset: UsageRangePreset;
customStartDate?: number;
customEndDate?: number;
}
export type AppTypeFilter = "all" | "claude" | "codex" | "gemini"; export type AppTypeFilter = "all" | "claude" | "codex" | "gemini";
export interface StatsFilters { export interface StatsFilters {
timeRange: TimeRange; timeRange: UsageRangePreset;
providerId?: string; providerId?: string;
appType?: string; appType?: string;
} }