feat: /admin/usage页面增加模型分布情况显示
This commit is contained in:
@@ -127,12 +127,25 @@ func (h *DashboardHandler) GetRealtimeMetrics(c *gin.Context) {
|
|||||||
|
|
||||||
// GetUsageTrend handles getting usage trend data
|
// GetUsageTrend handles getting usage trend data
|
||||||
// GET /api/v1/admin/dashboard/trend
|
// GET /api/v1/admin/dashboard/trend
|
||||||
// Query params: start_date, end_date (YYYY-MM-DD), granularity (day/hour)
|
// Query params: start_date, end_date (YYYY-MM-DD), granularity (day/hour), user_id, api_key_id
|
||||||
func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
|
func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
|
||||||
startTime, endTime := parseTimeRange(c)
|
startTime, endTime := parseTimeRange(c)
|
||||||
granularity := c.DefaultQuery("granularity", "day")
|
granularity := c.DefaultQuery("granularity", "day")
|
||||||
|
|
||||||
trend, err := h.usageRepo.GetUsageTrend(c.Request.Context(), startTime, endTime, granularity)
|
// Parse optional filter params
|
||||||
|
var userID, apiKeyID int64
|
||||||
|
if userIDStr := c.Query("user_id"); userIDStr != "" {
|
||||||
|
if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil {
|
||||||
|
userID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if apiKeyIDStr := c.Query("api_key_id"); apiKeyIDStr != "" {
|
||||||
|
if id, err := strconv.ParseInt(apiKeyIDStr, 10, 64); err == nil {
|
||||||
|
apiKeyID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trend, err := h.usageRepo.GetUsageTrendWithFilters(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, 500, "Failed to get usage trend")
|
response.Error(c, 500, "Failed to get usage trend")
|
||||||
return
|
return
|
||||||
@@ -148,11 +161,24 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
|
|||||||
|
|
||||||
// GetModelStats handles getting model usage statistics
|
// GetModelStats handles getting model usage statistics
|
||||||
// GET /api/v1/admin/dashboard/models
|
// GET /api/v1/admin/dashboard/models
|
||||||
// Query params: start_date, end_date (YYYY-MM-DD)
|
// Query params: start_date, end_date (YYYY-MM-DD), user_id, api_key_id
|
||||||
func (h *DashboardHandler) GetModelStats(c *gin.Context) {
|
func (h *DashboardHandler) GetModelStats(c *gin.Context) {
|
||||||
startTime, endTime := parseTimeRange(c)
|
startTime, endTime := parseTimeRange(c)
|
||||||
|
|
||||||
stats, err := h.usageRepo.GetModelStats(c.Request.Context(), startTime, endTime)
|
// Parse optional filter params
|
||||||
|
var userID, apiKeyID int64
|
||||||
|
if userIDStr := c.Query("user_id"); userIDStr != "" {
|
||||||
|
if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil {
|
||||||
|
userID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if apiKeyIDStr := c.Query("api_key_id"); apiKeyIDStr != "" {
|
||||||
|
if id, err := strconv.ParseInt(apiKeyIDStr, 10, 64); err == nil {
|
||||||
|
apiKeyID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := h.usageRepo.GetModelStatsWithFilters(c.Request.Context(), startTime, endTime, userID, apiKeyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, 500, "Failed to get model statistics")
|
response.Error(c, 500, "Failed to get model statistics")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -431,68 +431,6 @@ type UserUsageTrendPoint struct {
|
|||||||
ActualCost float64 `json:"actual_cost"` // 实际扣除
|
ActualCost float64 `json:"actual_cost"` // 实际扣除
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUsageTrend returns usage trend data grouped by date
|
|
||||||
// granularity: "day" or "hour"
|
|
||||||
func (r *UsageLogRepository) GetUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string) ([]TrendDataPoint, error) {
|
|
||||||
var results []TrendDataPoint
|
|
||||||
|
|
||||||
// Choose date format based on granularity
|
|
||||||
var dateFormat string
|
|
||||||
if granularity == "hour" {
|
|
||||||
dateFormat = "YYYY-MM-DD HH24:00"
|
|
||||||
} else {
|
|
||||||
dateFormat = "YYYY-MM-DD"
|
|
||||||
}
|
|
||||||
|
|
||||||
err := r.db.WithContext(ctx).Model(&model.UsageLog{}).
|
|
||||||
Select(`
|
|
||||||
TO_CHAR(created_at, ?) as date,
|
|
||||||
COUNT(*) as requests,
|
|
||||||
COALESCE(SUM(input_tokens), 0) as input_tokens,
|
|
||||||
COALESCE(SUM(output_tokens), 0) as output_tokens,
|
|
||||||
COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as cache_tokens,
|
|
||||||
COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as total_tokens,
|
|
||||||
COALESCE(SUM(total_cost), 0) as cost,
|
|
||||||
COALESCE(SUM(actual_cost), 0) as actual_cost
|
|
||||||
`, dateFormat).
|
|
||||||
Where("created_at >= ? AND created_at < ?", startTime, endTime).
|
|
||||||
Group("date").
|
|
||||||
Order("date ASC").
|
|
||||||
Scan(&results).Error
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return results, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetModelStats returns usage statistics grouped by model
|
|
||||||
func (r *UsageLogRepository) GetModelStats(ctx context.Context, startTime, endTime time.Time) ([]ModelStat, error) {
|
|
||||||
var results []ModelStat
|
|
||||||
|
|
||||||
err := r.db.WithContext(ctx).Model(&model.UsageLog{}).
|
|
||||||
Select(`
|
|
||||||
model,
|
|
||||||
COUNT(*) as requests,
|
|
||||||
COALESCE(SUM(input_tokens), 0) as input_tokens,
|
|
||||||
COALESCE(SUM(output_tokens), 0) as output_tokens,
|
|
||||||
COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as total_tokens,
|
|
||||||
COALESCE(SUM(total_cost), 0) as cost,
|
|
||||||
COALESCE(SUM(actual_cost), 0) as actual_cost
|
|
||||||
`).
|
|
||||||
Where("created_at >= ? AND created_at < ?", startTime, endTime).
|
|
||||||
Group("model").
|
|
||||||
Order("total_tokens DESC").
|
|
||||||
Scan(&results).Error
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return results, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApiKeyUsageTrendPoint represents API key usage trend data point
|
// ApiKeyUsageTrendPoint represents API key usage trend data point
|
||||||
type ApiKeyUsageTrendPoint struct {
|
type ApiKeyUsageTrendPoint struct {
|
||||||
Date string `json:"date"`
|
Date string `json:"date"`
|
||||||
@@ -959,6 +897,76 @@ func (r *UsageLogRepository) GetBatchApiKeyUsageStats(ctx context.Context, apiKe
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUsageTrendWithFilters returns usage trend data with optional user/api_key filters
|
||||||
|
func (r *UsageLogRepository) GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID int64) ([]TrendDataPoint, error) {
|
||||||
|
var results []TrendDataPoint
|
||||||
|
|
||||||
|
var dateFormat string
|
||||||
|
if granularity == "hour" {
|
||||||
|
dateFormat = "YYYY-MM-DD HH24:00"
|
||||||
|
} else {
|
||||||
|
dateFormat = "YYYY-MM-DD"
|
||||||
|
}
|
||||||
|
|
||||||
|
db := r.db.WithContext(ctx).Model(&model.UsageLog{}).
|
||||||
|
Select(`
|
||||||
|
TO_CHAR(created_at, ?) as date,
|
||||||
|
COUNT(*) as requests,
|
||||||
|
COALESCE(SUM(input_tokens), 0) as input_tokens,
|
||||||
|
COALESCE(SUM(output_tokens), 0) as output_tokens,
|
||||||
|
COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as cache_tokens,
|
||||||
|
COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as total_tokens,
|
||||||
|
COALESCE(SUM(total_cost), 0) as cost,
|
||||||
|
COALESCE(SUM(actual_cost), 0) as actual_cost
|
||||||
|
`, dateFormat).
|
||||||
|
Where("created_at >= ? AND created_at < ?", startTime, endTime)
|
||||||
|
|
||||||
|
if userID > 0 {
|
||||||
|
db = db.Where("user_id = ?", userID)
|
||||||
|
}
|
||||||
|
if apiKeyID > 0 {
|
||||||
|
db = db.Where("api_key_id = ?", apiKeyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := db.Group("date").Order("date ASC").Scan(&results).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetModelStatsWithFilters returns model statistics with optional user/api_key filters
|
||||||
|
func (r *UsageLogRepository) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID int64) ([]ModelStat, error) {
|
||||||
|
var results []ModelStat
|
||||||
|
|
||||||
|
db := r.db.WithContext(ctx).Model(&model.UsageLog{}).
|
||||||
|
Select(`
|
||||||
|
model,
|
||||||
|
COUNT(*) as requests,
|
||||||
|
COALESCE(SUM(input_tokens), 0) as input_tokens,
|
||||||
|
COALESCE(SUM(output_tokens), 0) as output_tokens,
|
||||||
|
COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as total_tokens,
|
||||||
|
COALESCE(SUM(total_cost), 0) as cost,
|
||||||
|
COALESCE(SUM(actual_cost), 0) as actual_cost
|
||||||
|
`).
|
||||||
|
Where("created_at >= ? AND created_at < ?", startTime, endTime)
|
||||||
|
|
||||||
|
if userID > 0 {
|
||||||
|
db = db.Where("user_id = ?", userID)
|
||||||
|
}
|
||||||
|
if apiKeyID > 0 {
|
||||||
|
db = db.Where("api_key_id = ?", apiKeyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := db.Group("model").Order("total_tokens DESC").Scan(&results).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetGlobalStats gets usage statistics for all users within a time range
|
// GetGlobalStats gets usage statistics for all users within a time range
|
||||||
func (r *UsageLogRepository) GetGlobalStats(ctx context.Context, startTime, endTime time.Time) (*UsageStats, error) {
|
func (r *UsageLogRepository) GetGlobalStats(ctx context.Context, startTime, endTime time.Time) (*UsageStats, error) {
|
||||||
var stats struct {
|
var stats struct {
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export interface TrendParams {
|
|||||||
start_date?: string;
|
start_date?: string;
|
||||||
end_date?: string;
|
end_date?: string;
|
||||||
granularity?: 'day' | 'hour';
|
granularity?: 'day' | 'hour';
|
||||||
|
user_id?: number;
|
||||||
|
api_key_id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrendResponse {
|
export interface TrendResponse {
|
||||||
@@ -57,6 +59,13 @@ export async function getUsageTrend(params?: TrendParams): Promise<TrendResponse
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ModelStatsParams {
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
user_id?: number;
|
||||||
|
api_key_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ModelStatsResponse {
|
export interface ModelStatsResponse {
|
||||||
models: ModelStat[];
|
models: ModelStat[];
|
||||||
start_date: string;
|
start_date: string;
|
||||||
@@ -68,7 +77,7 @@ export interface ModelStatsResponse {
|
|||||||
* @param params - Query parameters for filtering
|
* @param params - Query parameters for filtering
|
||||||
* @returns Model usage statistics
|
* @returns Model usage statistics
|
||||||
*/
|
*/
|
||||||
export async function getModelStats(params?: { start_date?: string; end_date?: string }): Promise<ModelStatsResponse> {
|
export async function getModelStats(params?: ModelStatsParams): Promise<ModelStatsResponse> {
|
||||||
const { data } = await apiClient.get<ModelStatsResponse>('/admin/dashboard/models', { params });
|
const { data } = await apiClient.get<ModelStatsResponse>('/admin/dashboard/models', { params });
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
125
frontend/src/components/charts/ModelDistributionChart.vue
Normal file
125
frontend/src/components/charts/ModelDistributionChart.vue
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card p-4">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.dashboard.modelDistribution') }}</h3>
|
||||||
|
<div v-if="loading" class="flex items-center justify-center h-48">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="modelStats.length > 0 && chartData" class="flex items-center gap-6">
|
||||||
|
<div class="w-48 h-48">
|
||||||
|
<Doughnut :data="chartData" :options="doughnutOptions" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 max-h-48 overflow-y-auto">
|
||||||
|
<table class="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-gray-500 dark:text-gray-400">
|
||||||
|
<th class="text-left pb-2">{{ t('admin.dashboard.model') }}</th>
|
||||||
|
<th class="text-right pb-2">{{ t('admin.dashboard.requests') }}</th>
|
||||||
|
<th class="text-right pb-2">{{ t('admin.dashboard.tokens') }}</th>
|
||||||
|
<th class="text-right pb-2">{{ t('admin.dashboard.actual') }}</th>
|
||||||
|
<th class="text-right pb-2">{{ t('admin.dashboard.standard') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="model in modelStats" :key="model.model" class="border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<td class="py-1.5 text-gray-900 dark:text-white font-medium truncate max-w-[100px]" :title="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>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex items-center justify-center h-48 text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
{{ t('admin.dashboard.noDataAvailable') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } 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 { ModelStat } from '@/types'
|
||||||
|
|
||||||
|
ChartJS.register(ArcElement, Tooltip, Legend)
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelStats: ModelStat[]
|
||||||
|
loading?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const chartColors = [
|
||||||
|
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
|
||||||
|
'#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16'
|
||||||
|
]
|
||||||
|
|
||||||
|
const chartData = computed(() => {
|
||||||
|
if (!props.modelStats?.length) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: props.modelStats.map(m => m.model),
|
||||||
|
datasets: [{
|
||||||
|
data: props.modelStats.map(m => m.total_tokens),
|
||||||
|
backgroundColor: chartColors.slice(0, props.modelStats.length),
|
||||||
|
borderWidth: 0,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const doughnutOptions = computed(() => ({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (context: any) => {
|
||||||
|
const value = context.raw as number
|
||||||
|
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
|
||||||
|
const percentage = ((value / total) * 100).toFixed(1)
|
||||||
|
return `${context.label}: ${formatTokens(value)} (${percentage}%)`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
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 formatNumber = (value: number): string => {
|
||||||
|
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>
|
||||||
182
frontend/src/components/charts/TokenUsageTrend.vue
Normal file
182
frontend/src/components/charts/TokenUsageTrend.vue
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card p-4">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.dashboard.tokenUsageTrend') }}</h3>
|
||||||
|
<div v-if="loading" class="flex items-center justify-center h-48">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="trendData.length > 0 && chartData" class="h-48">
|
||||||
|
<Line :data="chartData" :options="lineOptions" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex items-center justify-center h-48 text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
{{ 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',
|
||||||
|
cache: '#f59e0b',
|
||||||
|
}))
|
||||||
|
|
||||||
|
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',
|
||||||
|
data: props.trendData.map(d => d.cache_tokens),
|
||||||
|
borderColor: chartColors.value.cache,
|
||||||
|
backgroundColor: `${chartColors.value.cache}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>
|
||||||
@@ -180,51 +180,14 @@
|
|||||||
|
|
||||||
<!-- Charts Grid -->
|
<!-- Charts Grid -->
|
||||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
<!-- Model Distribution Chart -->
|
<ModelDistributionChart
|
||||||
<div class="card p-4">
|
:model-stats="modelStats"
|
||||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.dashboard.modelDistribution') }}</h3>
|
:loading="chartsLoading"
|
||||||
<div class="flex items-center gap-6">
|
/>
|
||||||
<div class="w-48 h-48">
|
<TokenUsageTrend
|
||||||
<Doughnut v-if="modelChartData" :data="modelChartData" :options="doughnutOptions" />
|
:trend-data="trendData"
|
||||||
<div v-else class="flex items-center justify-center h-full text-gray-500 dark:text-gray-400 text-sm">
|
:loading="chartsLoading"
|
||||||
{{ t('admin.dashboard.noDataAvailable') }}
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 max-h-48 overflow-y-auto">
|
|
||||||
<table class="w-full text-xs">
|
|
||||||
<thead>
|
|
||||||
<tr class="text-gray-500 dark:text-gray-400">
|
|
||||||
<th class="text-left pb-2">{{ t('admin.dashboard.model') }}</th>
|
|
||||||
<th class="text-right pb-2">{{ t('admin.dashboard.requests') }}</th>
|
|
||||||
<th class="text-right pb-2">{{ t('admin.dashboard.tokens') }}</th>
|
|
||||||
<th class="text-right pb-2">{{ t('admin.dashboard.actual') }}</th>
|
|
||||||
<th class="text-right pb-2">{{ t('admin.dashboard.standard') }}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="model in modelStats" :key="model.model" class="border-t border-gray-100 dark:border-gray-700">
|
|
||||||
<td class="py-1.5 text-gray-900 dark:text-white font-medium truncate max-w-[100px]" :title="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>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Token Usage Trend Chart -->
|
|
||||||
<div class="card p-4">
|
|
||||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.dashboard.tokenUsageTrend') }}</h3>
|
|
||||||
<div class="h-48">
|
|
||||||
<Line v-if="trendChartData" :data="trendChartData" :options="lineOptions" />
|
|
||||||
<div v-else class="flex items-center justify-center h-full text-gray-500 dark:text-gray-400 text-sm">
|
|
||||||
{{ t('admin.dashboard.noDataAvailable') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- User Usage Trend (Full Width) -->
|
<!-- User Usage Trend (Full Width) -->
|
||||||
@@ -244,7 +207,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
|
||||||
@@ -255,6 +218,8 @@ import AppLayout from '@/components/layout/AppLayout.vue'
|
|||||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||||
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
||||||
import Select from '@/components/common/Select.vue'
|
import Select from '@/components/common/Select.vue'
|
||||||
|
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
|
||||||
|
import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Chart as ChartJS,
|
Chart as ChartJS,
|
||||||
@@ -262,13 +227,12 @@ import {
|
|||||||
LinearScale,
|
LinearScale,
|
||||||
PointElement,
|
PointElement,
|
||||||
LineElement,
|
LineElement,
|
||||||
ArcElement,
|
|
||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
Filler
|
Filler
|
||||||
} from 'chart.js'
|
} from 'chart.js'
|
||||||
import { Line, Doughnut } from 'vue-chartjs'
|
import { Line } from 'vue-chartjs'
|
||||||
|
|
||||||
// Register Chart.js components
|
// Register Chart.js components
|
||||||
ChartJS.register(
|
ChartJS.register(
|
||||||
@@ -276,7 +240,6 @@ ChartJS.register(
|
|||||||
LinearScale,
|
LinearScale,
|
||||||
PointElement,
|
PointElement,
|
||||||
LineElement,
|
LineElement,
|
||||||
ArcElement,
|
|
||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
@@ -286,6 +249,7 @@ ChartJS.register(
|
|||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const stats = ref<DashboardStats | null>(null)
|
const stats = ref<DashboardStats | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const chartsLoading = ref(false)
|
||||||
|
|
||||||
// Chart data
|
// Chart data
|
||||||
const trendData = ref<TrendDataPoint[]>([])
|
const trendData = ref<TrendDataPoint[]>([])
|
||||||
@@ -312,34 +276,9 @@ const isDarkMode = computed(() => {
|
|||||||
const chartColors = computed(() => ({
|
const chartColors = computed(() => ({
|
||||||
text: isDarkMode.value ? '#e5e7eb' : '#374151',
|
text: isDarkMode.value ? '#e5e7eb' : '#374151',
|
||||||
grid: isDarkMode.value ? '#374151' : '#e5e7eb',
|
grid: isDarkMode.value ? '#374151' : '#e5e7eb',
|
||||||
input: '#3b82f6',
|
|
||||||
output: '#10b981',
|
|
||||||
cache: '#f59e0b',
|
|
||||||
total: '#8b5cf6',
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Doughnut chart options
|
// Line chart options (for user trend chart)
|
||||||
const doughnutOptions = computed(() => ({
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
display: false,
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
callbacks: {
|
|
||||||
label: (context: any) => {
|
|
||||||
const value = context.raw as number
|
|
||||||
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
|
|
||||||
const percentage = ((value / total) * 100).toFixed(1)
|
|
||||||
return `${context.label}: ${formatTokens(value)} (${percentage}%)`
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Line chart options
|
|
||||||
const lineOptions = computed(() => ({
|
const lineOptions = computed(() => ({
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
@@ -365,15 +304,6 @@ const lineOptions = computed(() => ({
|
|||||||
label: (context: any) => {
|
label: (context: any) => {
|
||||||
return `${context.dataset.label}: ${formatTokens(context.raw)}`
|
return `${context.dataset.label}: ${formatTokens(context.raw)}`
|
||||||
},
|
},
|
||||||
footer: (tooltipItems: any) => {
|
|
||||||
// Show both costs for the day if we have trend data
|
|
||||||
const dataIndex = tooltipItems[0]?.dataIndex
|
|
||||||
if (dataIndex !== undefined && trendData.value[dataIndex]) {
|
|
||||||
const data = trendData.value[dataIndex]
|
|
||||||
return `Actual: $${formatCost(data.actual_cost)} | Standard: $${formatCost(data.cost)}`
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -404,60 +334,6 @@ const lineOptions = computed(() => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Model chart data
|
|
||||||
const modelChartData = computed(() => {
|
|
||||||
if (!modelStats.value?.length) return null
|
|
||||||
|
|
||||||
const colors = [
|
|
||||||
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
|
|
||||||
'#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16'
|
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
|
||||||
labels: modelStats.value.map(m => m.model),
|
|
||||||
datasets: [{
|
|
||||||
data: modelStats.value.map(m => m.total_tokens),
|
|
||||||
backgroundColor: colors.slice(0, modelStats.value.length),
|
|
||||||
borderWidth: 0,
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Trend chart data
|
|
||||||
const trendChartData = computed(() => {
|
|
||||||
if (!trendData.value?.length) return null
|
|
||||||
|
|
||||||
return {
|
|
||||||
labels: trendData.value.map(d => d.date),
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: 'Input',
|
|
||||||
data: trendData.value.map(d => d.input_tokens),
|
|
||||||
borderColor: chartColors.value.input,
|
|
||||||
backgroundColor: `${chartColors.value.input}20`,
|
|
||||||
fill: true,
|
|
||||||
tension: 0.3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Output',
|
|
||||||
data: trendData.value.map(d => d.output_tokens),
|
|
||||||
borderColor: chartColors.value.output,
|
|
||||||
backgroundColor: `${chartColors.value.output}20`,
|
|
||||||
fill: true,
|
|
||||||
tension: 0.3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Cache',
|
|
||||||
data: trendData.value.map(d => d.cache_tokens),
|
|
||||||
borderColor: chartColors.value.cache,
|
|
||||||
backgroundColor: `${chartColors.value.cache}20`,
|
|
||||||
fill: true,
|
|
||||||
tension: 0.3,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// User trend chart data
|
// User trend chart data
|
||||||
const userTrendChartData = computed(() => {
|
const userTrendChartData = computed(() => {
|
||||||
if (!userTrend.value?.length) return null
|
if (!userTrend.value?.length) return null
|
||||||
@@ -578,6 +454,7 @@ const loadDashboardStats = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadChartData = async () => {
|
const loadChartData = async () => {
|
||||||
|
chartsLoading.value = true
|
||||||
try {
|
try {
|
||||||
const params = {
|
const params = {
|
||||||
start_date: startDate.value,
|
start_date: startDate.value,
|
||||||
@@ -596,6 +473,8 @@ const loadChartData = async () => {
|
|||||||
userTrend.value = userResponse.trend || []
|
userTrend.value = userResponse.trend || []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading chart data:', error)
|
console.error('Error loading chart data:', error)
|
||||||
|
} finally {
|
||||||
|
chartsLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,11 +483,6 @@ onMounted(() => {
|
|||||||
initializeDateRange()
|
initializeDateRange()
|
||||||
loadChartData()
|
loadChartData()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Watch for dark mode changes
|
|
||||||
watch(isDarkMode, () => {
|
|
||||||
// Force chart re-render on theme change
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('usage.totalCost') }}</p>
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('usage.totalCost') }}</p>
|
||||||
<p class="text-xl font-bold text-green-600 dark:text-green-400">${{ (usageStats?.total_actual_cost || 0).toFixed(4) }}</p>
|
<p class="text-xl font-bold text-green-600 dark:text-green-400">${{ (usageStats?.total_actual_cost || 0).toFixed(4) }}</p>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{{ t('usage.actualCost') }} / <span class="line-through">${{ (usageStats?.total_cost || 0).toFixed(4) }}</span> {{ t('usage.standardCost') }}
|
<span class="line-through">${{ (usageStats?.total_cost || 0).toFixed(4) }}</span> {{ t('usage.standardCost') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,6 +70,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts Section -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Chart Controls -->
|
||||||
|
<div class="card p-4">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.dashboard.granularity') }}:</span>
|
||||||
|
<div class="w-28">
|
||||||
|
<Select
|
||||||
|
v-model="granularity"
|
||||||
|
:options="granularityOptions"
|
||||||
|
@change="onGranularityChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts Grid -->
|
||||||
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
<ModelDistributionChart
|
||||||
|
:model-stats="modelStats"
|
||||||
|
:loading="chartsLoading"
|
||||||
|
/>
|
||||||
|
<TokenUsageTrend
|
||||||
|
:trend-data="trendData"
|
||||||
|
:loading="chartsLoading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="px-6 py-4">
|
<div class="px-6 py-4">
|
||||||
@@ -324,7 +353,9 @@ import Pagination from '@/components/common/Pagination.vue'
|
|||||||
import EmptyState from '@/components/common/EmptyState.vue'
|
import EmptyState from '@/components/common/EmptyState.vue'
|
||||||
import Select from '@/components/common/Select.vue'
|
import Select from '@/components/common/Select.vue'
|
||||||
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
||||||
import type { UsageLog } from '@/types'
|
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
|
||||||
|
import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
|
||||||
|
import type { UsageLog, TrendDataPoint, ModelStat } from '@/types'
|
||||||
import type { Column } from '@/components/common/types'
|
import type { Column } from '@/components/common/types'
|
||||||
import type { SimpleUser, SimpleApiKey, AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage'
|
import type { SimpleUser, SimpleApiKey, AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage'
|
||||||
|
|
||||||
@@ -334,6 +365,18 @@ const appStore = useAppStore()
|
|||||||
// Usage stats from API
|
// Usage stats from API
|
||||||
const usageStats = ref<AdminUsageStatsResponse | null>(null)
|
const usageStats = ref<AdminUsageStatsResponse | null>(null)
|
||||||
|
|
||||||
|
// Chart data
|
||||||
|
const trendData = ref<TrendDataPoint[]>([])
|
||||||
|
const modelStats = ref<ModelStat[]>([])
|
||||||
|
const chartsLoading = ref(false)
|
||||||
|
const granularity = ref<'day' | 'hour'>('day')
|
||||||
|
|
||||||
|
// Granularity options for Select component
|
||||||
|
const granularityOptions = computed(() => [
|
||||||
|
{ value: 'day', label: t('admin.dashboard.day') },
|
||||||
|
{ value: 'hour', label: t('admin.dashboard.hour') },
|
||||||
|
])
|
||||||
|
|
||||||
const columns = computed<Column[]>(() => [
|
const columns = computed<Column[]>(() => [
|
||||||
{ key: 'user', label: t('admin.usage.user'), sortable: false },
|
{ key: 'user', label: t('admin.usage.user'), sortable: false },
|
||||||
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
|
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
|
||||||
@@ -535,10 +578,45 @@ const loadUsageStats = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadChartData = async () => {
|
||||||
|
chartsLoading.value = true
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
start_date: filters.value.start_date || startDate.value,
|
||||||
|
end_date: filters.value.end_date || endDate.value,
|
||||||
|
granularity: granularity.value,
|
||||||
|
user_id: filters.value.user_id,
|
||||||
|
api_key_id: filters.value.api_key_id ? Number(filters.value.api_key_id) : undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
const [trendResponse, modelResponse] = await Promise.all([
|
||||||
|
adminAPI.dashboard.getUsageTrend(params),
|
||||||
|
adminAPI.dashboard.getModelStats({
|
||||||
|
start_date: params.start_date,
|
||||||
|
end_date: params.end_date,
|
||||||
|
user_id: params.user_id,
|
||||||
|
api_key_id: params.api_key_id,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
trendData.value = trendResponse.trend || []
|
||||||
|
modelStats.value = modelResponse.models || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load chart data:', error)
|
||||||
|
} finally {
|
||||||
|
chartsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onGranularityChange = () => {
|
||||||
|
loadChartData()
|
||||||
|
}
|
||||||
|
|
||||||
const applyFilters = () => {
|
const applyFilters = () => {
|
||||||
pagination.value.page = 1
|
pagination.value.page = 1
|
||||||
loadUsageLogs()
|
loadUsageLogs()
|
||||||
loadUsageStats()
|
loadUsageStats()
|
||||||
|
loadChartData()
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
@@ -552,11 +630,13 @@ const resetFilters = () => {
|
|||||||
start_date: undefined,
|
start_date: undefined,
|
||||||
end_date: undefined
|
end_date: undefined
|
||||||
}
|
}
|
||||||
|
granularity.value = 'day'
|
||||||
// Reset date range to default (last 7 days)
|
// Reset date range to default (last 7 days)
|
||||||
initializeDateRange()
|
initializeDateRange()
|
||||||
pagination.value.page = 1
|
pagination.value.page = 1
|
||||||
loadUsageLogs()
|
loadUsageLogs()
|
||||||
loadUsageStats()
|
loadUsageStats()
|
||||||
|
loadChartData()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePageChange = (page: number) => {
|
const handlePageChange = (page: number) => {
|
||||||
@@ -614,6 +694,7 @@ onMounted(() => {
|
|||||||
initializeDateRange()
|
initializeDateRange()
|
||||||
loadUsageLogs()
|
loadUsageLogs()
|
||||||
loadUsageStats()
|
loadUsageStats()
|
||||||
|
loadChartData()
|
||||||
document.addEventListener('click', handleClickOutside)
|
document.addEventListener('click', handleClickOutside)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/groups.ts","./src/api/index.ts","./src/api/keys.ts","./src/api/redeem.ts","./src/api/setup.ts","./src/api/subscriptions.ts","./src/api/usage.ts","./src/api/user.ts","./src/api/admin/accounts.ts","./src/api/admin/dashboard.ts","./src/api/admin/groups.ts","./src/api/admin/index.ts","./src/api/admin/proxies.ts","./src/api/admin/redeem.ts","./src/api/admin/settings.ts","./src/api/admin/subscriptions.ts","./src/api/admin/system.ts","./src/api/admin/usage.ts","./src/api/admin/users.ts","./src/components/account/index.ts","./src/components/common/index.ts","./src/components/common/types.ts","./src/components/layout/index.ts","./src/composables/useAccountOAuth.ts","./src/composables/useClipboard.ts","./src/i18n/index.ts","./src/i18n/locales/en.ts","./src/i18n/locales/zh.ts","./src/router/index.ts","./src/router/meta.d.ts","./src/stores/app.ts","./src/stores/auth.ts","./src/stores/index.ts","./src/types/index.ts","./src/utils/format.ts","./src/views/auth/index.ts","./src/App.vue","./src/components/TurnstileWidget.vue","./src/components/account/AccountStatusIndicator.vue","./src/components/account/AccountTestModal.vue","./src/components/account/AccountTodayStatsCell.vue","./src/components/account/AccountUsageCell.vue","./src/components/account/CreateAccountModal.vue","./src/components/account/EditAccountModal.vue","./src/components/account/OAuthAuthorizationFlow.vue","./src/components/account/ReAuthAccountModal.vue","./src/components/account/SetupTokenTimeWindow.vue","./src/components/account/UsageProgressBar.vue","./src/components/common/ConfirmDialog.vue","./src/components/common/DataTable.vue","./src/components/common/DateRangePicker.vue","./src/components/common/EmptyState.vue","./src/components/common/GroupBadge.vue","./src/components/common/GroupSelector.vue","./src/components/common/LoadingSpinner.vue","./src/components/common/LocaleSwitcher.vue","./src/components/common/Modal.vue","./src/components/common/Pagination.vue","./src/components/common/ProxySelector.vue","./src/components/common/Select.vue","./src/components/common/StatCard.vue","./src/components/common/SubscriptionProgressMini.vue","./src/components/common/Toast.vue","./src/components/common/Toggle.vue","./src/components/common/VersionBadge.vue","./src/components/keys/UseKeyModal.vue","./src/components/layout/AppHeader.vue","./src/components/layout/AppLayout.vue","./src/components/layout/AppSidebar.vue","./src/components/layout/AuthLayout.vue","./src/views/HomeView.vue","./src/views/NotFoundView.vue","./src/views/admin/AccountsView.vue","./src/views/admin/DashboardView.vue","./src/views/admin/GroupsView.vue","./src/views/admin/ProxiesView.vue","./src/views/admin/RedeemView.vue","./src/views/admin/SettingsView.vue","./src/views/admin/SubscriptionsView.vue","./src/views/admin/UsageView.vue","./src/views/admin/UsersView.vue","./src/views/auth/EmailVerifyView.vue","./src/views/auth/LoginView.vue","./src/views/auth/RegisterView.vue","./src/views/setup/SetupWizardView.vue","./src/views/user/DashboardView.vue","./src/views/user/KeysView.vue","./src/views/user/ProfileView.vue","./src/views/user/RedeemView.vue","./src/views/user/SubscriptionsView.vue","./src/views/user/UsageView.vue"],"version":"5.6.3"}
|
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/groups.ts","./src/api/index.ts","./src/api/keys.ts","./src/api/redeem.ts","./src/api/setup.ts","./src/api/subscriptions.ts","./src/api/usage.ts","./src/api/user.ts","./src/api/admin/accounts.ts","./src/api/admin/dashboard.ts","./src/api/admin/groups.ts","./src/api/admin/index.ts","./src/api/admin/proxies.ts","./src/api/admin/redeem.ts","./src/api/admin/settings.ts","./src/api/admin/subscriptions.ts","./src/api/admin/system.ts","./src/api/admin/usage.ts","./src/api/admin/users.ts","./src/components/account/index.ts","./src/components/common/index.ts","./src/components/common/types.ts","./src/components/layout/index.ts","./src/composables/useAccountOAuth.ts","./src/composables/useClipboard.ts","./src/i18n/index.ts","./src/i18n/locales/en.ts","./src/i18n/locales/zh.ts","./src/router/index.ts","./src/router/meta.d.ts","./src/stores/app.ts","./src/stores/auth.ts","./src/stores/index.ts","./src/types/index.ts","./src/utils/format.ts","./src/views/auth/index.ts","./src/App.vue","./src/components/TurnstileWidget.vue","./src/components/account/AccountStatusIndicator.vue","./src/components/account/AccountTestModal.vue","./src/components/account/AccountTodayStatsCell.vue","./src/components/account/AccountUsageCell.vue","./src/components/account/CreateAccountModal.vue","./src/components/account/EditAccountModal.vue","./src/components/account/OAuthAuthorizationFlow.vue","./src/components/account/ReAuthAccountModal.vue","./src/components/account/SetupTokenTimeWindow.vue","./src/components/account/UsageProgressBar.vue","./src/components/charts/ModelDistributionChart.vue","./src/components/charts/TokenUsageTrend.vue","./src/components/common/ConfirmDialog.vue","./src/components/common/DataTable.vue","./src/components/common/DateRangePicker.vue","./src/components/common/EmptyState.vue","./src/components/common/GroupBadge.vue","./src/components/common/GroupSelector.vue","./src/components/common/LoadingSpinner.vue","./src/components/common/LocaleSwitcher.vue","./src/components/common/Modal.vue","./src/components/common/Pagination.vue","./src/components/common/ProxySelector.vue","./src/components/common/Select.vue","./src/components/common/StatCard.vue","./src/components/common/SubscriptionProgressMini.vue","./src/components/common/Toast.vue","./src/components/common/Toggle.vue","./src/components/common/VersionBadge.vue","./src/components/keys/UseKeyModal.vue","./src/components/layout/AppHeader.vue","./src/components/layout/AppLayout.vue","./src/components/layout/AppSidebar.vue","./src/components/layout/AuthLayout.vue","./src/views/HomeView.vue","./src/views/NotFoundView.vue","./src/views/admin/AccountsView.vue","./src/views/admin/DashboardView.vue","./src/views/admin/GroupsView.vue","./src/views/admin/ProxiesView.vue","./src/views/admin/RedeemView.vue","./src/views/admin/SettingsView.vue","./src/views/admin/SubscriptionsView.vue","./src/views/admin/UsageView.vue","./src/views/admin/UsersView.vue","./src/views/auth/EmailVerifyView.vue","./src/views/auth/LoginView.vue","./src/views/auth/RegisterView.vue","./src/views/setup/SetupWizardView.vue","./src/views/user/DashboardView.vue","./src/views/user/KeysView.vue","./src/views/user/ProfileView.vue","./src/views/user/RedeemView.vue","./src/views/user/SubscriptionsView.vue","./src/views/user/UsageView.vue"],"version":"5.6.3"}
|
||||||
Reference in New Issue
Block a user