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:
@@ -0,0 +1,73 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
const mockUsageStore = vi.hoisted(() => ({
|
||||
dailyUsage: [
|
||||
{
|
||||
date: '2026-05-12',
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_read_tokens: 75,
|
||||
cache_write_tokens: 10,
|
||||
sessions: 2,
|
||||
errors: 0,
|
||||
cost: 0.02,
|
||||
visualTokens: 225,
|
||||
inputPercent: 44.444,
|
||||
outputPercent: 22.222,
|
||||
cachePercent: 33.333,
|
||||
},
|
||||
],
|
||||
modelUsage: [
|
||||
{
|
||||
model: 'gpt-5',
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheTokens: 75,
|
||||
cacheWriteTokens: 10,
|
||||
totalTokens: 150,
|
||||
visualTokens: 225,
|
||||
sessions: 2,
|
||||
color: '#4fd1c5',
|
||||
inputPercent: 44.444,
|
||||
outputPercent: 22.222,
|
||||
cachePercent: 33.333,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/usage', () => ({
|
||||
useUsageStore: () => mockUsageStore,
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
import DailyTrend from '@/components/hermes/usage/DailyTrend.vue'
|
||||
import ModelBreakdown from '@/components/hermes/usage/ModelBreakdown.vue'
|
||||
|
||||
describe('usage cache visualizations', () => {
|
||||
it('renders cache-read as a visible segment in the daily usage bars', () => {
|
||||
const wrapper = mount(DailyTrend)
|
||||
|
||||
const cacheSegment = wrapper.find('.bar-segment.cache')
|
||||
expect(cacheSegment.exists()).toBe(true)
|
||||
expect(cacheSegment.attributes('style')).toContain('height: 33.333%')
|
||||
expect(wrapper.text()).toContain('usage.cacheRead')
|
||||
})
|
||||
|
||||
it('renders model breakdown as input/output/cache stacked segments', () => {
|
||||
const wrapper = mount(ModelBreakdown)
|
||||
|
||||
expect(wrapper.find('.model-swatch').attributes('style')).toContain('background: rgb(79, 209, 197)')
|
||||
expect(wrapper.find('.model-bar-segment.input').exists()).toBe(true)
|
||||
expect(wrapper.find('.model-bar-segment.output').exists()).toBe(true)
|
||||
const cacheSegment = wrapper.find('.model-bar-segment.cache')
|
||||
expect(cacheSegment.exists()).toBe(true)
|
||||
expect(cacheSegment.attributes('style')).toContain('width: 33.333%')
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,21 @@ vi.mock('@/api/hermes/sessions', () => ({
|
||||
fetchUsageStats: usageApiMock.fetchUsageStats,
|
||||
}))
|
||||
|
||||
function emptyStats(totalSessions = 0, periodDays = 30) {
|
||||
return {
|
||||
total_input_tokens: totalSessions,
|
||||
total_output_tokens: 0,
|
||||
total_cache_read_tokens: 0,
|
||||
total_cache_write_tokens: 0,
|
||||
total_reasoning_tokens: 0,
|
||||
total_cost: 0,
|
||||
total_sessions: totalSessions,
|
||||
period_days: periodDays,
|
||||
model_usage: [],
|
||||
daily_usage: [],
|
||||
}
|
||||
}
|
||||
|
||||
describe('usage store analytics adapter', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
@@ -31,8 +46,8 @@ describe('usage store analytics adapter', () => {
|
||||
{ model: '', input_tokens: 20, output_tokens: 10, cache_read_tokens: 5, cache_write_tokens: 2, reasoning_tokens: 3, sessions: 1 },
|
||||
],
|
||||
daily_usage: [
|
||||
{ date: '2026-04-29', tokens: 100, cache: 20, sessions: 1, cost: 0.01 },
|
||||
{ date: '2026-04-30', tokens: 50, cache: 5, sessions: 1, cost: 0.0023 },
|
||||
{ date: '2026-04-29', input_tokens: 80, output_tokens: 20, cache_read_tokens: 40, cache_write_tokens: 4, sessions: 1, errors: 0, cost: 0.01 },
|
||||
{ date: '2026-04-30', input_tokens: 30, output_tokens: 20, cache_read_tokens: 5, cache_write_tokens: 1, sessions: 1, errors: 0, cost: 0.0023 },
|
||||
],
|
||||
})
|
||||
|
||||
@@ -44,28 +59,51 @@ describe('usage store analytics adapter', () => {
|
||||
expect(store.totalTokens).toBe(150)
|
||||
expect(store.cacheHitRate).toBeCloseTo(25 / 125 * 100)
|
||||
expect(store.hasData).toBe(true)
|
||||
expect(store.modelUsage).toEqual([
|
||||
{ model: 'gpt-5', totalTokens: 120, inputTokens: 80, outputTokens: 40, cacheTokens: 20, sessions: 1 },
|
||||
{ model: 'unknown', totalTokens: 30, inputTokens: 20, outputTokens: 10, cacheTokens: 5, sessions: 1 },
|
||||
])
|
||||
expect(store.dailyUsage).toEqual([
|
||||
{ date: '2026-04-29', tokens: 100, cache: 20, sessions: 1, cost: 0.01 },
|
||||
{ date: '2026-04-30', tokens: 50, cache: 5, sessions: 1, cost: 0.0023 },
|
||||
])
|
||||
expect(store.modelUsage).toHaveLength(2)
|
||||
expect(store.modelUsage[0]).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
totalTokens: 120,
|
||||
inputTokens: 80,
|
||||
outputTokens: 40,
|
||||
cacheTokens: 20,
|
||||
cacheWriteTokens: 3,
|
||||
visualTokens: 140,
|
||||
sessions: 1,
|
||||
})
|
||||
expect(store.modelUsage[0].color).toMatch(/^#[0-9a-f]{6}$/i)
|
||||
expect(store.modelUsage[0].inputPercent).toBeCloseTo(80 / 140 * 100)
|
||||
expect(store.modelUsage[0].outputPercent).toBeCloseTo(40 / 140 * 100)
|
||||
expect(store.modelUsage[0].cachePercent).toBeCloseTo(20 / 140 * 100)
|
||||
expect(store.modelUsage[1]).toMatchObject({
|
||||
model: 'unknown',
|
||||
totalTokens: 30,
|
||||
inputTokens: 20,
|
||||
outputTokens: 10,
|
||||
cacheTokens: 5,
|
||||
cacheWriteTokens: 2,
|
||||
visualTokens: 35,
|
||||
sessions: 1,
|
||||
})
|
||||
expect(store.modelUsage[1].color).toBe(store.getModelColor('unknown'))
|
||||
expect(store.modelLegend.map(m => m.model)).toEqual(['gpt-5', 'unknown'])
|
||||
expect(store.dailyUsage).toHaveLength(2)
|
||||
expect(store.dailyUsage[0]).toMatchObject({
|
||||
date: '2026-04-29',
|
||||
input_tokens: 80,
|
||||
output_tokens: 20,
|
||||
cache_read_tokens: 40,
|
||||
cache_write_tokens: 4,
|
||||
visualTokens: 140,
|
||||
sessions: 1,
|
||||
cost: 0.01,
|
||||
})
|
||||
expect(store.dailyUsage[0].inputPercent).toBeCloseTo(80 / 140 * 100)
|
||||
expect(store.dailyUsage[0].outputPercent).toBeCloseTo(20 / 140 * 100)
|
||||
expect(store.dailyUsage[0].cachePercent).toBeCloseTo(40 / 140 * 100)
|
||||
})
|
||||
|
||||
it('allows callers to request a different period', async () => {
|
||||
usageApiMock.fetchUsageStats.mockResolvedValue({
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
total_cache_read_tokens: 0,
|
||||
total_cache_write_tokens: 0,
|
||||
total_reasoning_tokens: 0,
|
||||
total_cost: 0,
|
||||
total_sessions: 0,
|
||||
model_usage: [],
|
||||
daily_usage: [],
|
||||
})
|
||||
usageApiMock.fetchUsageStats.mockResolvedValue(emptyStats())
|
||||
|
||||
const { useUsageStore } = await import('@/stores/hermes/usage')
|
||||
const store = useUsageStore()
|
||||
@@ -74,4 +112,58 @@ describe('usage store analytics adapter', () => {
|
||||
expect(usageApiMock.fetchUsageStats).toHaveBeenCalledWith(7)
|
||||
expect(store.hasData).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps loading true when an older overlapping request resolves first', async () => {
|
||||
let resolve30: (value: ReturnType<typeof emptyStats>) => void = () => {}
|
||||
let resolve7: (value: ReturnType<typeof emptyStats>) => void = () => {}
|
||||
usageApiMock.fetchUsageStats.mockImplementation((days: number) => new Promise(resolve => {
|
||||
if (days === 30) resolve30 = resolve
|
||||
if (days === 7) resolve7 = resolve
|
||||
}))
|
||||
|
||||
const { useUsageStore } = await import('@/stores/hermes/usage')
|
||||
const store = useUsageStore()
|
||||
const firstLoad = store.loadSessions(30)
|
||||
const secondLoad = store.loadSessions(7)
|
||||
|
||||
expect(store.isLoading).toBe(true)
|
||||
resolve30(emptyStats(30, 30))
|
||||
await firstLoad
|
||||
|
||||
expect(store.isLoading).toBe(true)
|
||||
expect(store.stats).toBeNull()
|
||||
|
||||
resolve7(emptyStats(7, 7))
|
||||
await secondLoad
|
||||
|
||||
expect(store.isLoading).toBe(false)
|
||||
expect(store.stats?.period_days).toBe(7)
|
||||
expect(store.totalSessions).toBe(7)
|
||||
})
|
||||
|
||||
it('ignores stale overlapping responses that resolve after the selected period', async () => {
|
||||
let resolve30: (value: ReturnType<typeof emptyStats>) => void = () => {}
|
||||
let resolve7: (value: ReturnType<typeof emptyStats>) => void = () => {}
|
||||
usageApiMock.fetchUsageStats.mockImplementation((days: number) => new Promise(resolve => {
|
||||
if (days === 30) resolve30 = resolve
|
||||
if (days === 7) resolve7 = resolve
|
||||
}))
|
||||
|
||||
const { useUsageStore } = await import('@/stores/hermes/usage')
|
||||
const store = useUsageStore()
|
||||
const firstLoad = store.loadSessions(30)
|
||||
const secondLoad = store.loadSessions(7)
|
||||
|
||||
resolve7(emptyStats(7, 7))
|
||||
await secondLoad
|
||||
|
||||
expect(store.isLoading).toBe(false)
|
||||
expect(store.stats?.period_days).toBe(7)
|
||||
|
||||
resolve30(emptyStats(30, 30))
|
||||
await firstLoad
|
||||
|
||||
expect(store.stats?.period_days).toBe(7)
|
||||
expect(store.totalSessions).toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
const mockUsageStore = vi.hoisted(() => ({
|
||||
isLoading: false,
|
||||
hasData: true,
|
||||
loadSessions: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/usage', () => ({
|
||||
useUsageStore: () => mockUsageStore,
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', () => ({
|
||||
NButton: defineComponent({
|
||||
name: 'NButton',
|
||||
props: {
|
||||
loading: Boolean,
|
||||
type: String,
|
||||
secondary: Boolean,
|
||||
quaternary: Boolean,
|
||||
size: String,
|
||||
ariaPressed: [Boolean, String],
|
||||
},
|
||||
emits: ['click'],
|
||||
template: '<button class="n-button-stub" :data-type="type" :aria-pressed="ariaPressed" @click="$emit(\'click\')"><slot /></button>',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/usage/StatCards.vue', () => ({
|
||||
default: defineComponent({ name: 'StatCards', template: '<section class="stat-cards-stub" />' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/usage/ModelBreakdown.vue', () => ({
|
||||
default: defineComponent({ name: 'ModelBreakdown', template: '<section class="model-breakdown-stub" />' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/usage/DailyTrend.vue', () => ({
|
||||
default: defineComponent({ name: 'DailyTrend', template: '<section class="daily-trend-stub" />' }),
|
||||
}))
|
||||
|
||||
import UsageView from '@/views/hermes/UsageView.vue'
|
||||
|
||||
describe('UsageView period selector', () => {
|
||||
beforeEach(() => {
|
||||
mockUsageStore.isLoading = false
|
||||
mockUsageStore.hasData = true
|
||||
mockUsageStore.loadSessions.mockReset()
|
||||
})
|
||||
|
||||
it('loads the default 30-day period on mount', () => {
|
||||
mount(UsageView)
|
||||
|
||||
expect(mockUsageStore.loadSessions).toHaveBeenCalledWith(30)
|
||||
})
|
||||
|
||||
it('lets users switch usage statistics between common dashboard periods', async () => {
|
||||
const wrapper = mount(UsageView)
|
||||
|
||||
const periodButtons = wrapper.findAll('.period-option')
|
||||
expect(periodButtons.map(button => button.text())).toEqual(['7d', '30d', '90d', '365d'])
|
||||
expect(wrapper.find('.period-selector').attributes('role')).toBe('group')
|
||||
|
||||
await periodButtons[0].trigger('click')
|
||||
expect(mockUsageStore.loadSessions).toHaveBeenLastCalledWith(7)
|
||||
expect(periodButtons[0].attributes('data-type')).toBe('primary')
|
||||
expect(periodButtons[0].attributes('aria-pressed')).toBe('true')
|
||||
|
||||
await wrapper.find('.refresh-button').trigger('click')
|
||||
expect(mockUsageStore.loadSessions).toHaveBeenLastCalledWith(7)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user