| 1 | import { useEffect, useMemo, useRef, useState } from 'react' |
| 2 | import Chart from 'chart.js/auto' |
| 3 | import { |
| 4 | Activity, |
| 5 | ArrowDownToLine, |
| 6 | ArrowUpFromLine, |
| 7 | Coins, |
| 8 | RefreshCw |
| 9 | } from 'lucide-react' |
| 10 | import type { ModelUsagePeriod, ModelUsageStats } from '@shared/model-usage' |
| 11 | import { ipc } from '../../lib/ipc' |
| 12 | import { useLang } from '../../i18n' |
| 13 | import { Button } from '../ui/Button' |
| 14 | import { Tabs, TabsList, TabsTrigger } from '../ui/Tabs' |
| 15 | |
| 16 | const CHART_COLORS = ['#5D6B4D', '#8FBC8F', '#C8B89E', '#D4E4C1', '#3E4A32', '#a8b89a'] |
| 17 | |
| 18 | const formatTokens = (value: number): string => { |
| 19 | if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M` |
| 20 | if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K` |
| 21 | return value.toLocaleString() |
| 22 | } |
| 23 | |
| 24 | export function TokenUsageDashboard(): React.JSX.Element { |
| 25 | const { t } = useLang() |
| 26 | const [period, setPeriod] = useState<ModelUsagePeriod>('30d') |
| 27 | const [stats, setStats] = useState<ModelUsageStats | null>(null) |
| 28 | const [loading, setLoading] = useState(true) |
| 29 | const [error, setError] = useState('') |
| 30 | const [refreshKey, setRefreshKey] = useState(0) |
| 31 | const trendCanvasRef = useRef<HTMLCanvasElement | null>(null) |
| 32 | const modelCanvasRef = useRef<HTMLCanvasElement | null>(null) |
| 33 | const hourlyCanvasRef = useRef<HTMLCanvasElement | null>(null) |
| 34 | const [todayStats, setTodayStats] = useState<ModelUsageStats | null>(null) |
| 35 | |
| 36 | useEffect(() => { |
| 37 | let active = true |
| 38 | setLoading(true) |
| 39 | setError('') |
| 40 | void ipc |
| 41 | .getModelUsage(period) |
| 42 | .then((result) => { |
| 43 | if (active) setStats(result) |
| 44 | }) |
| 45 | .catch((loadError: unknown) => { |
| 46 | if (!active) return |
| 47 | setError(loadError instanceof Error ? loadError.message : t('settings.usageLoadFailed')) |
| 48 | }) |
| 49 | .finally(() => { |
| 50 | if (active) setLoading(false) |
| 51 | }) |
| 52 | return () => { |
| 53 | active = false |
| 54 | } |
| 55 | }, [period, refreshKey, t]) |
| 56 | |
| 57 | useEffect(() => { |
| 58 | let active = true |
| 59 | void ipc |
| 60 | .getModelUsage('today') |
| 61 | .then((result) => { |
| 62 | if (active) setTodayStats(result) |
| 63 | }) |
| 64 | .catch(() => { |
| 65 | /* hourly panel is supplementary, ignore errors */ |
| 66 | }) |
| 67 | return () => { |
| 68 | active = false |
| 69 | } |
| 70 | }, [refreshKey]) |
| 71 | |
| 72 | const modelRows = useMemo(() => stats?.byModel.slice(0, 8) || [], [stats]) |
| 73 | const modelMax = useMemo( |
| 74 | () => modelRows.reduce((max, item) => Math.max(max, item.totalTokens), 0) || 1, |
| 75 | [modelRows] |
| 76 | ) |
| 77 | |
| 78 | const totalTokens = stats?.totals.totalTokens || 0 |
| 79 | const inputTokens = stats?.totals.inputTokens || 0 |
| 80 | const outputTokens = stats?.totals.outputTokens || 0 |
| 81 | const callCount = stats?.totals.callCount || 0 |
| 82 | |
| 83 | const statCards = [ |
| 84 | { |
| 85 | label: t('settings.usageTotalTokens'), |
| 86 | value: totalTokens, |
| 87 | icon: Coins, |
| 88 | bg: 'bg-[#d4e4c1]', |
| 89 | border: 'border-[#c8d6ba]', |
| 90 | iconBg: 'bg-[#5d6b4d]', |
| 91 | blob: 'bg-[#8fbc8f]/30' |
| 92 | }, |
| 93 | { |
| 94 | label: t('settings.usageInputTokens'), |
| 95 | value: inputTokens, |
| 96 | icon: ArrowDownToLine, |
| 97 | bg: 'bg-[#e8e0d0]', |
| 98 | border: 'border-[#d9cfbd]', |
| 99 | iconBg: 'bg-[#6f8a5b]', |
| 100 | blob: 'bg-[#d4e4c1]/55' |
| 101 | }, |
| 102 | { |
| 103 | label: t('settings.usageOutputTokens'), |
| 104 | value: outputTokens, |
| 105 | icon: ArrowUpFromLine, |
| 106 | bg: 'bg-[#f5f1e8]', |
| 107 | border: 'border-[#e0d8c8]', |
| 108 | iconBg: 'bg-[#b18f5e]', |
| 109 | blob: 'bg-[#c8b89e]/30' |
| 110 | }, |
| 111 | { |
| 112 | label: t('settings.usageCalls'), |
| 113 | value: callCount, |
| 114 | icon: Activity, |
| 115 | bg: 'bg-[#d4e4c1]', |
| 116 | border: 'border-[#a9bd97]', |
| 117 | iconBg: 'bg-[#3e4a32]', |
| 118 | blob: 'bg-[#5d6b4d]/22' |
| 119 | } |
| 120 | ] |
| 121 | |
| 122 | useEffect(() => { |
| 123 | const canvas = trendCanvasRef.current |
| 124 | if (!canvas || !stats) return |
| 125 | const totals = stats.byDay.map((item) => item.totalTokens) |
| 126 | const chart = new Chart(canvas, { |
| 127 | data: { |
| 128 | labels: stats.byDay.map((item) => item.date.slice(5)), |
| 129 | datasets: [ |
| 130 | { |
| 131 | type: 'bar', |
| 132 | label: t('settings.usageInputTokens'), |
| 133 | data: stats.byDay.map((item) => item.inputTokens), |
| 134 | backgroundColor: '#9bb48b', |
| 135 | borderRadius: { topLeft: 5, topRight: 5, bottomLeft: 0, bottomRight: 0 }, |
| 136 | borderSkipped: false, |
| 137 | stack: 'tokens', |
| 138 | maxBarThickness: 26, |
| 139 | yAxisID: 'y', |
| 140 | order: 2 |
| 141 | }, |
| 142 | { |
| 143 | type: 'bar', |
| 144 | label: t('settings.usageOutputTokens'), |
| 145 | data: stats.byDay.map((item) => item.outputTokens), |
| 146 | backgroundColor: '#d9a26f', |
| 147 | borderRadius: { topLeft: 5, topRight: 5, bottomLeft: 0, bottomRight: 0 }, |
| 148 | borderSkipped: false, |
| 149 | stack: 'tokens', |
| 150 | maxBarThickness: 26, |
| 151 | yAxisID: 'y', |
| 152 | order: 3 |
| 153 | }, |
| 154 | { |
| 155 | type: 'line', |
| 156 | label: t('settings.usageTotalTokens'), |
| 157 | data: totals, |
| 158 | borderColor: '#3E4A32', |
| 159 | backgroundColor: '#3E4A32', |
| 160 | borderWidth: 2, |
| 161 | tension: 0.4, |
| 162 | pointRadius: 0, |
| 163 | pointHoverRadius: 5, |
| 164 | pointHoverBackgroundColor: '#3E4A32', |
| 165 | pointHoverBorderColor: '#F5F1E8', |
| 166 | pointHoverBorderWidth: 2, |
| 167 | yAxisID: 'y1', |
| 168 | order: 1 |
| 169 | } |
| 170 | ] |
| 171 | }, |
| 172 | options: { |
| 173 | responsive: true, |
| 174 | maintainAspectRatio: false, |
| 175 | interaction: { intersect: false, mode: 'index' }, |
| 176 | plugins: { |
| 177 | legend: { |
| 178 | position: 'bottom', |
| 179 | labels: { |
| 180 | boxWidth: 8, |
| 181 | boxHeight: 8, |
| 182 | padding: 10, |
| 183 | color: '#5D6B4D', |
| 184 | font: { size: 10 } |
| 185 | } |
| 186 | }, |
| 187 | tooltip: { |
| 188 | backgroundColor: '#3E4A32', |
| 189 | padding: 10, |
| 190 | cornerRadius: 10, |
| 191 | titleColor: '#F5F1E8', |
| 192 | bodyColor: '#D4E4C1', |
| 193 | titleFont: { size: 12 }, |
| 194 | bodyFont: { size: 12 }, |
| 195 | displayColors: true, |
| 196 | boxPadding: 4, |
| 197 | callbacks: { |
| 198 | label: (context) => `${context.dataset.label}: ${Number(context.raw).toLocaleString()}` |
| 199 | } |
| 200 | } |
| 201 | }, |
| 202 | scales: { |
| 203 | x: { |
| 204 | stacked: true, |
| 205 | grid: { display: false }, |
| 206 | ticks: { color: '#8A9A7B', font: { size: 11 } }, |
| 207 | border: { display: false } |
| 208 | }, |
| 209 | y: { |
| 210 | stacked: true, |
| 211 | beginAtZero: true, |
| 212 | ticks: { |
| 213 | callback: (value) => formatTokens(Number(value)), |
| 214 | color: '#8A9A7B', |
| 215 | font: { size: 11 } |
| 216 | }, |
| 217 | grid: { color: 'rgba(93, 107, 77, 0.08)' }, |
| 218 | border: { display: false } |
| 219 | }, |
| 220 | y1: { |
| 221 | stacked: false, |
| 222 | beginAtZero: true, |
| 223 | display: false, |
| 224 | max: Math.max(...totals, 1) * 1.15 |
| 225 | } |
| 226 | } |
| 227 | } |
| 228 | }) |
| 229 | return () => chart.destroy() |
| 230 | }, [stats, t]) |
| 231 | |
| 232 | useEffect(() => { |
| 233 | const canvas = modelCanvasRef.current |
| 234 | if (!canvas || modelRows.length === 0) return |
| 235 | const chart = new Chart(canvas, { |
| 236 | type: 'doughnut', |
| 237 | data: { |
| 238 | labels: modelRows.map((item) => item.model), |
| 239 | datasets: [ |
| 240 | { |
| 241 | data: modelRows.map((item) => item.totalTokens), |
| 242 | backgroundColor: modelRows.map((_, index) => CHART_COLORS[index % CHART_COLORS.length]), |
| 243 | borderColor: '#F5F1E8', |
| 244 | borderWidth: 3, |
| 245 | hoverOffset: 8 |
| 246 | } |
| 247 | ] |
| 248 | }, |
| 249 | options: { |
| 250 | responsive: true, |
| 251 | maintainAspectRatio: false, |
| 252 | cutout: '68%', |
| 253 | plugins: { |
| 254 | legend: { display: false }, |
| 255 | tooltip: { |
| 256 | backgroundColor: '#3E4A32', |
| 257 | padding: 10, |
| 258 | cornerRadius: 10, |
| 259 | titleColor: '#F5F1E8', |
| 260 | bodyColor: '#D4E4C1', |
| 261 | callbacks: { |
| 262 | label: (context) => `${context.label}: ${formatTokens(Number(context.raw))}` |
| 263 | } |
| 264 | } |
| 265 | } |
| 266 | } |
| 267 | }) |
| 268 | return () => chart.destroy() |
| 269 | }, [modelRows]) |
| 270 | |
| 271 | useEffect(() => { |
| 272 | const canvas = hourlyCanvasRef.current |
| 273 | const hours = todayStats?.byHour |
| 274 | if (!canvas || !Array.isArray(hours) || hours.length === 0) return |
| 275 | const ctx = canvas.getContext('2d') |
| 276 | const makeFill = (color: string) => { |
| 277 | if (!ctx) return `${color}22` |
| 278 | const gradient = ctx.createLinearGradient(0, 0, 0, 200) |
| 279 | gradient.addColorStop(0, `${color}40`) |
| 280 | gradient.addColorStop(1, `${color}00`) |
| 281 | return gradient |
| 282 | } |
| 283 | const chart = new Chart(canvas, { |
| 284 | type: 'line', |
| 285 | data: { |
| 286 | labels: hours.map((item) => `${String(item.hour).padStart(2, '0')}:00`), |
| 287 | datasets: [ |
| 288 | { |
| 289 | label: t('settings.usageInputTokens'), |
| 290 | data: hours.map((item) => item.inputTokens), |
| 291 | borderColor: '#5D6B4D', |
| 292 | backgroundColor: makeFill('#5D6B4D'), |
| 293 | borderWidth: 2, |
| 294 | tension: 0.4, |
| 295 | fill: true, |
| 296 | pointRadius: 0, |
| 297 | pointHoverRadius: 4, |
| 298 | pointHoverBackgroundColor: '#5D6B4D', |
| 299 | pointHoverBorderColor: '#F5F1E8', |
| 300 | pointHoverBorderWidth: 2 |
| 301 | }, |
| 302 | { |
| 303 | label: t('settings.usageOutputTokens'), |
| 304 | data: hours.map((item) => item.outputTokens), |
| 305 | borderColor: '#8FBC8F', |
| 306 | backgroundColor: makeFill('#8FBC8F'), |
| 307 | borderWidth: 2, |
| 308 | tension: 0.4, |
| 309 | fill: true, |
| 310 | pointRadius: 0, |
| 311 | pointHoverRadius: 4, |
| 312 | pointHoverBackgroundColor: '#8FBC8F', |
| 313 | pointHoverBorderColor: '#F5F1E8', |
| 314 | pointHoverBorderWidth: 2 |
| 315 | } |
| 316 | ] |
| 317 | }, |
| 318 | options: { |
| 319 | responsive: true, |
| 320 | maintainAspectRatio: false, |
| 321 | interaction: { intersect: false, mode: 'index' }, |
| 322 | plugins: { |
| 323 | legend: { |
| 324 | position: 'bottom', |
| 325 | labels: { |
| 326 | boxWidth: 8, |
| 327 | boxHeight: 8, |
| 328 | padding: 10, |
| 329 | color: '#5D6B4D', |
| 330 | font: { size: 10 } |
| 331 | } |
| 332 | }, |
| 333 | tooltip: { |
| 334 | backgroundColor: '#3E4A32', |
| 335 | padding: 10, |
| 336 | cornerRadius: 10, |
| 337 | titleColor: '#F5F1E8', |
| 338 | bodyColor: '#D4E4C1', |
| 339 | titleFont: { size: 12 }, |
| 340 | bodyFont: { size: 12 }, |
| 341 | displayColors: true, |
| 342 | boxPadding: 4, |
| 343 | callbacks: { |
| 344 | label: (context) => `${context.dataset.label}: ${Number(context.raw).toLocaleString()}` |
| 345 | } |
| 346 | } |
| 347 | }, |
| 348 | scales: { |
| 349 | x: { |
| 350 | grid: { display: false }, |
| 351 | ticks: { |
| 352 | color: '#8A9A7B', |
| 353 | font: { size: 10 }, |
| 354 | maxRotation: 0, |
| 355 | autoSkip: true, |
| 356 | maxTicksLimit: 8 |
| 357 | }, |
| 358 | border: { display: false } |
| 359 | }, |
| 360 | y: { |
| 361 | beginAtZero: true, |
| 362 | ticks: { |
| 363 | callback: (value) => formatTokens(Number(value)), |
| 364 | color: '#8A9A7B', |
| 365 | font: { size: 10 } |
| 366 | }, |
| 367 | grid: { color: 'rgba(93, 107, 77, 0.08)' }, |
| 368 | border: { display: false } |
| 369 | } |
| 370 | } |
| 371 | } |
| 372 | }) |
| 373 | return () => chart.destroy() |
| 374 | }, [todayStats, t]) |
| 375 | |
| 376 | const periods: Array<{ value: ModelUsagePeriod; label: string }> = [ |
| 377 | { value: '7d', label: t('settings.usagePeriod7d') }, |
| 378 | { value: '30d', label: t('settings.usagePeriod30d') }, |
| 379 | { value: 'all', label: t('settings.usagePeriodAll') } |
| 380 | ] |
| 381 | |
| 382 | if (loading && !stats) { |
| 383 | return ( |
| 384 | <div className="flex items-center justify-center gap-3 py-16 text-[#5D6B4D]"> |
| 385 | <RefreshCw className="h-4 w-4 animate-spin" /> |
| 386 | <span className="text-sm">{t('settings.usageLoading')}</span> |
| 387 | </div> |
| 388 | ) |
| 389 | } |
| 390 | |
| 391 | return ( |
| 392 | <div className="space-y-4"> |
| 393 | <div className="flex flex-wrap items-center justify-between gap-2"> |
| 394 | <Tabs |
| 395 | value={period} |
| 396 | onValueChange={(value) => setPeriod(value as ModelUsagePeriod)} |
| 397 | > |
| 398 | <TabsList className="min-h-9 rounded-full bg-[#F5F1E8] px-1"> |
| 399 | {periods.map((item) => ( |
| 400 | <TabsTrigger |
| 401 | key={item.value} |
| 402 | value={item.value} |
| 403 | className="h-7 rounded-full px-3.5 text-xs data-[state=active]:bg-[#5D6B4D] data-[state=active]:text-[#F5F1E8] data-[state=active]:shadow-none" |
| 404 | > |
| 405 | {item.label} |
| 406 | </TabsTrigger> |
| 407 | ))} |
| 408 | </TabsList> |
| 409 | </Tabs> |
| 410 | <Button |
| 411 | variant="ghost" |
| 412 | size="sm" |
| 413 | disabled={loading} |
| 414 | onClick={() => setRefreshKey((value) => value + 1)} |
| 415 | className="h-8 text-[#5D6B4D] hover:text-[#3E4A32]" |
| 416 | > |
| 417 | <RefreshCw className={`mr-1.5 h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} /> |
| 418 | {t('settings.usageRefresh')} |
| 419 | </Button> |
| 420 | </div> |
| 421 | |
| 422 | {error ? ( |
| 423 | <div className="rounded-2xl border border-[#C8B89E] bg-[#E8E0D0] p-4 text-sm text-[#3E4A32]"> |
| 424 | {error} |
| 425 | </div> |
| 426 | ) : null} |
| 427 | |
| 428 | <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> |
| 429 | {statCards.map((card) => { |
| 430 | const Icon = card.icon |
| 431 | return ( |
| 432 | <div |
| 433 | key={card.label} |
| 434 | className={`group relative overflow-hidden rounded-[1.5rem] border ${card.border} ${card.bg} p-5 shadow-[0_14px_34px_rgba(86,73,54,0.10)] transition-transform duration-200 hover:-translate-y-0.5`} |
| 435 | > |
| 436 | <div |
| 437 | className={`pointer-events-none absolute -right-8 -top-8 h-24 w-24 rounded-[30%_70%_70%_30%/30%_30%_70%_70%] ${card.blob} transition-transform duration-300 group-hover:scale-110`} |
| 438 | /> |
| 439 | <div className="relative flex items-start justify-between"> |
| 440 | <div |
| 441 | className={`flex h-10 w-10 items-center justify-center rounded-[10%_90%_16%_84%/78%_22%_78%_22%] ${card.iconBg} text-white shadow-[0_8px_18px_rgba(86,73,54,0.16)]`} |
| 442 | > |
| 443 | <Icon className="h-4 w-4" /> |
| 444 | </div> |
| 445 | </div> |
| 446 | <div className="relative mt-4"> |
| 447 | <p className="text-[10px] font-medium uppercase tracking-[0.16em] text-[#5d6b4d]"> |
| 448 | {card.label} |
| 449 | </p> |
| 450 | <p className="organic-serif mt-1.5 text-[24px] font-semibold leading-none text-[#3e4a32]"> |
| 451 | {card.value.toLocaleString()} |
| 452 | </p> |
| 453 | </div> |
| 454 | </div> |
| 455 | ) |
| 456 | })} |
| 457 | </div> |
| 458 | |
| 459 | <div className="rounded-2xl bg-white p-5"> |
| 460 | <h3 className="organic-serif mb-3 text-base font-semibold text-[#3E4A32]"> |
| 461 | {t('settings.usageTodayHourly')} |
| 462 | </h3> |
| 463 | <div className="h-[clamp(200px,20vw,320px)]"> |
| 464 | {todayStats?.byHour?.some((item) => item.callCount > 0) ? ( |
| 465 | <canvas ref={hourlyCanvasRef} /> |
| 466 | ) : ( |
| 467 | <div className="flex h-full items-center justify-center text-sm text-[#8A9A7B]"> |
| 468 | {t('settings.usageEmpty')} |
| 469 | </div> |
| 470 | )} |
| 471 | </div> |
| 472 | </div> |
| 473 | |
| 474 | <div className="grid gap-4 lg:grid-cols-[minmax(0,1.65fr)_minmax(280px,1fr)]"> |
| 475 | <div className="flex h-full flex-col rounded-2xl bg-white p-5"> |
| 476 | <h3 className="organic-serif mb-3 text-base font-semibold text-[#3E4A32]"> |
| 477 | {t('settings.usageTrend')} |
| 478 | </h3> |
| 479 | <div className="min-h-[240px] flex-1"> |
| 480 | {stats?.byDay.length ? ( |
| 481 | <canvas ref={trendCanvasRef} /> |
| 482 | ) : ( |
| 483 | <div className="flex h-full items-center justify-center text-sm text-[#8A9A7B]"> |
| 484 | {t('settings.usageEmpty')} |
| 485 | </div> |
| 486 | )} |
| 487 | </div> |
| 488 | </div> |
| 489 | |
| 490 | <div className="rounded-2xl bg-white p-5"> |
| 491 | <h3 className="organic-serif mb-3 text-base font-semibold text-[#3E4A32]"> |
| 492 | {t('settings.usageByModel')} |
| 493 | </h3> |
| 494 | {modelRows.length ? ( |
| 495 | <> |
| 496 | <div className="mx-auto h-[140px] max-w-[180px]"> |
| 497 | <canvas ref={modelCanvasRef} /> |
| 498 | </div> |
| 499 | <div className="mt-3 space-y-2"> |
| 500 | {modelRows.map((item, index) => { |
| 501 | const color = CHART_COLORS[index % CHART_COLORS.length] |
| 502 | const barPct = (item.totalTokens / modelMax) * 100 |
| 503 | return ( |
| 504 | <div key={`${item.provider}:${item.model}`}> |
| 505 | <div className="flex items-center gap-2 text-xs"> |
| 506 | <span |
| 507 | className="h-2 w-2 shrink-0 rounded-full" |
| 508 | style={{ backgroundColor: color }} |
| 509 | /> |
| 510 | <span |
| 511 | className="min-w-0 flex-1 truncate text-[#3E4A32]" |
| 512 | title={`${item.provider} / ${item.model}`} |
| 513 | > |
| 514 | {item.model} |
| 515 | </span> |
| 516 | <span className="tabular-nums text-[#5D6B4D]"> |
| 517 | {formatTokens(item.totalTokens)} |
| 518 | </span> |
| 519 | </div> |
| 520 | <div className="mt-1 h-1 overflow-hidden rounded-full bg-[#F5F1E8]"> |
| 521 | <div |
| 522 | className="h-full rounded-full transition-all" |
| 523 | style={{ width: `${barPct}%`, backgroundColor: color }} |
| 524 | /> |
| 525 | </div> |
| 526 | </div> |
| 527 | ) |
| 528 | })} |
| 529 | </div> |
| 530 | </> |
| 531 | ) : ( |
| 532 | <div className="flex h-[220px] items-center justify-center text-sm text-[#8A9A7B]"> |
| 533 | {t('settings.usageEmpty')} |
| 534 | </div> |
| 535 | )} |
| 536 | </div> |
| 537 | </div> |
| 538 | </div> |
| 539 | ) |
| 540 | } |
| 541 |