diff --git a/src/components/usage/hooks/useChartData.ts b/src/components/usage/hooks/useChartData.ts index d7376cb..7805df3 100644 --- a/src/components/usage/hooks/useChartData.ts +++ b/src/components/usage/hooks/useChartData.ts @@ -9,6 +9,7 @@ export interface UseChartDataOptions { chartLines: string[]; isDark: boolean; isMobile: boolean; + hourWindowHours?: number; } export interface UseChartDataReturn { @@ -26,20 +27,21 @@ export function useChartData({ usage, chartLines, isDark, - isMobile + isMobile, + hourWindowHours }: UseChartDataOptions): UseChartDataReturn { const [requestsPeriod, setRequestsPeriod] = useState<'hour' | 'day'>('day'); const [tokensPeriod, setTokensPeriod] = useState<'hour' | 'day'>('day'); const requestsChartData = useMemo(() => { if (!usage) return { labels: [], datasets: [] }; - return buildChartData(usage, requestsPeriod, 'requests', chartLines); - }, [usage, requestsPeriod, chartLines]); + return buildChartData(usage, requestsPeriod, 'requests', chartLines, { hourWindowHours }); + }, [usage, requestsPeriod, chartLines, hourWindowHours]); const tokensChartData = useMemo(() => { if (!usage) return { labels: [], datasets: [] }; - return buildChartData(usage, tokensPeriod, 'tokens', chartLines); - }, [usage, tokensPeriod, chartLines]); + return buildChartData(usage, tokensPeriod, 'tokens', chartLines, { hourWindowHours }); + }, [usage, tokensPeriod, chartLines, hourWindowHours]); const requestsChartOptions = useMemo( () => diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 558d1eb..ca64e53 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -750,6 +750,11 @@ "api_details": "API Details", "by_hour": "By Hour", "by_day": "By Day", + "range_filter": "Time Range", + "range_all": "All Time", + "range_7h": "Last 7 Hours", + "range_24h": "Last 24 Hours", + "range_7d": "Last 7 Days", "refresh": "Refresh", "export": "Export", "import": "Import", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 1431682..700dd47 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -753,6 +753,11 @@ "api_details": "Детали API", "by_hour": "По часам", "by_day": "По дням", + "range_filter": "Диапазон времени", + "range_all": "За всё время", + "range_7h": "Последние 7 часов", + "range_24h": "Последние 24 часа", + "range_7d": "Последние 7 дней", "refresh": "Обновить", "export": "Экспорт", "import": "Импорт", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index b629bbb..3eee0e8 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -750,6 +750,11 @@ "api_details": "API 详细统计", "by_hour": "按小时", "by_day": "按天", + "range_filter": "时间范围", + "range_all": "全部时间", + "range_7h": "最近7小时", + "range_24h": "最近24小时", + "range_7d": "最近7天", "refresh": "刷新", "export": "导出数据", "import": "导入数据", diff --git a/src/pages/UsagePage.module.scss b/src/pages/UsagePage.module.scss index bce9d85..062a923 100644 --- a/src/pages/UsagePage.module.scss +++ b/src/pages/UsagePage.module.scss @@ -25,6 +25,59 @@ flex-wrap: wrap; } +.timeRangeGroup { + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.timeRangeLabel { + font-size: 12px; + color: var(--text-secondary); + font-weight: 600; +} + +.timeRangeSelectWrap { + position: relative; + display: inline-flex; + align-items: center; +} + +.select.timeRangeSelect { + min-width: 164px; + height: 40px; + border-color: var(--border-color); + border-radius: $radius-md; + background-color: var(--bg-primary); + box-shadow: var(--shadow); + color: var(--text-primary); + font-size: 13px; + font-weight: 500; + padding-right: 34px; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + + &:hover { + border-color: var(--border-hover); + } + + &:focus { + box-shadow: var(--shadow), 0 0 0 3px rgba(59, 130, 246, 0.15); + } +} + +.timeRangeSelectIcon { + position: absolute; + right: 11px; + top: 50%; + transform: translateY(-50%); + display: inline-flex; + color: var(--text-secondary); + pointer-events: none; +} + .pageTitle { font-size: 28px; font-weight: 700; diff --git a/src/pages/UsagePage.tsx b/src/pages/UsagePage.tsx index 9207e7a..daf081a 100644 --- a/src/pages/UsagePage.tsx +++ b/src/pages/UsagePage.tsx @@ -13,6 +13,7 @@ import { } from 'chart.js'; import { Button } from '@/components/ui/Button'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; +import { IconChevronDown } from '@/components/ui/icons'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useHeaderRefresh } from '@/hooks/useHeaderRefresh'; import { useThemeStore } from '@/stores'; @@ -27,7 +28,13 @@ import { useSparklines, useChartData } from '@/components/usage'; -import { getModelNamesFromUsage, getApiStats, getModelStats } from '@/utils/usage'; +import { + getModelNamesFromUsage, + getApiStats, + getModelStats, + filterUsageByTimeRange, + type UsageTimeRange +} from '@/utils/usage'; import styles from './UsagePage.module.scss'; // Register Chart.js components @@ -43,8 +50,18 @@ ChartJS.register( ); const CHART_LINES_STORAGE_KEY = 'cli-proxy-usage-chart-lines-v1'; +const TIME_RANGE_STORAGE_KEY = 'cli-proxy-usage-time-range-v1'; const DEFAULT_CHART_LINES = ['all']; +const DEFAULT_TIME_RANGE: UsageTimeRange = '24h'; const MAX_CHART_LINES = 9; +const HOUR_WINDOW_BY_TIME_RANGE: Record, number> = { + '7h': 7, + '24h': 24, + '7d': 7 * 24 +}; + +const isUsageTimeRange = (value: unknown): value is UsageTimeRange => + value === '7h' || value === '24h' || value === '7d' || value === 'all'; const normalizeChartLines = (value: unknown, maxLines = MAX_CHART_LINES): string[] => { if (!Array.isArray(value)) { @@ -75,6 +92,18 @@ const loadChartLines = (): string[] => { } }; +const loadTimeRange = (): UsageTimeRange => { + try { + if (typeof localStorage === 'undefined') { + return DEFAULT_TIME_RANGE; + } + const raw = localStorage.getItem(TIME_RANGE_STORAGE_KEY); + return isUsageTimeRange(raw) ? raw : DEFAULT_TIME_RANGE; + } catch { + return DEFAULT_TIME_RANGE; + } +}; + export function UsagePage() { const { t } = useTranslation(); const isMobile = useMediaQuery('(max-width: 768px)'); @@ -101,6 +130,14 @@ export function UsagePage() { // Chart lines state const [chartLines, setChartLines] = useState(loadChartLines); + const [timeRange, setTimeRange] = useState(loadTimeRange); + + const filteredUsage = useMemo( + () => (usage ? filterUsageByTimeRange(usage, timeRange) : null), + [usage, timeRange] + ); + const hourWindowHours = + timeRange === 'all' ? undefined : HOUR_WINDOW_BY_TIME_RANGE[timeRange]; const handleChartLinesChange = useCallback((lines: string[]) => { setChartLines(normalizeChartLines(lines)); @@ -117,6 +154,17 @@ export function UsagePage() { } }, [chartLines]); + useEffect(() => { + try { + if (typeof localStorage === 'undefined') { + return; + } + localStorage.setItem(TIME_RANGE_STORAGE_KEY, timeRange); + } catch { + // Ignore storage errors. + } + }, [timeRange]); + // Sparklines hook const { requestsSparkline, @@ -124,7 +172,7 @@ export function UsagePage() { rpmSparkline, tpmSparkline, costSparkline - } = useSparklines({ usage, loading }); + } = useSparklines({ usage: filteredUsage, loading }); // Chart data hook const { @@ -136,12 +184,18 @@ export function UsagePage() { tokensChartData, requestsChartOptions, tokensChartOptions - } = useChartData({ usage, chartLines, isDark, isMobile }); + } = useChartData({ usage: filteredUsage, chartLines, isDark, isMobile, hourWindowHours }); // Derived data const modelNames = useMemo(() => getModelNamesFromUsage(usage), [usage]); - const apiStats = useMemo(() => getApiStats(usage, modelPrices), [usage, modelPrices]); - const modelStats = useMemo(() => getModelStats(usage, modelPrices), [usage, modelPrices]); + const apiStats = useMemo( + () => getApiStats(filteredUsage, modelPrices), + [filteredUsage, modelPrices] + ); + const modelStats = useMemo( + () => getModelStats(filteredUsage, modelPrices), + [filteredUsage, modelPrices] + ); const hasPrices = Object.keys(modelPrices).length > 0; return ( @@ -158,6 +212,24 @@ export function UsagePage() {

{t('usage_stats.title')}

+
+ {t('usage_stats.range_filter')} +
+ + +
+