feat: add per-app usage filtering (Claude/Codex/Gemini)

Add dashboard-level app type filter to usage statistics, replacing the
DataSourceBar with a more useful segmented control. All components
(summary cards, trend chart, provider stats, model stats, request logs)
now respond to the selected app filter.

Backend: add optional app_type parameter to get_usage_summary,
get_daily_trends, get_provider_stats, and get_model_stats queries.
Frontend: new AppTypeFilter type, updated query keys with appType
dimension for proper cache separation, and RequestLogTable local
filter auto-locks when dashboard filter is active.
This commit is contained in:
Jason
2026-04-06 22:41:35 +08:00
parent c0bcd19d44
commit 687ffc237d
14 changed files with 337 additions and 121 deletions
+18 -6
View File
@@ -11,8 +11,11 @@ pub fn get_usage_summary(
state: State<'_, AppState>, state: State<'_, AppState>,
start_date: Option<i64>, start_date: Option<i64>,
end_date: Option<i64>, end_date: Option<i64>,
app_type: Option<String>,
) -> Result<UsageSummary, AppError> { ) -> Result<UsageSummary, AppError> {
state.db.get_usage_summary(start_date, end_date) state
.db
.get_usage_summary(start_date, end_date, app_type.as_deref())
} }
/// 获取每日趋势 /// 获取每日趋势
@@ -21,20 +24,29 @@ pub fn get_usage_trends(
state: State<'_, AppState>, state: State<'_, AppState>,
start_date: Option<i64>, start_date: Option<i64>,
end_date: Option<i64>, end_date: Option<i64>,
app_type: Option<String>,
) -> Result<Vec<DailyStats>, AppError> { ) -> Result<Vec<DailyStats>, AppError> {
state.db.get_daily_trends(start_date, end_date) state
.db
.get_daily_trends(start_date, end_date, app_type.as_deref())
} }
/// 获取 Provider 统计 /// 获取 Provider 统计
#[tauri::command] #[tauri::command]
pub fn get_provider_stats(state: State<'_, AppState>) -> Result<Vec<ProviderStats>, AppError> { pub fn get_provider_stats(
state.db.get_provider_stats() state: State<'_, AppState>,
app_type: Option<String>,
) -> Result<Vec<ProviderStats>, AppError> {
state.db.get_provider_stats(app_type.as_deref())
} }
/// 获取模型统计 /// 获取模型统计
#[tauri::command] #[tauri::command]
pub fn get_model_stats(state: State<'_, AppState>) -> Result<Vec<ModelStats>, AppError> { pub fn get_model_stats(
state.db.get_model_stats() state: State<'_, AppState>,
app_type: Option<String>,
) -> Result<Vec<ModelStats>, AppError> {
state.db.get_model_stats(app_type.as_deref())
} }
/// 获取请求日志列表 /// 获取请求日志列表
+135 -65
View File
@@ -123,44 +123,54 @@ impl Database {
&self, &self,
start_date: Option<i64>, start_date: Option<i64>,
end_date: Option<i64>, end_date: Option<i64>,
app_type: Option<&str>,
) -> Result<UsageSummary, AppError> { ) -> Result<UsageSummary, AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
let (where_clause, params_vec) = if start_date.is_some() || end_date.is_some() { // Build detail WHERE clause
let mut conditions = Vec::new(); let mut conditions = Vec::new();
let mut params = Vec::new(); let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(start) = start_date { if let Some(start) = start_date {
conditions.push("created_at >= ?"); conditions.push("created_at >= ?");
params.push(start); params_vec.push(Box::new(start));
} }
if let Some(end) = end_date { if let Some(end) = end_date {
conditions.push("created_at <= ?"); conditions.push("created_at <= ?");
params.push(end); params_vec.push(Box::new(end));
} }
if let Some(at) = app_type {
conditions.push("app_type = ?");
params_vec.push(Box::new(at.to_string()));
}
(format!("WHERE {}", conditions.join(" AND ")), params) let where_clause = if conditions.is_empty() {
String::new()
} else { } else {
(String::new(), Vec::new()) format!("WHERE {}", conditions.join(" AND "))
}; };
// Build rollup WHERE clause using date strings (use ? for sequential binding) // Build rollup WHERE clause using date strings
let (rollup_where, rollup_params) = if start_date.is_some() || end_date.is_some() { let mut rollup_conditions: Vec<String> = Vec::new();
let mut conditions: Vec<String> = Vec::new(); let mut rollup_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
let mut params = Vec::new();
if let Some(start) = start_date { if let Some(start) = start_date {
conditions.push("date >= date(?, 'unixepoch', 'localtime')".to_string()); rollup_conditions.push("date >= date(?, 'unixepoch', 'localtime')".to_string());
params.push(start); rollup_params.push(Box::new(start));
} }
if let Some(end) = end_date { if let Some(end) = end_date {
conditions.push("date <= date(?, 'unixepoch', 'localtime')".to_string()); rollup_conditions.push("date <= date(?, 'unixepoch', 'localtime')".to_string());
params.push(end); rollup_params.push(Box::new(end));
} }
if let Some(at) = app_type {
rollup_conditions.push("app_type = ?".to_string());
rollup_params.push(Box::new(at.to_string()));
}
(format!("WHERE {}", conditions.join(" AND ")), params) let rollup_where = if rollup_conditions.is_empty() {
String::new()
} else { } else {
(String::new(), Vec::new()) format!("WHERE {}", rollup_conditions.join(" AND "))
}; };
let sql = format!( let sql = format!(
@@ -194,10 +204,11 @@ impl Database {
); );
// Combine params: detail params first, then rollup params // Combine params: detail params first, then rollup params
let mut all_params: Vec<i64> = params_vec; let mut all_params: Vec<Box<dyn rusqlite::ToSql>> = params_vec;
all_params.extend(rollup_params); all_params.extend(rollup_params);
let param_refs: Vec<&dyn rusqlite::ToSql> = all_params.iter().map(|p| p.as_ref()).collect();
let result = conn.query_row(&sql, rusqlite::params_from_iter(all_params), |row| { let result = conn.query_row(&sql, param_refs.as_slice(), |row| {
let total_requests: i64 = row.get(0)?; let total_requests: i64 = row.get(0)?;
let total_cost: f64 = row.get(1)?; let total_cost: f64 = row.get(1)?;
let total_input_tokens: i64 = row.get(2)?; let total_input_tokens: i64 = row.get(2)?;
@@ -231,6 +242,7 @@ impl Database {
&self, &self,
start_date: Option<i64>, start_date: Option<i64>,
end_date: Option<i64>, end_date: Option<i64>,
app_type: Option<&str>,
) -> Result<Vec<DailyStats>, AppError> { ) -> Result<Vec<DailyStats>, AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
@@ -262,9 +274,15 @@ impl Database {
bucket_count = 1; bucket_count = 1;
} }
let app_type_filter = if app_type.is_some() {
"AND app_type = ?4"
} else {
""
};
// Query detail logs // Query detail logs
let sql = " let sql = format!(
SELECT "SELECT
CAST((created_at - ?1) / ?3 AS INTEGER) as bucket_idx, CAST((created_at - ?1) / ?3 AS INTEGER) as bucket_idx,
COUNT(*) as request_count, COUNT(*) as request_count,
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost, COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
@@ -274,12 +292,13 @@ impl Database {
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens
FROM proxy_request_logs FROM proxy_request_logs
WHERE created_at >= ?1 AND created_at <= ?2 WHERE created_at >= ?1 AND created_at <= ?2 {app_type_filter}
GROUP BY bucket_idx GROUP BY bucket_idx
ORDER BY bucket_idx ASC"; ORDER BY bucket_idx ASC"
);
let mut stmt = conn.prepare(sql)?; let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params![start_ts, end_ts, bucket_seconds], |row| { let row_mapper = |row: &rusqlite::Row| {
Ok(( Ok((
row.get::<_, i64>(0)?, row.get::<_, i64>(0)?,
DailyStats { DailyStats {
@@ -293,24 +312,33 @@ impl Database {
total_cache_read_tokens: row.get::<_, i64>(7)? as u64, total_cache_read_tokens: row.get::<_, i64>(7)? as u64,
}, },
)) ))
})?; };
let mut map: HashMap<i64, DailyStats> = HashMap::new(); let mut map: HashMap<i64, DailyStats> = HashMap::new();
for row in rows {
let (mut bucket_idx, stat) = row?; // Collect rows into map (need to handle both param variants)
if bucket_idx < 0 { {
continue; let rows = if let Some(at) = app_type {
stmt.query_map(params![start_ts, end_ts, bucket_seconds, at], row_mapper)?
} else {
stmt.query_map(params![start_ts, end_ts, bucket_seconds], row_mapper)?
};
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);
} }
if bucket_idx >= bucket_count {
bucket_idx = bucket_count - 1;
}
map.insert(bucket_idx, stat);
} }
// Also query rollup data (daily granularity, only useful for daily buckets) // Also query rollup data (daily granularity, only useful for daily buckets)
if bucket_seconds >= 86400 { if bucket_seconds >= 86400 {
let rollup_sql = " let rollup_sql = format!(
SELECT "SELECT
CAST((CAST(strftime('%s', date) AS INTEGER) - ?1) / ?3 AS INTEGER) as bucket_idx, CAST((CAST(strftime('%s', date) AS INTEGER) - ?1) / ?3 AS INTEGER) as bucket_idx,
COALESCE(SUM(request_count), 0), COALESCE(SUM(request_count), 0),
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0), COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0),
@@ -320,12 +348,12 @@ impl Database {
COALESCE(SUM(cache_creation_tokens), 0), COALESCE(SUM(cache_creation_tokens), 0),
COALESCE(SUM(cache_read_tokens), 0) COALESCE(SUM(cache_read_tokens), 0)
FROM usage_daily_rollups FROM usage_daily_rollups
WHERE date >= date(?1, 'unixepoch', 'localtime') AND date <= date(?2, 'unixepoch', 'localtime') WHERE date >= date(?1, 'unixepoch', 'localtime') AND date <= date(?2, 'unixepoch', 'localtime') {app_type_filter}
GROUP BY bucket_idx GROUP BY bucket_idx
ORDER BY bucket_idx ASC"; ORDER BY bucket_idx ASC"
);
let mut rstmt = conn.prepare(rollup_sql)?; let rollup_row_mapper = |row: &rusqlite::Row| {
let rrows = rstmt.query_map(params![start_ts, end_ts, bucket_seconds], |row| {
Ok(( Ok((
row.get::<_, i64>(0)?, row.get::<_, i64>(0)?,
( (
@@ -338,7 +366,14 @@ impl Database {
row.get::<_, i64>(7)? as u64, row.get::<_, i64>(7)? as u64,
), ),
)) ))
})?; };
let mut rstmt = conn.prepare(&rollup_sql)?;
let rrows = if let Some(at) = app_type {
rstmt.query_map(params![start_ts, end_ts, bucket_seconds, at], rollup_row_mapper)?
} else {
rstmt.query_map(params![start_ts, end_ts, bucket_seconds], rollup_row_mapper)?
};
for row in rrows { for row in rrows {
let (mut bucket_idx, (req, cost, tok, inp, out, cc, cr)) = row?; let (mut bucket_idx, (req, cost, tok, inp, out, cc, cr)) = row?;
@@ -400,11 +435,21 @@ impl Database {
} }
/// 获取 Provider 统计 /// 获取 Provider 统计
pub fn get_provider_stats(&self) -> Result<Vec<ProviderStats>, AppError> { pub fn get_provider_stats(
&self,
app_type: Option<&str>,
) -> 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() {
("WHERE l.app_type = ?1", "WHERE r.app_type = ?2")
} else {
("", "")
};
// UNION detail logs + rollup data, then aggregate // UNION detail logs + rollup data, then aggregate
let sql = "SELECT let sql = format!(
"SELECT
provider_id, app_type, provider_name, provider_id, app_type, provider_name,
SUM(request_count) as request_count, SUM(request_count) as request_count,
SUM(total_tokens) as total_tokens, SUM(total_tokens) as total_tokens,
@@ -423,6 +468,7 @@ impl Database {
COALESCE(SUM(l.latency_ms), 0) as latency_sum COALESCE(SUM(l.latency_ms), 0) as latency_sum
FROM proxy_request_logs l FROM proxy_request_logs l
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
{detail_where}
GROUP BY l.provider_id, l.app_type GROUP BY l.provider_id, l.app_type
UNION ALL UNION ALL
SELECT r.provider_id, r.app_type, SELECT r.provider_id, r.app_type,
@@ -434,13 +480,15 @@ impl Database {
COALESCE(SUM(r.avg_latency_ms * r.request_count), 0) COALESCE(SUM(r.avg_latency_ms * r.request_count), 0)
FROM usage_daily_rollups r FROM usage_daily_rollups r
LEFT JOIN providers p2 ON r.provider_id = p2.id AND r.app_type = p2.app_type LEFT JOIN providers p2 ON r.provider_id = p2.id AND r.app_type = p2.app_type
{rollup_where}
GROUP BY r.provider_id, r.app_type GROUP BY r.provider_id, r.app_type
) )
GROUP BY provider_id, app_type GROUP BY provider_id, app_type
ORDER BY total_cost DESC"; ORDER BY total_cost DESC"
);
let mut stmt = conn.prepare(sql)?; let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map([], |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)?;
let success_rate = if request_count > 0 { let success_rate = if request_count > 0 {
@@ -460,7 +508,13 @@ impl Database {
success_rate, success_rate,
avg_latency_ms: row.get::<_, f64>(7)? as u64, avg_latency_ms: row.get::<_, f64>(7)? as u64,
}) })
})?; };
let rows = if let Some(at) = app_type {
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 {
@@ -471,11 +525,18 @@ impl Database {
} }
/// 获取模型统计 /// 获取模型统计
pub fn get_model_stats(&self) -> Result<Vec<ModelStats>, AppError> { pub fn get_model_stats(&self, 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() {
("WHERE app_type = ?1", "WHERE app_type = ?2")
} else {
("", "")
};
// UNION detail logs + rollup data // UNION detail logs + rollup data
let sql = "SELECT let sql = format!(
"SELECT
model, model,
SUM(request_count) as request_count, SUM(request_count) as request_count,
SUM(total_tokens) as total_tokens, SUM(total_tokens) as total_tokens,
@@ -486,6 +547,7 @@ impl Database {
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost
FROM proxy_request_logs FROM proxy_request_logs
{detail_where}
GROUP BY model GROUP BY model
UNION ALL UNION ALL
SELECT model, SELECT model,
@@ -493,13 +555,15 @@ impl Database {
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
{rollup_where}
GROUP BY model GROUP BY 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 rows = stmt.query_map([], |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)?;
let avg_cost = if request_count > 0 { let avg_cost = if request_count > 0 {
@@ -515,7 +579,13 @@ impl Database {
total_cost: format!("{total_cost:.6}"), total_cost: format!("{total_cost:.6}"),
avg_cost_per_request: format!("{avg_cost:.6}"), avg_cost_per_request: format!("{avg_cost:.6}"),
}) })
})?; };
let rows = if let Some(at) = app_type {
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 {
@@ -1044,7 +1114,7 @@ mod tests {
)?; )?;
} }
let summary = db.get_usage_summary(None, None)?; let summary = db.get_usage_summary(None, None, None)?;
assert_eq!(summary.total_requests, 2); assert_eq!(summary.total_requests, 2);
assert_eq!(summary.success_rate, 100.0); assert_eq!(summary.success_rate, 100.0);
@@ -1079,7 +1149,7 @@ mod tests {
)?; )?;
} }
let stats = db.get_model_stats()?; let stats = db.get_model_stats(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);
+6 -2
View File
@@ -11,12 +11,16 @@ import { useModelStats } from "@/lib/query/usage";
import { fmtUsd } from "./format"; import { fmtUsd } from "./format";
interface ModelStatsTableProps { interface ModelStatsTableProps {
appType?: string;
refreshIntervalMs: number; refreshIntervalMs: number;
} }
export function ModelStatsTable({ refreshIntervalMs }: ModelStatsTableProps) { export function ModelStatsTable({
appType,
refreshIntervalMs,
}: ModelStatsTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: stats, isLoading } = useModelStats({ const { data: stats, isLoading } = useModelStats(appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
}); });
+3 -1
View File
@@ -11,14 +11,16 @@ import { useProviderStats } from "@/lib/query/usage";
import { fmtUsd } from "./format"; import { fmtUsd } from "./format";
interface ProviderStatsTableProps { interface ProviderStatsTableProps {
appType?: string;
refreshIntervalMs: number; refreshIntervalMs: number;
} }
export function ProviderStatsTable({ export function ProviderStatsTable({
appType,
refreshIntervalMs, refreshIntervalMs,
}: ProviderStatsTableProps) { }: ProviderStatsTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: stats, isLoading } = useProviderStats({ const { data: stats, isLoading } = useProviderStats(appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
}); });
+18 -3
View File
@@ -29,6 +29,7 @@ import {
} from "./format"; } from "./format";
interface RequestLogTableProps { interface RequestLogTableProps {
appType?: string;
refreshIntervalMs: number; refreshIntervalMs: number;
} }
@@ -37,7 +38,10 @@ const MAX_FIXED_RANGE_SECONDS = 30 * ONE_DAY_SECONDS;
type TimeMode = "rolling" | "fixed"; type TimeMode = "rolling" | "fixed";
export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) { export function RequestLogTable({
appType: dashboardAppType,
refreshIntervalMs,
}: RequestLogTableProps) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -56,8 +60,14 @@ export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) {
const pageSize = 20; const pageSize = 20;
const [validationError, setValidationError] = useState<string | null>(null); const [validationError, setValidationError] = useState<string | null>(null);
// When dashboard-level app filter is active (not "all"), override the local appType filter
const dashboardAppTypeActive = dashboardAppType && dashboardAppType !== "all";
const effectiveFilters: LogFilters = dashboardAppTypeActive
? { ...appliedFilters, appType: dashboardAppType }
: appliedFilters;
const { data: result, isLoading } = useRequestLogs({ const { data: result, isLoading } = useRequestLogs({
filters: appliedFilters, filters: effectiveFilters,
timeMode: appliedTimeMode, timeMode: appliedTimeMode,
rollingWindowSeconds: ONE_DAY_SECONDS, rollingWindowSeconds: ONE_DAY_SECONDS,
page, page,
@@ -174,13 +184,18 @@ export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) {
<div className="flex flex-col gap-4 rounded-lg border bg-card/50 p-4 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-3"> <div className="flex flex-wrap items-center gap-3">
<Select <Select
value={draftFilters.appType || "all"} value={
dashboardAppTypeActive
? dashboardAppType
: draftFilters.appType || "all"
}
onValueChange={(v) => onValueChange={(v) =>
setDraftFilters({ setDraftFilters({
...draftFilters, ...draftFilters,
appType: v === "all" ? undefined : v, appType: v === "all" ? undefined : v,
}) })
} }
disabled={!!dashboardAppTypeActive}
> >
<SelectTrigger className="w-[130px] bg-background"> <SelectTrigger className="w-[130px] bg-background">
<SelectValue placeholder={t("usage.appType")} /> <SelectValue placeholder={t("usage.appType")} />
+70 -8
View File
@@ -6,8 +6,8 @@ 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 { DataSourceBar } from "./DataSourceBar"; import type { AppTypeFilter, TimeRange } from "@/types/usage";
import type { TimeRange } from "@/types/usage"; import { useUsageSummary } from "@/lib/query/usage";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { import {
BarChart3, BarChart3,
@@ -26,11 +26,21 @@ import {
AccordionTrigger, AccordionTrigger,
} 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 { fmtUsd, parseFiniteNumber } from "./format";
const APP_FILTER_OPTIONS: AppTypeFilter[] = [
"all",
"claude",
"codex",
"gemini",
];
export function UsageDashboard() { export function UsageDashboard() {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [timeRange, setTimeRange] = useState<TimeRange>("1d"); const [timeRange, setTimeRange] = useState<TimeRange>("1d");
const [appType, setAppType] = useState<AppTypeFilter>("all");
const [refreshIntervalMs, setRefreshIntervalMs] = useState(30000); const [refreshIntervalMs, setRefreshIntervalMs] = useState(30000);
const refreshIntervalOptionsMs = [0, 5000, 10000, 30000, 60000] as const; const refreshIntervalOptionsMs = [0, 5000, 10000, 30000, 60000] as const;
@@ -47,6 +57,11 @@ export function UsageDashboard() {
const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30; const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30;
// Summary data for the app filter bar
const { data: summaryData } = useUsageSummary(days, appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
return ( return (
<motion.div <motion.div
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
@@ -101,11 +116,49 @@ export function UsageDashboard() {
</Tabs> </Tabs>
</div> </div>
<DataSourceBar refreshIntervalMs={refreshIntervalMs} /> {/* App type filter bar (replaces DataSourceBar) */}
<div className="rounded-xl border border-border/50 bg-card/40 backdrop-blur-sm p-4 space-y-3">
<div className="flex flex-wrap items-center gap-1.5">
{APP_FILTER_OPTIONS.map((type) => (
<button
key={type}
type="button"
onClick={() => setAppType(type)}
className={cn(
"px-4 py-1.5 rounded-lg text-sm font-medium transition-all",
appType === type
? "bg-primary/10 text-primary shadow-sm border border-primary/20"
: "text-muted-foreground hover:text-primary hover:bg-muted/50 border border-transparent",
)}
>
{t(`usage.appFilter.${type}`)}
</button>
))}
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<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>
<UsageSummaryCards days={days} refreshIntervalMs={refreshIntervalMs} /> <UsageSummaryCards
days={days}
appType={appType}
refreshIntervalMs={refreshIntervalMs}
/>
<UsageTrendChart days={days} refreshIntervalMs={refreshIntervalMs} /> <UsageTrendChart
days={days}
appType={appType}
refreshIntervalMs={refreshIntervalMs}
/>
<div className="space-y-4"> <div className="space-y-4">
<Tabs defaultValue="logs" className="w-full"> <Tabs defaultValue="logs" className="w-full">
@@ -132,15 +185,24 @@ export function UsageDashboard() {
transition={{ delay: 0.2 }} transition={{ delay: 0.2 }}
> >
<TabsContent value="logs" className="mt-0"> <TabsContent value="logs" className="mt-0">
<RequestLogTable refreshIntervalMs={refreshIntervalMs} /> <RequestLogTable
appType={appType}
refreshIntervalMs={refreshIntervalMs}
/>
</TabsContent> </TabsContent>
<TabsContent value="providers" className="mt-0"> <TabsContent value="providers" className="mt-0">
<ProviderStatsTable refreshIntervalMs={refreshIntervalMs} /> <ProviderStatsTable
appType={appType}
refreshIntervalMs={refreshIntervalMs}
/>
</TabsContent> </TabsContent>
<TabsContent value="models" className="mt-0"> <TabsContent value="models" className="mt-0">
<ModelStatsTable refreshIntervalMs={refreshIntervalMs} /> <ModelStatsTable
appType={appType}
refreshIntervalMs={refreshIntervalMs}
/>
</TabsContent> </TabsContent>
</motion.div> </motion.div>
</Tabs> </Tabs>
+3 -1
View File
@@ -8,16 +8,18 @@ import { fmtUsd, parseFiniteNumber } from "./format";
interface UsageSummaryCardsProps { interface UsageSummaryCardsProps {
days: number; days: number;
appType?: string;
refreshIntervalMs: number; refreshIntervalMs: number;
} }
export function UsageSummaryCards({ export function UsageSummaryCards({
days, days,
appType,
refreshIntervalMs, refreshIntervalMs,
}: UsageSummaryCardsProps) { }: UsageSummaryCardsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: summary, isLoading } = useUsageSummary(days, { const { data: summary, isLoading } = useUsageSummary(days, appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
}); });
+3 -1
View File
@@ -20,15 +20,17 @@ import {
interface UsageTrendChartProps { interface UsageTrendChartProps {
days: number; days: number;
appType?: string;
refreshIntervalMs: number; refreshIntervalMs: number;
} }
export function UsageTrendChart({ export function UsageTrendChart({
days, days,
appType,
refreshIntervalMs, refreshIntervalMs,
}: UsageTrendChartProps) { }: UsageTrendChartProps) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { data: trends, isLoading } = useUsageTrends(days, { const { data: trends, isLoading } = useUsageTrends(days, appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
}); });
+8
View File
@@ -1019,6 +1019,14 @@
"stream": "Stream", "stream": "Stream",
"nonStream": "Non-stream", "nonStream": "Non-stream",
"source": "Source", "source": "Source",
"requestsLabel": "requests",
"costLabel": "total cost",
"appFilter": {
"all": "All",
"claude": "Claude Code",
"codex": "Codex",
"gemini": "Gemini"
},
"dataSources": "Data Sources", "dataSources": "Data Sources",
"dataSource": { "dataSource": {
"proxy": "Proxy", "proxy": "Proxy",
+8
View File
@@ -1019,6 +1019,14 @@
"stream": "ストリーム", "stream": "ストリーム",
"nonStream": "非ストリーム", "nonStream": "非ストリーム",
"source": "ソース", "source": "ソース",
"requestsLabel": "リクエスト",
"costLabel": "合計コスト",
"appFilter": {
"all": "すべて",
"claude": "Claude Code",
"codex": "Codex",
"gemini": "Gemini"
},
"dataSources": "データソース", "dataSources": "データソース",
"dataSource": { "dataSource": {
"proxy": "プロキシ", "proxy": "プロキシ",
+8
View File
@@ -1019,6 +1019,14 @@
"stream": "流", "stream": "流",
"nonStream": "非流", "nonStream": "非流",
"source": "来源", "source": "来源",
"requestsLabel": "次请求",
"costLabel": "总成本",
"appFilter": {
"all": "全部",
"claude": "Claude Code",
"codex": "Codex",
"gemini": "Gemini"
},
"dataSources": "数据来源", "dataSources": "数据来源",
"dataSource": { "dataSource": {
"proxy": "代理", "proxy": "代理",
+8 -6
View File
@@ -50,23 +50,25 @@ export const usageApi = {
getUsageSummary: async ( getUsageSummary: async (
startDate?: number, startDate?: number,
endDate?: number, endDate?: number,
appType?: string,
): Promise<UsageSummary> => { ): Promise<UsageSummary> => {
return invoke("get_usage_summary", { startDate, endDate }); return invoke("get_usage_summary", { startDate, endDate, appType });
}, },
getUsageTrends: async ( getUsageTrends: async (
startDate?: number, startDate?: number,
endDate?: number, endDate?: number,
appType?: string,
): Promise<DailyStats[]> => { ): Promise<DailyStats[]> => {
return invoke("get_usage_trends", { startDate, endDate }); return invoke("get_usage_trends", { startDate, endDate, appType });
}, },
getProviderStats: async (): Promise<ProviderStats[]> => { getProviderStats: async (appType?: string): Promise<ProviderStats[]> => {
return invoke("get_provider_stats"); return invoke("get_provider_stats", { appType });
}, },
getModelStats: async (): Promise<ModelStats[]> => { getModelStats: async (appType?: string): Promise<ModelStats[]> => {
return invoke("get_model_stats"); return invoke("get_model_stats", { appType });
}, },
getRequestLogs: async ( getRequestLogs: async (
+47 -28
View File
@@ -34,10 +34,14 @@ type RequestLogsKey = {
// Query keys // Query keys
export const usageKeys = { export const usageKeys = {
all: ["usage"] as const, all: ["usage"] as const,
summary: (days: number) => [...usageKeys.all, "summary", days] as const, summary: (days: number, appType?: string) =>
trends: (days: number) => [...usageKeys.all, "trends", days] as const, [...usageKeys.all, "summary", days, appType ?? "all"] as const,
providerStats: () => [...usageKeys.all, "provider-stats"] as const, trends: (days: number, appType?: string) =>
modelStats: () => [...usageKeys.all, "model-stats"] as const, [...usageKeys.all, "trends", days, appType ?? "all"] as const,
providerStats: (appType?: string) =>
[...usageKeys.all, "provider-stats", appType ?? "all"] as const,
modelStats: (appType?: string) =>
[...usageKeys.all, "model-stats", appType ?? "all"] as const,
logs: (key: RequestLogsKey, page: number, pageSize: number) => logs: (key: RequestLogsKey, page: number, pageSize: number) =>
[ [
...usageKeys.all, ...usageKeys.all,
@@ -67,44 +71,59 @@ const getWindow = (days: number) => {
}; };
// Hooks // Hooks
export function useUsageSummary(days: number, options?: UsageQueryOptions) { export function useUsageSummary(
days: number,
appType?: string,
options?: UsageQueryOptions,
) {
const effectiveAppType = appType === "all" ? undefined : appType;
return useQuery({ return useQuery({
queryKey: usageKeys.summary(days), queryKey: usageKeys.summary(days, appType),
queryFn: () => { queryFn: () => {
const { startDate, endDate } = getWindow(days); const { startDate, endDate } = getWindow(days);
return usageApi.getUsageSummary(startDate, endDate); return usageApi.getUsageSummary(startDate, endDate, effectiveAppType);
}, },
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新 refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false, // 后台不刷新
});
}
export function useUsageTrends(days: number, options?: UsageQueryOptions) {
return useQuery({
queryKey: usageKeys.trends(days),
queryFn: () => {
const { startDate, endDate } = getWindow(days);
return usageApi.getUsageTrends(startDate, endDate);
},
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false, refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
}); });
} }
export function useProviderStats(options?: UsageQueryOptions) { export function useUsageTrends(
days: number,
appType?: string,
options?: UsageQueryOptions,
) {
const effectiveAppType = appType === "all" ? undefined : appType;
return useQuery({ return useQuery({
queryKey: usageKeys.providerStats(), queryKey: usageKeys.trends(days, appType),
queryFn: usageApi.getProviderStats, queryFn: () => {
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新 const { startDate, endDate } = getWindow(days);
return usageApi.getUsageTrends(startDate, endDate, effectiveAppType);
},
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false, refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
}); });
} }
export function useModelStats(options?: UsageQueryOptions) { export function useProviderStats(
appType?: string,
options?: UsageQueryOptions,
) {
const effectiveAppType = appType === "all" ? undefined : appType;
return useQuery({ return useQuery({
queryKey: usageKeys.modelStats(), queryKey: usageKeys.providerStats(appType),
queryFn: usageApi.getModelStats, queryFn: () => usageApi.getProviderStats(effectiveAppType),
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新 refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
});
}
export function useModelStats(appType?: string, options?: UsageQueryOptions) {
const effectiveAppType = appType === "all" ? undefined : appType;
return useQuery({
queryKey: usageKeys.modelStats(appType),
queryFn: () => usageApi.getModelStats(effectiveAppType),
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false, refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
}); });
} }
+2
View File
@@ -123,6 +123,8 @@ export interface ProviderLimitStatus {
export type TimeRange = "1d" | "7d" | "30d"; export type TimeRange = "1d" | "7d" | "30d";
export type AppTypeFilter = "all" | "claude" | "codex" | "gemini";
export interface StatsFilters { export interface StatsFilters {
timeRange: TimeRange; timeRange: TimeRange;
providerId?: string; providerId?: string;