mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-02 18:41:35 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1ac925167 | |||
| b37f553258 |
@@ -35,26 +35,18 @@ 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
|
state.db.get_provider_stats(app_type.as_deref())
|
||||||
.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
|
state.db.get_model_stats(app_type.as_deref())
|
||||||
.db
|
|
||||||
.get_model_stats(start_date, end_date, app_type.as_deref())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取请求日志列表
|
/// 获取请求日志列表
|
||||||
|
|||||||
@@ -275,9 +275,14 @@ impl Database {
|
|||||||
let mut bucket_count: i64 = if duration <= 0 {
|
let mut bucket_count: i64 = if duration <= 0 {
|
||||||
1
|
1
|
||||||
} else {
|
} else {
|
||||||
(duration + bucket_seconds - 1) / bucket_seconds
|
((duration as f64) / bucket_seconds as f64).ceil() as i64
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 固定 24 小时窗口为 24 个小时桶,避免浮点误差
|
||||||
|
if bucket_seconds == 60 * 60 {
|
||||||
|
bucket_count = 24;
|
||||||
|
}
|
||||||
|
|
||||||
if bucket_count < 1 {
|
if bucket_count < 1 {
|
||||||
bucket_count = 1;
|
bucket_count = 1;
|
||||||
}
|
}
|
||||||
@@ -448,50 +453,14 @@ 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 mut detail_conditions = Vec::new();
|
let (detail_where, rollup_where) = if app_type.is_some() {
|
||||||
let mut detail_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
("WHERE l.app_type = ?1", "WHERE r.app_type = ?2")
|
||||||
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
|
||||||
@@ -537,9 +506,6 @@ 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)?;
|
||||||
@@ -560,7 +526,11 @@ impl Database {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let rows = stmt.query_map(param_refs.as_slice(), row_mapper)?;
|
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 {
|
||||||
@@ -571,52 +541,13 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 获取模型统计
|
/// 获取模型统计
|
||||||
pub fn get_model_stats(
|
pub fn get_model_stats(&self, app_type: Option<&str>) -> Result<Vec<ModelStats>, AppError> {
|
||||||
&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 mut detail_conditions = Vec::new();
|
let (detail_where, rollup_where) = if app_type.is_some() {
|
||||||
let mut detail_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
("WHERE app_type = ?1", "WHERE app_type = ?2")
|
||||||
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
|
||||||
@@ -627,30 +558,27 @@ 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 l.model,
|
SELECT model,
|
||||||
COUNT(*) as request_count,
|
COUNT(*) as request_count,
|
||||||
COALESCE(SUM(l.input_tokens + l.output_tokens), 0) as total_tokens,
|
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
|
||||||
COALESCE(SUM(CAST(l.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 l
|
FROM proxy_request_logs
|
||||||
{detail_where}
|
{detail_where}
|
||||||
GROUP BY l.model
|
GROUP BY model
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT r.model,
|
SELECT 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 r
|
FROM usage_daily_rollups
|
||||||
{rollup_where}
|
{rollup_where}
|
||||||
GROUP BY r.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 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)?;
|
||||||
@@ -669,7 +597,11 @@ impl Database {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let rows = stmt.query_map(param_refs.as_slice(), row_mapper)?;
|
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 {
|
||||||
@@ -1236,7 +1168,7 @@ mod tests {
|
|||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let stats = db.get_model_stats(None, None, None)?;
|
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);
|
||||||
@@ -1244,73 +1176,6 @@ 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()?;
|
||||||
|
|||||||
@@ -9,21 +9,18 @@ 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(range, appType, {
|
const { data: stats, isLoading } = useModelStats(appType, {
|
||||||
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,21 +9,18 @@ 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(range, appType, {
|
const { data: stats, isLoading } = useProviderStats(appType, {
|
||||||
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { useRequestLogs } from "@/lib/query/usage";
|
import { useRequestLogs, usageKeys } from "@/lib/query/usage";
|
||||||
import type { LogFilters, UsageRangeSelection } from "@/types/usage";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Calendar, ChevronLeft, ChevronRight, Search, X } from "lucide-react";
|
import type { LogFilters } from "@/types/usage";
|
||||||
|
import { ChevronLeft, ChevronRight, RefreshCw, Search, X } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
fmtInt,
|
fmtInt,
|
||||||
fmtUsd,
|
fmtUsd,
|
||||||
@@ -28,30 +29,51 @@ 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 range changes
|
// Reset page when the dashboard time range changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(0);
|
setPage(0);
|
||||||
}, [range.preset, range.customStartDate, range.customEndDate]);
|
}, [timeRange]);
|
||||||
|
|
||||||
// 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";
|
||||||
@@ -61,7 +83,8 @@ export function RequestLogTable({
|
|||||||
|
|
||||||
const { data: result, isLoading } = useRequestLogs({
|
const { data: result, isLoading } = useRequestLogs({
|
||||||
filters: effectiveFilters,
|
filters: effectiveFilters,
|
||||||
range,
|
timeMode: appliedTimeMode,
|
||||||
|
rollingWindowSeconds,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
options: {
|
options: {
|
||||||
@@ -74,11 +97,50 @@ export function RequestLogTable({
|
|||||||
const totalPages = Math.ceil(total / pageSize);
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
setAppliedFilters(draftFilters);
|
setValidationError(null);
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -93,33 +155,73 @@ 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-wrap items-center gap-1.5">
|
<div className="flex flex-col gap-4 rounded-lg border bg-card/50 p-4 backdrop-blur-sm">
|
||||||
{/* App type */}
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{/* App type */}
|
|
||||||
<Select
|
<Select
|
||||||
value={
|
value={
|
||||||
dashboardAppTypeActive
|
dashboardAppTypeActive
|
||||||
? dashboardAppType
|
? dashboardAppType
|
||||||
: draftFilters.appType || "all"
|
: draftFilters.appType || "all"
|
||||||
}
|
}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) =>
|
||||||
const next = {
|
setDraftFilters({
|
||||||
...draftFilters,
|
...draftFilters,
|
||||||
appType: v === "all" ? undefined : v,
|
appType: v === "all" ? undefined : v,
|
||||||
};
|
})
|
||||||
setDraftFilters(next);
|
}
|
||||||
setAppliedFilters(next);
|
|
||||||
setPage(0);
|
|
||||||
}}
|
|
||||||
disabled={!!dashboardAppTypeActive}
|
disabled={!!dashboardAppTypeActive}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 w-[110px] bg-background text-xs">
|
<SelectTrigger className="w-[130px] bg-background">
|
||||||
<SelectValue placeholder={t("usage.appType")} />
|
<SelectValue placeholder={t("usage.appType")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -130,11 +232,10 @@ 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) =>
|
||||||
const next = {
|
setDraftFilters({
|
||||||
...draftFilters,
|
...draftFilters,
|
||||||
statusCode:
|
statusCode:
|
||||||
v === "all"
|
v === "all"
|
||||||
@@ -142,49 +243,40 @@ 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="h-8 w-[100px] bg-background text-xs">
|
<SelectTrigger className="w-[130px] bg-background">
|
||||||
<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</SelectItem>
|
<SelectItem value="400">400 Bad Request</SelectItem>
|
||||||
<SelectItem value="401">401</SelectItem>
|
<SelectItem value="401">401 Unauthorized</SelectItem>
|
||||||
<SelectItem value="429">429</SelectItem>
|
<SelectItem value="429">429 Rate Limit</SelectItem>
|
||||||
<SelectItem value="500">500</SelectItem>
|
<SelectItem value="500">500 Server Error</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{/* Provider search */}
|
<div className="flex items-center gap-2 flex-1 min-w-[300px]">
|
||||||
<div className="relative min-w-[140px] flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-2 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("usage.searchProviderPlaceholder")}
|
placeholder={t("usage.searchProviderPlaceholder")}
|
||||||
className="h-8 bg-background pl-7 text-xs"
|
className="pl-9 bg-background"
|
||||||
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) => {
|
/>
|
||||||
if (e.key === "Enter") handleSearch();
|
</div>
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Model search */}
|
|
||||||
<div className="relative min-w-[120px] flex-1">
|
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("usage.searchModelPlaceholder")}
|
placeholder={t("usage.searchModelPlaceholder")}
|
||||||
className="h-8 bg-background text-xs"
|
className="w-[180px] bg-background"
|
||||||
value={draftFilters.model || ""}
|
value={draftFilters.model || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setDraftFilters({
|
setDraftFilters({
|
||||||
@@ -192,40 +284,89 @@ export function RequestLogTable({
|
|||||||
model: e.target.value || undefined,
|
model: e.target.value || undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onKeyDown={(e) => {
|
/>
|
||||||
if (e.key === "Enter") handleSearch();
|
</div>
|
||||||
|
</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>
|
||||||
|
|
||||||
{/* Time range badge */}
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
<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">
|
<Button
|
||||||
<Calendar className="h-3.5 w-3.5 shrink-0" />
|
size="sm"
|
||||||
<span className="max-w-[180px] truncate text-foreground">
|
variant="default"
|
||||||
{rangeLabel}
|
onClick={handleSearch}
|
||||||
</span>
|
className="h-8"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
{/* Search & Reset (icon-only) */}
|
|
||||||
<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>
|
||||||
|
|
||||||
|
{validationError && (
|
||||||
|
<div className="text-sm text-red-600">{validationError}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -236,31 +377,40 @@ export function RequestLogTable({
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="text-center whitespace-nowrap">
|
<TableHead className="whitespace-nowrap">
|
||||||
{t("usage.time")}
|
{t("usage.time")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-center whitespace-nowrap">
|
<TableHead className="whitespace-nowrap">
|
||||||
{t("usage.provider")}
|
{t("usage.provider")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-center whitespace-nowrap">
|
<TableHead className="min-w-[200px] whitespace-nowrap">
|
||||||
{t("usage.billingModel")}
|
{t("usage.billingModel")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-center whitespace-nowrap">
|
<TableHead className="text-right whitespace-nowrap">
|
||||||
{t("usage.inputTokens")}
|
{t("usage.inputTokens")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-center whitespace-nowrap">
|
<TableHead className="text-right whitespace-nowrap">
|
||||||
{t("usage.outputTokens")}
|
{t("usage.outputTokens")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-center whitespace-nowrap">
|
<TableHead className="text-right min-w-[90px] 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 whitespace-nowrap">
|
<TableHead className="text-center min-w-[140px] whitespace-nowrap">
|
||||||
{t("usage.timingInfo")}
|
{t("usage.timingInfo")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-center whitespace-nowrap">
|
<TableHead className="whitespace-nowrap">
|
||||||
{t("usage.status")}
|
{t("usage.status")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-center whitespace-nowrap">
|
<TableHead className="whitespace-nowrap">
|
||||||
{t("usage.source", { defaultValue: "Source" })}
|
{t("usage.source", { defaultValue: "Source" })}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -269,7 +419,7 @@ export function RequestLogTable({
|
|||||||
{logs.length === 0 ? (
|
{logs.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={9}
|
colSpan={12}
|
||||||
className="text-center text-muted-foreground"
|
className="text-center text-muted-foreground"
|
||||||
>
|
>
|
||||||
{t("usage.noData")}
|
{t("usage.noData")}
|
||||||
@@ -278,96 +428,140 @@ export function RequestLogTable({
|
|||||||
) : (
|
) : (
|
||||||
logs.map((log) => (
|
logs.map((log) => (
|
||||||
<TableRow key={log.requestId}>
|
<TableRow key={log.requestId}>
|
||||||
<TableCell className="text-center whitespace-nowrap text-xs px-1.5">
|
<TableCell>
|
||||||
{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 className="text-center">
|
<TableCell>
|
||||||
{log.providerName || t("usage.unknownProvider")}
|
{log.providerName || t("usage.unknownProvider")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center font-mono text-xs max-w-[200px]">
|
<TableCell className="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
|
||||||
? `${log.requestModel} → ${log.model}`
|
? `${t("usage.requestModel")}: ${log.requestModel}\n${t("usage.responseModel")}: ${log.model}`
|
||||||
: log.model
|
: log.model
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{log.requestModel &&
|
{log.model}
|
||||||
log.requestModel !== log.model ? (
|
|
||||||
<span>
|
|
||||||
{log.requestModel}
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{" → "}
|
|
||||||
{log.model}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
log.model
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
{log.requestModel && log.requestModel !== log.model && (
|
||||||
<TableCell className="text-center px-1.5">
|
<div
|
||||||
<div className="tabular-nums">
|
className="truncate text-muted-foreground text-[10px]"
|
||||||
{fmtInt(log.inputTokens, locale)}
|
title={log.requestModel}
|
||||||
</div>
|
>
|
||||||
{(log.cacheReadTokens > 0 ||
|
← {log.requestModel}
|
||||||
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-center">
|
<TableCell className="text-right">
|
||||||
|
{fmtInt(log.inputTokens, locale)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
{fmtInt(log.outputTokens, locale)}
|
{fmtInt(log.outputTokens, locale)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center px-1.5">
|
<TableCell className="text-right">
|
||||||
<div className="font-medium tabular-nums">
|
{fmtInt(log.cacheReadTokens, locale)}
|
||||||
{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-center whitespace-nowrap text-xs tabular-nums">
|
<TableCell className="text-right">
|
||||||
{(log.latencyMs / 1000).toFixed(1)}s
|
{fmtInt(log.cacheCreationTokens, locale)}
|
||||||
{log.firstTokenMs != null && (
|
</TableCell>
|
||||||
<span className="text-muted-foreground">
|
<TableCell className="text-right font-mono text-xs">
|
||||||
/{(log.firstTokenMs / 1000).toFixed(1)}s
|
{(parseFiniteNumber(log.costMultiplier) ?? 1) !== 1 ? (
|
||||||
|
<span className="text-orange-600">
|
||||||
|
×{log.costMultiplier}
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">×1</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-right">
|
||||||
|
{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={
|
className={`inline-flex rounded-full px-2 py-1 text-xs ${
|
||||||
log.statusCode >= 200 && log.statusCode < 300
|
log.statusCode >= 200 && log.statusCode < 300
|
||||||
? "text-green-600"
|
? "bg-green-100 text-green-800"
|
||||||
: "text-red-600"
|
: "bg-red-100 text-red-800"
|
||||||
}
|
}`}
|
||||||
>
|
>
|
||||||
{log.statusCode}
|
{log.statusCode}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center text-xs text-muted-foreground">
|
<TableCell>
|
||||||
{log.dataSource || "proxy"}
|
{log.dataSource && 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>
|
||||||
))
|
))
|
||||||
@@ -376,84 +570,89 @@ export function RequestLogTable({
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
{/* 分页控件 */}
|
||||||
<span>{t("usage.totalRecords", { total })}</span>
|
{total > 0 && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center justify-between px-2">
|
||||||
<Button
|
<span className="text-sm text-muted-foreground">
|
||||||
variant="outline"
|
{t("usage.totalRecords", { total })}
|
||||||
size="sm"
|
</span>
|
||||||
onClick={() => setPage(Math.max(0, page - 1))}
|
<div className="flex items-center gap-1">
|
||||||
disabled={page === 0}
|
<Button
|
||||||
>
|
variant="outline"
|
||||||
<ChevronLeft className="h-4 w-4" />
|
size="sm"
|
||||||
</Button>
|
onClick={() => setPage(Math.max(0, page - 1))}
|
||||||
{(() => {
|
disabled={page === 0}
|
||||||
const pages: (number | string)[] = [];
|
>
|
||||||
// 3 head + 3 tail + 3 neighborhood = 9 max distinct pages
|
<ChevronLeft className="h-4 w-4" />
|
||||||
if (totalPages <= 9) {
|
|
||||||
for (let i = 0; i < totalPages; i++) pages.push(i);
|
|
||||||
} else {
|
|
||||||
const pageSet = new Set<number>();
|
|
||||||
for (let i = 0; i < 3; i++) pageSet.add(i);
|
|
||||||
for (let i = totalPages - 3; i < totalPages; i++)
|
|
||||||
pageSet.add(i);
|
|
||||||
for (
|
|
||||||
let i = Math.max(0, page - 1);
|
|
||||||
i <= Math.min(totalPages - 1, page + 1);
|
|
||||||
i++
|
|
||||||
)
|
|
||||||
pageSet.add(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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return pages.map((p) =>
|
|
||||||
typeof p === "string" ? (
|
|
||||||
<span key={p} className="px-2 text-muted-foreground">
|
|
||||||
...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
key={p}
|
|
||||||
variant={p === page ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
onClick={() => setPage(p)}
|
|
||||||
>
|
|
||||||
{p + 1}
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPage(page + 1)}
|
|
||||||
disabled={page >= totalPages - 1}
|
|
||||||
>
|
|
||||||
<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>
|
||||||
|
{(() => {
|
||||||
|
const pages: (number | string)[] = [];
|
||||||
|
// 3 head + 3 tail + 3 neighborhood = 9 max distinct pages
|
||||||
|
if (totalPages <= 9) {
|
||||||
|
for (let i = 0; i < totalPages; i++) pages.push(i);
|
||||||
|
} else {
|
||||||
|
const pageSet = new Set<number>();
|
||||||
|
for (let i = 0; i < 3; i++) pageSet.add(i);
|
||||||
|
for (let i = totalPages - 3; i < totalPages; i++)
|
||||||
|
pageSet.add(i);
|
||||||
|
for (
|
||||||
|
let i = Math.max(0, page - 1);
|
||||||
|
i <= Math.min(totalPages - 1, page + 1);
|
||||||
|
i++
|
||||||
|
)
|
||||||
|
pageSet.add(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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pages.map((p) =>
|
||||||
|
typeof p === "string" ? (
|
||||||
|
<span key={p} className="px-2 text-muted-foreground">
|
||||||
|
...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
key={p}
|
||||||
|
variant={p === page ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => setPage(p)}
|
||||||
|
>
|
||||||
|
{p + 1}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { 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 {
|
import type { AppTypeFilter, TimeRange } from "@/types/usage";
|
||||||
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,
|
||||||
@@ -30,10 +26,6 @@ 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",
|
||||||
@@ -42,32 +34,10 @@ 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, i18n } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [range, setRange] = useState<UsageRangeSelection>({ preset: "today" });
|
const [timeRange, setTimeRange] = useState<TimeRange>("1d");
|
||||||
const [appType, setAppType] = useState<AppTypeFilter>("all");
|
const [appType, setAppType] = useState<AppTypeFilter>("all");
|
||||||
const [refreshIntervalMs, setRefreshIntervalMs] = useState(30000);
|
const [refreshIntervalMs, setRefreshIntervalMs] = useState(30000);
|
||||||
|
|
||||||
@@ -76,29 +46,14 @@ 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;
|
const safeIndex = currentIndex >= 0 ? currentIndex : 3; // default 30s
|
||||||
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 language = i18n.resolvedLanguage || i18n.language || "en";
|
const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30;
|
||||||
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
|
||||||
@@ -107,94 +62,82 @@ 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 gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex flex-col gap-1">
|
<h2 className="text-2xl font-bold">{t("usage.title")}</h2>
|
||||||
<h2 className="text-2xl font-bold">{t("usage.title")}</h2>
|
<p className="text-sm text-muted-foreground">{t("usage.subtitle")}</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("usage.subtitle")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-border/50 bg-card/40 backdrop-blur-sm p-4 space-y-4">
|
<Tabs
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
value={timeRange}
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
onValueChange={(v) => setTimeRange(v as TimeRange)}
|
||||||
{APP_FILTER_OPTIONS.map((type) => (
|
className="w-full sm:w-auto"
|
||||||
<button
|
>
|
||||||
key={type}
|
<div className="flex w-full sm:w-auto items-center gap-1">
|
||||||
type="button"
|
<Button
|
||||||
onClick={() => setAppType(type)}
|
type="button"
|
||||||
className={cn(
|
variant="ghost"
|
||||||
"px-4 py-1.5 rounded-lg text-sm font-medium transition-all",
|
size="sm"
|
||||||
appType === type
|
className="h-10 px-2 text-xs text-muted-foreground"
|
||||||
? "bg-primary/10 text-primary shadow-sm border border-primary/20"
|
title={t("common.refresh", "刷新")}
|
||||||
: "text-muted-foreground hover:text-primary hover:bg-muted/50 border border-transparent",
|
onClick={changeRefreshInterval}
|
||||||
)}
|
>
|
||||||
>
|
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||||
{t(`usage.appFilter.${type}`)}
|
{refreshIntervalMs > 0 ? `${refreshIntervalMs / 1000}s` : "--"}
|
||||||
</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">
|
||||||
</div>
|
<TabsTrigger
|
||||||
|
value="1d"
|
||||||
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
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"
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 px-2 text-xs text-muted-foreground"
|
|
||||||
title={t("common.refresh", "刷新")}
|
|
||||||
onClick={changeRefreshInterval}
|
|
||||||
>
|
>
|
||||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
{t("usage.today")}
|
||||||
{refreshIntervalMs > 0 ? `${refreshIntervalMs / 1000}s` : "--"}
|
</TabsTrigger>
|
||||||
</Button>
|
<TabsTrigger
|
||||||
|
value="7d"
|
||||||
{RANGE_PRESETS.map((preset) => (
|
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"
|
||||||
<Button
|
>
|
||||||
key={preset}
|
{t("usage.last7days")}
|
||||||
type="button"
|
</TabsTrigger>
|
||||||
size="sm"
|
<TabsTrigger
|
||||||
variant={range.preset === preset ? "default" : "outline"}
|
value="30d"
|
||||||
onClick={() => setRange({ preset })}
|
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"
|
||||||
>
|
>
|
||||||
{getPresetLabel(preset, t)}
|
{t("usage.last30days")}
|
||||||
</Button>
|
</TabsTrigger>
|
||||||
))}
|
</TabsList>
|
||||||
|
|
||||||
<UsageDateRangePicker
|
|
||||||
selection={range}
|
|
||||||
triggerLabel={rangeLabel}
|
|
||||||
onApply={(nextRange) => setRange(nextRange)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
|
{/* App type filter bar (replaces DataSourceBar) */}
|
||||||
<span className="font-medium text-foreground">{rangeLabel}</span>
|
<div className="rounded-xl border border-border/50 bg-card/40 backdrop-blur-sm p-4 space-y-3">
|
||||||
<span className="text-border">|</span>
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<span>
|
{APP_FILTER_OPTIONS.map((type) => (
|
||||||
{(summaryData?.totalRequests ?? 0).toLocaleString()}{" "}
|
<button
|
||||||
{t("usage.requestsLabel")}
|
key={type}
|
||||||
</span>
|
type="button"
|
||||||
<span className="text-border">|</span>
|
onClick={() => setAppType(type)}
|
||||||
<span>
|
className={cn(
|
||||||
{fmtUsd(parseFiniteNumber(summaryData?.totalCost) ?? 0, 4)}{" "}
|
"px-4 py-1.5 rounded-lg text-sm font-medium transition-all",
|
||||||
{t("usage.costLabel")}
|
appType === type
|
||||||
</span>
|
? "bg-primary/10 text-primary shadow-sm border border-primary/20"
|
||||||
</div>
|
: "text-muted-foreground hover:text-primary hover:bg-muted/50 border border-transparent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t(`usage.appFilter.${type}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UsageSummaryCards
|
<UsageSummaryCards
|
||||||
range={range}
|
days={days}
|
||||||
appType={appType}
|
appType={appType}
|
||||||
refreshIntervalMs={refreshIntervalMs}
|
refreshIntervalMs={refreshIntervalMs}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UsageTrendChart
|
<UsageTrendChart
|
||||||
range={range}
|
days={days}
|
||||||
rangeLabel={rangeLabel}
|
|
||||||
appType={appType}
|
appType={appType}
|
||||||
refreshIntervalMs={refreshIntervalMs}
|
refreshIntervalMs={refreshIntervalMs}
|
||||||
/>
|
/>
|
||||||
@@ -225,16 +168,14 @@ 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}
|
||||||
/>
|
/>
|
||||||
@@ -242,7 +183,6 @@ 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}
|
||||||
/>
|
/>
|
||||||
@@ -251,6 +191,7 @@ 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"
|
||||||
|
|||||||
@@ -1,415 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,22 +5,21 @@ 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 {
|
||||||
range: UsageRangeSelection;
|
days: number;
|
||||||
appType?: string;
|
appType?: string;
|
||||||
refreshIntervalMs: number;
|
refreshIntervalMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UsageSummaryCards({
|
export function UsageSummaryCards({
|
||||||
range,
|
days,
|
||||||
appType,
|
appType,
|
||||||
refreshIntervalMs,
|
refreshIntervalMs,
|
||||||
}: UsageSummaryCardsProps) {
|
}: UsageSummaryCardsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { data: summary, isLoading } = useUsageSummary(range, appType, {
|
const { data: summary, isLoading } = useUsageSummary(days, appType, {
|
||||||
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,25 +17,20 @@ import {
|
|||||||
getLocaleFromLanguage,
|
getLocaleFromLanguage,
|
||||||
parseFiniteNumber,
|
parseFiniteNumber,
|
||||||
} from "./format";
|
} from "./format";
|
||||||
import { resolveUsageRange } from "@/lib/usageRange";
|
|
||||||
import type { UsageRangeSelection } from "@/types/usage";
|
|
||||||
|
|
||||||
interface UsageTrendChartProps {
|
interface UsageTrendChartProps {
|
||||||
range: UsageRangeSelection;
|
days: number;
|
||||||
rangeLabel: string;
|
|
||||||
appType?: string;
|
appType?: string;
|
||||||
refreshIntervalMs: number;
|
refreshIntervalMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UsageTrendChart({
|
export function UsageTrendChart({
|
||||||
range,
|
days,
|
||||||
rangeLabel,
|
|
||||||
appType,
|
appType,
|
||||||
refreshIntervalMs,
|
refreshIntervalMs,
|
||||||
}: UsageTrendChartProps) {
|
}: UsageTrendChartProps) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { startDate, endDate } = resolveUsageRange(range);
|
const { data: trends, isLoading } = useUsageTrends(days, appType, {
|
||||||
const { data: trends, isLoading } = useUsageTrends(range, appType, {
|
|
||||||
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,8 +42,7 @@ export function UsageTrendChart({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const durationSeconds = Math.max(endDate - startDate, 0);
|
const isToday = days === 1;
|
||||||
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 =
|
||||||
@@ -57,7 +51,7 @@ export function UsageTrendChart({
|
|||||||
const cost = parseFiniteNumber(stat.totalCost);
|
const cost = parseFiniteNumber(stat.totalCost);
|
||||||
return {
|
return {
|
||||||
rawDate: stat.date,
|
rawDate: stat.date,
|
||||||
label: isHourly
|
label: isToday
|
||||||
? pointDate.toLocaleString(dateLocale, {
|
? pointDate.toLocaleString(dateLocale, {
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
@@ -114,7 +108,13 @@ 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">{rangeLabel}</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{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">
|
||||||
|
|||||||
@@ -1048,11 +1048,6 @@
|
|||||||
"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",
|
||||||
@@ -1073,8 +1068,6 @@
|
|||||||
"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",
|
||||||
@@ -1146,10 +1139,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -1048,11 +1048,6 @@
|
|||||||
"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": "コスト",
|
||||||
@@ -1073,8 +1068,6 @@
|
|||||||
"outputTokens": "出力",
|
"outputTokens": "出力",
|
||||||
"cacheReadTokens": "キャッシュヒット",
|
"cacheReadTokens": "キャッシュヒット",
|
||||||
"cacheCreationTokens": "キャッシュ作成",
|
"cacheCreationTokens": "キャッシュ作成",
|
||||||
"cacheReadShort": "キャッシュ読",
|
|
||||||
"cacheWriteShort": "書",
|
|
||||||
"timingInfo": "応答時間/TTFT",
|
"timingInfo": "応答時間/TTFT",
|
||||||
"status": "ステータス",
|
"status": "ステータス",
|
||||||
"multiplier": "倍率",
|
"multiplier": "倍率",
|
||||||
@@ -1146,10 +1139,6 @@
|
|||||||
"searchProviderPlaceholder": "プロバイダーを検索...",
|
"searchProviderPlaceholder": "プロバイダーを検索...",
|
||||||
"searchModelPlaceholder": "モデルを検索...",
|
"searchModelPlaceholder": "モデルを検索...",
|
||||||
"timeRange": "期間",
|
"timeRange": "期間",
|
||||||
"customRange": "カレンダーフィルター",
|
|
||||||
"customRangeHint": "日付と時刻の両方に対応、最大 30 日",
|
|
||||||
"startTime": "開始時刻",
|
|
||||||
"endTime": "終了時刻",
|
|
||||||
"input": "Input",
|
"input": "Input",
|
||||||
"output": "Output",
|
"output": "Output",
|
||||||
"cacheWrite": "作成",
|
"cacheWrite": "作成",
|
||||||
|
|||||||
@@ -1049,11 +1049,6 @@
|
|||||||
"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": "成本",
|
||||||
@@ -1074,8 +1069,6 @@
|
|||||||
"outputTokens": "输出",
|
"outputTokens": "输出",
|
||||||
"cacheReadTokens": "缓存命中",
|
"cacheReadTokens": "缓存命中",
|
||||||
"cacheCreationTokens": "缓存创建",
|
"cacheCreationTokens": "缓存创建",
|
||||||
"cacheReadShort": "缓存读",
|
|
||||||
"cacheWriteShort": "写",
|
|
||||||
"timingInfo": "用时/首字",
|
"timingInfo": "用时/首字",
|
||||||
"status": "状态",
|
"status": "状态",
|
||||||
"multiplier": "倍率",
|
"multiplier": "倍率",
|
||||||
@@ -1147,10 +1140,6 @@
|
|||||||
"searchProviderPlaceholder": "搜索供应商...",
|
"searchProviderPlaceholder": "搜索供应商...",
|
||||||
"searchModelPlaceholder": "搜索模型...",
|
"searchModelPlaceholder": "搜索模型...",
|
||||||
"timeRange": "时间范围",
|
"timeRange": "时间范围",
|
||||||
"customRange": "日历筛选",
|
|
||||||
"customRangeHint": "支持日期与时间,最长 30 天",
|
|
||||||
"startTime": "开始时间",
|
|
||||||
"endTime": "结束时间",
|
|
||||||
"input": "Input",
|
"input": "Input",
|
||||||
"output": "Output",
|
"output": "Output",
|
||||||
"cacheWrite": "创建",
|
"cacheWrite": "创建",
|
||||||
|
|||||||
+4
-12
@@ -63,20 +63,12 @@ export const usageApi = {
|
|||||||
return invoke("get_usage_trends", { startDate, endDate, appType });
|
return invoke("get_usage_trends", { startDate, endDate, appType });
|
||||||
},
|
},
|
||||||
|
|
||||||
getProviderStats: async (
|
getProviderStats: async (appType?: string): Promise<ProviderStats[]> => {
|
||||||
startDate?: number,
|
return invoke("get_provider_stats", { appType });
|
||||||
endDate?: number,
|
|
||||||
appType?: string,
|
|
||||||
): Promise<ProviderStats[]> => {
|
|
||||||
return invoke("get_provider_stats", { startDate, endDate, appType });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getModelStats: async (
|
getModelStats: async (appType?: string): Promise<ModelStats[]> => {
|
||||||
startDate?: number,
|
return invoke("get_model_stats", { appType });
|
||||||
endDate?: number,
|
|
||||||
appType?: string,
|
|
||||||
): Promise<ModelStats[]> => {
|
|
||||||
return invoke("get_model_stats", { startDate, endDate, appType });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getRequestLogs: async (
|
getRequestLogs: async (
|
||||||
|
|||||||
+55
-112
@@ -1,7 +1,6 @@
|
|||||||
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 { resolveUsageRange } from "@/lib/usageRange";
|
import type { LogFilters } from "@/types/usage";
|
||||||
import type { LogFilters, UsageRangeSelection } from "@/types/usage";
|
|
||||||
|
|
||||||
const DEFAULT_REFETCH_INTERVAL_MS = 30000;
|
const DEFAULT_REFETCH_INTERVAL_MS = 30000;
|
||||||
|
|
||||||
@@ -10,94 +9,51 @@ type UsageQueryOptions = {
|
|||||||
refetchIntervalInBackground?: boolean;
|
refetchIntervalInBackground?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RequestLogsTimeMode = "rolling" | "fixed";
|
||||||
|
|
||||||
type RequestLogsQueryArgs = {
|
type RequestLogsQueryArgs = {
|
||||||
filters: LogFilters;
|
filters: LogFilters;
|
||||||
range: UsageRangeSelection;
|
timeMode: RequestLogsTimeMode;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
rollingWindowSeconds?: number;
|
||||||
options?: UsageQueryOptions;
|
options?: UsageQueryOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RequestLogsKey = {
|
type RequestLogsKey = {
|
||||||
preset: UsageRangeSelection["preset"];
|
timeMode: RequestLogsTimeMode;
|
||||||
customStartDate?: number;
|
rollingWindowSeconds?: 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: (
|
summary: (days: number, appType?: string) =>
|
||||||
preset: UsageRangeSelection["preset"],
|
[...usageKeys.all, "summary", days, appType ?? "all"] as const,
|
||||||
customStartDate: number | undefined,
|
trends: (days: number, appType?: string) =>
|
||||||
customEndDate: number | undefined,
|
[...usageKeys.all, "trends", days, appType ?? "all"] as const,
|
||||||
appType?: string,
|
providerStats: (appType?: string) =>
|
||||||
) =>
|
[...usageKeys.all, "provider-stats", appType ?? "all"] as const,
|
||||||
[
|
modelStats: (appType?: string) =>
|
||||||
...usageKeys.all,
|
[...usageKeys.all, "model-stats", appType ?? "all"] as const,
|
||||||
"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.preset,
|
key.timeMode,
|
||||||
key.customStartDate ?? 0,
|
key.rollingWindowSeconds ?? 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,
|
||||||
@@ -108,22 +64,23 @@ 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(
|
||||||
range: UsageRangeSelection,
|
days: number,
|
||||||
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(
|
queryKey: usageKeys.summary(days, appType),
|
||||||
range.preset,
|
|
||||||
range.customStartDate,
|
|
||||||
range.customEndDate,
|
|
||||||
appType,
|
|
||||||
),
|
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const { startDate, endDate } = resolveUsageRange(range);
|
const { startDate, endDate } = getWindow(days);
|
||||||
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,
|
||||||
@@ -132,20 +89,15 @@ export function useUsageSummary(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useUsageTrends(
|
export function useUsageTrends(
|
||||||
range: UsageRangeSelection,
|
days: number,
|
||||||
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(
|
queryKey: usageKeys.trends(days, appType),
|
||||||
range.preset,
|
|
||||||
range.customStartDate,
|
|
||||||
range.customEndDate,
|
|
||||||
appType,
|
|
||||||
),
|
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const { startDate, endDate } = resolveUsageRange(range);
|
const { startDate, endDate } = getWindow(days);
|
||||||
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,
|
||||||
@@ -154,70 +106,61 @@ 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(
|
queryKey: usageKeys.providerStats(appType),
|
||||||
range.preset,
|
queryFn: () => usageApi.getProviderStats(effectiveAppType),
|
||||||
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(
|
export function useModelStats(appType?: string, options?: UsageQueryOptions) {
|
||||||
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(
|
queryKey: usageKeys.modelStats(appType),
|
||||||
range.preset,
|
queryFn: () => usageApi.getModelStats(effectiveAppType),
|
||||||
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,
|
||||||
range,
|
timeMode,
|
||||||
page = 0,
|
page = 0,
|
||||||
pageSize = 20,
|
pageSize = 20,
|
||||||
|
rollingWindowSeconds = 24 * 60 * 60,
|
||||||
options,
|
options,
|
||||||
}: RequestLogsQueryArgs) {
|
}: RequestLogsQueryArgs) {
|
||||||
const key: RequestLogsKey = {
|
const key: RequestLogsKey = {
|
||||||
preset: range.preset,
|
timeMode,
|
||||||
customStartDate: range.customStartDate,
|
rollingWindowSeconds:
|
||||||
customEndDate: range.customEndDate,
|
timeMode === "rolling" ? rollingWindowSeconds : undefined,
|
||||||
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 = { ...filters, ...resolveUsageRange(range) };
|
const effectiveFilters =
|
||||||
|
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秒自动刷新
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
+2
-8
@@ -121,18 +121,12 @@ export interface ProviderLimitStatus {
|
|||||||
monthlyExceeded: boolean;
|
monthlyExceeded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UsageRangePreset = "today" | "1d" | "7d" | "14d" | "30d" | "custom";
|
export type TimeRange = "1d" | "7d" | "30d";
|
||||||
|
|
||||||
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: UsageRangePreset;
|
timeRange: TimeRange;
|
||||||
providerId?: string;
|
providerId?: string;
|
||||||
appType?: string;
|
appType?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user