mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 03:10:50 +08:00
refactor(usage): modularize UsagePage into separate section components
This commit is contained in:
79
src/components/usage/ApiDetailsCard.tsx
Normal file
79
src/components/usage/ApiDetailsCard.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { formatTokensInMillions, formatUsd, type ApiStats } from '@/utils/usage';
|
||||||
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
|
export interface ApiDetailsCardProps {
|
||||||
|
apiStats: ApiStats[];
|
||||||
|
loading: boolean;
|
||||||
|
hasPrices: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [expandedApis, setExpandedApis] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const toggleExpand = (endpoint: string) => {
|
||||||
|
setExpandedApis((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(endpoint)) {
|
||||||
|
newSet.delete(endpoint);
|
||||||
|
} else {
|
||||||
|
newSet.add(endpoint);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title={t('usage_stats.api_details')}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.hint}>{t('common.loading')}</div>
|
||||||
|
) : apiStats.length > 0 ? (
|
||||||
|
<div className={styles.apiList}>
|
||||||
|
{apiStats.map((api) => (
|
||||||
|
<div key={api.endpoint} className={styles.apiItem}>
|
||||||
|
<div className={styles.apiHeader} onClick={() => toggleExpand(api.endpoint)}>
|
||||||
|
<div className={styles.apiInfo}>
|
||||||
|
<span className={styles.apiEndpoint}>{api.endpoint}</span>
|
||||||
|
<div className={styles.apiStats}>
|
||||||
|
<span className={styles.apiBadge}>
|
||||||
|
{t('usage_stats.requests_count')}: {api.totalRequests}
|
||||||
|
</span>
|
||||||
|
<span className={styles.apiBadge}>
|
||||||
|
Tokens: {formatTokensInMillions(api.totalTokens)}
|
||||||
|
</span>
|
||||||
|
{hasPrices && api.totalCost > 0 && (
|
||||||
|
<span className={styles.apiBadge}>
|
||||||
|
{t('usage_stats.total_cost')}: {formatUsd(api.totalCost)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={styles.expandIcon}>
|
||||||
|
{expandedApis.has(api.endpoint) ? '▼' : '▶'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{expandedApis.has(api.endpoint) && (
|
||||||
|
<div className={styles.apiModels}>
|
||||||
|
{Object.entries(api.models).map(([model, stats]) => (
|
||||||
|
<div key={model} className={styles.modelRow}>
|
||||||
|
<span className={styles.modelName}>{model}</span>
|
||||||
|
<span className={styles.modelStat}>
|
||||||
|
{stats.requests} {t('usage_stats.requests_count')}
|
||||||
|
</span>
|
||||||
|
<span className={styles.modelStat}>{formatTokensInMillions(stats.tokens)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/components/usage/ChartLineSelector.tsx
Normal file
92
src/components/usage/ChartLineSelector.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
|
export interface ChartLineSelectorProps {
|
||||||
|
chartLines: string[];
|
||||||
|
modelNames: string[];
|
||||||
|
maxLines?: number;
|
||||||
|
onChange: (lines: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartLineSelector({
|
||||||
|
chartLines,
|
||||||
|
modelNames,
|
||||||
|
maxLines = 9,
|
||||||
|
onChange
|
||||||
|
}: ChartLineSelectorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (chartLines.length >= maxLines) return;
|
||||||
|
const unusedModel = modelNames.find((m) => !chartLines.includes(m));
|
||||||
|
if (unusedModel) {
|
||||||
|
onChange([...chartLines, unusedModel]);
|
||||||
|
} else {
|
||||||
|
onChange([...chartLines, 'all']);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (index: number) => {
|
||||||
|
if (chartLines.length <= 1) return;
|
||||||
|
const newLines = [...chartLines];
|
||||||
|
newLines.splice(index, 1);
|
||||||
|
onChange(newLines);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (index: number, value: string) => {
|
||||||
|
const newLines = [...chartLines];
|
||||||
|
newLines[index] = value;
|
||||||
|
onChange(newLines);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={t('usage_stats.chart_line_actions_label')}
|
||||||
|
extra={
|
||||||
|
<div className={styles.chartLineHeader}>
|
||||||
|
<span className={styles.chartLineCount}>
|
||||||
|
{chartLines.length}/{maxLines}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={chartLines.length >= maxLines}
|
||||||
|
>
|
||||||
|
{t('usage_stats.chart_line_add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.chartLineList}>
|
||||||
|
{chartLines.map((line, index) => (
|
||||||
|
<div key={index} className={styles.chartLineItem}>
|
||||||
|
<span className={styles.chartLineLabel}>
|
||||||
|
{t(`usage_stats.chart_line_label_${index + 1}`)}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={line}
|
||||||
|
onChange={(e) => handleChange(index, e.target.value)}
|
||||||
|
className={styles.select}
|
||||||
|
>
|
||||||
|
<option value="all">{t('usage_stats.chart_line_all')}</option>
|
||||||
|
{modelNames.map((name) => (
|
||||||
|
<option key={name} value={name}>
|
||||||
|
{name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{chartLines.length > 1 && (
|
||||||
|
<Button variant="danger" size="sm" onClick={() => handleRemove(index)}>
|
||||||
|
{t('usage_stats.chart_line_delete')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className={styles.chartLineHint}>{t('usage_stats.chart_line_hint')}</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/components/usage/ModelStatsCard.tsx
Normal file
54
src/components/usage/ModelStatsCard.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { formatTokensInMillions, formatUsd } from '@/utils/usage';
|
||||||
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
|
export interface ModelStat {
|
||||||
|
model: string;
|
||||||
|
requests: number;
|
||||||
|
tokens: number;
|
||||||
|
cost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelStatsCardProps {
|
||||||
|
modelStats: ModelStat[];
|
||||||
|
loading: boolean;
|
||||||
|
hasPrices: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title={t('usage_stats.models')}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.hint}>{t('common.loading')}</div>
|
||||||
|
) : modelStats.length > 0 ? (
|
||||||
|
<div className={styles.tableWrapper}>
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('usage_stats.model_name')}</th>
|
||||||
|
<th>{t('usage_stats.requests_count')}</th>
|
||||||
|
<th>{t('usage_stats.tokens_count')}</th>
|
||||||
|
{hasPrices && <th>{t('usage_stats.total_cost')}</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{modelStats.map((stat) => (
|
||||||
|
<tr key={stat.model}>
|
||||||
|
<td className={styles.modelCell}>{stat.model}</td>
|
||||||
|
<td>{stat.requests.toLocaleString()}</td>
|
||||||
|
<td>{formatTokensInMillions(stat.tokens)}</td>
|
||||||
|
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
src/components/usage/PriceSettingsCard.tsx
Normal file
164
src/components/usage/PriceSettingsCard.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import type { ModelPrice } from '@/utils/usage';
|
||||||
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
|
export interface PriceSettingsCardProps {
|
||||||
|
modelNames: string[];
|
||||||
|
modelPrices: Record<string, ModelPrice>;
|
||||||
|
onPricesChange: (prices: Record<string, ModelPrice>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PriceSettingsCard({
|
||||||
|
modelNames,
|
||||||
|
modelPrices,
|
||||||
|
onPricesChange
|
||||||
|
}: PriceSettingsCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [selectedModel, setSelectedModel] = useState('');
|
||||||
|
const [promptPrice, setPromptPrice] = useState('');
|
||||||
|
const [completionPrice, setCompletionPrice] = useState('');
|
||||||
|
const [cachePrice, setCachePrice] = useState('');
|
||||||
|
|
||||||
|
const handleSavePrice = () => {
|
||||||
|
if (!selectedModel) return;
|
||||||
|
const prompt = parseFloat(promptPrice) || 0;
|
||||||
|
const completion = parseFloat(completionPrice) || 0;
|
||||||
|
const cache = cachePrice.trim() === '' ? prompt : parseFloat(cachePrice) || 0;
|
||||||
|
const newPrices = { ...modelPrices, [selectedModel]: { prompt, completion, cache } };
|
||||||
|
onPricesChange(newPrices);
|
||||||
|
setSelectedModel('');
|
||||||
|
setPromptPrice('');
|
||||||
|
setCompletionPrice('');
|
||||||
|
setCachePrice('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePrice = (model: string) => {
|
||||||
|
const newPrices = { ...modelPrices };
|
||||||
|
delete newPrices[model];
|
||||||
|
onPricesChange(newPrices);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditPrice = (model: string) => {
|
||||||
|
const price = modelPrices[model];
|
||||||
|
setSelectedModel(model);
|
||||||
|
setPromptPrice(price?.prompt?.toString() || '');
|
||||||
|
setCompletionPrice(price?.completion?.toString() || '');
|
||||||
|
setCachePrice(price?.cache?.toString() || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModelSelect = (value: string) => {
|
||||||
|
setSelectedModel(value);
|
||||||
|
const price = modelPrices[value];
|
||||||
|
if (price) {
|
||||||
|
setPromptPrice(price.prompt.toString());
|
||||||
|
setCompletionPrice(price.completion.toString());
|
||||||
|
setCachePrice(price.cache.toString());
|
||||||
|
} else {
|
||||||
|
setPromptPrice('');
|
||||||
|
setCompletionPrice('');
|
||||||
|
setCachePrice('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title={t('usage_stats.model_price_settings')}>
|
||||||
|
<div className={styles.pricingSection}>
|
||||||
|
{/* Price Form */}
|
||||||
|
<div className={styles.priceForm}>
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<div className={styles.formField}>
|
||||||
|
<label>{t('usage_stats.model_name')}</label>
|
||||||
|
<select
|
||||||
|
value={selectedModel}
|
||||||
|
onChange={(e) => handleModelSelect(e.target.value)}
|
||||||
|
className={styles.select}
|
||||||
|
>
|
||||||
|
<option value="">{t('usage_stats.model_price_select_placeholder')}</option>
|
||||||
|
{modelNames.map((name) => (
|
||||||
|
<option key={name} value={name}>
|
||||||
|
{name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formField}>
|
||||||
|
<label>{t('usage_stats.model_price_prompt')} ($/1M)</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={promptPrice}
|
||||||
|
onChange={(e) => setPromptPrice(e.target.value)}
|
||||||
|
placeholder="0.00"
|
||||||
|
step="0.0001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formField}>
|
||||||
|
<label>{t('usage_stats.model_price_completion')} ($/1M)</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={completionPrice}
|
||||||
|
onChange={(e) => setCompletionPrice(e.target.value)}
|
||||||
|
placeholder="0.00"
|
||||||
|
step="0.0001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formField}>
|
||||||
|
<label>{t('usage_stats.model_price_cache')} ($/1M)</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={cachePrice}
|
||||||
|
onChange={(e) => setCachePrice(e.target.value)}
|
||||||
|
placeholder="0.00"
|
||||||
|
step="0.0001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="primary" onClick={handleSavePrice} disabled={!selectedModel}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Saved Prices List */}
|
||||||
|
<div className={styles.pricesList}>
|
||||||
|
<h4 className={styles.pricesTitle}>{t('usage_stats.saved_prices')}</h4>
|
||||||
|
{Object.keys(modelPrices).length > 0 ? (
|
||||||
|
<div className={styles.pricesGrid}>
|
||||||
|
{Object.entries(modelPrices).map(([model, price]) => (
|
||||||
|
<div key={model} className={styles.priceItem}>
|
||||||
|
<div className={styles.priceInfo}>
|
||||||
|
<span className={styles.priceModel}>{model}</span>
|
||||||
|
<div className={styles.priceMeta}>
|
||||||
|
<span>
|
||||||
|
{t('usage_stats.model_price_prompt')}: ${price.prompt.toFixed(4)}/1M
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t('usage_stats.model_price_completion')}: ${price.completion.toFixed(4)}/1M
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t('usage_stats.model_price_cache')}: ${price.cache.toFixed(4)}/1M
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.priceActions}>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => handleEditPrice(model)}>
|
||||||
|
{t('common.edit')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" size="sm" onClick={() => handleDeletePrice(model)}>
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.hint}>{t('usage_stats.model_price_empty')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
src/components/usage/StatCards.tsx
Normal file
184
src/components/usage/StatCards.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import type { CSSProperties, ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Line } from 'react-chartjs-2';
|
||||||
|
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
|
||||||
|
import {
|
||||||
|
formatTokensInMillions,
|
||||||
|
formatPerMinuteValue,
|
||||||
|
formatUsd,
|
||||||
|
calculateTokenBreakdown,
|
||||||
|
calculateRecentPerMinuteRates,
|
||||||
|
calculateTotalCost,
|
||||||
|
type ModelPrice
|
||||||
|
} from '@/utils/usage';
|
||||||
|
import { sparklineOptions } from '@/utils/usage/chartConfig';
|
||||||
|
import type { UsagePayload } from './hooks/useUsageData';
|
||||||
|
import type { SparklineBundle } from './hooks/useSparklines';
|
||||||
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
|
interface StatCardData {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
accent: string;
|
||||||
|
accentSoft: string;
|
||||||
|
accentBorder: string;
|
||||||
|
value: string;
|
||||||
|
meta?: ReactNode;
|
||||||
|
trend: SparklineBundle | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatCardsProps {
|
||||||
|
usage: UsagePayload | null;
|
||||||
|
loading: boolean;
|
||||||
|
modelPrices: Record<string, ModelPrice>;
|
||||||
|
sparklines: {
|
||||||
|
requests: SparklineBundle | null;
|
||||||
|
tokens: SparklineBundle | null;
|
||||||
|
rpm: SparklineBundle | null;
|
||||||
|
tpm: SparklineBundle | null;
|
||||||
|
cost: SparklineBundle | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatCards({ usage, loading, modelPrices, sparklines }: StatCardsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const tokenBreakdown = usage ? calculateTokenBreakdown(usage) : { cachedTokens: 0, reasoningTokens: 0 };
|
||||||
|
const rateStats = usage
|
||||||
|
? calculateRecentPerMinuteRates(30, usage)
|
||||||
|
: { rpm: 0, tpm: 0, windowMinutes: 30, requestCount: 0, tokenCount: 0 };
|
||||||
|
const totalCost = usage ? calculateTotalCost(usage, modelPrices) : 0;
|
||||||
|
const hasPrices = Object.keys(modelPrices).length > 0;
|
||||||
|
|
||||||
|
const statsCards: StatCardData[] = [
|
||||||
|
{
|
||||||
|
key: 'requests',
|
||||||
|
label: t('usage_stats.total_requests'),
|
||||||
|
icon: <IconSatellite size={16} />,
|
||||||
|
accent: '#3b82f6',
|
||||||
|
accentSoft: 'rgba(59, 130, 246, 0.18)',
|
||||||
|
accentBorder: 'rgba(59, 130, 246, 0.35)',
|
||||||
|
value: loading ? '-' : (usage?.total_requests ?? 0).toLocaleString(),
|
||||||
|
meta: (
|
||||||
|
<>
|
||||||
|
<span className={styles.statMetaItem}>
|
||||||
|
<span className={styles.statMetaDot} style={{ backgroundColor: '#10b981' }} />
|
||||||
|
{t('usage_stats.success_requests')}: {loading ? '-' : (usage?.success_count ?? 0)}
|
||||||
|
</span>
|
||||||
|
<span className={styles.statMetaItem}>
|
||||||
|
<span className={styles.statMetaDot} style={{ backgroundColor: '#ef4444' }} />
|
||||||
|
{t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
trend: sparklines.requests
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tokens',
|
||||||
|
label: t('usage_stats.total_tokens'),
|
||||||
|
icon: <IconDiamond size={16} />,
|
||||||
|
accent: '#8b5cf6',
|
||||||
|
accentSoft: 'rgba(139, 92, 246, 0.18)',
|
||||||
|
accentBorder: 'rgba(139, 92, 246, 0.35)',
|
||||||
|
value: loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0),
|
||||||
|
meta: (
|
||||||
|
<>
|
||||||
|
<span className={styles.statMetaItem}>
|
||||||
|
{t('usage_stats.cached_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.cachedTokens)}
|
||||||
|
</span>
|
||||||
|
<span className={styles.statMetaItem}>
|
||||||
|
{t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.reasoningTokens)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
trend: sparklines.tokens
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rpm',
|
||||||
|
label: t('usage_stats.rpm_30m'),
|
||||||
|
icon: <IconTimer size={16} />,
|
||||||
|
accent: '#22c55e',
|
||||||
|
accentSoft: 'rgba(34, 197, 94, 0.18)',
|
||||||
|
accentBorder: 'rgba(34, 197, 94, 0.32)',
|
||||||
|
value: loading ? '-' : formatPerMinuteValue(rateStats.rpm),
|
||||||
|
meta: (
|
||||||
|
<span className={styles.statMetaItem}>
|
||||||
|
{t('usage_stats.total_requests')}: {loading ? '-' : rateStats.requestCount.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
trend: sparklines.rpm
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tpm',
|
||||||
|
label: t('usage_stats.tpm_30m'),
|
||||||
|
icon: <IconTrendingUp size={16} />,
|
||||||
|
accent: '#f97316',
|
||||||
|
accentSoft: 'rgba(249, 115, 22, 0.18)',
|
||||||
|
accentBorder: 'rgba(249, 115, 22, 0.32)',
|
||||||
|
value: loading ? '-' : formatPerMinuteValue(rateStats.tpm),
|
||||||
|
meta: (
|
||||||
|
<span className={styles.statMetaItem}>
|
||||||
|
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(rateStats.tokenCount)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
trend: sparklines.tpm
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cost',
|
||||||
|
label: t('usage_stats.total_cost'),
|
||||||
|
icon: <IconDollarSign size={16} />,
|
||||||
|
accent: '#f59e0b',
|
||||||
|
accentSoft: 'rgba(245, 158, 11, 0.18)',
|
||||||
|
accentBorder: 'rgba(245, 158, 11, 0.32)',
|
||||||
|
value: loading ? '-' : hasPrices ? formatUsd(totalCost) : '--',
|
||||||
|
meta: (
|
||||||
|
<>
|
||||||
|
<span className={styles.statMetaItem}>
|
||||||
|
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0)}
|
||||||
|
</span>
|
||||||
|
{!hasPrices && (
|
||||||
|
<span className={`${styles.statMetaItem} ${styles.statSubtle}`}>
|
||||||
|
{t('usage_stats.cost_need_price')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
trend: hasPrices ? sparklines.cost : null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.statsGrid}>
|
||||||
|
{statsCards.map((card) => (
|
||||||
|
<div
|
||||||
|
key={card.key}
|
||||||
|
className={styles.statCard}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--accent': card.accent,
|
||||||
|
'--accent-soft': card.accentSoft,
|
||||||
|
'--accent-border': card.accentBorder
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.statCardHeader}>
|
||||||
|
<div className={styles.statLabelGroup}>
|
||||||
|
<span className={styles.statLabel}>{card.label}</span>
|
||||||
|
</div>
|
||||||
|
<span className={styles.statIconBadge}>{card.icon}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statValue}>{card.value}</div>
|
||||||
|
{card.meta && <div className={styles.statMetaRow}>{card.meta}</div>}
|
||||||
|
<div className={styles.statTrend}>
|
||||||
|
{card.trend ? (
|
||||||
|
<Line className={styles.sparkline} data={card.trend.data} options={sparklineOptions} />
|
||||||
|
) : (
|
||||||
|
<div className={styles.statTrendPlaceholder}></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/components/usage/UsageChart.tsx
Normal file
91
src/components/usage/UsageChart.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { ChartOptions } from 'chart.js';
|
||||||
|
import { Line } from 'react-chartjs-2';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import type { ChartData } from '@/utils/usage';
|
||||||
|
import { getHourChartMinWidth } from '@/utils/usage/chartConfig';
|
||||||
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
|
export interface UsageChartProps {
|
||||||
|
title: string;
|
||||||
|
period: 'hour' | 'day';
|
||||||
|
onPeriodChange: (period: 'hour' | 'day') => void;
|
||||||
|
chartData: ChartData;
|
||||||
|
chartOptions: ChartOptions<'line'>;
|
||||||
|
loading: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
|
emptyText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsageChart({
|
||||||
|
title,
|
||||||
|
period,
|
||||||
|
onPeriodChange,
|
||||||
|
chartData,
|
||||||
|
chartOptions,
|
||||||
|
loading,
|
||||||
|
isMobile,
|
||||||
|
emptyText
|
||||||
|
}: UsageChartProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={title}
|
||||||
|
extra={
|
||||||
|
<div className={styles.periodButtons}>
|
||||||
|
<Button
|
||||||
|
variant={period === 'hour' ? 'primary' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPeriodChange('hour')}
|
||||||
|
>
|
||||||
|
{t('usage_stats.by_hour')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={period === 'day' ? 'primary' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPeriodChange('day')}
|
||||||
|
>
|
||||||
|
{t('usage_stats.by_day')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.hint}>{t('common.loading')}</div>
|
||||||
|
) : chartData.labels.length > 0 ? (
|
||||||
|
<div className={styles.chartWrapper}>
|
||||||
|
<div className={styles.chartLegend} aria-label="Chart legend">
|
||||||
|
{chartData.datasets.map((dataset, index) => (
|
||||||
|
<div
|
||||||
|
key={`${dataset.label}-${index}`}
|
||||||
|
className={styles.legendItem}
|
||||||
|
title={dataset.label}
|
||||||
|
>
|
||||||
|
<span className={styles.legendDot} style={{ backgroundColor: dataset.borderColor }} />
|
||||||
|
<span className={styles.legendLabel}>{dataset.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.chartArea}>
|
||||||
|
<div className={styles.chartScroller}>
|
||||||
|
<div
|
||||||
|
className={styles.chartCanvas}
|
||||||
|
style={
|
||||||
|
period === 'hour'
|
||||||
|
? { minWidth: getHourChartMinWidth(chartData.labels.length, isMobile) }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Line data={chartData} options={chartOptions} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.hint}>{emptyText}</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
src/components/usage/hooks/index.ts
Normal file
8
src/components/usage/hooks/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export { useUsageData } from './useUsageData';
|
||||||
|
export type { UsagePayload, UseUsageDataReturn } from './useUsageData';
|
||||||
|
|
||||||
|
export { useSparklines } from './useSparklines';
|
||||||
|
export type { SparklineData, SparklineBundle, UseSparklinesOptions, UseSparklinesReturn } from './useSparklines';
|
||||||
|
|
||||||
|
export { useChartData } from './useChartData';
|
||||||
|
export type { UseChartDataOptions, UseChartDataReturn } from './useChartData';
|
||||||
76
src/components/usage/hooks/useChartData.ts
Normal file
76
src/components/usage/hooks/useChartData.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import type { ChartOptions } from 'chart.js';
|
||||||
|
import { buildChartData, type ChartData } from '@/utils/usage';
|
||||||
|
import { buildChartOptions } from '@/utils/usage/chartConfig';
|
||||||
|
import type { UsagePayload } from './useUsageData';
|
||||||
|
|
||||||
|
export interface UseChartDataOptions {
|
||||||
|
usage: UsagePayload | null;
|
||||||
|
chartLines: string[];
|
||||||
|
isDark: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseChartDataReturn {
|
||||||
|
requestsPeriod: 'hour' | 'day';
|
||||||
|
setRequestsPeriod: (period: 'hour' | 'day') => void;
|
||||||
|
tokensPeriod: 'hour' | 'day';
|
||||||
|
setTokensPeriod: (period: 'hour' | 'day') => void;
|
||||||
|
requestsChartData: ChartData;
|
||||||
|
tokensChartData: ChartData;
|
||||||
|
requestsChartOptions: ChartOptions<'line'>;
|
||||||
|
tokensChartOptions: ChartOptions<'line'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChartData({
|
||||||
|
usage,
|
||||||
|
chartLines,
|
||||||
|
isDark,
|
||||||
|
isMobile
|
||||||
|
}: 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]);
|
||||||
|
|
||||||
|
const tokensChartData = useMemo(() => {
|
||||||
|
if (!usage) return { labels: [], datasets: [] };
|
||||||
|
return buildChartData(usage, tokensPeriod, 'tokens', chartLines);
|
||||||
|
}, [usage, tokensPeriod, chartLines]);
|
||||||
|
|
||||||
|
const requestsChartOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
buildChartOptions({
|
||||||
|
period: requestsPeriod,
|
||||||
|
labels: requestsChartData.labels,
|
||||||
|
isDark,
|
||||||
|
isMobile
|
||||||
|
}),
|
||||||
|
[requestsPeriod, requestsChartData.labels, isDark, isMobile]
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokensChartOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
buildChartOptions({
|
||||||
|
period: tokensPeriod,
|
||||||
|
labels: tokensChartData.labels,
|
||||||
|
isDark,
|
||||||
|
isMobile
|
||||||
|
}),
|
||||||
|
[tokensPeriod, tokensChartData.labels, isDark, isMobile]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestsPeriod,
|
||||||
|
setRequestsPeriod,
|
||||||
|
tokensPeriod,
|
||||||
|
setTokensPeriod,
|
||||||
|
requestsChartData,
|
||||||
|
tokensChartData,
|
||||||
|
requestsChartOptions,
|
||||||
|
tokensChartOptions
|
||||||
|
};
|
||||||
|
}
|
||||||
138
src/components/usage/hooks/useSparklines.ts
Normal file
138
src/components/usage/hooks/useSparklines.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { collectUsageDetails, extractTotalTokens } from '@/utils/usage';
|
||||||
|
import type { UsagePayload } from './useUsageData';
|
||||||
|
|
||||||
|
export interface SparklineData {
|
||||||
|
labels: string[];
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: number[];
|
||||||
|
borderColor: string;
|
||||||
|
backgroundColor: string;
|
||||||
|
fill: boolean;
|
||||||
|
tension: number;
|
||||||
|
pointRadius: number;
|
||||||
|
borderWidth: number;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SparklineBundle {
|
||||||
|
data: SparklineData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseSparklinesOptions {
|
||||||
|
usage: UsagePayload | null;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseSparklinesReturn {
|
||||||
|
requestsSparkline: SparklineBundle | null;
|
||||||
|
tokensSparkline: SparklineBundle | null;
|
||||||
|
rpmSparkline: SparklineBundle | null;
|
||||||
|
tpmSparkline: SparklineBundle | null;
|
||||||
|
costSparkline: SparklineBundle | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSparklines({ usage, loading }: UseSparklinesOptions): UseSparklinesReturn {
|
||||||
|
const buildLastHourSeries = useCallback(
|
||||||
|
(metric: 'requests' | 'tokens'): { labels: string[]; data: number[] } => {
|
||||||
|
if (!usage) return { labels: [], data: [] };
|
||||||
|
const details = collectUsageDetails(usage);
|
||||||
|
if (!details.length) return { labels: [], data: [] };
|
||||||
|
|
||||||
|
const windowMinutes = 60;
|
||||||
|
const now = Date.now();
|
||||||
|
const windowStart = now - windowMinutes * 60 * 1000;
|
||||||
|
const buckets = new Array(windowMinutes).fill(0);
|
||||||
|
|
||||||
|
details.forEach((detail) => {
|
||||||
|
const timestamp = Date.parse(detail.timestamp);
|
||||||
|
if (Number.isNaN(timestamp) || timestamp < windowStart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const minuteIndex = Math.min(
|
||||||
|
windowMinutes - 1,
|
||||||
|
Math.floor((timestamp - windowStart) / 60000)
|
||||||
|
);
|
||||||
|
const increment = metric === 'tokens' ? extractTotalTokens(detail) : 1;
|
||||||
|
buckets[minuteIndex] += increment;
|
||||||
|
});
|
||||||
|
|
||||||
|
const labels = buckets.map((_, idx) => {
|
||||||
|
const date = new Date(windowStart + (idx + 1) * 60000);
|
||||||
|
const h = date.getHours().toString().padStart(2, '0');
|
||||||
|
const m = date.getMinutes().toString().padStart(2, '0');
|
||||||
|
return `${h}:${m}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { labels, data: buckets };
|
||||||
|
},
|
||||||
|
[usage]
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildSparkline = useCallback(
|
||||||
|
(
|
||||||
|
series: { labels: string[]; data: number[] },
|
||||||
|
color: string,
|
||||||
|
backgroundColor: string
|
||||||
|
): SparklineBundle | null => {
|
||||||
|
if (loading || !series?.data?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const sliceStart = Math.max(series.data.length - 60, 0);
|
||||||
|
const labels = series.labels.slice(sliceStart);
|
||||||
|
const points = series.data.slice(sliceStart);
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: points,
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.45,
|
||||||
|
pointRadius: 0,
|
||||||
|
borderWidth: 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[loading]
|
||||||
|
);
|
||||||
|
|
||||||
|
const requestsSparkline = useMemo(
|
||||||
|
() => buildSparkline(buildLastHourSeries('requests'), '#3b82f6', 'rgba(59, 130, 246, 0.18)'),
|
||||||
|
[buildLastHourSeries, buildSparkline]
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokensSparkline = useMemo(
|
||||||
|
() => buildSparkline(buildLastHourSeries('tokens'), '#8b5cf6', 'rgba(139, 92, 246, 0.18)'),
|
||||||
|
[buildLastHourSeries, buildSparkline]
|
||||||
|
);
|
||||||
|
|
||||||
|
const rpmSparkline = useMemo(
|
||||||
|
() => buildSparkline(buildLastHourSeries('requests'), '#22c55e', 'rgba(34, 197, 94, 0.18)'),
|
||||||
|
[buildLastHourSeries, buildSparkline]
|
||||||
|
);
|
||||||
|
|
||||||
|
const tpmSparkline = useMemo(
|
||||||
|
() => buildSparkline(buildLastHourSeries('tokens'), '#f97316', 'rgba(249, 115, 22, 0.18)'),
|
||||||
|
[buildLastHourSeries, buildSparkline]
|
||||||
|
);
|
||||||
|
|
||||||
|
const costSparkline = useMemo(
|
||||||
|
() => buildSparkline(buildLastHourSeries('tokens'), '#f59e0b', 'rgba(245, 158, 11, 0.18)'),
|
||||||
|
[buildLastHourSeries, buildSparkline]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestsSparkline,
|
||||||
|
tokensSparkline,
|
||||||
|
rpmSparkline,
|
||||||
|
tpmSparkline,
|
||||||
|
costSparkline
|
||||||
|
};
|
||||||
|
}
|
||||||
153
src/components/usage/hooks/useUsageData.ts
Normal file
153
src/components/usage/hooks/useUsageData.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNotificationStore } from '@/stores';
|
||||||
|
import { usageApi } from '@/services/api/usage';
|
||||||
|
import { loadModelPrices, saveModelPrices, type ModelPrice } from '@/utils/usage';
|
||||||
|
|
||||||
|
export interface UsagePayload {
|
||||||
|
total_requests?: number;
|
||||||
|
success_count?: number;
|
||||||
|
failure_count?: number;
|
||||||
|
total_tokens?: number;
|
||||||
|
apis?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseUsageDataReturn {
|
||||||
|
usage: UsagePayload | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
|
modelPrices: Record<string, ModelPrice>;
|
||||||
|
setModelPrices: (prices: Record<string, ModelPrice>) => void;
|
||||||
|
loadUsage: () => Promise<void>;
|
||||||
|
handleExport: () => Promise<void>;
|
||||||
|
handleImport: () => void;
|
||||||
|
handleImportChange: (event: React.ChangeEvent<HTMLInputElement>) => Promise<void>;
|
||||||
|
importInputRef: React.RefObject<HTMLInputElement | null>;
|
||||||
|
exporting: boolean;
|
||||||
|
importing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUsageData(): UseUsageDataReturn {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { showNotification } = useNotificationStore();
|
||||||
|
|
||||||
|
const [usage, setUsage] = useState<UsagePayload | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const importInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const loadUsage = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const data = await usageApi.getUsage();
|
||||||
|
const payload = data?.usage ?? data;
|
||||||
|
setUsage(payload);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : t('usage_stats.loading_error');
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUsage();
|
||||||
|
setModelPrices(loadModelPrices());
|
||||||
|
}, [loadUsage]);
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
const data = await usageApi.exportUsage();
|
||||||
|
const exportedAt =
|
||||||
|
typeof data?.exported_at === 'string' ? new Date(data.exported_at) : new Date();
|
||||||
|
const safeTimestamp = Number.isNaN(exportedAt.getTime())
|
||||||
|
? new Date().toISOString()
|
||||||
|
: exportedAt.toISOString();
|
||||||
|
const filename = `usage-export-${safeTimestamp.replace(/[:.]/g, '-')}.json`;
|
||||||
|
const blob = new Blob([JSON.stringify(data ?? {}, null, 2)], { type: 'application/json' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
link.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
showNotification(t('usage_stats.export_success'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(
|
||||||
|
`${t('notification.download_failed')}${message ? `: ${message}` : ''}`,
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = () => {
|
||||||
|
importInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
event.target.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setImporting(true);
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
let payload: unknown;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
showNotification(t('usage_stats.import_invalid'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await usageApi.importUsage(payload);
|
||||||
|
showNotification(
|
||||||
|
t('usage_stats.import_success', {
|
||||||
|
added: result?.added ?? 0,
|
||||||
|
skipped: result?.skipped ?? 0,
|
||||||
|
total: result?.total_requests ?? 0,
|
||||||
|
failed: result?.failed_requests ?? 0
|
||||||
|
}),
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
await loadUsage();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(
|
||||||
|
`${t('notification.upload_failed')}${message ? `: ${message}` : ''}`,
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetModelPrices = useCallback((prices: Record<string, ModelPrice>) => {
|
||||||
|
setModelPrices(prices);
|
||||||
|
saveModelPrices(prices);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
usage,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
modelPrices,
|
||||||
|
setModelPrices: handleSetModelPrices,
|
||||||
|
loadUsage,
|
||||||
|
handleExport,
|
||||||
|
handleImport,
|
||||||
|
handleImportChange,
|
||||||
|
importInputRef,
|
||||||
|
exporting,
|
||||||
|
importing
|
||||||
|
};
|
||||||
|
}
|
||||||
28
src/components/usage/index.ts
Normal file
28
src/components/usage/index.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// Hooks
|
||||||
|
export { useUsageData } from './hooks/useUsageData';
|
||||||
|
export type { UsagePayload, UseUsageDataReturn } from './hooks/useUsageData';
|
||||||
|
|
||||||
|
export { useSparklines } from './hooks/useSparklines';
|
||||||
|
export type { SparklineData, SparklineBundle, UseSparklinesOptions, UseSparklinesReturn } from './hooks/useSparklines';
|
||||||
|
|
||||||
|
export { useChartData } from './hooks/useChartData';
|
||||||
|
export type { UseChartDataOptions, UseChartDataReturn } from './hooks/useChartData';
|
||||||
|
|
||||||
|
// Components
|
||||||
|
export { StatCards } from './StatCards';
|
||||||
|
export type { StatCardsProps } from './StatCards';
|
||||||
|
|
||||||
|
export { UsageChart } from './UsageChart';
|
||||||
|
export type { UsageChartProps } from './UsageChart';
|
||||||
|
|
||||||
|
export { ChartLineSelector } from './ChartLineSelector';
|
||||||
|
export type { ChartLineSelectorProps } from './ChartLineSelector';
|
||||||
|
|
||||||
|
export { ApiDetailsCard } from './ApiDetailsCard';
|
||||||
|
export type { ApiDetailsCardProps } from './ApiDetailsCard';
|
||||||
|
|
||||||
|
export { ModelStatsCard } from './ModelStatsCard';
|
||||||
|
export type { ModelStatsCardProps, ModelStat } from './ModelStatsCard';
|
||||||
|
|
||||||
|
export { PriceSettingsCard } from './PriceSettingsCard';
|
||||||
|
export type { PriceSettingsCardProps } from './PriceSettingsCard';
|
||||||
File diff suppressed because it is too large
Load Diff
142
src/utils/usage/chartConfig.ts
Normal file
142
src/utils/usage/chartConfig.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Chart.js configuration utilities for usage statistics
|
||||||
|
* Extracted from UsagePage.tsx for reusability
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ChartOptions } from 'chart.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static sparkline chart options (no dependencies on theme/mobile)
|
||||||
|
*/
|
||||||
|
export const sparklineOptions: ChartOptions<'line'> = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { display: false }, tooltip: { enabled: false } },
|
||||||
|
scales: { x: { display: false }, y: { display: false } },
|
||||||
|
elements: { line: { tension: 0.45 }, point: { radius: 0 } }
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ChartConfigOptions {
|
||||||
|
period: 'hour' | 'day';
|
||||||
|
labels: string[];
|
||||||
|
isDark: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build chart options with theme and responsive awareness
|
||||||
|
*/
|
||||||
|
export function buildChartOptions({
|
||||||
|
period,
|
||||||
|
labels,
|
||||||
|
isDark,
|
||||||
|
isMobile
|
||||||
|
}: ChartConfigOptions): ChartOptions<'line'> {
|
||||||
|
const pointRadius = isMobile && period === 'hour' ? 0 : isMobile ? 2 : 4;
|
||||||
|
const tickFontSize = isMobile ? 10 : 12;
|
||||||
|
const maxTickLabelCount = isMobile ? (period === 'hour' ? 8 : 6) : period === 'hour' ? 12 : 10;
|
||||||
|
const gridColor = isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(17, 24, 39, 0.06)';
|
||||||
|
const axisBorderColor = isDark ? 'rgba(255, 255, 255, 0.10)' : 'rgba(17, 24, 39, 0.10)';
|
||||||
|
const tickColor = isDark ? 'rgba(255, 255, 255, 0.72)' : 'rgba(17, 24, 39, 0.72)';
|
||||||
|
const tooltipBg = isDark ? 'rgba(17, 24, 39, 0.92)' : 'rgba(255, 255, 255, 0.98)';
|
||||||
|
const tooltipTitle = isDark ? '#ffffff' : '#111827';
|
||||||
|
const tooltipBody = isDark ? 'rgba(255, 255, 255, 0.86)' : '#374151';
|
||||||
|
const tooltipBorder = isDark ? 'rgba(255, 255, 255, 0.10)' : 'rgba(17, 24, 39, 0.10)';
|
||||||
|
|
||||||
|
return {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: tooltipBg,
|
||||||
|
titleColor: tooltipTitle,
|
||||||
|
bodyColor: tooltipBody,
|
||||||
|
borderColor: tooltipBorder,
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 10,
|
||||||
|
displayColors: true,
|
||||||
|
usePointStyle: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: {
|
||||||
|
color: gridColor,
|
||||||
|
drawTicks: false
|
||||||
|
},
|
||||||
|
border: {
|
||||||
|
color: axisBorderColor
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: tickColor,
|
||||||
|
font: { size: tickFontSize },
|
||||||
|
maxRotation: isMobile ? 0 : 45,
|
||||||
|
minRotation: 0,
|
||||||
|
autoSkip: true,
|
||||||
|
maxTicksLimit: maxTickLabelCount,
|
||||||
|
callback: (value) => {
|
||||||
|
const index = typeof value === 'number' ? value : Number(value);
|
||||||
|
const raw =
|
||||||
|
Number.isFinite(index) && labels[index] ? labels[index] : typeof value === 'string' ? value : '';
|
||||||
|
|
||||||
|
if (period === 'hour') {
|
||||||
|
const [md, time] = raw.split(' ');
|
||||||
|
if (!time) return raw;
|
||||||
|
if (time.startsWith('00:')) {
|
||||||
|
return md ? [md, time] : time;
|
||||||
|
}
|
||||||
|
return time;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
const parts = raw.split('-');
|
||||||
|
if (parts.length === 3) {
|
||||||
|
return `${parts[1]}-${parts[2]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: {
|
||||||
|
color: gridColor
|
||||||
|
},
|
||||||
|
border: {
|
||||||
|
color: axisBorderColor
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: tickColor,
|
||||||
|
font: { size: tickFontSize }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
elements: {
|
||||||
|
line: {
|
||||||
|
tension: 0.35,
|
||||||
|
borderWidth: isMobile ? 1.5 : 2
|
||||||
|
},
|
||||||
|
point: {
|
||||||
|
borderWidth: 2,
|
||||||
|
radius: pointRadius,
|
||||||
|
hoverRadius: 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate minimum chart width for hourly data on mobile devices
|
||||||
|
*/
|
||||||
|
export function getHourChartMinWidth(labelCount: number, isMobile: boolean): string | undefined {
|
||||||
|
if (!isMobile || labelCount <= 0) return undefined;
|
||||||
|
const perPoint = 56;
|
||||||
|
const minWidth = Math.min(labelCount * perPoint, 3000);
|
||||||
|
return `${minWidth}px`;
|
||||||
|
}
|
||||||
6
src/utils/usage/index.ts
Normal file
6
src/utils/usage/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Chart configuration utilities
|
||||||
|
export { sparklineOptions, buildChartOptions, getHourChartMinWidth } from './chartConfig';
|
||||||
|
export type { ChartConfigOptions } from './chartConfig';
|
||||||
|
|
||||||
|
// Re-export everything from the main usage.ts for backwards compatibility
|
||||||
|
export * from '../usage';
|
||||||
Reference in New Issue
Block a user