| <template> |
| <div class="card p-4"> |
| <h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white"> |
| {{ t('admin.dashboard.tokenUsageTrend') }} |
| </h3> |
| <div v-if="loading" class="flex h-48 items-center justify-center"> |
| <LoadingSpinner /> |
| </div> |
| <div v-else-if="trendData.length > 0 && chartData" class="h-48"> |
| <Line :data="chartData" :options="lineOptions" /> |
| </div> |
| <div |
| v-else |
| class="flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400" |
| > |
| {{ t('admin.dashboard.noDataAvailable') }} |
| </div> |
| </div> |
| </template> |
| |
| <script setup lang="ts"> |
| import { computed } from 'vue' |
| import { useI18n } from 'vue-i18n' |
| import { |
| Chart as ChartJS, |
| CategoryScale, |
| LinearScale, |
| PointElement, |
| LineElement, |
| Title, |
| Tooltip, |
| Legend, |
| Filler |
| } from 'chart.js' |
| import { Line } from 'vue-chartjs' |
| import LoadingSpinner from '@/components/common/LoadingSpinner.vue' |
| import type { TrendDataPoint } from '@/types' |
| |
| ChartJS.register( |
| CategoryScale, |
| LinearScale, |
| PointElement, |
| LineElement, |
| Title, |
| Tooltip, |
| Legend, |
| Filler |
| ) |
| |
| const { t } = useI18n() |
| |
| const props = defineProps<{ |
| trendData: TrendDataPoint[] |
| loading?: boolean |
| }>() |
| |
| const isDarkMode = computed(() => { |
| return document.documentElement.classList.contains('dark') |
| }) |
| |
| const chartColors = computed(() => ({ |
| text: isDarkMode.value ? '#e5e7eb' : '#374151', |
| grid: isDarkMode.value ? '#374151' : '#e5e7eb', |
| input: '#3b82f6', |
| output: '#10b981', |
| cacheCreation: '#f59e0b', |
| cacheRead: '#06b6d4' |
| })) |
| |
| const chartData = computed(() => { |
| if (!props.trendData?.length) return null |
| |
| return { |
| labels: props.trendData.map((d) => d.date), |
| datasets: [ |
| { |
| label: 'Input', |
| data: props.trendData.map((d) => d.input_tokens), |
| borderColor: chartColors.value.input, |
| backgroundColor: `${chartColors.value.input}20`, |
| fill: true, |
| tension: 0.3 |
| }, |
| { |
| label: 'Output', |
| data: props.trendData.map((d) => d.output_tokens), |
| borderColor: chartColors.value.output, |
| backgroundColor: `${chartColors.value.output}20`, |
| fill: true, |
| tension: 0.3 |
| }, |
| { |
| label: 'Cache Creation', |
| data: props.trendData.map((d) => d.cache_creation_tokens), |
| borderColor: chartColors.value.cacheCreation, |
| backgroundColor: `${chartColors.value.cacheCreation}20`, |
| fill: true, |
| tension: 0.3 |
| }, |
| { |
| label: 'Cache Read', |
| data: props.trendData.map((d) => d.cache_read_tokens), |
| borderColor: chartColors.value.cacheRead, |
| backgroundColor: `${chartColors.value.cacheRead}20`, |
| fill: true, |
| tension: 0.3 |
| } |
| ] |
| } |
| }) |
| |
| const lineOptions = computed(() => ({ |
| responsive: true, |
| maintainAspectRatio: false, |
| interaction: { |
| intersect: false, |
| mode: 'index' as const |
| }, |
| plugins: { |
| legend: { |
| position: 'top' as const, |
| labels: { |
| color: chartColors.value.text, |
| usePointStyle: true, |
| pointStyle: 'circle', |
| padding: 15, |
| font: { |
| size: 11 |
| } |
| } |
| }, |
| tooltip: { |
| callbacks: { |
| label: (context: any) => { |
| return `${context.dataset.label}: ${formatTokens(context.raw)}` |
| }, |
| footer: (tooltipItems: any) => { |
| const dataIndex = tooltipItems[0]?.dataIndex |
| if (dataIndex !== undefined && props.trendData[dataIndex]) { |
| const data = props.trendData[dataIndex] |
| return `Actual: $${formatCost(data.actual_cost)} | Standard: $${formatCost(data.cost)}` |
| } |
| return '' |
| } |
| } |
| } |
| }, |
| scales: { |
| x: { |
| grid: { |
| color: chartColors.value.grid |
| }, |
| ticks: { |
| color: chartColors.value.text, |
| font: { |
| size: 10 |
| } |
| } |
| }, |
| y: { |
| grid: { |
| color: chartColors.value.grid |
| }, |
| ticks: { |
| color: chartColors.value.text, |
| font: { |
| size: 10 |
| }, |
| callback: (value: string | number) => formatTokens(Number(value)) |
| } |
| } |
| } |
| })) |
| |
| const formatTokens = (value: number): string => { |
| if (value >= 1_000_000_000) { |
| return `${(value / 1_000_000_000).toFixed(2)}B` |
| } else if (value >= 1_000_000) { |
| return `${(value / 1_000_000).toFixed(2)}M` |
| } else if (value >= 1_000) { |
| return `${(value / 1_000).toFixed(2)}K` |
| } |
| return value.toLocaleString() |
| } |
| |
| const formatCost = (value: number): string => { |
| if (value >= 1000) { |
| return (value / 1000).toFixed(2) + 'K' |
| } else if (value >= 1) { |
| return value.toFixed(2) |
| } else if (value >= 0.01) { |
| return value.toFixed(3) |
| } |
| return value.toFixed(4) |
| } |
| </script> |
| |