@@ -51,6 +82,37 @@ onMounted(() => {
flex-direction: column;
}
+.page-header {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 21px 20px;
+ border-bottom: 1px solid $border-color;
+}
+
+.header-title {
+ margin: 0;
+ color: $text-primary;
+ font-size: 16px;
+ font-weight: 600;
+}
+
+.usage-toolbar,
+.period-selector {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.period-selector {
+ padding: 2px;
+ border: 1px solid $border-light;
+ border-radius: $radius-sm;
+ background: $bg-secondary;
+}
+
.usage-content {
flex: 1;
overflow-y: auto;
@@ -73,4 +135,20 @@ onMounted(() => {
color: $text-muted;
font-size: 14px;
}
+
+@media (max-width: $breakpoint-mobile) {
+ .page-header,
+ .usage-toolbar {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .usage-toolbar {
+ width: 100%;
+ }
+
+ .period-selector {
+ flex-wrap: wrap;
+ }
+}
diff --git a/packages/server/src/controllers/hermes/sessions.ts b/packages/server/src/controllers/hermes/sessions.ts
index 85060fa..0597fee 100644
--- a/packages/server/src/controllers/hermes/sessions.ts
+++ b/packages/server/src/controllers/hermes/sessions.ts
@@ -450,8 +450,9 @@ export async function usageStats(ctx: any) {
const totalSessions = local.sessions + hermes.sessions
const modelMap = new Map()
- for (const m of [...local.by_model, ...hermes.by_model].filter(m => m.model)) {
- const existing = modelMap.get(m.model)
+ for (const m of [...local.by_model, ...hermes.by_model]) {
+ const model = (m.model || '').trim() || 'unknown'
+ const existing = modelMap.get(model)
if (existing) {
existing.input_tokens += m.input_tokens
existing.output_tokens += m.output_tokens
@@ -460,7 +461,7 @@ export async function usageStats(ctx: any) {
existing.reasoning_tokens += m.reasoning_tokens
existing.sessions += m.sessions
} else {
- modelMap.set(m.model, { ...m })
+ modelMap.set(model, { ...m, model })
}
}
diff --git a/tests/client/usage-components-cache-visuals.test.ts b/tests/client/usage-components-cache-visuals.test.ts
new file mode 100644
index 0000000..ea58b4c
--- /dev/null
+++ b/tests/client/usage-components-cache-visuals.test.ts
@@ -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%')
+ })
+})
diff --git a/tests/client/usage-store.test.ts b/tests/client/usage-store.test.ts
index 07acf4d..c82e830 100644
--- a/tests/client/usage-store.test.ts
+++ b/tests/client/usage-store.test.ts
@@ -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) => void = () => {}
+ let resolve7: (value: ReturnType) => 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) => void = () => {}
+ let resolve7: (value: ReturnType) => 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)
+ })
})
diff --git a/tests/client/usage-view-period.test.ts b/tests/client/usage-view-period.test.ts
new file mode 100644
index 0000000..caf2ce4
--- /dev/null
+++ b/tests/client/usage-view-period.test.ts
@@ -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: '',
+ }),
+}))
+
+vi.mock('@/components/hermes/usage/StatCards.vue', () => ({
+ default: defineComponent({ name: 'StatCards', template: '' }),
+}))
+
+vi.mock('@/components/hermes/usage/ModelBreakdown.vue', () => ({
+ default: defineComponent({ name: 'ModelBreakdown', template: '' }),
+}))
+
+vi.mock('@/components/hermes/usage/DailyTrend.vue', () => ({
+ default: defineComponent({ name: 'DailyTrend', template: '' }),
+}))
+
+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)
+ })
+})
diff --git a/tests/server/sessions-controller.test.ts b/tests/server/sessions-controller.test.ts
index f3fb025..adc4306 100644
--- a/tests/server/sessions-controller.test.ts
+++ b/tests/server/sessions-controller.test.ts
@@ -220,6 +220,43 @@ describe('session conversations controller', () => {
})
})
+ it('keeps blank model usage under an unknown bucket', async () => {
+ getLocalUsageStatsMock.mockReturnValue({
+ input_tokens: 3,
+ output_tokens: 1,
+ cache_read_tokens: 2,
+ cache_write_tokens: 0,
+ reasoning_tokens: 0,
+ sessions: 1,
+ by_model: [
+ { model: '', input_tokens: 3, output_tokens: 1, cache_read_tokens: 2, cache_write_tokens: 0, reasoning_tokens: 0, sessions: 1 },
+ ],
+ by_day: [],
+ })
+ getUsageStatsFromDbMock.mockResolvedValue({
+ input_tokens: 2,
+ output_tokens: 1,
+ cache_read_tokens: 1,
+ cache_write_tokens: 1,
+ reasoning_tokens: 0,
+ sessions: 1,
+ cost: 0,
+ total_api_calls: 0,
+ by_model: [
+ { model: ' ', input_tokens: 2, output_tokens: 1, cache_read_tokens: 1, cache_write_tokens: 1, reasoning_tokens: 0, sessions: 1 },
+ ],
+ by_day: [],
+ })
+
+ const mod = await import('../../packages/server/src/controllers/hermes/sessions')
+ const ctx: any = { query: { days: '2' }, body: null }
+ await mod.usageStats(ctx)
+
+ expect(ctx.body.model_usage).toEqual([
+ { model: 'unknown', input_tokens: 5, output_tokens: 2, cache_read_tokens: 3, cache_write_tokens: 1, reasoning_tokens: 0, sessions: 2 },
+ ])
+ })
+
describe('exportSession', () => {
it('returns session as JSON download with correct headers (full mode)', async () => {
const sessionData = { id: 'abc-123', title: 'Test Session', messages: [{ id: 1, role: 'user', content: 'hello' }] }