diff --git a/src/components/usage/TokenBreakdownChart.tsx b/src/components/usage/TokenBreakdownChart.tsx index d773c41d..95ab6462 100644 --- a/src/components/usage/TokenBreakdownChart.tsx +++ b/src/components/usage/TokenBreakdownChart.tsx @@ -1,13 +1,13 @@ import { useState, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import type { ChartData, ChartOptions } from 'chart.js'; +import type { ChartData, ChartOptions, TooltipItem } from 'chart.js'; import { Bar } from 'react-chartjs-2'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { buildHourlyTokenBreakdown, buildDailyTokenBreakdown, - type TokenCategory + type TokenCategory, } from '@/utils/usage'; import { buildChartOptions, getHourChartMinWidth } from '@/utils/usage/chartConfig'; import type { UsagePayload } from './hooks/useUsageData'; @@ -17,7 +17,7 @@ const TOKEN_COLORS: Record = { input: '#8CC21F', output: '#FA6450', cached: '#F5ED58', - reasoning: '#00ABA5' + reasoning: '#00ABA5', }; const CATEGORIES: TokenCategory[] = ['input', 'output', 'cached', 'reasoning']; @@ -41,7 +41,7 @@ export function TokenBreakdownChart({ loading, isDark, isMobile, - hourWindowHours + hourWindowHours, }: TokenBreakdownChartProps) { const { t } = useTranslation(); const [period, setPeriod] = useState<'hour' | 'day'>('hour'); @@ -55,54 +55,42 @@ export function TokenBreakdownChart({ input: t('usage_stats.input_tokens'), output: t('usage_stats.output_tokens'), cached: t('usage_stats.cached_tokens'), - reasoning: t('usage_stats.reasoning_tokens') + reasoning: t('usage_stats.reasoning_tokens'), }; - const categoryTotals: Record = { - input: 0, - output: 0, - cached: 0, - reasoning: 0 - }; - - CATEGORIES.forEach((cat) => { - categoryTotals[cat] = series.dataByCategory[cat].reduce( - (sum, val) => sum + (Number(val) || 0), - 0, - ); - }); - const data: ChartData<'bar'> = { labels: series.labels, datasets: CATEGORIES.map((cat) => ({ label: categoryLabels[cat], - categoryId: cat, data: series.dataByCategory[cat], backgroundColor: TOKEN_COLORS[cat], borderColor: isDark ? '#0f172a' : '#ffffff', borderWidth: 1, borderSkipped: false, - grouped: false, - // Higher order is drawn earlier by Chart.js, so larger series stay behind smaller ones. - order: categoryTotals[cat], - categoryPercentage: 0.78, - barPercentage: 0.82 - })) + grouped: true, + categoryPercentage: 0.82, + barPercentage: 0.88, + })), }; - const baseOptions = buildChartOptions({ period, labels: series.labels, isDark, isMobile }) as ChartOptions<'bar'>; + const baseOptions = buildChartOptions({ + period, + labels: series.labels, + isDark, + isMobile, + }) as ChartOptions<'bar'>; const options: ChartOptions<'bar'> = { ...baseOptions, scales: { ...baseOptions.scales, y: { ...baseOptions.scales?.y, - stacked: false + stacked: false, }, x: { ...baseOptions.scales?.x, - stacked: false - } + stacked: false, + }, }, plugins: { ...baseOptions.plugins, @@ -111,16 +99,13 @@ export function TokenBreakdownChart({ itemSort: (a, b) => a.datasetIndex - b.datasetIndex, callbacks: { ...baseOptions.plugins?.tooltip?.callbacks, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - label: function (context: any) { + label: function (context: TooltipItem<'bar'>) { const val = Number(context.raw) || 0; - const cat = context.dataset.categoryId; + const cat = CATEGORIES[context.datasetIndex]; let text = `${context.dataset.label}: ${formatTokens(val)}`; if (cat === 'cached') { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const inputDs = context.chart.data.datasets.find((ds: any) => ds.categoryId === 'input'); - const inputVal = inputDs ? (Number(inputDs.data[context.dataIndex]) || 0) : 0; + const inputVal = Number(series.dataByCategory.input[context.dataIndex]) || 0; if (inputVal > 0) { const perc = ((val / inputVal) * 100).toFixed(2); text += ` (${perc}%)`; @@ -128,19 +113,9 @@ export function TokenBreakdownChart({ } return text; }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - footer: function (tooltipItems: any[]) { - let total = 0; - const dataIndex = tooltipItems[0].dataIndex; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tooltipItems[0].chart.data.datasets.forEach((ds: any) => { - total += Number(ds.data[dataIndex]) || 0; - }); - return `Total: ${formatTokens(total)}`; - } - } - } - } + }, + }, + }, }; return { chartData: data, chartOptions: options }; @@ -180,7 +155,10 @@ export function TokenBreakdownChart({ className={styles.legendItem} title={dataset.label} > - + {dataset.label} ))} diff --git a/src/pages/UsagePage.tsx b/src/pages/UsagePage.tsx index a64bef8f..ab186bdd 100644 --- a/src/pages/UsagePage.tsx +++ b/src/pages/UsagePage.tsx @@ -10,7 +10,7 @@ import { Title, Tooltip, Legend, - Filler + Filler, } from 'chart.js'; import { Button } from '@/components/ui/Button'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; @@ -34,14 +34,14 @@ import { ServiceHealthCard, useUsageData, useSparklines, - useChartData + useChartData, } from '@/components/usage'; import { getModelNamesFromUsage, getApiStats, getModelStats, filterUsageByTimeRange, - type UsageTimeRange + type UsageTimeRange, } from '@/utils/usage'; import styles from './UsagePage.module.scss'; @@ -72,7 +72,7 @@ const TIME_RANGE_OPTIONS: ReadonlyArray<{ value: UsageTimeRange; labelKey: strin const HOUR_WINDOW_BY_TIME_RANGE: Record, number> = { '7h': 7, '24h': 24, - '7d': 7 * 24 + '7d': 7 * 24, }; const isUsageTimeRange = (value: unknown): value is UsageTimeRange => @@ -145,7 +145,7 @@ export function UsagePage() { handleImportChange, importInputRef, exporting, - importing + importing, } = useUsageData(); useHeaderRefresh(loadUsage); @@ -178,13 +178,13 @@ export function UsagePage() { const openaiProvidersForUsage = openaiProviderState && openaiProviderState.source === openaiCompatibilityConfig ? openaiProviderState.providers - : openaiCompatibilityConfig ?? []; + : (openaiCompatibilityConfig ?? []); const timeRangeOptions = useMemo( () => TIME_RANGE_OPTIONS.map((opt) => ({ value: opt.value, - label: t(opt.labelKey) + label: t(opt.labelKey), })), [t] ); @@ -193,8 +193,7 @@ export function UsagePage() { () => (usage ? filterUsageByTimeRange(usage, timeRange) : null), [usage, timeRange] ); - const hourWindowHours = - timeRange === 'all' ? undefined : HOUR_WINDOW_BY_TIME_RANGE[timeRange]; + const hourWindowHours = timeRange === 'all' ? undefined : HOUR_WINDOW_BY_TIME_RANGE[timeRange]; const handleChartLinesChange = useCallback((lines: string[]) => { setChartLines(normalizeChartLines(lines)); @@ -225,13 +224,8 @@ export function UsagePage() { const nowMs = lastRefreshedAt?.getTime() ?? 0; // Sparklines hook - const { - requestsSparkline, - tokensSparkline, - rpmSparkline, - tpmSparkline, - costSparkline - } = useSparklines({ usage: filteredUsage, loading, nowMs }); + const { requestsSparkline, tokensSparkline, rpmSparkline, tpmSparkline, costSparkline } = + useSparklines({ usage: filteredUsage, loading, nowMs }); // Chart data hook const { @@ -242,7 +236,7 @@ export function UsagePage() { requestsChartData, tokensChartData, requestsChartOptions, - tokensChartOptions + tokensChartOptions, } = useChartData({ usage: filteredUsage, chartLines, isDark, isMobile, hourWindowHours }); // Derived data @@ -336,7 +330,7 @@ export function UsagePage() { tokens: tokensSparkline, rpm: rpmSparkline, tpm: tpmSparkline, - cost: costSparkline + cost: costSparkline, }} />