mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-18 10:40:50 +08:00
384 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|