feat(dashboard): add per-user drill-down for group, model, and endpoint distributions

Click on a group name, model name, or endpoint name in the distribution
tables to expand and show per-user usage breakdown (requests, tokens,
actual cost, standard cost).

Backend: new GET /admin/dashboard/user-breakdown API with group_id,
model, endpoint, endpoint_type filters.
Frontend: clickable rows with expand/collapse sub-table in all three
distribution charts.
This commit is contained in:
erio
2026-03-16 21:31:52 +08:00
parent f42c8f2abe
commit 4b41e898a4
16 changed files with 474 additions and 74 deletions

View File

@@ -71,7 +71,7 @@
<div class="h-48 w-48">
<Doughnut :data="chartData" :options="doughnutOptions" />
</div>
<div class="max-h-48 flex-1 overflow-y-auto">
<div class="max-h-64 flex-1 overflow-y-auto">
<table class="w-full text-xs">
<thead>
<tr class="text-gray-500 dark:text-gray-400">
@@ -83,30 +83,43 @@
</tr>
</thead>
<tbody>
<tr
v-for="model in displayModelStats"
:key="model.model"
class="border-t border-gray-100 dark:border-gray-700"
>
<td
class="max-w-[100px] truncate py-1.5 font-medium text-gray-900 dark:text-white"
:title="model.model"
<template v-for="model in displayModelStats" :key="model.model">
<tr
class="border-t border-gray-100 cursor-pointer transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-dark-700/40"
@click="toggleBreakdown('model', model.model)"
>
{{ model.model }}
</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
{{ formatNumber(model.requests) }}
</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
{{ formatTokens(model.total_tokens) }}
</td>
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
${{ formatCost(model.actual_cost) }}
</td>
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
${{ formatCost(model.cost) }}
</td>
</tr>
<td
class="max-w-[100px] truncate py-1.5 font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
:title="model.model"
>
<span class="inline-flex items-center gap-1">
<svg v-if="expandedKey === `model-${model.model}`" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
<svg v-else class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
{{ model.model }}
</span>
</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
{{ formatNumber(model.requests) }}
</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
{{ formatTokens(model.total_tokens) }}
</td>
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
${{ formatCost(model.actual_cost) }}
</td>
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
${{ formatCost(model.cost) }}
</td>
</tr>
<tr v-if="expandedKey === `model-${model.model}`">
<td colspan="5" class="p-0">
<UserBreakdownSubTable
:items="breakdownItems"
:loading="breakdownLoading"
/>
</td>
</tr>
</template>
</tbody>
</table>
</div>
@@ -193,7 +206,9 @@ import { useI18n } from 'vue-i18n'
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'
import { Doughnut } from 'vue-chartjs'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import type { ModelStat, UserSpendingRankingItem } from '@/types'
import UserBreakdownSubTable from './UserBreakdownSubTable.vue'
import type { ModelStat, UserSpendingRankingItem, UserBreakdownItem } from '@/types'
import { getUserBreakdown } from '@/api/admin/dashboard'
ChartJS.register(ArcElement, Tooltip, Legend)
@@ -213,6 +228,8 @@ const props = withDefaults(defineProps<{
showMetricToggle?: boolean
rankingLoading?: boolean
rankingError?: boolean
startDate?: string
endDate?: string
}>(), {
enableRankingView: false,
rankingItems: () => [],
@@ -226,6 +243,33 @@ const props = withDefaults(defineProps<{
rankingError: false
})
const expandedKey = ref<string | null>(null)
const breakdownItems = ref<UserBreakdownItem[]>([])
const breakdownLoading = ref(false)
const toggleBreakdown = async (type: string, id: string) => {
const key = `${type}-${id}`
if (expandedKey.value === key) {
expandedKey.value = null
return
}
expandedKey.value = key
breakdownLoading.value = true
breakdownItems.value = []
try {
const res = await getUserBreakdown({
start_date: props.startDate,
end_date: props.endDate,
model: id,
})
breakdownItems.value = res.users || []
} catch {
breakdownItems.value = []
} finally {
breakdownLoading.value = false
}
}
const emit = defineEmits<{
'update:metric': [value: DistributionMetric]
'ranking-click': [item: UserSpendingRankingItem]