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

@@ -87,27 +87,40 @@
</tr>
</thead>
<tbody>
<tr
v-for="item in displayEndpointStats"
:key="item.endpoint"
class="border-t border-gray-100 dark:border-gray-700"
>
<td class="max-w-[180px] truncate py-1.5 font-medium text-gray-900 dark:text-white" :title="item.endpoint">
{{ item.endpoint }}
</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
{{ formatNumber(item.requests) }}
</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
{{ formatTokens(item.total_tokens) }}
</td>
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
${{ formatCost(item.actual_cost) }}
</td>
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
${{ formatCost(item.cost) }}
</td>
</tr>
<template v-for="item in displayEndpointStats" :key="item.endpoint">
<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(item.endpoint)"
>
<td class="max-w-[180px] truncate py-1.5 font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" :title="item.endpoint">
<span class="inline-flex items-center gap-1">
<svg v-if="expandedKey === item.endpoint" 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>
{{ item.endpoint }}
</span>
</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
{{ formatNumber(item.requests) }}
</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
{{ formatTokens(item.total_tokens) }}
</td>
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
${{ formatCost(item.actual_cost) }}
</td>
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
${{ formatCost(item.cost) }}
</td>
</tr>
<tr v-if="expandedKey === item.endpoint">
<td colspan="5" class="p-0">
<UserBreakdownSubTable
:items="breakdownItems"
:loading="breakdownLoading"
/>
</td>
</tr>
</template>
</tbody>
</table>
</div>
@@ -119,12 +132,14 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
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 { EndpointStat } from '@/types'
import UserBreakdownSubTable from './UserBreakdownSubTable.vue'
import type { EndpointStat, UserBreakdownItem } from '@/types'
import { getUserBreakdown } from '@/api/admin/dashboard'
ChartJS.register(ArcElement, Tooltip, Legend)
@@ -144,6 +159,8 @@ const props = withDefaults(
source?: EndpointSource
showMetricToggle?: boolean
showSourceToggle?: boolean
startDate?: string
endDate?: string
}>(),
{
upstreamEndpointStats: () => [],
@@ -162,6 +179,33 @@ const emit = defineEmits<{
'update:source': [value: EndpointSource]
}>()
const expandedKey = ref<string | null>(null)
const breakdownItems = ref<UserBreakdownItem[]>([])
const breakdownLoading = ref(false)
const toggleBreakdown = async (endpoint: string) => {
if (expandedKey.value === endpoint) {
expandedKey.value = null
return
}
expandedKey.value = endpoint
breakdownLoading.value = true
breakdownItems.value = []
try {
const res = await getUserBreakdown({
start_date: props.startDate,
end_date: props.endDate,
endpoint,
endpoint_type: props.source,
})
breakdownItems.value = res.users || []
} catch {
breakdownItems.value = []
} finally {
breakdownLoading.value = false
}
}
const chartColors = [
'#3b82f6',
'#10b981',