Files
Cli-Proxy-API-Management-Ce…/src/pages/UsagePage.tsx

384 lines
11 KiB
TypeScript

import { useState, useMemo, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { Select } from '@/components/ui/Select';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useThemeStore, useConfigStore } from '@/stores';
import {
StatCards,
UsageChart,
ChartLineSelector,
ApiDetailsCard,
ModelStatsCard,
PriceSettingsCard,
CredentialStatsCard,
TokenBreakdownChart,
CostTrendChart,
ServiceHealthCard,
useUsageData,
useSparklines,
useChartData
} from '@/components/usage';
import {
getModelNamesFromUsage,
getApiStats,
getModelStats,
filterUsageByTimeRange,
type UsageTimeRange
} from '@/utils/usage';
import styles from './UsagePage.module.scss';
// Register Chart.js components
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
);
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 TIME_RANGE_OPTIONS: ReadonlyArray<{ value: UsageTimeRange; labelKey: string }> = [
{ value: 'all', labelKey: 'usage_stats.range_all' },
{ value: '7h', labelKey: 'usage_stats.range_7h' },
{ value: '24h', labelKey: 'usage_stats.range_24h' },
{ value: '7d', labelKey: 'usage_stats.range_7d' },
];
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)) {
return DEFAULT_CHART_LINES;
}
const filtered = value
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.filter(Boolean)
.slice(0, maxLines);
return filtered.length ? filtered : DEFAULT_CHART_LINES;
};
const loadChartLines = (): string[] => {
try {
if (typeof localStorage === 'undefined') {
return DEFAULT_CHART_LINES;
}
const raw = localStorage.getItem(CHART_LINES_STORAGE_KEY);
if (!raw) {
return DEFAULT_CHART_LINES;
}
return normalizeChartLines(JSON.parse(raw));
} catch {
return DEFAULT_CHART_LINES;
}
};
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)');
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const isDark = resolvedTheme === 'dark';
const config = useConfigStore((state) => state.config);
// Data hook
const {
usage,
loading,
error,
lastRefreshedAt,
modelPrices,
setModelPrices,
loadUsage,
handleExport,
handleImport,
handleImportChange,
importInputRef,
exporting,
importing
} = useUsageData();
useHeaderRefresh(loadUsage);
// Chart lines state
const [chartLines, setChartLines] = useState<string[]>(loadChartLines);
const [timeRange, setTimeRange] = useState<UsageTimeRange>(loadTimeRange);
const timeRangeOptions = useMemo(
() =>
TIME_RANGE_OPTIONS.map((opt) => ({
value: opt.value,
label: t(opt.labelKey)
})),
[t]
);
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));
}, []);
useEffect(() => {
try {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(CHART_LINES_STORAGE_KEY, JSON.stringify(chartLines));
} catch {
// Ignore storage errors.
}
}, [chartLines]);
useEffect(() => {
try {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(TIME_RANGE_STORAGE_KEY, timeRange);
} catch {
// Ignore storage errors.
}
}, [timeRange]);
// Sparklines hook
const {
requestsSparkline,
tokensSparkline,
rpmSparkline,
tpmSparkline,
costSparkline
} = useSparklines({ usage: filteredUsage, loading });
// Chart data hook
const {
requestsPeriod,
setRequestsPeriod,
tokensPeriod,
setTokensPeriod,
requestsChartData,
tokensChartData,
requestsChartOptions,
tokensChartOptions
} = useChartData({ usage: filteredUsage, chartLines, isDark, isMobile, hourWindowHours });
// Derived data
const modelNames = useMemo(() => getModelNamesFromUsage(usage), [usage]);
const apiStats = useMemo(
() => getApiStats(filteredUsage, modelPrices),
[filteredUsage, modelPrices]
);
const modelStats = useMemo(
() => getModelStats(filteredUsage, modelPrices),
[filteredUsage, modelPrices]
);
const hasPrices = Object.keys(modelPrices).length > 0;
return (
<div className={styles.container}>
{loading && !usage && (
<div className={styles.loadingOverlay} aria-busy="true">
<div className={styles.loadingOverlayContent}>
<LoadingSpinner size={28} className={styles.loadingOverlaySpinner} />
<span className={styles.loadingOverlayText}>{t('common.loading')}</span>
</div>
</div>
)}
<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>
<Select
value={timeRange}
options={timeRangeOptions}
onChange={(value) => setTimeRange(value as UsageTimeRange)}
className={styles.timeRangeSelectControl}
ariaLabel={t('usage_stats.range_filter')}
fullWidth={false}
/>
</div>
<Button
variant="secondary"
size="sm"
onClick={handleExport}
loading={exporting}
disabled={loading || importing}
>
{t('usage_stats.export')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleImport}
loading={importing}
disabled={loading || exporting}
>
{t('usage_stats.import')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={loadUsage}
disabled={loading || exporting || importing}
>
{loading ? t('common.loading') : t('usage_stats.refresh')}
</Button>
<input
ref={importInputRef}
type="file"
accept=".json,application/json"
style={{ display: 'none' }}
onChange={handleImportChange}
/>
{lastRefreshedAt && (
<span className={styles.lastRefreshed}>
{t('usage_stats.last_updated')}: {lastRefreshedAt.toLocaleTimeString()}
</span>
)}
</div>
</div>
{error && <div className={styles.errorBox}>{error}</div>}
{/* Stats Overview Cards */}
<StatCards
usage={filteredUsage}
loading={loading}
modelPrices={modelPrices}
sparklines={{
requests: requestsSparkline,
tokens: tokensSparkline,
rpm: rpmSparkline,
tpm: tpmSparkline,
cost: costSparkline
}}
/>
{/* Chart Line Selection */}
<ChartLineSelector
chartLines={chartLines}
modelNames={modelNames}
maxLines={MAX_CHART_LINES}
onChange={handleChartLinesChange}
/>
{/* Service Health */}
<ServiceHealthCard usage={usage} loading={loading} />
{/* Charts Grid */}
<div className={styles.chartsGrid}>
<UsageChart
title={t('usage_stats.requests_trend')}
period={requestsPeriod}
onPeriodChange={setRequestsPeriod}
chartData={requestsChartData}
chartOptions={requestsChartOptions}
loading={loading}
isMobile={isMobile}
emptyText={t('usage_stats.no_data')}
/>
<UsageChart
title={t('usage_stats.tokens_trend')}
period={tokensPeriod}
onPeriodChange={setTokensPeriod}
chartData={tokensChartData}
chartOptions={tokensChartOptions}
loading={loading}
isMobile={isMobile}
emptyText={t('usage_stats.no_data')}
/>
</div>
{/* Token Breakdown Chart */}
<TokenBreakdownChart
usage={filteredUsage}
loading={loading}
isDark={isDark}
isMobile={isMobile}
hourWindowHours={hourWindowHours}
/>
{/* Cost Trend Chart */}
<CostTrendChart
usage={filteredUsage}
loading={loading}
isDark={isDark}
isMobile={isMobile}
modelPrices={modelPrices}
hourWindowHours={hourWindowHours}
/>
{/* Details Grid */}
<div className={styles.detailsGrid}>
<ApiDetailsCard apiStats={apiStats} loading={loading} hasPrices={hasPrices} />
<ModelStatsCard modelStats={modelStats} loading={loading} hasPrices={hasPrices} />
</div>
{/* Credential Stats */}
<CredentialStatsCard
usage={filteredUsage}
loading={loading}
geminiKeys={config?.geminiApiKeys || []}
claudeConfigs={config?.claudeApiKeys || []}
codexConfigs={config?.codexApiKeys || []}
vertexConfigs={config?.vertexApiKeys || []}
openaiProviders={config?.openaiCompatibility || []}
/>
{/* Price Settings */}
<PriceSettingsCard
modelNames={modelNames}
modelPrices={modelPrices}
onPricesChange={setModelPrices}
/>
</div>
);
}