mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-18 18:50:49 +08:00
feat(usage): add time range filter for stats and charts
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<Exclude<UsageTimeRange, 'all'>, 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<string[]>(loadChartLines);
|
||||
const [timeRange, setTimeRange] = useState<UsageTimeRange>(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() {
|
||||
<div className={styles.header}>
|
||||
<h1 className={styles.pageTitle}>{t('usage_stats.title')}</h1>
|
||||
<div className={styles.headerActions}>
|
||||
<div className={styles.timeRangeGroup}>
|
||||
<span className={styles.timeRangeLabel}>{t('usage_stats.range_filter')}</span>
|
||||
<div className={styles.timeRangeSelectWrap}>
|
||||
<select
|
||||
value={timeRange}
|
||||
onChange={(event) => setTimeRange(event.target.value as UsageTimeRange)}
|
||||
className={`${styles.select} ${styles.timeRangeSelect}`}
|
||||
>
|
||||
<option value="all">{t('usage_stats.range_all')}</option>
|
||||
<option value="7h">{t('usage_stats.range_7h')}</option>
|
||||
<option value="24h">{t('usage_stats.range_24h')}</option>
|
||||
<option value="7d">{t('usage_stats.range_7d')}</option>
|
||||
</select>
|
||||
<span className={styles.timeRangeSelectIcon} aria-hidden="true">
|
||||
<IconChevronDown size={14} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -198,7 +270,7 @@ export function UsagePage() {
|
||||
|
||||
{/* Stats Overview Cards */}
|
||||
<StatCards
|
||||
usage={usage}
|
||||
usage={filteredUsage}
|
||||
loading={loading}
|
||||
modelPrices={modelPrices}
|
||||
sparklines={{
|
||||
|
||||
Reference in New Issue
Block a user