diff --git a/src/web-ui/src/infrastructure/api/tokenUsageStatisticsApi.ts b/src/web-ui/src/infrastructure/api/tokenUsageStatisticsApi.ts index 5263e64065..41cfd89dce 100644 --- a/src/web-ui/src/infrastructure/api/tokenUsageStatisticsApi.ts +++ b/src/web-ui/src/infrastructure/api/tokenUsageStatisticsApi.ts @@ -59,6 +59,7 @@ export interface UsageTrendPoint { inputTokens: number; outputTokens: number; cacheReadTokens: number; + /** Tokens written into the provider cache for this bucket. */ cacheWriteTokens: number; /** 0.0..=1.0 when the bucket has cache telemetry. */ cacheHitRate: number | null; @@ -70,6 +71,7 @@ export interface UsageStatistics { totalInputTokens: number; totalOutputTokens: number; totalCachedTokens: number; + /** Tokens written into provider caches across the selected range. */ totalCacheWriteTokens: number; /** Prompt input tokens from requests that reported cache telemetry. */ totalCacheReportedInputTokens: number; diff --git a/src/web-ui/src/infrastructure/config/components/UsageStatisticsConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/UsageStatisticsConfig.test.tsx index 00be7eeda4..5165e4a554 100644 --- a/src/web-ui/src/infrastructure/config/components/UsageStatisticsConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/UsageStatisticsConfig.test.tsx @@ -137,7 +137,7 @@ const SAMPLE_STATS: UsageStatistics = { inputTokens: 1_000_000, outputTokens: 100_000, cacheReadTokens: 900_000, - cacheWriteTokens: 0, + cacheWriteTokens: 50_000, cacheHitRate: 0.9, }, { @@ -197,10 +197,69 @@ describe('UsageStatisticsConfig', () => { expect(container.querySelector('[data-bf-part="trendPanel"]')).not.toBeNull(); expect(container.querySelectorAll('.bitfun-usage-stats__donut').length).toBe(3); expect(container.querySelectorAll('[data-bf-part="trendPanel"] svg').length).toBe(1); + expect(container.textContent).not.toContain('trend.legend.cacheCreation'); // Hit rate is truncated to two decimals, never rounded up. expect(container.textContent).toContain('95.00%'); }); + it('keeps idle hit-rate points continuous but splits active telemetry gaps', async () => { + const idlePoint = { + ...SAMPLE_STATS.trend[0], + bucket: '2026-08-16T12:00:00.000Z', + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + cacheHitRate: null, + }; + const activeGapPoint = { + ...SAMPLE_STATS.trend[0], + bucket: '2026-08-16T14:00:00.000Z', + cacheHitRate: null, + }; + getStatisticsMock.mockResolvedValue({ + ...SAMPLE_STATS, + trend: [ + SAMPLE_STATS.trend[0], + idlePoint, + { ...SAMPLE_STATS.trend[1], bucket: '2026-08-16T13:00:00.000Z' }, + activeGapPoint, + { ...SAMPLE_STATS.trend[1], bucket: '2026-08-16T15:00:00.000Z' }, + ], + }); + + await render(); + + expect(container.querySelectorAll('[data-cache-hit-rate-segment="line"]')).toHaveLength(1); + expect(container.querySelectorAll('[data-cache-hit-rate-segment="point"]')).toHaveLength(1); + + const hoverCapture = container.querySelector( + '.bitfun-usage-stats__trend-svg > rect[fill="transparent"]', + ) as SVGRectElement; + vi.spyOn(hoverCapture, 'getBoundingClientRect').mockReturnValue({ + left: 0, + width: 400, + } as DOMRect); + + await act(async () => { + hoverCapture.dispatchEvent(new MouseEvent('mousemove', { + bubbles: true, + clientX: 300, + })); + }); + let tooltipRows = container.querySelectorAll('.bitfun-usage-stats__trend-tooltip-row'); + expect(tooltipRows[tooltipRows.length - 1]?.textContent).toContain('–'); + + await act(async () => { + hoverCapture.dispatchEvent(new MouseEvent('mousemove', { + bubbles: true, + clientX: 100, + })); + }); + tooltipRows = container.querySelectorAll('.bitfun-usage-stats__trend-tooltip-row'); + expect(tooltipRows[tooltipRows.length - 1]?.textContent).toContain('0.00%'); + }); + it('keeps same-named models distinct and labels deleted configurations', async () => { getStatisticsMock.mockResolvedValue({ ...SAMPLE_STATS, diff --git a/src/web-ui/src/infrastructure/config/components/UsageStatisticsConfig.tsx b/src/web-ui/src/infrastructure/config/components/UsageStatisticsConfig.tsx index 79eccfb2e6..11e4e9360c 100644 --- a/src/web-ui/src/infrastructure/config/components/UsageStatisticsConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/UsageStatisticsConfig.tsx @@ -33,7 +33,6 @@ import './UsageStatisticsConfig.scss'; const SERIES_COLORS = { input: 'var(--bf-appearance-token-color-accent-500)', output: 'var(--bf-appearance-token-color-success)', - cacheCreation: 'var(--bf-appearance-token-color-warning)', cacheRead: 'var(--bf-appearance-token-color-cyan-500)', cacheHitRate: 'var(--bf-appearance-token-color-purple-500)', } as const; @@ -366,13 +365,12 @@ interface TrendChartProps { } const TREND_SERIES: { - key: 'inputTokens' | 'outputTokens' | 'cacheReadTokens' | 'cacheWriteTokens'; + key: 'inputTokens' | 'outputTokens' | 'cacheReadTokens'; color: string; legendKey: string; }[] = [ { key: 'inputTokens', color: SERIES_COLORS.input, legendKey: 'trend.legend.input' }, { key: 'outputTokens', color: SERIES_COLORS.output, legendKey: 'trend.legend.output' }, - { key: 'cacheWriteTokens', color: SERIES_COLORS.cacheCreation, legendKey: 'trend.legend.cacheCreation' }, { key: 'cacheReadTokens', color: SERIES_COLORS.cacheRead, legendKey: 'trend.legend.cacheRead' }, ]; @@ -391,6 +389,18 @@ function niceMax(value: number): number { return nice * magnitude; } +function cacheHitRateForTrend( + point: UsageStatistics['trend'][number], +): number | null { + if (point.cacheHitRate !== null) return point.cacheHitRate; + + const isIdleBucket = point.inputTokens === 0 + && point.outputTokens === 0 + && point.cacheReadTokens === 0 + && point.cacheWriteTokens === 0; + return isIdleBucket ? 0 : null; +} + const TrendChart: React.FC = ({ points, granularity, timeZone }) => { const { t, formatDate } = useI18n('settings/usage-statistics'); const [hoverIndex, setHoverIndex] = useState(null); @@ -404,7 +414,6 @@ const TrendChart: React.FC = ({ points, granularity, timeZone } point.inputTokens, point.outputTokens, point.cacheReadTokens, - point.cacheWriteTokens, ), 0), ); const yTicks = 4; @@ -416,8 +425,8 @@ const TrendChart: React.FC = ({ points, granularity, timeZone } const yFor = (value: number): number => ( PAD_TOP + plotHeight - (value / maxTokens) * plotHeight ); - const rateFor = (value: number | null): number | null => ( - value === null ? null : PAD_TOP + plotHeight - value * plotHeight + const rateFor = (value: number): number => ( + PAD_TOP + plotHeight - value * plotHeight ); const xTickIndexes = useMemo(() => { @@ -430,6 +439,27 @@ const TrendChart: React.FC = ({ points, granularity, timeZone } if (points.length === 0) return null; const hovered = hoverIndex !== null ? points[hoverIndex] : null; + const hoveredHitRate = hovered ? cacheHitRateForTrend(hovered) : null; + + // Synthesized idle buckets sit at 0% to keep ordinary idle stretches + // continuous. Active buckets without cache telemetry remain real gaps so + // the chart does not claim that an unsupported provider had a 0% hit rate. + const hitRateSegments: Array> = []; + { + let current: Array<{ x: number; y: number }> = []; + points.forEach((point, index) => { + const rate = cacheHitRateForTrend(point); + if (rate === null) { + if (current.length > 0) { + hitRateSegments.push(current); + current = []; + } + return; + } + current.push({ x: xFor(index), y: rateFor(rate) }); + }); + if (current.length > 0) hitRateSegments.push(current); + } return (
@@ -490,22 +520,31 @@ const TrendChart: React.FC = ({ points, granularity, timeZone } /> ))} - {/* Cache hit rate (right axis, dashed) */} - { - const y = rateFor(point.cacheHitRate); - return y === null ? '' : `${xFor(index)},${y}`; - }) - .filter(Boolean) - .join(' ')} - fill="none" - stroke={SERIES_COLORS.cacheHitRate} - strokeWidth="2" - strokeDasharray="4 4" - strokeLinejoin="round" - strokeLinecap="round" - /> + {/* Cache hit rate (right axis, dashed). */} + {hitRateSegments.map((segment, segmentIndex) => + segment.length === 1 ? ( + + ) : ( + `${point.x},${point.y}`).join(' ')} + fill="none" + stroke={SERIES_COLORS.cacheHitRate} + strokeWidth="2" + strokeDasharray="4 4" + strokeLinejoin="round" + strokeLinecap="round" + data-cache-hit-rate-segment="line" + /> + ), + )} {/* Hover capture */} = ({ points, granularity, timeZone } y2={PAD_TOP + plotHeight} className="bitfun-usage-stats__trend-cursor" /> + {/* Hover markers: one dot per series so small values stay visible + where a zero-baseline token axis would otherwise flatten them + (e.g. 276K next to a 20M peak). */} + {TREND_SERIES.map((series) => ( + + ))} + {hoveredHitRate !== null && ( + + )} = ({ points, granularity, timeZone } {[ { label: t('trend.legend.input'), value: hovered.inputTokens, color: SERIES_COLORS.input }, { label: t('trend.legend.output'), value: hovered.outputTokens, color: SERIES_COLORS.output }, - { label: t('trend.legend.cacheCreation'), value: hovered.cacheWriteTokens, color: SERIES_COLORS.cacheCreation }, { label: t('trend.legend.cacheRead'), value: hovered.cacheReadTokens, color: SERIES_COLORS.cacheRead }, { label: t('trend.legend.cacheHitRate'), - value: hovered.cacheHitRate === null ? null : `${Math.round(hovered.cacheHitRate * 100)}%`, + value: hoveredHitRate, color: SERIES_COLORS.cacheHitRate, + isRate: true, }, ].map((row, index) => ( = ({ points, granularity, timeZone } className="bitfun-usage-stats__trend-tooltip-row" > - {row.label}: {row.value === null ? '–' : formatTokens(row.value as number)} + {row.label}:{' '} + {row.isRate + ? formatHitRate(row.value as number | null) + : row.value === null + ? '–' + : formatTokens(row.value as number)} ))} diff --git a/src/web-ui/src/locales/en-US/settings/usage-statistics.json b/src/web-ui/src/locales/en-US/settings/usage-statistics.json index ee81341929..fd8a1bc846 100644 --- a/src/web-ui/src/locales/en-US/settings/usage-statistics.json +++ b/src/web-ui/src/locales/en-US/settings/usage-statistics.json @@ -63,7 +63,6 @@ "legend": { "input": "Input", "output": "Output", - "cacheCreation": "Cache Creation", "cacheRead": "Cache Read", "cacheHitRate": "Cache Hit Rate" } diff --git a/src/web-ui/src/locales/zh-CN/settings/usage-statistics.json b/src/web-ui/src/locales/zh-CN/settings/usage-statistics.json index 6569eb8360..eb106a6c89 100644 --- a/src/web-ui/src/locales/zh-CN/settings/usage-statistics.json +++ b/src/web-ui/src/locales/zh-CN/settings/usage-statistics.json @@ -63,7 +63,6 @@ "legend": { "input": "Input", "output": "Output", - "cacheCreation": "Cache Creation", "cacheRead": "Cache Read", "cacheHitRate": "缓存命中率" } diff --git a/src/web-ui/src/locales/zh-TW/settings/usage-statistics.json b/src/web-ui/src/locales/zh-TW/settings/usage-statistics.json index df275d52f3..7443728466 100644 --- a/src/web-ui/src/locales/zh-TW/settings/usage-statistics.json +++ b/src/web-ui/src/locales/zh-TW/settings/usage-statistics.json @@ -63,7 +63,6 @@ "legend": { "input": "Input", "output": "Output", - "cacheCreation": "Cache Creation", "cacheRead": "Cache Read", "cacheHitRate": "快取命中率" }