feat(webui): add limit bars, forecasts, events, exports and URL state

The Usage tab shows a bar for each configured limit. It turns amber at
80% and red at 100%. The tab also shows rejected requests per day, a
projected month-end cost with budget headroom, and CSV export for users
and groups. Names link to Charts with the matching scope.

The Charts tab ranks models over the full 24h, 30d or 12mo window. A
provider filter is applied on the server, and the ranking can be
exported as CSV. A new Events tab lists recent failures, filtered by
kind and user. The selected tab, filters and scope live in the URL
hash. Polling stops while the page is hidden. Views show which replica
answered, and a banner lists configuration warnings.

CSV cells that start with a formula character are neutralised. Links
sit outside the accordion buttons, so both controls keep their
accessible names. admin_assets_gen.go is regenerated.

Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
2026-09-23 13:09:57 +01:00
parent 25e6a7779e
commit 4261efc41b
53 changed files with 4777 additions and 238 deletions
+58
View File
@@ -29,6 +29,9 @@ const mockedAdminFetch = vi.mocked(adminFetch)
const emptyOverview: AdminOverviewResponse = {
version: '1',
replica: 'pod-a',
instance: 'test-gateway',
warnings: [],
providers: [],
groups: [],
aliases: [],
@@ -50,6 +53,7 @@ const emptyUsage: AdminUsageResponse = {
tokensOutPerMonth: 0,
costPerDayMicroUsd: 0,
costPerMonthMicroUsd: 0,
rejectionsPerDay: 0,
},
}
const emptyTargets: AdminTargetsResponse = { mcpServers: [], agents: [] }
@@ -160,3 +164,57 @@ describe('useDashboardStore.refresh', () => {
expect(overviewCalls).toHaveLength(1)
})
})
// F5 (dashboard-plan.md): startPolling now drives refresh() through the
// shared createVisibilityPoller (lib/polling.ts) instead of a bare
// setInterval — same observable "fetch once, then poll" shape as before,
// just built on the tested shared primitive.
describe('useDashboardStore.startPolling', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockedAdminFetch.mockReset()
useAuthStore().submit('test-admin-key')
mockRoutes({})
})
it('fetches immediately on startPolling, then again every POLL_MS', async () => {
vi.useFakeTimers()
try {
const dashboard = useDashboardStore()
dashboard.startPolling()
await vi.advanceTimersByTimeAsync(0)
expect(dashboard.lastUpdated).not.toBeNull()
const callsAfterFirst = mockedAdminFetch.mock.calls.length
await vi.advanceTimersByTimeAsync(5000) // POLL_MS
expect(mockedAdminFetch.mock.calls.length).toBeGreaterThan(callsAfterFirst)
} finally {
vi.useRealTimers()
}
})
it('startPolling is idempotent — a second call does not double the interval', async () => {
vi.useFakeTimers()
try {
const dashboard = useDashboardStore()
dashboard.startPolling()
dashboard.startPolling()
await vi.advanceTimersByTimeAsync(0)
const callsAfterFirst = mockedAdminFetch.mock.calls.length
await vi.advanceTimersByTimeAsync(5000)
// One additional round of three requests (overview/usage/targets),
// not two.
expect(mockedAdminFetch.mock.calls.length - callsAfterFirst).toBe(3)
} finally {
vi.useRealTimers()
}
})
// P11 review fix: a `stopPolling` test used to live here too — the
// action itself was dead application-facing API surface (nothing outside
// this spec ever called it, App.vue polls this store for the app's whole
// session) and has been removed from stores/dashboard.ts; see that
// file's own comment.
})
+21 -5
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { createVisibilityPoller, type VisibilityPoller } from '@/lib/polling'
import { useAuthStore } from '@/stores/auth'
import type { AdminOverviewResponse, AdminTargetsResponse, AdminUsageResponse } from '@/types/api'
@@ -24,7 +25,7 @@ export const useDashboardStore = defineStore('dashboard', {
targets: null as AdminTargetsResponse | null,
lastUpdated: null as Date | null,
error: '',
timer: undefined as ReturnType<typeof setInterval> | undefined,
poller: undefined as VisibilityPoller | undefined,
/**
* refreshing guards against overlapping polls (review finding): the 5s
* setInterval has no in-flight check on its own, so a round trip that
@@ -95,11 +96,26 @@ export const useDashboardStore = defineStore('dashboard', {
.map(({ label, reason }) => `${label}: ${reason instanceof Error ? reason.message : String(reason)}`)
.join('; ')
},
/** startPolling is idempotent and safe to call before a key is stored — refresh() no-ops until then. */
/**
* startPolling is idempotent and safe to call before a key is stored —
* refresh() no-ops until then. Driven by the shared
* createVisibilityPoller (lib/polling.ts, F5) rather than a bare
* setInterval: an immediate tick (the same "fetch once, then poll"
* shape this action already had), then every POLL_MS while the tab is
* visible, pausing while it is hidden and catching up the moment it is
* looked at again.
*/
startPolling(): void {
if (this.timer !== undefined) return
void this.refresh()
this.timer = setInterval(() => void this.refresh(), POLL_MS)
if (this.poller) return
this.poller = createVisibilityPoller({ intervalMs: POLL_MS, tick: () => void this.refresh() })
this.poller.start()
},
// P11 review fix: a stopPolling action mirroring history.ts/events.ts's
// own stop() used to live here too, but nothing outside its OWN spec
// ever called it — App.vue mounts this store's poller once, for the
// app's whole session, and never unmounts it (unlike history.ts/
// events.ts, whose stores are torn down when ChartsView/EventsView
// unmount). Dead application-facing API surface, removed; `poller`
// itself stays (startPolling's own idempotency guard still needs it).
},
})
+197
View File
@@ -0,0 +1,197 @@
// events.spec.ts covers useEventsStore's refresh action and its polling
// wiring — same harness as dashboard.spec.ts/history.spec.ts: a Pinia
// store is plain reactive state in the 'node' environment, adminFetch
// fully mocked, so no real network or sessionStorage access happens.
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/api', async () => {
const actual = await vi.importActual<typeof import('@/lib/api')>('@/lib/api')
return { ...actual, adminFetch: vi.fn() }
})
import { AdminApiError, adminFetch } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import { useEventsStore } from '@/stores/events'
import type { AdminEventsResponse } from '@/types/api'
const mockedAdminFetch = vi.mocked(adminFetch)
function eventsResponse(overrides: Partial<AdminEventsResponse> = {}): AdminEventsResponse {
return {
events: [
{
time: '2026-08-20T12:00:00Z',
replica: 'pod-a',
route: 'chat/completions',
kind: 'rate_limit',
message: 'requests/min limit exceeded',
status: 429,
user: 'alice',
},
],
source: 'redis',
replica: 'pod-a',
capacity: 200,
...overrides,
}
}
describe('useEventsStore.refresh', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockedAdminFetch.mockReset()
useAuthStore().submit('test-admin-key')
})
it('populates events/source/replica/capacity on success and clears error', async () => {
mockedAdminFetch.mockResolvedValue(eventsResponse())
const events = useEventsStore()
await events.refresh()
expect(events.events).toHaveLength(1)
expect(events.source).toBe('redis')
expect(events.replica).toBe('pod-a')
expect(events.capacity).toBe(200)
expect(events.degraded).toBe(false)
expect(events.error).toBe('')
expect(events.lastUpdated).not.toBeNull()
})
it('requests the ring capacity as the limit', async () => {
mockedAdminFetch.mockResolvedValue(eventsResponse())
const events = useEventsStore()
await events.refresh()
expect(mockedAdminFetch).toHaveBeenCalledWith('/admin/api/events?limit=200')
})
it('surfaces degraded when the response marks it', async () => {
mockedAdminFetch.mockResolvedValue(eventsResponse({ source: 'replica', degraded: true }))
const events = useEventsStore()
await events.refresh()
expect(events.source).toBe('replica')
expect(events.degraded).toBe(true)
})
it('defaults degraded to false when the response omits it (replica source, Redis simply unconfigured)', async () => {
mockedAdminFetch.mockResolvedValue(eventsResponse({ source: 'replica', degraded: undefined }))
const events = useEventsStore()
await events.refresh()
expect(events.degraded).toBe(false)
})
it('surfaces a non-auth failure as error and leaves events untouched', async () => {
mockedAdminFetch.mockResolvedValue(eventsResponse())
const events = useEventsStore()
await events.refresh()
const seeded = events.events
mockedAdminFetch.mockRejectedValue(new Error('events store down'))
await events.refresh()
expect(events.error).toBe('events store down')
expect(events.events).toBe(seeded) // never blanked on a failed poll
})
it('does not surface an error on a 401 (the auth store owns that case)', async () => {
mockedAdminFetch.mockRejectedValue(new AdminApiError('unauthorized', 401))
const events = useEventsStore()
await events.refresh()
expect(events.error).toBe('')
})
it('skips a refresh() call that starts while one is already in flight', async () => {
let resolveFirst!: (v: AdminEventsResponse) => void
mockedAdminFetch.mockImplementation(
() => new Promise<AdminEventsResponse>((resolve) => (resolveFirst = resolve)) as ReturnType<typeof adminFetch>,
)
const events = useEventsStore()
const first = events.refresh()
const second = events.refresh()
resolveFirst(eventsResponse())
await Promise.all([first, second])
expect(mockedAdminFetch).toHaveBeenCalledTimes(1)
})
it('no-ops before a key is stored', async () => {
useAuthStore().reject('logged out')
const events = useEventsStore()
await events.refresh()
expect(mockedAdminFetch).not.toHaveBeenCalled()
})
})
describe('useEventsStore.startPolling / stopPolling', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockedAdminFetch.mockReset()
mockedAdminFetch.mockResolvedValue(eventsResponse())
useAuthStore().submit('test-admin-key')
})
it('fetches immediately on startPolling, then again every POLL_MS', async () => {
vi.useFakeTimers()
try {
const events = useEventsStore()
events.startPolling()
await vi.advanceTimersByTimeAsync(0)
expect(mockedAdminFetch).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(5000)
expect(mockedAdminFetch).toHaveBeenCalledTimes(2)
events.stopPolling()
} finally {
vi.useRealTimers()
}
})
it('startPolling is idempotent', async () => {
vi.useFakeTimers()
try {
const events = useEventsStore()
events.startPolling()
events.startPolling()
await vi.advanceTimersByTimeAsync(0)
expect(mockedAdminFetch).toHaveBeenCalledTimes(1)
events.stopPolling()
} finally {
vi.useRealTimers()
}
})
it('stopPolling stops further fetches', async () => {
vi.useFakeTimers()
try {
const events = useEventsStore()
events.startPolling()
await vi.advanceTimersByTimeAsync(0)
events.stopPolling()
const callsAfterStop = mockedAdminFetch.mock.calls.length
await vi.advanceTimersByTimeAsync(15_000)
expect(mockedAdminFetch.mock.calls.length).toBe(callsAfterStop)
} finally {
vi.useRealTimers()
}
})
})
+100
View File
@@ -0,0 +1,100 @@
import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { createVisibilityPoller, type VisibilityPoller } from '@/lib/polling'
import { useAuthStore } from '@/stores/auth'
import type { AdminEventsResponse, AdminEventView } from '@/types/api'
/** How often the Events view polls while mounted — same cadence as the Providers/Usage/Targets dashboard poll (stores/dashboard.ts's own POLL_MS): a rate-limit/upstream event is exactly the kind of thing an operator wants to see within a few seconds, not thirty. */
const POLL_MS = 5000
/** EVENTS_LIMIT matches the ring's own fixed capacity (events.go: eventRingCap, admin.go: adminEventsMaxLimit) — asking for fewer would silently hide events the ring/Redis list still holds. */
const EVENTS_LIMIT = 200
/**
* useEventsStore polls GET /admin/api/events?limit=200, feeding the new
* Events tab (App.vue, F3). Unlike useDashboardStore, this store is
* polled only while the Events view is mounted (EventsView.vue's own
* onMounted/onUnmounted) rather than for the app's whole lifetime — an
* operator who never opens the tab pays no extra request for it.
*/
export const useEventsStore = defineStore('events', {
state: () => ({
/** Newest first, exactly as the server orders them — see types/api.ts's AdminEventsResponse doc comment. */
events: [] as AdminEventView[],
/** 'redis' (fleet-wide) or 'replica' (this replica's own in-memory ring, read because Redis is unreachable or unconfigured) — the view must caption this rather than present a replica-only fallback as fleet-wide. */
source: 'replica' as 'redis' | 'replica',
replica: '',
/** The ring's fixed capacity (events.go: eventRingCap) — lets the view show "N of capacity" rather than a bare list length. */
capacity: 0,
/** Set only when Redis IS configured but the read failed (never when Redis is simply not configured at all — see AdminEventsResponse's own doc comment). */
degraded: false,
lastUpdated: null as Date | null,
error: '',
/**
* kindFilter/userFilter (F9, hash-state) — the Events view's own kind
* Select and user SearchInput. Lifted up here rather than kept as
* local refs in EventsView.vue, mirroring stores/nav.ts's own
* usageQuery doc comment: state that must survive a #<tab>?<query>
* hash round trip (composables/useHashState.ts, lib/hash-state.ts's
* parseEventsParams/kind,user) needs one canonical place to read from
* and write to, not a component-local ref useHashState cannot reach.
* kindFilter is an exact AdminEventKind string, or '' for "every
* kind" (EventsView.vue's own ALL_KINDS sentinel maps to/from this at
* the template boundary — see lib/events-filter.ts's own EventFilter
* shape, which this pairs with directly). userFilter is the raw,
* un-normalized search text; EventsView.vue binds it via
* useSearchQuery's external-ref option (composables/useSearchQuery.ts)
* the same way UsageView.vue binds nav.usageQuery.
*/
kindFilter: '',
userFilter: '',
poller: undefined as VisibilityPoller | undefined,
/** Mirrors dashboard.ts's own in-flight guard: a round trip slower than POLL_MS must never stack a second refresh() on top of the first. */
refreshing: false,
}),
actions: {
async refresh(): Promise<void> {
const auth = useAuthStore()
if (!auth.isAuthenticated) return
if (this.refreshing) return
this.refreshing = true
try {
const res = await adminFetch<AdminEventsResponse>(`/admin/api/events?limit=${EVENTS_LIMIT}`)
this.events = res.events
this.source = res.source
this.replica = res.replica
this.capacity = res.capacity
this.degraded = res.degraded ?? false
this.lastUpdated = new Date()
this.error = ''
} catch (err) {
// A 401/403 already rejected the key (lib/api.ts) — the AuthGate
// takes over the whole view, matching dashboard.ts's own
// identical carve-out; nothing left to report here.
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) return
this.error = err instanceof Error ? err.message : String(err)
} finally {
this.refreshing = false
}
},
/** setKindFilter sets the exact-match kind filter — '' means "every kind" (EventsView.vue translates its own ALL_KINDS Select sentinel to/from '' at the template boundary). */
setKindFilter(kind: string): void {
this.kindFilter = kind
},
/** setUserFilter sets the raw (un-normalized) user/group search text — EventsView.vue reads the normalized form through useSearchQuery's own `normalized` computed rather than this action re-deriving it. */
setUserFilter(user: string): void {
this.userFilter = user
},
/** startPolling is idempotent, driven by the shared createVisibilityPoller (lib/polling.ts, F5) — an immediate fetch, then every POLL_MS while the tab is visible. */
startPolling(): void {
if (this.poller) return
this.poller = createVisibilityPoller({ intervalMs: POLL_MS, tick: () => void this.refresh() })
this.poller.start()
},
stopPolling(): void {
this.poller?.stop()
this.poller = undefined
},
},
})
+278 -9
View File
@@ -25,7 +25,7 @@ import type { AdminUsageModelsResponse, UsageHistoryResponse } from '@/types/api
const mockedAdminFetch = vi.mocked(adminFetch)
function modelsResponse(models: AdminUsageModelsResponse['models']): AdminUsageModelsResponse {
return { metric: 'cost', window: 'hour', models }
return { metric: 'cost', window: 'hour', span: 24, models }
}
function seriesResponse(): UsageHistoryResponse {
return { scope: 'total', metric: 'req', window: 'hour', points: [{ bucket: '2026082012', value: 5 }] }
@@ -95,16 +95,20 @@ describe('useHistoryStore model ranking', () => {
expect(rankingCall).toBeDefined()
})
it('refreshes the picker list alongside a time series', async () => {
routeFetch({ models: () => modelsResponse([{ id: 'alpha/a-model-1', value: 3 }]) })
// F2 (dashboard-plan.md): the ranking now sums the same WINDOW_SPAN
// buckets the time-series tabs chart, not just window's current bucket —
// WINDOW_LABEL ("24h"/"30d"/"12mo") is honest for the Models tab too.
it('asks the ranking endpoint for the window span, not just the current bucket', async () => {
routeFetch({})
const history = useHistoryStore()
history.tab = 'models'
await history.refresh()
// A model that has just started receiving traffic becomes selectable
// without a reload, even while a non-Models tab is on screen.
expect(history.modelOptions).toEqual([{ id: 'alpha/a-model-1', value: 3 }])
expect(history.seriesByMetric.req).toHaveLength(1)
const rankingCall = mockedAdminFetch.mock.calls
.map((c) => String(c[0]))
.find((p) => p.startsWith('/admin/api/usage/models'))
expect(rankingCall).toContain('span=24') // WINDOW_SPAN.hour, the default window
})
it('asks the picker list for requests, so a free model is still selectable', async () => {
@@ -115,6 +119,33 @@ describe('useHistoryStore model ranking', () => {
const optionsCall = mockedAdminFetch.mock.calls.map((c) => String(c[0])).find((p) => p.includes('/usage/models'))
expect(optionsCall).toContain('metric=req')
expect(optionsCall).toContain('span=24') // WINDOW_SPAN.hour, the default window
})
// Q1 (dashboard-plan.md DECISIONS, read-amplification review finding):
// fetchModelOptions is a real extra store read and must NOT repeat on
// every refresh() call — refresh() is what both the 30s auto-refresh
// tick and every setScope/setTab/setModelMetric change call.
it('never fetches the picker list from a plain refresh() call (Q1: read amplification)', async () => {
routeFetch({ series: () => seriesResponse() })
const history = useHistoryStore()
await history.refresh()
expect(history.seriesByMetric.req).toHaveLength(1)
expect(history.modelOptions).toEqual([])
const optionsCall = mockedAdminFetch.mock.calls.map((c) => String(c[0])).some((p) => p.includes('/usage/models'))
expect(optionsCall).toBe(false)
})
it('fetches the picker list on a window change, unlike a scope/tab/modelMetric change', async () => {
routeFetch({ models: () => modelsResponse([{ id: 'alpha/a-model-1', value: 3 }]) })
const history = useHistoryStore()
history.setWindow('day')
await flush()
expect(history.modelOptions).toEqual([{ id: 'alpha/a-model-1', value: 3 }])
})
it('swallows a picker-list failure without touching the chart error', async () => {
@@ -135,18 +166,24 @@ describe('useHistoryStore model ranking', () => {
expect(history.modelOptions).toEqual([])
})
it('keeps a rendered chart intact when the picker list fails', async () => {
// setWindow fires refresh() (series/ranking) and fetchModelOptions
// (picker list) in parallel (Q1's two trigger points) — one failing must
// never touch the other's own state.
it('keeps the freshly-fetched chart intact when the picker list fails on the same window change', async () => {
routeFetch({
series: () => seriesResponse(),
models: () => {
throw new Error('models endpoint down')
},
})
const history = useHistoryStore()
await history.refresh()
history.setWindow('day')
await flush()
expect(history.seriesByMetric.req).toHaveLength(1)
expect(history.error).toBe('')
expect(history.modelOptions).toEqual([])
})
it('surfaces a failing ranking fetch, unlike the picker list', async () => {
@@ -163,6 +200,144 @@ describe('useHistoryStore model ranking', () => {
})
})
// F6 (dashboard-plan.md): modelFilter narrows the Models tab's ranking to a
// provider/model prefix, set by ProvidersView.vue's provider-header link
// (nav.goToModels) or a `#charts?tab=models&filter=...` hash param
// (lib/hash-state.ts, composables/useHashState.ts). P3 review fix: while
// the Models tab is active, setModelFilter ALSO sends the new prefix to
// the server and refetches the ranking (fetchModelRanking's own doc
// comment) — it is no longer a pure client-side re-slice. Setting it while
// any OTHER tab is active stays fetch-free, since only the Models tab
// reads modelFilter at all (ChartsView.vue).
describe('useHistoryStore model filter (F6)', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockedAdminFetch.mockReset()
useAuthStore().submit('test-admin-key')
})
it('starts with no filter', () => {
expect(useHistoryStore().modelFilter).toBe('')
})
it('setModelFilter sets the prefix without fetching anything when not on the Models tab', async () => {
routeFetch({})
const history = useHistoryStore()
history.tab = 'requests' // explicit, not relying on the store's own default
const callsBefore = mockedAdminFetch.mock.calls.length
history.setModelFilter('openai/')
await flush()
expect(history.modelFilter).toBe('openai/')
expect(mockedAdminFetch.mock.calls.length).toBe(callsBefore)
})
it('setModelFilter refetches the ranking with the encoded prefix when the Models tab IS active', async () => {
routeFetch({})
const history = useHistoryStore()
history.tab = 'models'
const callsBefore = mockedAdminFetch.mock.calls.length
history.setModelFilter('openai/')
await flush()
expect(history.modelFilter).toBe('openai/')
expect(mockedAdminFetch.mock.calls.length).toBeGreaterThan(callsBefore)
const rankingCall = mockedAdminFetch.mock.calls.map((c) => String(c[0])).find((p) => p.startsWith('/admin/api/usage/models'))
expect(rankingCall).toContain(`prefix=${encodeURIComponent('openai/')}`)
})
it('setTab clears a previously-set modelFilter (a plain tab click must not carry a stale provider filter)', () => {
routeFetch({})
const history = useHistoryStore()
history.setModelFilter('openai/')
history.setTab('tokens')
expect(history.modelFilter).toBe('')
})
it('setTab(models) also clears modelFilter — nav.goToModels relies on re-applying setModelFilter AFTER this', () => {
routeFetch({})
const history = useHistoryStore()
history.tab = 'requests'
history.setModelFilter('openai/')
history.setTab('models')
expect(history.modelFilter).toBe('')
})
it('setTab is a no-op (including on modelFilter) when the tab does not actually change', () => {
routeFetch({})
const history = useHistoryStore()
history.tab = 'models'
history.setModelFilter('openai/')
history.setTab('models') // already on 'models' — the early return must skip the clear too
expect(history.modelFilter).toBe('openai/')
})
// P3 review fix: the ranking fetch now sends the active prefix to the
// server (filtering BEFORE the limit is applied), instead of only ever
// re-slicing an already-fetched, unprefixed top-N client-side.
it('sends &prefix=<encoded filter> on the ranking fetch when modelFilter is set', async () => {
routeFetch({})
const history = useHistoryStore()
history.tab = 'models'
history.modelFilter = 'openai/'
await history.fetchModelRanking()
const rankingCall = mockedAdminFetch.mock.calls.map((c) => String(c[0])).find((p) => p.startsWith('/admin/api/usage/models'))
expect(rankingCall).toContain(`prefix=${encodeURIComponent('openai/')}`)
})
it('omits the prefix param entirely when modelFilter is empty', async () => {
routeFetch({})
const history = useHistoryStore()
history.tab = 'models'
await history.fetchModelRanking()
const rankingCall = mockedAdminFetch.mock.calls.map((c) => String(c[0])).find((p) => p.startsWith('/admin/api/usage/models'))
expect(rankingCall).not.toContain('prefix=')
})
// P3 review fix: setModelFilter now refetches too, but ONLY while the
// Models tab is active — a stale (unprefixed, or differently-prefixed)
// already-fetched ranking must not linger once the filter changes, since
// the server-side prefix now genuinely changes what that fetch returns.
it('setModelFilter refetches the ranking when the Models tab is active and the prefix actually changes', async () => {
routeFetch({})
const history = useHistoryStore()
history.tab = 'models'
const callsBefore = mockedAdminFetch.mock.calls.length
history.setModelFilter('openai/')
await flush()
expect(mockedAdminFetch.mock.calls.length).toBeGreaterThan(callsBefore)
const rankingCall = mockedAdminFetch.mock.calls.map((c) => String(c[0])).find((p) => p.startsWith('/admin/api/usage/models'))
expect(rankingCall).toContain(`prefix=${encodeURIComponent('openai/')}`)
})
it('setModelFilter does NOT refetch when the value is unchanged (early return)', async () => {
routeFetch({})
const history = useHistoryStore()
history.tab = 'models'
history.setModelFilter('openai/')
await flush()
const callsBefore = mockedAdminFetch.mock.calls.length
history.setModelFilter('openai/') // same value again
expect(mockedAdminFetch.mock.calls.length).toBe(callsBefore)
})
})
// Review findings 1/2/9: out-of-order responses must never overwrite a
// newer selection, stale data must never render under the new selection's
// labels, and `loading` must only ever be cleared by the request that is
@@ -265,6 +440,26 @@ describe('useHistoryStore: latest-request-wins', () => {
expect(history.loaded).toBe(false)
})
it('the 30s auto-refresh tick (startAutoRefresh) never re-fetches the picker list — only refresh() itself (Q1)', async () => {
vi.useFakeTimers()
try {
routeFetch({ series: () => seriesResponse() })
const history = useHistoryStore()
history.startAutoRefresh() // ticks once immediately, then every REFRESH_MS (lib/polling.ts)
await vi.advanceTimersByTimeAsync(0)
await vi.advanceTimersByTimeAsync(90_000) // three more ticks
await vi.advanceTimersByTimeAsync(0)
expect(history.modelOptions).toEqual([])
const optionsCall = mockedAdminFetch.mock.calls.map((c) => String(c[0])).some((p) => p.includes('/usage/models'))
expect(optionsCall).toBe(false)
history.stopAutoRefresh()
} finally {
vi.useRealTimers()
}
})
it('auto-refresh tick skips while a fetch is still in flight, so a slow request is never superseded by the timer', async () => {
vi.useFakeTimers()
try {
@@ -288,3 +483,77 @@ describe('useHistoryStore: latest-request-wins', () => {
}
})
})
// P12 review fix: restoring a Charts hash with several params set at once
// (composables/useHashState.ts's applyFromHash) or clicking through via
// nav.ts's goToCharts/goToModels used to call several of the single-field
// setters above back to back, each independently clearing state and
// firing its OWN refresh() (and, for a window change, fetchModelOptions()
// too) — every fetch but the last immediately superseded and discarded.
// setSelection batches the whole change into ONE refresh() call.
describe('useHistoryStore.setSelection (P12: batched, one fetch)', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockedAdminFetch.mockReset()
useAuthStore().submit('test-admin-key')
})
it('applying scope+window+tab+modelMetric together fires exactly one series fetch (plus one picker-list fetch for the window change) — not one per field', async () => {
routeFetch({ series: () => seriesResponse() })
const history = useHistoryStore()
// tab stays at its default ('requests', metricsForTab -> 1 metric), so
// the "one series fetch" below is a single adminFetch call, not the
// 2-call 'tokens' case — isolating the batching behavior itself from
// metricsForTab's own separate multi-metric fan-out.
history.setSelection({ scope: 'user:alice', window: 'day', tab: 'requests', modelMetric: 'req' })
await flush()
expect(history.scope).toBe('user:alice')
expect(history.window).toBe('day')
expect(history.modelMetric).toBe('req')
const calls = mockedAdminFetch.mock.calls.map((c) => String(c[0]))
const seriesCalls = calls.filter((p) => p.startsWith('/admin/api/usage/history'))
const optionsCalls = calls.filter((p) => p.startsWith('/admin/api/usage/models'))
expect(seriesCalls).toHaveLength(1) // one series fetch, not the 3-4 the old per-field setters would have fired
expect(optionsCalls).toHaveLength(1) // fetchModelOptions, fired once for the window change
})
it('does nothing (no state change, no fetch) when every field already matches the current selection', async () => {
routeFetch({})
const history = useHistoryStore()
const callsBefore = mockedAdminFetch.mock.calls.length
history.setSelection({ scope: 'total', window: 'hour', tab: 'requests', modelMetric: 'cost' }) // all defaults
await flush()
expect(mockedAdminFetch.mock.calls.length).toBe(callsBefore)
})
it('applies modelFilter AFTER tab resets it — setSelection({ tab: "models", modelFilter: prefix }) lands on the intended prefix (nav.ts goToModels\' own call shape)', async () => {
routeFetch({})
const history = useHistoryStore()
history.setSelection({ tab: 'models', modelFilter: 'openai/' })
await flush()
expect(history.tab).toBe('models')
expect(history.modelFilter).toBe('openai/')
const rankingCall = mockedAdminFetch.mock.calls.map((c) => String(c[0])).find((p) => p.startsWith('/admin/api/usage/models'))
expect(rankingCall).toContain(`prefix=${encodeURIComponent('openai/')}`)
// Only ONE ranking fetch — not a first unprefixed one from the tab
// switch followed by a second, corrective one from the filter.
const rankingCalls = mockedAdminFetch.mock.calls.map((c) => String(c[0])).filter((p) => p.startsWith('/admin/api/usage/models'))
expect(rankingCalls).toHaveLength(1)
})
it('only fetchModelOptions is skipped when window is not part of the call, even though other fields change', async () => {
routeFetch({ series: () => seriesResponse() })
const history = useHistoryStore()
history.setSelection({ scope: 'user:alice' })
await flush()
const optionsCalls = mockedAdminFetch.mock.calls.map((c) => String(c[0])).filter((p) => p.startsWith('/admin/api/usage/models'))
expect(optionsCalls).toHaveLength(0)
})
})
+195 -44
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { createVisibilityPoller, type VisibilityPoller } from '@/lib/polling'
import { useAuthStore } from '@/stores/auth'
import type {
AdminUsageModelEntry,
@@ -48,8 +49,13 @@ export const MODEL_METRIC_LABEL: Record<ModelMetric, string> = {
/**
* How many ranked models the Models tab requests. Matches the server's own
* default (admin.go: usageModelsDefaultLimit) and stays well under its max.
* Exported (P3 review fix) so ChartsView.vue can show a "Top N models"
* note whenever the fetched ranking is exactly this long — the reader must
* be told the list is a truncated top-N, not the complete set, the same
* signal the on-screen search/filter affordances elsewhere in this panel
* already give for a narrowed-but-possibly-incomplete list.
*/
const MODEL_RANKING_LIMIT = 20
export const MODEL_RANKING_LIMIT = 20
/**
* How many models the SCOPE PICKER offers. Higher than the ranking's own
@@ -77,31 +83,21 @@ export const WINDOW_SPAN: Record<HistoryWindow, number> = {
month: 12,
}
/** WINDOW_LABEL is the switcher's own display text for each window, for the three TIME-SERIES tabs (requests/tokens/cost) — a real span of buckets, matching WINDOW_SPAN above. */
/**
* WINDOW_LABEL is the window-switcher's own display text — a real span of
* buckets, matching WINDOW_SPAN above, for EVERY tab including Models: the
* ranking endpoint now sums the same WINDOW_SPAN buckets the time-series
* tabs chart (F2 review fix), rather than reading modelTotals' old
* single-current-bucket behaviour, so "24h"/"30d"/"12mo" is accurate for
* it too and this is the one label every tab shares — no separate
* MODEL_WINDOW_LABEL needed any more.
*/
export const WINDOW_LABEL: Record<HistoryWindow, string> = {
hour: '24h',
day: '30d',
month: '12mo',
}
/**
* MODEL_WINDOW_LABEL is the Models tab's OWN window-switcher text — that
* tab shares the same three-way Tabs control as the time-series tabs
* (ChartsView.vue), but reads a fundamentally different query: modelTotals
* (limits.go) returns ONE counter at window's CURRENT bucket, never a span
* of WINDOW_SPAN buckets. Labeling that control "24h" when it actually
* means "since the top of the current UTC hour" understates how little
* data is behind it — at 14:05 UTC, "24h" would visually promise a full
* day while the ranking covers five minutes. "(UTC)" is explicit rather
* than implied, matching formatBucketLabel's own hour-bucket fix
* (lib/format.ts) — bucketFor (limits.go) always buckets in UTC.
*/
export const MODEL_WINDOW_LABEL: Record<HistoryWindow, string> = {
hour: 'This hour (UTC)',
day: 'Today (UTC)',
month: 'This month (UTC)',
}
/**
* metricsForTab maps a chart tab to the metric(s) it needs. "tokens" is the
* one tab needing two: GET /admin/api/usage/history takes a single metric
@@ -143,11 +139,29 @@ export const useHistoryStore = defineStore('history', {
modelRanking: [] as AdminUsageModelEntry[],
/** Models with non-zero traffic, for the scope picker's model entries. */
modelOptions: [] as AdminUsageModelEntry[],
/**
* modelFilter narrows the Models tab's ranking to ids starting with
* this prefix (F6, dashboard-plan.md). P3 review fix: while the
* Models tab is active, this is now also a SERVER query param —
* setModelFilter sends it to /admin/api/usage/models as `prefix`
* (fetchModelRanking's own doc comment) and refetches, since
* filtering must happen before the server applies its top-N limit.
* lib/model-filter.ts's filterModelsByPrefix still re-slices the
* result client-side too (ChartsView.vue), a harmless no-op pass once
* the server has already narrowed the set. Set by ProvidersView.vue's
* provider-header link (nav.goToModels(`${p.name}/`), stores/nav.ts —
* WP-B1) so "view this provider's models" lands on a pre-filtered
* ranking instead of the reader hunting for it themselves, and it
* round-trips through the `#charts?tab=models&filter=` hash param
* (lib/hash-state.ts, composables/useHashState.ts) so a reload or a
* shared link restores it too. Empty string means no filter.
*/
modelFilter: '',
loading: false,
/** True once the current selection's first fetch committed; cleared by the setters. Drives the "Loading…" line so background refreshes don't flash it over rendered data. */
loaded: false,
error: '',
timer: undefined as ReturnType<typeof setInterval> | undefined,
poller: undefined as VisibilityPoller | undefined,
/**
* seriesReqId guards fetchSeries and fetchModelRanking against
* out-of-order responses (review finding: "latest-request-wins"). The
@@ -180,23 +194,125 @@ export const useHistoryStore = defineStore('history', {
setWindow(window: HistoryWindow): void {
if (window === this.window) return
this.window = window
// Window changes both the time-series bucket resolution AND the
// Models ranking's single current bucket — clear both.
// Window changes the time-series bucket resolution, the Models
// ranking's own span, AND the picker's model list (Q1,
// dashboard-plan.md DECISIONS: fetchModelOptions runs only on a
// window change or initial load, never on the 30s auto-refresh tick)
// — clear/refetch all three.
this.seriesByMetric = {}
this.modelRanking = []
this.error = ''
this.loaded = false
void this.refresh()
void this.fetchModelOptions()
},
setTab(tab: ChartTab): void {
if (tab === this.tab) return
this.tab = tab
this.seriesByMetric = {}
this.modelRanking = []
// A stale provider-prefix filter from an earlier nav.goToModels visit
// must not silently carry into a plain "Models" tab click — F6's
// nav.goToModels always re-applies its own setModelFilter call right
// after this one, so clearing here unconditionally never fights it.
this.modelFilter = ''
this.error = ''
this.loaded = false
void this.refresh()
},
/**
* setModelFilter narrows the Models tab's ranking to ids starting with
* `prefix`.
*
* P3 review fix: this used to be a PURE client-side re-slice of
* already-fetched data, never a refetch (modelFilter's own state doc
* comment above still describes the re-slice half, lib/model-filter.ts's
* filterModelsByPrefix). Now that fetchModelRanking also sends `prefix`
* to the SERVER (see that action's own doc comment), the already-fetched
* ranking can be stale relative to a NEW prefix — it may have been
* fetched under the old prefix (or no prefix at all), so simply
* re-slicing it client-side no longer reflects what the server would
* return for the new one. Refetching only when the Models tab is
* actually active (the only tab that reads modelFilter at all — see
* filterModelsByPrefix's own call site, ChartsView.vue) keeps this a
* no-op fetch-wise everywhere else, and the early return on an
* unchanged value keeps repeated identical calls (or nav.goToModels
* re-applying the same prefix) from firing a redundant fetch.
*/
setModelFilter(prefix: string): void {
if (prefix === this.modelFilter) return
this.modelFilter = prefix
if (this.tab !== 'models') return
this.modelRanking = []
this.error = ''
this.loaded = false
void this.refresh()
},
/**
* setSelection (P12 review fix) applies scope/window/tab/modelMetric/
* modelFilter as ONE atomic change, instead of the caller invoking
* several of the single-field setters above back to back. Each of
* those setters independently clears state and calls refresh() (and,
* for window, fetchModelOptions() too) — restoring a Charts hash with
* several params set at once (composables/useHashState.ts's
* applyFromHash) or nav.ts's goToCharts/goToModels used to call three
* or four of them in a row, firing that many fetches, every one but
* the LAST immediately discarded by seriesReqId's own
* latest-request-wins guard (stores/history.ts state doc comment) —
* wasted round trips against a Redis-backed endpoint for a result that
* was never going to render. setSelection instead applies every
* provided field directly, clears the derived state ONCE, and fires
* exactly one refresh() (plus one fetchModelOptions() only when
* `window` is part of this call) once every field has been applied.
*
* Field application order matters for one case: `tab` resets
* `modelFilter` to '' first (mirroring setTab's own "a stale
* provider-prefix filter must not silently carry into a plain tab
* switch" convention), and only THEN does this function's own
* `modelFilter` field (if provided in the SAME call) apply the real
* value — so `setSelection({ tab: 'models', modelFilter: prefix })`
* (nav.ts's goToModels) lands on the intended prefix, not '' followed
* by a second, separate fetch to correct it.
*/
setSelection(next: {
scope?: string
window?: HistoryWindow
tab?: ChartTab
modelMetric?: ModelMetric
modelFilter?: string
}): void {
let changed = false
let windowChanged = false
if (next.window !== undefined && next.window !== this.window) {
this.window = next.window
changed = true
windowChanged = true
}
if (next.tab !== undefined && next.tab !== this.tab) {
this.tab = next.tab
this.modelFilter = ''
changed = true
}
if (next.scope !== undefined && next.scope !== this.scope) {
this.scope = next.scope
changed = true
}
if (next.modelMetric !== undefined && next.modelMetric !== this.modelMetric) {
this.modelMetric = next.modelMetric
changed = true
}
if (next.modelFilter !== undefined && next.modelFilter !== this.modelFilter) {
this.modelFilter = next.modelFilter
changed = true
}
if (!changed) return
this.seriesByMetric = {}
this.modelRanking = []
this.error = ''
this.loaded = false
void this.refresh()
if (windowChanged) void this.fetchModelOptions()
},
setModelMetric(metric: ModelMetric): void {
if (metric === this.modelMetric) return
this.modelMetric = metric
@@ -207,12 +323,17 @@ export const useHistoryStore = defineStore('history', {
},
/**
* refresh fetches whatever the CURRENT selection needs: the ranking on
* the Models tab, a time series otherwise. The scope picker's model
* list is refreshed alongside either, so a model that has just started
* receiving traffic becomes selectable without a page reload.
* the Models tab, a time series otherwise. It deliberately does NOT
* also refresh the scope picker's model list any more (Q1,
* dashboard-plan.md DECISIONS — folded from a review finding on read
* amplification): fetchModelOptions is a real extra store read
* (modelSpanTotals, admin.go), and this action is what the 30s
* auto-refresh timer calls every tick — repeating that read every
* cycle bought the picker nothing a reader would ever notice. See
* fetchModelOptions' own doc comment for its two actual call sites.
*/
async refresh(): Promise<void> {
await Promise.all([this.tab === 'models' ? this.fetchModelRanking() : this.fetchSeries(), this.fetchModelOptions()])
await (this.tab === 'models' ? this.fetchModelRanking() : this.fetchSeries())
},
async fetchSeries(): Promise<void> {
const auth = useAuthStore()
@@ -259,8 +380,19 @@ export const useHistoryStore = defineStore('history', {
const requestId = ++this.seriesReqId
this.loading = true
try {
const span = WINDOW_SPAN[this.window]
// P3 review fix: when a provider-prefix filter is active
// (modelFilter, set by ProvidersView.vue's provider-header link via
// nav.goToModels), send it to the server so filtering happens
// BEFORE the limit is applied — the client-side filterModelsByPrefix
// pass in ChartsView.vue stays too, as a harmless second pass (it
// is a no-op once the server has already narrowed the set), but
// without this the server's own fleet-wide top-N could omit a
// provider's models entirely, or return only a partial view of them
// (the CONFIRMED problem this fixes).
const prefixParam = this.modelFilter ? `&prefix=${encodeURIComponent(this.modelFilter)}` : ''
const res = await adminFetch<AdminUsageModelsResponse>(
`/admin/api/usage/models?metric=${this.modelMetric}&window=${this.window}&limit=${MODEL_RANKING_LIMIT}`,
`/admin/api/usage/models?metric=${this.modelMetric}&window=${this.window}&limit=${MODEL_RANKING_LIMIT}&span=${span}${prefixParam}`,
)
if (requestId !== this.seriesReqId) return
this.modelRanking = res.models
@@ -278,20 +410,27 @@ export const useHistoryStore = defineStore('history', {
},
/**
* fetchModelOptions loads the picker's model list: models with at least
* one REQUEST in the current window. Requests, not the ranking's own
* metric, deliberately — a model can serve traffic while reporting no
* tokens and costing nothing, and such a model must still be
* selectable. A failure here is swallowed rather than surfaced: it
* one REQUEST across the current window's span. Requests, not the
* ranking's own metric, deliberately — a model can serve traffic while
* reporting no tokens and costing nothing, and such a model must still
* be selectable. A failure here is swallowed rather than surfaced: it
* degrades the picker, and must not replace a rendered chart's own
* error (or clear it) on a background refresh.
* error (or clear it).
*
* Called only on initial load and on a window change (Q1,
* dashboard-plan.md DECISIONS) — never from refresh() itself, so the
* 30s auto-refresh tick pays for the ranking/series alone. See
* setWindow above and ChartsView.vue's onMounted for the two call
* sites.
*/
async fetchModelOptions(): Promise<void> {
const auth = useAuthStore()
if (!auth.isAuthenticated) return
const requestId = ++this.optionsReqId
try {
const span = WINDOW_SPAN[this.window]
const res = await adminFetch<AdminUsageModelsResponse>(
`/admin/api/usage/models?metric=req&window=${this.window}&limit=${MODEL_OPTIONS_LIMIT}`,
`/admin/api/usage/models?metric=req&window=${this.window}&limit=${MODEL_OPTIONS_LIMIT}&span=${span}`,
)
// Stale: a later window change already fired its own picker-list
// fetch — never let an older window's model list overwrite it.
@@ -301,19 +440,31 @@ export const useHistoryStore = defineStore('history', {
// Intentionally ignored — see the doc comment above.
}
},
/**
* startAutoRefresh drives refresh() through the shared
* createVisibilityPoller (lib/polling.ts, F5) instead of a bare
* setInterval: it ticks once immediately (the selection's initial
* fetch — see ChartsView.vue's onMounted, which no longer calls
* refresh() itself), then every REFRESH_MS while the tab is visible,
* pausing while it is hidden and catching up the moment it is looked
* at again. The in-flight skip is unchanged from before this move: a
* tick bumps seriesReqId on every real fetch, so an unconditional tick
* would supersede (and discard) any request slower than REFRESH_MS,
* forever.
*/
startAutoRefresh(): void {
if (this.timer !== undefined) return
// Skip a tick while the previous fetch is still in flight: each fetch
// bumps seriesReqId, so an unconditional tick would supersede (and
// discard) any request slower than REFRESH_MS, forever.
this.timer = setInterval(() => {
if (!this.loading) void this.refresh()
}, REFRESH_MS)
if (this.poller) return
this.poller = createVisibilityPoller({
intervalMs: REFRESH_MS,
tick: () => {
if (!this.loading) void this.refresh()
},
})
this.poller.start()
},
stopAutoRefresh(): void {
if (this.timer === undefined) return
clearInterval(this.timer)
this.timer = undefined
this.poller?.stop()
this.poller = undefined
},
},
})
+86
View File
@@ -0,0 +1,86 @@
// nav.spec.ts covers useNavStore's two navigation actions (F6). Real
// Pinia + real useHistoryStore, no mocking needed: useAuthStore.
// isAuthenticated defaults to false in this 'node' test environment (no
// sessionStorage — key-storage.ts's own try/catch guard), so history.ts's
// setScope/setTab/setModelFilter run their synchronous state updates and
// their own fire-and-forget refresh()/fetchSeries() short-circuits
// immediately at its own `if (!auth.isAuthenticated) return` guard,
// exactly like dashboard.spec.ts's own precedent (see that file's header
// comment).
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useHistoryStore } from '@/stores/history'
import { useNavStore } from '@/stores/nav'
beforeEach(() => {
setActivePinia(createPinia())
})
describe('useNavStore: initial state', () => {
it('defaults activeTab to "providers" and usageQuery to empty', () => {
const nav = useNavStore()
expect(nav.activeTab).toBe('providers')
expect(nav.usageQuery).toBe('')
})
})
describe('useNavStore.goToCharts', () => {
it('sets the history scope and switches activeTab to "charts"', () => {
const nav = useNavStore()
const history = useHistoryStore()
nav.goToCharts('user:alice')
expect(history.scope).toBe('user:alice')
expect(nav.activeTab).toBe('charts')
})
it('accepts every scope prefix history.ts recognizes', () => {
const nav = useNavStore()
const history = useHistoryStore()
for (const scope of ['total', 'user:alice', 'group:eng', 'model:openai/gpt-4']) {
nav.goToCharts(scope)
expect(history.scope).toBe(scope)
}
})
it('switches the Charts tab back to "requests" first when it was showing "models" (no scope picker there)', () => {
const nav = useNavStore()
const history = useHistoryStore()
history.setTab('models')
nav.goToCharts('group:eng')
expect(history.tab).toBe('requests')
expect(history.scope).toBe('group:eng')
})
it('leaves a non-models tab untouched', () => {
const nav = useNavStore()
const history = useHistoryStore()
history.setTab('cost')
nav.goToCharts('total')
expect(history.tab).toBe('cost')
})
})
describe('useNavStore.goToModels', () => {
it('switches to the Models tab, applies the prefix filter, and switches activeTab to "charts"', () => {
const nav = useNavStore()
const history = useHistoryStore()
nav.goToModels('openai/')
expect(history.tab).toBe('models')
expect(history.modelFilter).toBe('openai/')
expect(nav.activeTab).toBe('charts')
})
it('applies the filter AFTER switching tabs, so setTab\'s own modelFilter reset never wins the race', () => {
const nav = useNavStore()
const history = useHistoryStore()
// Already on the Models tab with a stale filter from an earlier visit —
// setTab('models') is a no-op (tab === this.tab guard, history.ts) and
// so does NOT reset modelFilter on its own; goToModels's own explicit
// setModelFilter call is what must still land the new prefix.
history.setTab('models')
history.setModelFilter('stale/')
nav.goToModels('anthropic/')
expect(history.modelFilter).toBe('anthropic/')
})
})
+78
View File
@@ -0,0 +1,78 @@
import { defineStore } from 'pinia'
import type { TabValue } from '@/composables/useTabHash'
import { useHistoryStore } from '@/stores/history'
/**
* useNavStore (F6) is the one place cross-tab "jump to Charts, pointed at
* X" navigation lives: every ScopeLink.vue click (usage-columns.ts's id
* cell, the Groups accordion trigger here, and WP-B2's ProvidersView.vue
* per-model links) and the Providers tab's own per-provider header link
* call one of the two actions below, instead of each call site poking
* history.ts's scope/tab state AND App.vue's own active-tab separately.
*
* `activeTab` is typed with TabValue, the SAME union App.vue's Tabs
* v-model already binds to (composables/useTabHash.ts) — WP-B2 extends
* that union with 'events' (F3); this store picks the extension up for
* free with no edit here, since it imports the type rather than
* redeclaring its own copy (vue.md: "share types once, import
* everywhere").
*/
export const useNavStore = defineStore('nav', {
state: () => ({
activeTab: 'providers' as TabValue,
/**
* usageQuery is the Usage tab's own search box value (F9's
* useSearchQuery(external), WP-B2 — composables/useSearchQuery.ts
* gains an optional external ref so UsageView.vue can bind this
* instead of owning a local ref), lifted up here rather than kept
* local to that component so it survives a navigation away from and
* back to the Usage tab, and so #<tab>?q=... hash state (F9,
* lib/hash-state.ts) has one canonical place to read/write it from.
*/
usageQuery: '',
}),
actions: {
/**
* goToCharts switches to the Charts tab with `scope` selected —
* "total" | "user:{id}" | "group:{id}" | "model:{id}" (history.ts's
* own scope string convention, unchanged here). If the Charts view is
* currently showing the Models ranking — which has no scope picker at
* all (ChartsView.vue's own template branch) — it is switched back to
* the Requests tab FIRST, so the newly-set scope is immediately
* visible rather than silently inert behind the ranking view.
*
* P12 review fix: both fields are applied through ONE
* history.setSelection() call rather than two separate setTab()/
* setScope() calls — each of THOSE independently fires its own
* refresh(), so switching off the Models tab AND changing scope in the
* same click used to fire two fetches back to back, the first always
* discarded (seriesReqId's own latest-request-wins guard) the instant
* the second landed.
*/
goToCharts(scope: string): void {
const history = useHistoryStore()
history.setSelection(history.tab === 'models' ? { tab: 'requests', scope } : { scope })
this.activeTab = 'charts'
},
/**
* goToModels switches to the Charts tab's Models ranking, pre-filtered
* to `prefix` (ProvidersView.vue's per-provider header link passes
* `${provider.name}/`) — history.ts's own modelFilter/setModelFilter
* (WP-B2, F6 charts+providers side).
*
* P12 review fix: `tab` and `modelFilter` are applied through ONE
* history.setSelection() call — setSelection's own field-ordering
* (tab's own modelFilter reset happens BEFORE this call's `modelFilter`
* field is applied, see its doc comment) preserves the exact ordering
* this used to need two separate calls for (setTab('models') resetting
* modelFilter, THEN setModelFilter(prefix) applying the real value),
* while firing exactly one ranking fetch instead of one per call.
*/
goToModels(prefix: string): void {
const history = useHistoryStore()
history.setSelection({ tab: 'models', modelFilter: prefix })
this.activeTab = 'charts'
},
},
})