'use client'; import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import * as echarts from 'echarts/core'; import { LineChart } from 'echarts/charts'; import { GridComponent, TooltipComponent } from 'echarts/components'; import { SVGRenderer } from 'echarts/renderers'; import { Loader2, RefreshCw, BarChart3 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { useI18n } from '@/lib/hooks/use-i18n'; import { useTheme } from '@/lib/hooks/use-theme'; echarts.use([LineChart, GridComponent, TooltipComponent, SVGRenderer]); type UsageKind = 'llm' | 'image' | 'video' | 'tts' | 'asr'; type UsageUnit = 'token' | 'image' | 'second' | 'character'; interface Bucket { key: string; kind: UsageKind; unit: UsageUnit; requests: number; totalTokens: number; quantity: number; } interface UsageResponse { totals: { requests: number; llmTokens: number }; byModel: Bucket[]; byDay: Bucket[]; byKind: Bucket[]; } function fmtNum(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; return String(Math.round(n)); } const KIND_LABEL_KEY: Record = { llm: 'settings.usage.kindLlm', image: 'settings.usage.kindImage', video: 'settings.usage.kindVideo', tts: 'settings.usage.kindTts', asr: 'settings.usage.kindAsr', }; const UNIT_LABEL_KEY: Record = { token: 'settings.usage.unitToken', image: 'settings.usage.unitImage', second: 'settings.usage.unitSecond', character: 'settings.usage.unitCharacter', }; /** Display order of modality sections. */ const KIND_ORDER: UsageKind[] = ['llm', 'image', 'video', 'tts', 'asr']; export function UsageDashboard() { const { t } = useI18n(); const { resolvedTheme } = useTheme(); const isDark = resolvedTheme === 'dark'; const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const chartRef = useRef(null); const chartInstance = useRef(null); const load = useCallback(async () => { setLoading(true); try { const res = await fetch('/api/usage'); const json = await res.json(); if (json.success !== false) setData(json as UsageResponse); } catch { // best-effort } finally { setLoading(false); } }, []); useEffect(() => { void load(); }, [load]); const byDay = useMemo(() => data?.byDay ?? [], [data]); /** A single usage figure with its unit, for a model/kind bucket. */ const usageValue = (b: Bucket): number => (b.kind === 'llm' ? b.totalTokens : b.quantity); const usageDisplay = (b: Bucket): string => `${fmtNum(usageValue(b))} ${t(UNIT_LABEL_KEY[b.kind === 'llm' ? 'token' : b.unit])}`; // Group models by modality, in display order, dropping empty modalities. const sections = useMemo(() => { const byKind = new Map(); for (const m of data?.byModel ?? []) { if (!byKind.has(m.kind)) byKind.set(m.kind, { models: [] }); byKind.get(m.kind)!.models.push(m); } for (const k of data?.byKind ?? []) { if (byKind.has(k.kind)) byKind.get(k.kind)!.kindBucket = k; } return KIND_ORDER.filter((k) => byKind.has(k)).map((k) => ({ kind: k, summary: byKind.get(k)!.kindBucket, models: byKind.get(k)!.models.sort((a, b) => b.requests - a.requests), })); }, [data]); // Daily REQUESTS trend — unit-agnostic so it works across all modalities. // Area-only with a soft gradient + faint line, theme-aware, to avoid the // harsh solid stroke in dark mode. useEffect(() => { if (!chartRef.current) return; if (!chartInstance.current) { chartInstance.current = echarts.init(chartRef.current, undefined, { renderer: 'svg' }); } const chart = chartInstance.current; const axis = isDark ? 'rgba(255,255,255,0.45)' : 'rgba(0,0,0,0.45)'; const split = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'; const accent = isDark ? '#a78bfa' : '#7c3aed'; // violet, matches primary chart.setOption({ tooltip: { trigger: 'axis' }, grid: { left: 44, right: 16, top: 16, bottom: 28 }, xAxis: { type: 'category', data: byDay.map((b) => b.key), axisLabel: { color: axis, fontSize: 11 }, axisLine: { lineStyle: { color: split } }, axisTick: { show: false }, }, yAxis: { type: 'value', minInterval: 1, axisLabel: { color: axis, fontSize: 11 }, splitLine: { lineStyle: { color: split } }, }, series: [ { name: t('settings.usage.totalRequests'), type: 'line', smooth: true, symbol: 'circle', symbolSize: 5, itemStyle: { color: accent }, // Faint, thin connecting line instead of a hard solid stroke. lineStyle: { color: accent, width: 1, opacity: isDark ? 0.5 : 0.7 }, areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [ { offset: 0, color: isDark ? 'rgba(167,139,250,0.35)' : 'rgba(124,58,237,0.25)' }, { offset: 1, color: isDark ? 'rgba(167,139,250,0.02)' : 'rgba(124,58,237,0.02)' }, ], }, }, data: byDay.map((b) => b.requests), }, ], }); chart.resize(); }, [byDay, t, isDark]); useEffect(() => { const onResize = () => chartInstance.current?.resize(); window.addEventListener('resize', onResize); return () => { window.removeEventListener('resize', onResize); chartInstance.current?.dispose(); chartInstance.current = null; }; }, []); const totals = data?.totals; return (

{t('settings.usage.title')}

{t('settings.usage.disclaimer')}

{/* Per-modality summary chips — each with its own unit. */} {sections.length > 0 ? (
{t('settings.usage.totalRequests')} {totals?.requests ?? 0}
{sections.map( (s) => s.summary && (
{t(KIND_LABEL_KEY[s.kind])} {usageDisplay(s.summary)} ({s.summary.requests})
), )}
) : null} {/* Daily request trend — unit-agnostic across modalities. */}
{t('settings.usage.dailyTrend')}
{byDay.length > 0 ? (
) : (
{t('settings.usage.empty')}
)}
{/* Per-modality tables — each section's usage column shares one unit. */} {sections.map((s) => (
{t(KIND_LABEL_KEY[s.kind])} {s.summary && ( {usageDisplay(s.summary)} · {s.summary.requests} {t('settings.usage.reqs')} )}
{s.models.map((m) => ( ))}
{t('settings.usage.model')} {t('settings.usage.reqs')} {t('settings.usage.usage')}
{m.key} {m.requests} {usageDisplay(m)}
))}
); }