diff --git a/src/components/usage/ApiDetailsCard.tsx b/src/components/usage/ApiDetailsCard.tsx new file mode 100644 index 00000000..9c693275 --- /dev/null +++ b/src/components/usage/ApiDetailsCard.tsx @@ -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>(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 ( + + {loading ? ( +
{t('common.loading')}
+ ) : apiStats.length > 0 ? ( +
+ {apiStats.map((api) => ( +
+
toggleExpand(api.endpoint)}> +
+ {api.endpoint} +
+ + {t('usage_stats.requests_count')}: {api.totalRequests} + + + Tokens: {formatTokensInMillions(api.totalTokens)} + + {hasPrices && api.totalCost > 0 && ( + + {t('usage_stats.total_cost')}: {formatUsd(api.totalCost)} + + )} +
+
+ + {expandedApis.has(api.endpoint) ? '▼' : '▶'} + +
+ {expandedApis.has(api.endpoint) && ( +
+ {Object.entries(api.models).map(([model, stats]) => ( +
+ {model} + + {stats.requests} {t('usage_stats.requests_count')} + + {formatTokensInMillions(stats.tokens)} +
+ ))} +
+ )} +
+ ))} +
+ ) : ( +
{t('usage_stats.no_data')}
+ )} +
+ ); +} diff --git a/src/components/usage/ChartLineSelector.tsx b/src/components/usage/ChartLineSelector.tsx new file mode 100644 index 00000000..f56344e8 --- /dev/null +++ b/src/components/usage/ChartLineSelector.tsx @@ -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 ( + + + {chartLines.length}/{maxLines} + + + + } + > +
+ {chartLines.map((line, index) => ( +
+ + {t(`usage_stats.chart_line_label_${index + 1}`)} + + + {chartLines.length > 1 && ( + + )} +
+ ))} +
+

{t('usage_stats.chart_line_hint')}

+
+ ); +} diff --git a/src/components/usage/ModelStatsCard.tsx b/src/components/usage/ModelStatsCard.tsx new file mode 100644 index 00000000..859fa16c --- /dev/null +++ b/src/components/usage/ModelStatsCard.tsx @@ -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 ( + + {loading ? ( +
{t('common.loading')}
+ ) : modelStats.length > 0 ? ( +
+ + + + + + + {hasPrices && } + + + + {modelStats.map((stat) => ( + + + + + {hasPrices && } + + ))} + +
{t('usage_stats.model_name')}{t('usage_stats.requests_count')}{t('usage_stats.tokens_count')}{t('usage_stats.total_cost')}
{stat.model}{stat.requests.toLocaleString()}{formatTokensInMillions(stat.tokens)}{stat.cost > 0 ? formatUsd(stat.cost) : '--'}
+
+ ) : ( +
{t('usage_stats.no_data')}
+ )} +
+ ); +} diff --git a/src/components/usage/PriceSettingsCard.tsx b/src/components/usage/PriceSettingsCard.tsx new file mode 100644 index 00000000..4bd20955 --- /dev/null +++ b/src/components/usage/PriceSettingsCard.tsx @@ -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; + onPricesChange: (prices: Record) => 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 ( + +
+ {/* Price Form */} +
+
+
+ + +
+
+ + setPromptPrice(e.target.value)} + placeholder="0.00" + step="0.0001" + /> +
+
+ + setCompletionPrice(e.target.value)} + placeholder="0.00" + step="0.0001" + /> +
+
+ + setCachePrice(e.target.value)} + placeholder="0.00" + step="0.0001" + /> +
+ +
+
+ + {/* Saved Prices List */} +
+

{t('usage_stats.saved_prices')}

+ {Object.keys(modelPrices).length > 0 ? ( +
+ {Object.entries(modelPrices).map(([model, price]) => ( +
+
+ {model} +
+ + {t('usage_stats.model_price_prompt')}: ${price.prompt.toFixed(4)}/1M + + + {t('usage_stats.model_price_completion')}: ${price.completion.toFixed(4)}/1M + + + {t('usage_stats.model_price_cache')}: ${price.cache.toFixed(4)}/1M + +
+
+
+ + +
+
+ ))} +
+ ) : ( +
{t('usage_stats.model_price_empty')}
+ )} +
+
+
+ ); +} diff --git a/src/components/usage/StatCards.tsx b/src/components/usage/StatCards.tsx new file mode 100644 index 00000000..d914d4c9 --- /dev/null +++ b/src/components/usage/StatCards.tsx @@ -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; + 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: , + accent: '#3b82f6', + accentSoft: 'rgba(59, 130, 246, 0.18)', + accentBorder: 'rgba(59, 130, 246, 0.35)', + value: loading ? '-' : (usage?.total_requests ?? 0).toLocaleString(), + meta: ( + <> + + + {t('usage_stats.success_requests')}: {loading ? '-' : (usage?.success_count ?? 0)} + + + + {t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)} + + + ), + trend: sparklines.requests + }, + { + key: 'tokens', + label: t('usage_stats.total_tokens'), + icon: , + accent: '#8b5cf6', + accentSoft: 'rgba(139, 92, 246, 0.18)', + accentBorder: 'rgba(139, 92, 246, 0.35)', + value: loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0), + meta: ( + <> + + {t('usage_stats.cached_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.cachedTokens)} + + + {t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.reasoningTokens)} + + + ), + trend: sparklines.tokens + }, + { + key: 'rpm', + label: t('usage_stats.rpm_30m'), + icon: , + accent: '#22c55e', + accentSoft: 'rgba(34, 197, 94, 0.18)', + accentBorder: 'rgba(34, 197, 94, 0.32)', + value: loading ? '-' : formatPerMinuteValue(rateStats.rpm), + meta: ( + + {t('usage_stats.total_requests')}: {loading ? '-' : rateStats.requestCount.toLocaleString()} + + ), + trend: sparklines.rpm + }, + { + key: 'tpm', + label: t('usage_stats.tpm_30m'), + icon: , + accent: '#f97316', + accentSoft: 'rgba(249, 115, 22, 0.18)', + accentBorder: 'rgba(249, 115, 22, 0.32)', + value: loading ? '-' : formatPerMinuteValue(rateStats.tpm), + meta: ( + + {t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(rateStats.tokenCount)} + + ), + trend: sparklines.tpm + }, + { + key: 'cost', + label: t('usage_stats.total_cost'), + icon: , + accent: '#f59e0b', + accentSoft: 'rgba(245, 158, 11, 0.18)', + accentBorder: 'rgba(245, 158, 11, 0.32)', + value: loading ? '-' : hasPrices ? formatUsd(totalCost) : '--', + meta: ( + <> + + {t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0)} + + {!hasPrices && ( + + {t('usage_stats.cost_need_price')} + + )} + + ), + trend: hasPrices ? sparklines.cost : null + } + ]; + + return ( +
+ {statsCards.map((card) => ( +
+
+
+ {card.label} +
+ {card.icon} +
+
{card.value}
+ {card.meta &&
{card.meta}
} +
+ {card.trend ? ( + + ) : ( +
+ )} +
+
+ ))} +
+ ); +} diff --git a/src/components/usage/UsageChart.tsx b/src/components/usage/UsageChart.tsx new file mode 100644 index 00000000..b5f1834e --- /dev/null +++ b/src/components/usage/UsageChart.tsx @@ -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 ( + + + + + } + > + {loading ? ( +
{t('common.loading')}
+ ) : chartData.labels.length > 0 ? ( +
+
+ {chartData.datasets.map((dataset, index) => ( +
+ + {dataset.label} +
+ ))} +
+
+
+
+ +
+
+
+
+ ) : ( +
{emptyText}
+ )} +
+ ); +} diff --git a/src/components/usage/hooks/index.ts b/src/components/usage/hooks/index.ts new file mode 100644 index 00000000..3a845d90 --- /dev/null +++ b/src/components/usage/hooks/index.ts @@ -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'; diff --git a/src/components/usage/hooks/useChartData.ts b/src/components/usage/hooks/useChartData.ts new file mode 100644 index 00000000..d7376cbc --- /dev/null +++ b/src/components/usage/hooks/useChartData.ts @@ -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 + }; +} diff --git a/src/components/usage/hooks/useSparklines.ts b/src/components/usage/hooks/useSparklines.ts new file mode 100644 index 00000000..491a317a --- /dev/null +++ b/src/components/usage/hooks/useSparklines.ts @@ -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 + }; +} diff --git a/src/components/usage/hooks/useUsageData.ts b/src/components/usage/hooks/useUsageData.ts new file mode 100644 index 00000000..06e669dc --- /dev/null +++ b/src/components/usage/hooks/useUsageData.ts @@ -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; + [key: string]: unknown; +} + +export interface UseUsageDataReturn { + usage: UsagePayload | null; + loading: boolean; + error: string; + modelPrices: Record; + setModelPrices: (prices: Record) => void; + loadUsage: () => Promise; + handleExport: () => Promise; + handleImport: () => void; + handleImportChange: (event: React.ChangeEvent) => Promise; + importInputRef: React.RefObject; + exporting: boolean; + importing: boolean; +} + +export function useUsageData(): UseUsageDataReturn { + const { t } = useTranslation(); + const { showNotification } = useNotificationStore(); + + const [usage, setUsage] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [modelPrices, setModelPrices] = useState>({}); + const [exporting, setExporting] = useState(false); + const [importing, setImporting] = useState(false); + const importInputRef = useRef(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) => { + 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) => { + setModelPrices(prices); + saveModelPrices(prices); + }, []); + + return { + usage, + loading, + error, + modelPrices, + setModelPrices: handleSetModelPrices, + loadUsage, + handleExport, + handleImport, + handleImportChange, + importInputRef, + exporting, + importing + }; +} diff --git a/src/components/usage/index.ts b/src/components/usage/index.ts new file mode 100644 index 00000000..e138619d --- /dev/null +++ b/src/components/usage/index.ts @@ -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'; diff --git a/src/pages/UsagePage.tsx b/src/pages/UsagePage.tsx index 83148aff..ba254201 100644 --- a/src/pages/UsagePage.tsx +++ b/src/pages/UsagePage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback, useMemo, useRef, type CSSProperties } from 'react'; +import { useState, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Chart as ChartJS, @@ -9,35 +9,24 @@ import { Title, Tooltip, Legend, - Filler, - type ChartOptions + Filler } from 'chart.js'; -import { Line } from 'react-chartjs-2'; -import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/Input'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; -import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons'; import { useMediaQuery } from '@/hooks/useMediaQuery'; -import { useNotificationStore, useThemeStore } from '@/stores'; -import { usageApi } from '@/services/api/usage'; +import { useThemeStore } from '@/stores'; import { - formatTokensInMillions, - formatPerMinuteValue, - formatUsd, - calculateTokenBreakdown, - calculateRecentPerMinuteRates, - calculateTotalCost, - getModelNamesFromUsage, - getApiStats, - getModelStats, - loadModelPrices, - saveModelPrices, - buildChartData, - collectUsageDetails, - extractTotalTokens, - type ModelPrice -} from '@/utils/usage'; + StatCards, + UsageChart, + ChartLineSelector, + ApiDetailsCard, + ModelStatsCard, + PriceSettingsCard, + useUsageData, + useSparklines, + useChartData +} from '@/components/usage'; +import { getModelNamesFromUsage, getApiStats, getModelStats } from '@/utils/usage'; import styles from './UsagePage.module.scss'; // Register Chart.js components @@ -52,544 +41,59 @@ ChartJS.register( Filler ); -interface UsagePayload { - total_requests?: number; - success_count?: number; - failure_count?: number; - total_tokens?: number; - apis?: Record; - [key: string]: unknown; -} - export function UsagePage() { const { t } = useTranslation(); - const { showNotification } = useNotificationStore(); const isMobile = useMediaQuery('(max-width: 768px)'); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const isDark = resolvedTheme === 'dark'; - const [usage, setUsage] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - const [modelPrices, setModelPrices] = useState>({}); - const [exporting, setExporting] = useState(false); - const [importing, setImporting] = useState(false); - const importInputRef = useRef(null); + // Data hook + const { + usage, + loading, + error, + modelPrices, + setModelPrices, + loadUsage, + handleExport, + handleImport, + handleImportChange, + importInputRef, + exporting, + importing + } = useUsageData(); - // Model price form state - const [selectedModel, setSelectedModel] = useState(''); - const [promptPrice, setPromptPrice] = useState(''); - const [completionPrice, setCompletionPrice] = useState(''); - const [cachePrice, setCachePrice] = useState(''); - - // Expanded sections - const [expandedApis, setExpandedApis] = useState>(new Set()); - - // Chart state - const [requestsPeriod, setRequestsPeriod] = useState<'hour' | 'day'>('day'); - const [tokensPeriod, setTokensPeriod] = useState<'hour' | 'day'>('day'); + // Chart lines state const [chartLines, setChartLines] = useState(['all']); const MAX_CHART_LINES = 9; - 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]); + // Sparklines hook + const { + requestsSparkline, + tokensSparkline, + rpmSparkline, + tpmSparkline, + costSparkline + } = useSparklines({ usage, loading }); - useEffect(() => { - loadUsage(); - setModelPrices(loadModelPrices()); - }, [loadUsage]); + // Chart data hook + const { + requestsPeriod, + setRequestsPeriod, + tokensPeriod, + setTokensPeriod, + requestsChartData, + tokensChartData, + requestsChartOptions, + tokensChartOptions + } = useChartData({ usage, chartLines, isDark, isMobile }); - 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 handleImportClick = () => { - importInputRef.current?.click(); - }; - - const handleImportChange = async (event: React.ChangeEvent) => { - 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); - } - }; - - // Calculate derived data - 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 modelNames = usage ? getModelNamesFromUsage(usage) : []; - const apiStats = usage ? getApiStats(usage, modelPrices) : []; - const modelStats = usage ? getModelStats(usage, modelPrices) : []; + // 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 hasPrices = Object.keys(modelPrices).length > 0; - // Build chart data - 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 sparklineOptions = useMemo( - () => ({ - 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 } } - }), - [] - ); - - 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) => { - 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] - ); - - const buildChartOptions = useCallback( - (period: 'hour' | 'day', labels: string[]): 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: isMobile ? 0 : 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 - } - } - }; - }, - [isDark, isMobile] - ); - - const requestsChartOptions = useMemo( - () => buildChartOptions(requestsPeriod, requestsChartData.labels), - [buildChartOptions, requestsPeriod, requestsChartData.labels] - ); - - const tokensChartOptions = useMemo( - () => buildChartOptions(tokensPeriod, tokensChartData.labels), - [buildChartOptions, tokensPeriod, tokensChartData.labels] - ); - - const getHourChartMinWidth = useCallback( - (labelCount: number) => { - if (!isMobile || labelCount <= 0) return undefined; - // 24 小时标签在移动端需要更宽的画布,避免 X 轴与点位过度挤压 - const perPoint = 56; - const minWidth = Math.min(labelCount * perPoint, 3000); - return `${minWidth}px`; - }, - [isMobile] - ); - - // Chart line management - const handleAddChartLine = () => { - if (chartLines.length >= MAX_CHART_LINES) return; - const unusedModel = modelNames.find(m => !chartLines.includes(m)); - if (unusedModel) { - setChartLines([...chartLines, unusedModel]); - } else { - setChartLines([...chartLines, 'all']); - } - }; - - const handleRemoveChartLine = (index: number) => { - if (chartLines.length <= 1) return; - const newLines = [...chartLines]; - newLines.splice(index, 1); - setChartLines(newLines); - }; - - const handleChartLineChange = (index: number, value: string) => { - const newLines = [...chartLines]; - newLines[index] = value; - setChartLines(newLines); - }; - - // Handle model price save - 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 } }; - setModelPrices(newPrices); - saveModelPrices(newPrices); - setSelectedModel(''); - setPromptPrice(''); - setCompletionPrice(''); - setCachePrice(''); - }; - - // Handle model price delete - const handleDeletePrice = (model: string) => { - const newPrices = { ...modelPrices }; - delete newPrices[model]; - setModelPrices(newPrices); - saveModelPrices(newPrices); - }; - - // Handle edit price - const handleEditPrice = (model: string) => { - const price = modelPrices[model]; - setSelectedModel(model); - setPromptPrice(price?.prompt?.toString() || ''); - setCompletionPrice(price?.completion?.toString() || ''); - setCachePrice(price?.cache?.toString() || ''); - }; - - // Toggle API expansion - const toggleApiExpand = (endpoint: string) => { - setExpandedApis(prev => { - const newSet = new Set(prev); - if (newSet.has(endpoint)) { - newSet.delete(endpoint); - } else { - newSet.add(endpoint); - } - return newSet; - }); - }; - - const statsCards = [ - { - key: 'requests', - label: t('usage_stats.total_requests'), - icon: , - accent: '#3b82f6', - accentSoft: 'rgba(59, 130, 246, 0.18)', - accentBorder: 'rgba(59, 130, 246, 0.35)', - value: loading ? '-' : (usage?.total_requests ?? 0).toLocaleString(), - meta: ( - <> - - - {t('usage_stats.success_requests')}: {loading ? '-' : (usage?.success_count ?? 0)} - - - - {t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)} - - - ), - trend: requestsSparkline - }, - { - key: 'tokens', - label: t('usage_stats.total_tokens'), - icon: , - accent: '#8b5cf6', - accentSoft: 'rgba(139, 92, 246, 0.18)', - accentBorder: 'rgba(139, 92, 246, 0.35)', - value: loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0), - meta: ( - <> - - {t('usage_stats.cached_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.cachedTokens)} - - - {t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.reasoningTokens)} - - - ), - trend: tokensSparkline - }, - { - key: 'rpm', - label: t('usage_stats.rpm_30m'), - icon: , - accent: '#22c55e', - accentSoft: 'rgba(34, 197, 94, 0.18)', - accentBorder: 'rgba(34, 197, 94, 0.32)', - value: loading ? '-' : formatPerMinuteValue(rateStats.rpm), - meta: ( - - {t('usage_stats.total_requests')}: {loading ? '-' : rateStats.requestCount.toLocaleString()} - - ), - trend: rpmSparkline - }, - { - key: 'tpm', - label: t('usage_stats.tpm_30m'), - icon: , - accent: '#f97316', - accentSoft: 'rgba(249, 115, 22, 0.18)', - accentBorder: 'rgba(249, 115, 22, 0.32)', - value: loading ? '-' : formatPerMinuteValue(rateStats.tpm), - meta: ( - - {t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(rateStats.tokenCount)} - - ), - trend: tpmSparkline - }, - { - key: 'cost', - label: t('usage_stats.total_cost'), - icon: , - accent: '#f59e0b', - accentSoft: 'rgba(245, 158, 11, 0.18)', - accentBorder: 'rgba(245, 158, 11, 0.32)', - value: loading ? '-' : hasPrices ? formatUsd(totalCost) : '--', - meta: ( - <> - - {t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0)} - - {!hasPrices && ( - - {t('usage_stats.cost_need_price')} - - )} - - ), - trend: hasPrices ? costSparkline : null - } - ]; - return (
{loading && !usage && ( @@ -600,6 +104,7 @@ export function UsagePage() {
)} +

{t('usage_stats.title')}

@@ -615,7 +120,7 @@ export function UsagePage() { -
- } - > -
- {chartLines.map((line, index) => ( -
- - {t(`usage_stats.chart_line_label_${index + 1}`)} - - - {chartLines.length > 1 && ( - - )} -
- ))} -
-

{t('usage_stats.chart_line_hint')}

- + + {/* Charts Grid */}
- {/* Requests Chart */} - - - -
- } - > - {loading ? ( -
{t('common.loading')}
- ) : requestsChartData.labels.length > 0 ? ( -
-
- {requestsChartData.datasets.map((dataset, index) => ( -
- - {dataset.label} -
- ))} -
-
-
-
- -
-
-
-
- ) : ( -
{t('usage_stats.no_data')}
- )} - - - {/* Tokens Chart */} - + - - -
- } - > - {loading ? ( -
{t('common.loading')}
- ) : tokensChartData.labels.length > 0 ? ( -
-
- {tokensChartData.datasets.map((dataset, index) => ( -
- - {dataset.label} -
- ))} -
-
-
-
- -
-
-
-
- ) : ( -
{t('usage_stats.no_data')}
- )} - + period={tokensPeriod} + onPeriodChange={setTokensPeriod} + chartData={tokensChartData} + chartOptions={tokensChartOptions} + loading={loading} + isMobile={isMobile} + emptyText={t('usage_stats.no_data')} + /> + {/* Details Grid */}
- {/* API Key Statistics */} - - {loading ? ( -
{t('common.loading')}
- ) : apiStats.length > 0 ? ( -
- {apiStats.map((api) => ( -
-
toggleApiExpand(api.endpoint)} - > -
- {api.endpoint} -
- - {t('usage_stats.requests_count')}: {api.totalRequests} - - - Tokens: {formatTokensInMillions(api.totalTokens)} - - {hasPrices && api.totalCost > 0 && ( - - {t('usage_stats.total_cost')}: {formatUsd(api.totalCost)} - - )} -
-
- - {expandedApis.has(api.endpoint) ? '▼' : '▶'} - -
- {expandedApis.has(api.endpoint) && ( -
- {Object.entries(api.models).map(([model, stats]) => ( -
- {model} - {stats.requests} {t('usage_stats.requests_count')} - {formatTokensInMillions(stats.tokens)} -
- ))} -
- )} -
- ))} -
- ) : ( -
{t('usage_stats.no_data')}
- )} -
- - {/* Model Statistics */} - - {loading ? ( -
{t('common.loading')}
- ) : modelStats.length > 0 ? ( -
- - - - - - - {hasPrices && } - - - - {modelStats.map((stat) => ( - - - - - {hasPrices && } - - ))} - -
{t('usage_stats.model_name')}{t('usage_stats.requests_count')}{t('usage_stats.tokens_count')}{t('usage_stats.total_cost')}
{stat.model}{stat.requests.toLocaleString()}{formatTokensInMillions(stat.tokens)}{stat.cost > 0 ? formatUsd(stat.cost) : '--'}
-
- ) : ( -
{t('usage_stats.no_data')}
- )} -
+ +
- {/* Model Pricing Configuration */} - -
- {/* Price Form */} -
-
-
- - -
-
- - setPromptPrice(e.target.value)} - placeholder="0.00" - step="0.0001" - /> -
-
- - setCompletionPrice(e.target.value)} - placeholder="0.00" - step="0.0001" - /> -
-
- - setCachePrice(e.target.value)} - placeholder="0.00" - step="0.0001" - /> -
- -
-
- - {/* Saved Prices List */} -
-

{t('usage_stats.saved_prices')}

- {Object.keys(modelPrices).length > 0 ? ( -
- {Object.entries(modelPrices).map(([model, price]) => ( -
-
- {model} -
- {t('usage_stats.model_price_prompt')}: ${price.prompt.toFixed(4)}/1M - {t('usage_stats.model_price_completion')}: ${price.completion.toFixed(4)}/1M - {t('usage_stats.model_price_cache')}: ${price.cache.toFixed(4)}/1M -
-
-
- - -
-
- ))} -
- ) : ( -
{t('usage_stats.model_price_empty')}
- )} -
-
-
+ {/* Price Settings */} + ); } diff --git a/src/utils/usage/chartConfig.ts b/src/utils/usage/chartConfig.ts new file mode 100644 index 00000000..688da219 --- /dev/null +++ b/src/utils/usage/chartConfig.ts @@ -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`; +} diff --git a/src/utils/usage/index.ts b/src/utils/usage/index.ts new file mode 100644 index 00000000..45a9fa83 --- /dev/null +++ b/src/utils/usage/index.ts @@ -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';