feat: enhance usage analytics dashboard (#666)
- visualize input, output, and cache token segments in usage charts - add usage period selector for 7d, 30d, 90d, and 365d - guard usage stats against stale overlapping period requests - normalize blank model usage into unknown buckets - add client and server coverage for usage analytics behavior
This commit is contained in:
@@ -11,6 +11,10 @@ interface DailyUsage {
|
||||
sessions: number
|
||||
errors: number
|
||||
cost: number
|
||||
visualTokens: number
|
||||
inputPercent: number
|
||||
outputPercent: number
|
||||
cachePercent: number
|
||||
}
|
||||
|
||||
interface ModelUsage {
|
||||
@@ -18,22 +22,70 @@ interface ModelUsage {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheTokens: number
|
||||
cacheWriteTokens: number
|
||||
totalTokens: number
|
||||
visualTokens: number
|
||||
sessions: number
|
||||
color: string
|
||||
inputPercent: number
|
||||
outputPercent: number
|
||||
cachePercent: number
|
||||
}
|
||||
|
||||
const MODEL_COLORS = [
|
||||
'#4fd1c5',
|
||||
'#63b3ed',
|
||||
'#f6ad55',
|
||||
'#b794f4',
|
||||
'#68d391',
|
||||
'#fc8181',
|
||||
'#f687b3',
|
||||
'#90cdf4',
|
||||
'#fbd38d',
|
||||
'#9ae6b4',
|
||||
]
|
||||
|
||||
function normalizeModel(model: string | null | undefined): string {
|
||||
const trimmed = (model || '').trim()
|
||||
return trimmed || 'unknown'
|
||||
}
|
||||
|
||||
function percent(part: number, total: number): number {
|
||||
if (total <= 0) return 0
|
||||
return part / total * 100
|
||||
}
|
||||
|
||||
function getModelColor(model: string): string {
|
||||
const normalized = normalizeModel(model)
|
||||
let hash = 0
|
||||
for (let i = 0; i < normalized.length; i += 1) {
|
||||
hash = ((hash << 5) - hash) + normalized.charCodeAt(i)
|
||||
hash |= 0
|
||||
}
|
||||
return MODEL_COLORS[Math.abs(hash) % MODEL_COLORS.length]
|
||||
}
|
||||
|
||||
export const useUsageStore = defineStore('usage', () => {
|
||||
const stats = ref<UsageStatsResponse | null>(null)
|
||||
const isLoading = ref(false)
|
||||
let latestRequestId = 0
|
||||
|
||||
async function loadSessions(days = 30) {
|
||||
const requestId = ++latestRequestId
|
||||
isLoading.value = true
|
||||
try {
|
||||
stats.value = await fetchUsageStats(days)
|
||||
const response = await fetchUsageStats(days)
|
||||
if (requestId === latestRequestId) {
|
||||
stats.value = response
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load usage stats:', err)
|
||||
if (requestId === latestRequestId) {
|
||||
console.error('Failed to load usage stats:', err)
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
if (requestId === latestRequestId) {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,17 +108,46 @@ export const useUsageStore = defineStore('usage', () => {
|
||||
|
||||
const modelUsage = computed<ModelUsage[]>(() => {
|
||||
if (!stats.value) return []
|
||||
return stats.value.model_usage.map(m => ({
|
||||
model: m.model || 'unknown',
|
||||
inputTokens: m.input_tokens,
|
||||
outputTokens: m.output_tokens,
|
||||
cacheTokens: m.cache_read_tokens,
|
||||
totalTokens: m.input_tokens + m.output_tokens,
|
||||
sessions: m.sessions,
|
||||
})).sort((a, b) => b.totalTokens - a.totalTokens)
|
||||
return stats.value.model_usage.map(m => {
|
||||
const model = normalizeModel(m.model)
|
||||
const totalTokens = m.input_tokens + m.output_tokens
|
||||
const visualTokens = totalTokens + m.cache_read_tokens
|
||||
return {
|
||||
model,
|
||||
inputTokens: m.input_tokens,
|
||||
outputTokens: m.output_tokens,
|
||||
cacheTokens: m.cache_read_tokens,
|
||||
cacheWriteTokens: m.cache_write_tokens,
|
||||
totalTokens,
|
||||
visualTokens,
|
||||
sessions: m.sessions,
|
||||
color: getModelColor(model),
|
||||
inputPercent: percent(m.input_tokens, visualTokens),
|
||||
outputPercent: percent(m.output_tokens, visualTokens),
|
||||
cachePercent: percent(m.cache_read_tokens, visualTokens),
|
||||
}
|
||||
}).sort((a, b) => b.visualTokens - a.visualTokens)
|
||||
})
|
||||
|
||||
const dailyUsage = computed<DailyUsage[]>(() => stats.value?.daily_usage ?? [])
|
||||
const modelLegend = computed(() => {
|
||||
const seen = new Set<string>()
|
||||
return modelUsage.value.filter(m => {
|
||||
if (seen.has(m.model)) return false
|
||||
seen.add(m.model)
|
||||
return true
|
||||
}).map(m => ({ model: m.model, color: m.color }))
|
||||
})
|
||||
|
||||
const dailyUsage = computed<DailyUsage[]>(() => (stats.value?.daily_usage ?? []).map(d => {
|
||||
const visualTokens = d.input_tokens + d.output_tokens + d.cache_read_tokens
|
||||
return {
|
||||
...d,
|
||||
visualTokens,
|
||||
inputPercent: percent(d.input_tokens, visualTokens),
|
||||
outputPercent: percent(d.output_tokens, visualTokens),
|
||||
cachePercent: percent(d.cache_read_tokens, visualTokens),
|
||||
}
|
||||
}))
|
||||
|
||||
const avgSessionsPerDay = computed(() => {
|
||||
if (!stats.value || stats.value.daily_usage.length === 0) return 0
|
||||
@@ -88,7 +169,9 @@ export const useUsageStore = defineStore('usage', () => {
|
||||
cacheHitRate,
|
||||
estimatedCost,
|
||||
modelUsage,
|
||||
modelLegend,
|
||||
dailyUsage,
|
||||
avgSessionsPerDay,
|
||||
getModelColor,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user