feat(webui): redesign the admin dashboard around admin tasks

A sidebar replaces the flat tabs with seven pages, and one filter bar
sets the time range, comparison period and scope for all of them.

- Home: spend against budget, forecast, request rate, error rate,
  open breakers, top users and models, recent events and warnings.
- Spend: spend over time with comparison, budget burn-down, an
  attribution drill-down from group to user to model, pricing health
  and cost avoided by free models.
- Consumers: a directory with last seen and limit bars, an access
  matrix, and a detail page per user.
- Models: the catalog with billing prices, fleet latency and error
  rates, and provider health.
- Reliability: error, timeout, failover, 429 and 402 trends, events,
  and latency per provider.
- Config: the redacted config and a change helper that emits YAML or
  users.json snippets to paste into git.
- MCP & Agents: callers per target.

Page state lives in the URL hash, and old tab links map to the new
pages. The replaced views are removed. admin_assets_gen.go is
regenerated.

Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
2026-09-23 16:41:01 +01:00
parent e8abb91dc8
commit ee49467e64
127 changed files with 11226 additions and 3414 deletions
+146
View File
@@ -0,0 +1,146 @@
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 { adminFetch, AdminApiError } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import { useCatalogStore } from '@/stores/catalog'
import type { AdminCatalogResponse } from '@/types/api'
const mockedAdminFetch = vi.mocked(adminFetch)
const catalogFixture: AdminCatalogResponse = {
providers: [
{
name: 'openai',
type: 'openai',
lastRefresh: '2026-09-23T00:00:00Z',
openUntil: '0001-01-01T00:00:00Z',
healthState: 'closed',
discoveryEnabled: true,
models: [
{ id: 'openai/gpt-5', model: 'gpt-5', priceSource: 'builtin', inputPerMTokUsd: 1.25, outputPerMTokUsd: 10 },
{ id: 'openai/gpt-5-mini:free', model: 'gpt-5-mini:free', priceSource: 'unpriced', displayFree: true },
],
},
],
aliases: [],
}
describe('useCatalogStore.fetch', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockedAdminFetch.mockReset()
useAuthStore().submit('test-admin-key')
})
it('is a no-op while unauthenticated', async () => {
setActivePinia(createPinia())
const catalog = useCatalogStore()
await catalog.fetch()
expect(mockedAdminFetch).not.toHaveBeenCalled()
expect(catalog.data).toBeNull()
})
it('populates data and fetchedAt on success, clearing any prior error', async () => {
mockedAdminFetch.mockResolvedValue(catalogFixture)
const catalog = useCatalogStore()
await catalog.fetch()
expect(catalog.data).toEqual(catalogFixture)
expect(catalog.fetchedAt).not.toBeNull()
expect(catalog.error).toBe('')
expect(catalog.loading).toBe(false)
})
it('indexes modelsById by canonical id across every provider', async () => {
mockedAdminFetch.mockResolvedValue(catalogFixture)
const catalog = useCatalogStore()
await catalog.fetch()
expect(catalog.modelsById.get('openai/gpt-5')?.priceSource).toBe('builtin')
expect(catalog.modelsById.get('openai/gpt-5-mini:free')?.displayFree).toBe(true)
expect(catalog.modelsById.size).toBe(2)
})
it('records a plain error message on a non-auth failure', async () => {
mockedAdminFetch.mockRejectedValue(new Error('boom'))
const catalog = useCatalogStore()
await catalog.fetch()
expect(catalog.error).toBe('boom')
expect(catalog.data).toBeNull()
})
it('does not surface an error for a 401/403 (the auth store owns that case)', async () => {
mockedAdminFetch.mockRejectedValue(new AdminApiError('unauthorized', 401))
const catalog = useCatalogStore()
await catalog.fetch()
expect(catalog.error).toBe('')
})
it('latest-request-wins: an older in-flight fetch never overwrites a newer one', async () => {
let resolveFirst!: (v: AdminCatalogResponse) => void
mockedAdminFetch.mockImplementationOnce(
() => new Promise<AdminCatalogResponse>((resolve) => (resolveFirst = resolve)),
)
const catalog = useCatalogStore()
const first = catalog.fetch()
mockedAdminFetch.mockResolvedValueOnce(catalogFixture)
await catalog.fetch()
expect(catalog.data).toEqual(catalogFixture)
resolveFirst({ providers: [], aliases: [] })
await first
// The first (older) fetch's empty result must not clobber the second's.
expect(catalog.data).toEqual(catalogFixture)
})
})
describe('useCatalogStore.ensureLoaded', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockedAdminFetch.mockReset()
mockedAdminFetch.mockResolvedValue(catalogFixture)
useAuthStore().submit('test-admin-key')
})
it('fetches when never loaded', async () => {
const catalog = useCatalogStore()
await catalog.ensureLoaded()
expect(mockedAdminFetch).toHaveBeenCalledTimes(1)
})
it('does not refetch a fresh catalog', async () => {
const catalog = useCatalogStore()
await catalog.ensureLoaded()
await catalog.ensureLoaded()
expect(mockedAdminFetch).toHaveBeenCalledTimes(1)
})
it('refetches once stale', async () => {
vi.useFakeTimers()
try {
const catalog = useCatalogStore()
await catalog.ensureLoaded()
vi.advanceTimersByTime(5 * 60 * 1000 + 1)
await catalog.ensureLoaded()
expect(mockedAdminFetch).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
it('force bypasses the staleness check', async () => {
const catalog = useCatalogStore()
await catalog.ensureLoaded()
await catalog.ensureLoaded(true)
expect(mockedAdminFetch).toHaveBeenCalledTimes(2)
})
})
+98
View File
@@ -0,0 +1,98 @@
import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import type { AdminCatalogModel, AdminCatalogResponse } from '@/types/api'
/**
* How long a fetched catalog stays fresh before ensureLoaded() will fetch
* again (redesign-plan.md section 3.3: "catalog (/catalog on demand,
* 5-minute stale)"). GET /admin/api/catalog reads the registry snapshot —
* no live counter round trip (admin_catalog.go's own doc comment) — so it
* changes only on a discovery refresh or a config reload, far slower than
* the 5s overview poll; every page that needs it (Spend's pricing health
* and cost-avoided reference picker, WP-F's Models catalog table) can
* treat a 5-minute-old copy as current rather than re-fetching on every
* mount.
*/
const STALE_MS = 5 * 60 * 1000
/**
* useCatalogStore fetches GET /admin/api/catalog on demand (never part of
* the 5s dashboard poll, Q12/DECISIONS) and indexes it by canonical
* "provider/model" id (admin_catalog.go's providerModelScopeID — the SAME
* id AdminUsageModelEntry.id and AdminSeriesResponse's `model:{canonical}`
* scope already use, so every consumer joins against it with a plain map
* lookup, no id reformatting).
*
* Lands FIRST in WP-D (redesign-plan.md section 4): WP-F's Models page
* (ModelCatalogTable.vue, ProviderHealthPanel.vue) depends on this store
* existing before it can build against it.
*/
export const useCatalogStore = defineStore('catalog', {
state: () => ({
data: null as AdminCatalogResponse | null,
fetchedAt: null as Date | null,
loading: false,
error: '',
/** reqId guards against an out-of-order response overwriting a newer one — the same latest-request-wins convention every other store's own fetch action follows (stores/dashboard.ts, stores/spend.ts). */
reqId: 0,
}),
getters: {
/** stale is true before the first successful fetch, or once STALE_MS has elapsed since it — ensureLoaded's own refetch condition, also readable by a caller that wants to show "catalog data may be out of date" copy. */
stale(state): boolean {
if (!state.fetchedAt) return true
return Date.now() - state.fetchedAt.getTime() > STALE_MS
},
/**
* modelsById indexes every provider's models by canonical id — the one
* join key every consumer (PricingHealthTable, CostAvoidedCard's
* reference-model picker, WP-F's ModelCatalogTable) needs, computed
* once per fetch (a Pinia getter is cached per state reference) rather
* than every consumer re-scanning `data.providers` itself.
*/
modelsById(state): Map<string, AdminCatalogModel> {
const map = new Map<string, AdminCatalogModel>()
for (const provider of state.data?.providers ?? []) {
for (const model of provider.models) map.set(model.id, model)
}
return map
},
},
actions: {
/**
* ensureLoaded fetches only when the catalog has never loaded, or has
* gone stale (STALE_MS) — every page that reads catalog data calls
* this from its own onMounted rather than unconditionally fetching, so
* navigating between Spend/Models repeatedly does not re-pay this read
* every time. `force` bypasses the staleness check (a reader-initiated
* "refresh catalog" action, if one is ever added).
*/
async ensureLoaded(force = false): Promise<void> {
if (!force && this.data && !this.stale) return
await this.fetch()
},
async fetch(): Promise<void> {
const auth = useAuthStore()
if (!auth.isAuthenticated) return
const requestId = ++this.reqId
this.loading = true
try {
const res = await adminFetch<AdminCatalogResponse>('/admin/api/catalog')
// Stale: a newer fetch() call already superseded this one.
if (requestId !== this.reqId) return
this.data = res
this.fetchedAt = new Date()
this.error = ''
} catch (err) {
if (requestId !== this.reqId) return
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) {
return
}
this.error = err instanceof Error ? err.message : String(err)
} finally {
if (requestId === this.reqId) this.loading = false
}
},
},
})
+44
View File
@@ -0,0 +1,44 @@
import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import type { AdminConfigResponse } from '@/types/api'
/**
* useConfigStore backs the Config page (redesign-plan.md section 3.4): the
* redacted config tree plus config warnings, from GET /admin/api/config —
* fetched on demand (ConfigPage.vue's own onMounted), not polled: the
* plugin's own config is immutable for a running replica's lifetime (a
* config edit needs a reload/restart), so there is nothing to poll for.
* ConfigPage.vue still exposes a manual refresh (in case the operator just
* redeployed and wants the freshly-loaded config without a full page
* reload).
*/
export const useConfigStore = defineStore('config', {
state: () => ({
data: null as AdminConfigResponse | null,
loading: false,
error: '',
}),
actions: {
async fetchConfig(): Promise<void> {
const auth = useAuthStore()
if (!auth.isAuthenticated) return
this.loading = true
try {
this.data = await adminFetch<AdminConfigResponse>('/admin/api/config')
this.error = ''
} catch (err) {
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) return
this.error = err instanceof Error ? err.message : String(err)
} finally {
this.loading = false
}
},
/** ensureConfig fetches only when there is no data yet — ConfigPage.vue's own onMounted call. */
async ensureConfig(): Promise<void> {
if (this.loading || this.data) return
await this.fetchConfig()
},
},
})
+188
View File
@@ -0,0 +1,188 @@
// consumers.spec.ts mirrors dashboard.spec.ts's own mocking convention
// (that file's own doc comment): adminFetch (lib/api.ts) is mocked
// entirely, no real network or sessionStorage access.
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 { useConsumersStore } from '@/stores/consumers'
import type { AdminConsumersResponse, AdminSeriesResponse, AdminTotalsResponse } from '@/types/api'
const mockedAdminFetch = vi.mocked(adminFetch)
const emptyConsumers: AdminConsumersResponse = { users: [], groups: [] }
const emptySeries: AdminSeriesResponse = { metric: 'req', window: 'day', span: 7, offset: 0, buckets: [], series: [], unknown: [] }
const emptyTotals: AdminTotalsResponse = { kind: 'usermodel', window: 'day', span: 7, offset: 0, metrics: ['req'], rows: [] }
describe('useConsumersStore.fetchConsumers / ensureConsumers', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockedAdminFetch.mockReset()
useAuthStore().submit('test-admin-key')
})
it('fetches and stores the /admin/api/consumers response', async () => {
mockedAdminFetch.mockResolvedValue(emptyConsumers)
const store = useConsumersStore()
await store.fetchConsumers()
expect(mockedAdminFetch).toHaveBeenCalledWith('/admin/api/consumers')
// toEqual (not toBe): Pinia wraps state in a reactive Proxy, so
// store.data is never the same object reference as the fixture
// adminFetch resolved with, only structurally identical.
expect(store.data).toEqual(emptyConsumers)
expect(store.error).toBe('')
expect(store.fetchedAt).not.toBeNull()
})
it('records the error message on failure', async () => {
mockedAdminFetch.mockRejectedValue(new Error('boom'))
const store = useConsumersStore()
await store.fetchConsumers()
expect(store.error).toBe('boom')
})
it('does not surface an error on a 401 (the auth store owns that case)', async () => {
mockedAdminFetch.mockRejectedValue(new AdminApiError('unauthorized', 401))
const store = useConsumersStore()
await store.fetchConsumers()
expect(store.error).toBe('')
})
it('ensureConsumers fetches when there is no data yet', async () => {
mockedAdminFetch.mockResolvedValue(emptyConsumers)
const store = useConsumersStore()
await store.ensureConsumers()
expect(mockedAdminFetch).toHaveBeenCalledTimes(1)
})
it('ensureConsumers skips a second call while the first fetch is still fresh', async () => {
mockedAdminFetch.mockResolvedValue(emptyConsumers)
const store = useConsumersStore()
await store.ensureConsumers()
await store.ensureConsumers()
expect(mockedAdminFetch).toHaveBeenCalledTimes(1)
})
it('ensureConsumers re-fetches once the staleness window has passed', async () => {
vi.useFakeTimers()
try {
mockedAdminFetch.mockResolvedValue(emptyConsumers)
const store = useConsumersStore()
await store.ensureConsumers()
vi.advanceTimersByTime(60_001)
await store.ensureConsumers()
expect(mockedAdminFetch).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
})
describe('useConsumersStore.fetchUserDetail', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockedAdminFetch.mockReset()
useAuthStore().submit('test-admin-key')
})
it('fetches req and cost series for the given user, keyed by user id', async () => {
mockedAdminFetch.mockResolvedValue(emptySeries)
const store = useConsumersStore()
await store.fetchUserDetail('alice', 'day', 7, false)
const paths = mockedAdminFetch.mock.calls.map((c) => c[0] as string)
expect(paths.some((p) => p.includes('scope=user%3Aalice') && p.includes('metric=req'))).toBe(true)
expect(paths.some((p) => p.includes('scope=user%3Aalice') && p.includes('metric=cost'))).toBe(true)
expect(store.detail.alice.loading).toBe(false)
expect(store.detail.alice.error).toBe('')
expect(store.detail.alice.reqSeries).toEqual(emptySeries)
expect(store.detail.alice.costSeries).toEqual(emptySeries)
})
it('does not fetch usermodel totals when userModelStatsEnabled is false', async () => {
mockedAdminFetch.mockResolvedValue(emptySeries)
const store = useConsumersStore()
await store.fetchUserDetail('alice', 'day', 7, false)
const paths = mockedAdminFetch.mock.calls.map((c) => c[0] as string)
expect(paths.some((p) => p.includes('kind=usermodel'))).toBe(false)
expect(store.detail.alice.modelTotals).toBeNull()
})
it('fetches usermodel totals when userModelStatsEnabled is true', async () => {
mockedAdminFetch.mockImplementation((async (path: string) => {
if (path.includes('kind=usermodel')) return emptyTotals
return emptySeries
}) as typeof adminFetch)
const store = useConsumersStore()
await store.fetchUserDetail('alice', 'day', 7, true)
expect(store.detail.alice.modelTotals).toEqual(emptyTotals)
})
// P1 item 2: usermodel only accepts day/month (no hour bucket at all) —
// a bare `window=hour` 400s server-side BEFORE the 404-disabled-feature
// gate even runs. The previous spec matched adminFetch by path PREFIX
// only and never passed an hour-resolution window, so it never caught
// this. This test asserts the exact usermodel URL when the caller
// passes an hour window (the 24h/48h global ranges).
it('clamps an hour-resolution window to day/month (span 7) for the usermodel totals call, never window=hour', async () => {
let usermodelUrl = ''
mockedAdminFetch.mockImplementation((async (path: string) => {
if (path.includes('kind=usermodel')) {
usermodelUrl = path
return emptyTotals
}
return emptySeries
}) as typeof adminFetch)
const store = useConsumersStore()
await store.fetchUserDetail('alice', 'hour', 48, true)
expect(usermodelUrl).toBe('/admin/api/usage/totals?kind=usermodel&window=day&span=7&metrics=req%2Ccost&user=alice')
})
// P1 item 2: the req/cost series calls are UNCLAMPED — they legitimately
// accept any window (h/d/m), unlike usermodel. This pins that the fix
// above did not over-broadly clamp every call this action makes.
it('does NOT clamp the req/cost series calls — they pass the caller\'s window straight through', async () => {
const paths: string[] = []
mockedAdminFetch.mockImplementation((async (path: string) => {
paths.push(path)
if (path.includes('kind=usermodel')) return emptyTotals
return emptySeries
}) as typeof adminFetch)
const store = useConsumersStore()
await store.fetchUserDetail('alice', 'hour', 48, false)
const seriesUrls = paths.filter((p) => p.includes('/usage/series'))
expect(seriesUrls).toHaveLength(2)
for (const url of seriesUrls) expect(url).toContain('window=hour&span=48')
})
it('records a per-user error without clobbering a different user\'s already-loaded detail', async () => {
mockedAdminFetch.mockResolvedValue(emptySeries)
const store = useConsumersStore()
await store.fetchUserDetail('alice', 'day', 7, false)
mockedAdminFetch.mockRejectedValue(new Error('boom'))
await store.fetchUserDetail('bob', 'day', 7, false)
expect(store.detail.alice.error).toBe('')
expect(store.detail.alice.reqSeries).toEqual(emptySeries)
expect(store.detail.bob.error).toBe('boom')
})
})
+159
View File
@@ -0,0 +1,159 @@
import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { dayOrMonthWindow, seriesUrl, totalsUrl } from '@/lib/range'
import { useAuthStore } from '@/stores/auth'
import type { AdminConsumersResponse, AdminSeriesResponse, AdminTotalsResponse, HistoryWindow } from '@/types/api'
/** REFRESH_STALE_MS is how long a previously-fetched /admin/api/consumers response is trusted before ConsumersPage.vue's mount triggers a fresh fetch — this endpoint reflects the operator's own config (users/groups/access lists), which changes on a config edit, not every 5s like the polled dashboard store. A 60s staleness window (matches Q12's "new endpoints on demand or 60s" DECISION) means switching between pages within the Consumers tab never re-fetches on every mount, while a long-open session still notices a config reload eventually. */
const REFRESH_STALE_MS = 60_000
/** UserDetailState is one user's on-demand detail — UserDetail.vue's own data (timeline series, per-model totals), fetched only when that user's row is actually opened, keyed by user id so switching between two already-opened users never re-fetches. */
export interface UserDetailState {
loading: boolean
error: string
reqSeries: AdminSeriesResponse | null
costSeries: AdminSeriesResponse | null
/** null while userModelStats is off (no fetch attempted), the 404 case (see modelTotalsUnavailable), or before the first fetch resolves. */
modelTotals: AdminTotalsResponse | null
/** True once the usermodel totals call itself came back 404 (admin.go: disabled feature gate) — checked INDEPENDENTLY of the caller's own userModelStatsEnabled flag, since that flag reads the polled overview, which may not have loaded yet on a fresh deep link/reload, or may be stale relative to a live config change (see UserDetail.vue's own featuresUserModelStats watch). */
modelTotalsUnavailable: boolean
fetchedAt: number | null
/** reqId guards against an out-of-order response overwriting a newer one (latest-request-wins) — two ranges in flight for the same user can otherwise land out of order. */
reqId: number
}
function emptyDetail(): UserDetailState {
return {
loading: false,
error: '',
reqSeries: null,
costSeries: null,
modelTotals: null,
modelTotalsUnavailable: false,
fetchedAt: null,
reqId: 0,
}
}
/** settledError reads one Error message off a rejected PromiseSettledResult, or '' for a fulfilled one — fetchUserDetail's own small helper for building a combined error message out of whichever of its three independent requests failed. */
function settledError(result: PromiseSettledResult<unknown>): string {
if (result.status !== 'rejected') return ''
return result.reason instanceof Error ? result.reason.message : String(result.reason)
}
/**
* useConsumersStore backs the Consumers page (redesign-plan.md section
* 3.3/3.4): the user/group directory + access lists from GET /admin/api/
* consumers (fetched on demand, not on the 5s dashboard poll — this data
* is config-shaped, not live traffic), plus each opened user's own detail
* (timeline series, usermodel totals) fetched lazily per user id.
*/
export const useConsumersStore = defineStore('consumers', {
state: () => ({
data: null as AdminConsumersResponse | null,
loading: false,
error: '',
fetchedAt: null as number | null,
detail: {} as Record<string, UserDetailState>,
}),
actions: {
/** fetchConsumers fetches GET /admin/api/consumers unconditionally — callers that want the staleness guard use ensureConsumers instead. */
async fetchConsumers(): Promise<void> {
const auth = useAuthStore()
if (!auth.isAuthenticated) return
this.loading = true
try {
this.data = await adminFetch<AdminConsumersResponse>('/admin/api/consumers')
this.error = ''
this.fetchedAt = Date.now()
} catch (err) {
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) return
this.error = err instanceof Error ? err.message : String(err)
} finally {
this.loading = false
}
},
/** ensureConsumers fetches only when there is no data yet, or the last fetch is older than REFRESH_STALE_MS — ConsumersPage.vue's own onMounted call, so navigating back to the page within a minute does not re-fetch. */
async ensureConsumers(): Promise<void> {
if (this.loading) return
if (this.data && this.fetchedAt !== null && Date.now() - this.fetchedAt < REFRESH_STALE_MS) return
await this.fetchConsumers()
},
/**
* fetchUserDetail loads one user's timeline (req + cost series) and,
* only when `userModelStatsEnabled` is true (AdminFeaturesView.
* userModelStats — GET /admin/api/usage/totals?kind=usermodel 404s
* otherwise, admin.go), their per-model usermodel totals. Always
* re-fetches when called (UserDetail.vue calls this on mount and on
* every global range/span/features change) — the caller decides
* staleness, this action does not guess.
*
* The three requests run via Promise.allSettled, not Promise.all: a
* 400/404/503 on the usermodel call (or a transient failure on either
* series call) must never wipe out data from the OTHER two calls that
* did succeed — each field below keeps its previous value when its own
* request failed, rather than the whole detail view going blank over
* one failing endpoint (P1). The usermodel window/span is clamped to
* day/month (lib/range.ts's dayOrMonthWindow, shared with stores/
* spend.ts's drilldown and lib/target-columns.ts's targetcaller calls)
* — that endpoint has no hour bucket at all, so an hour-resolution
* global range (24h/48h) would otherwise 400 before the server's own
* 404-when-disabled feature gate even runs (stats_read.go).
*/
async fetchUserDetail(userId: string, window: HistoryWindow, span: number, userModelStatsEnabled: boolean): Promise<void> {
const auth = useAuthStore()
if (!auth.isAuthenticated) return
const existing = this.detail[userId] ?? emptyDetail()
const requestId = existing.reqId + 1
this.detail[userId] = { ...existing, loading: true, reqId: requestId }
const clamped = dayOrMonthWindow(window, span)
const [reqResult, costResult, modelResult] = await Promise.allSettled([
adminFetch<AdminSeriesResponse>(seriesUrl({ scope: [`user:${userId}`], metric: 'req', window, span })),
adminFetch<AdminSeriesResponse>(seriesUrl({ scope: [`user:${userId}`], metric: 'cost', window, span })),
userModelStatsEnabled
? adminFetch<AdminTotalsResponse>(
totalsUrl({ kind: 'usermodel', user: userId, window: clamped.window, span: clamped.span, metrics: ['req', 'cost'] }),
)
: Promise.resolve(null as AdminTotalsResponse | null),
])
// A newer fetchUserDetail call for this same user already
// superseded this one (latest-request-wins) — drop this result.
if (this.detail[userId]?.reqId !== requestId) return
const authRejected = [reqResult, costResult, modelResult].some(
(r) => r.status === 'rejected' && r.reason instanceof AdminApiError && (r.reason.status === 401 || r.reason.status === 403),
)
if (authRejected) return
const modelTotalsUnavailable =
modelResult.status === 'rejected' && modelResult.reason instanceof AdminApiError && modelResult.reason.status === 404
const current = this.detail[userId] ?? emptyDetail()
// A 404 on the usermodel call is a known, EXPECTED state (the
// feature is off server-side) — surfaced via modelTotalsUnavailable
// instead, never folded into the generic error message. Deduped
// (Set) — the req and cost calls commonly fail with the IDENTICAL
// message (e.g. the same network error, or the same 503 "usage
// store unavailable"), and repeating it twice reads as noise, not
// two distinct problems.
const errorMessages = new Set(
[settledError(reqResult), settledError(costResult), modelTotalsUnavailable ? '' : settledError(modelResult)].filter(Boolean),
)
const combinedError = Array.from(errorMessages).join('; ')
this.detail[userId] = {
loading: false,
error: combinedError,
reqSeries: reqResult.status === 'fulfilled' ? reqResult.value : current.reqSeries,
costSeries: costResult.status === 'fulfilled' ? costResult.value : current.costSeries,
modelTotals: modelResult.status === 'fulfilled' ? modelResult.value : modelTotalsUnavailable ? null : current.modelTotals,
modelTotalsUnavailable,
fetchedAt: Date.now(),
reqId: requestId,
}
},
},
})
+2
View File
@@ -38,6 +38,8 @@ const emptyOverview: AdminOverviewResponse = {
redis: { configured: false, lastErrAt: '0001-01-01T00:00:00Z' },
cache: { enabled: false },
retry: { enabled: false },
features: { userModelStats: false, latencyStats: false, cacheStats: false, lastSeen: false, failover: false },
pricing: { override: 0, builtin: 0, litellm: 0, free: 0, unpriced: 0 },
}
const emptyUsage: AdminUsageResponse = {
users: [],
+17 -14
View File
@@ -10,13 +10,14 @@ const POLL_MS = 5000
/**
* useDashboardStore polls GET /admin/api/overview, GET /admin/api/usage,
* and GET /admin/api/targets together every POLL_MS, feeding the
* Providers, Usage, and MCP & Agents views (the store's own `overview`
* field keeps the backend's name — see ProvidersView.vue's doc comment
* for the UI-label/API-name split). A single store (not three) because
* every route is always fetched together — the old vanilla-JS page's own
* Promise.all([overview, usage]) pattern, extended to targets (Feature B,
* v0.21) rather than given its own separate poll timer.
* and GET /admin/api/targets together every POLL_MS — the fleet-wide,
* always-current data every page in the redesigned shell reads from
* (Home's KPI tiles, ConsumerDirectory/UserDetail's own usage rows,
* ProviderHealthPanel's provider list, TargetsView's target list). A
* single store (not three) because every route is always fetched
* together — the old vanilla-JS page's own Promise.all([overview, usage])
* pattern, extended to targets (Feature B, v0.21) rather than given its
* own separate poll timer.
*/
export const useDashboardStore = defineStore('dashboard', {
state: () => ({
@@ -110,12 +111,14 @@ export const useDashboardStore = defineStore('dashboard', {
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).
// P11 review fix: a stopPolling action mirroring 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 events.ts, whose store
// IS torn down when EventsView unmounts — the same page-scoped
// pattern the pre-redesign Usage/Charts tabs' own now-deleted stores
// used to follow too). Dead application-facing API surface, removed;
// `poller` itself stays (startPolling's own idempotency guard still
// needs it).
},
})
+25
View File
@@ -195,3 +195,28 @@ describe('useEventsStore.startPolling / stopPolling', () => {
}
})
})
// initFromParams was removed (P2 item 10): ReliabilityPage.vue now keeps
// kindFilter/userFilter synced with nav.params via its own reactive
// `watch(() => [nav.params.kind, nav.params.user], ...)`, rather than a
// one-shot mount-time seed — that watch's own doc comment covers why.
describe('useEventsStore.setKindFilter / setUserFilter', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('setKindFilter sets the exact-match kind filter, including back to the "" (every kind) sentinel', () => {
const events = useEventsStore()
events.setKindFilter('timeout')
expect(events.kindFilter).toBe('timeout')
events.setKindFilter('')
expect(events.kindFilter).toBe('')
})
it('setUserFilter sets the raw, un-normalized user/group search text', () => {
const events = useEventsStore()
events.setUserFilter('alice')
expect(events.userFilter).toBe('alice')
})
})
+24 -19
View File
@@ -12,11 +12,13 @@ const POLL_MS = 5000
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.
* useEventsStore polls GET /admin/api/events?limit=200, feeding
* EventsView.vue — the Reliability page's own embedded events section
* (redesign-plan.md section 3.4), and Home's own latest-8-events list.
* Unlike useDashboardStore, this store is polled only while a page that
* reads it is mounted (EventsView.vue's own onMounted/onUnmounted) rather
* than for the app's whole lifetime — an operator who never opens
* Reliability or Home pays no extra request for it.
*/
export const useEventsStore = defineStore('events', {
state: () => ({
@@ -32,20 +34,23 @@ export const useEventsStore = defineStore('events', {
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 — the Events section's own kind Select and
* user SearchInput (redesign-plan.md section 3.4, EventsView.vue
* reused as a Reliability page section). Lifted up here rather than
* kept as local refs in EventsView.vue so ReliabilityPage.vue can
* keep them synced with nav.params (`kind`/`user`, section 3.1's
* Reliability page params) via its own reactive `watch(() =>
* [nav.params.kind, nav.params.user], ...)` — not just seeded once on
* mount (P2 item 10: a one-shot mount-time seed left a filter set on
* an earlier visit stuck in place across a remount with the param
* cleared, and never picked up a hash change, e.g. browser back/
* forward, while already mounted). 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).
*/
kindFilter: '',
userFilter: '',
+71
View File
@@ -0,0 +1,71 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useFiltersStore } from '@/stores/filters'
beforeEach(() => {
setActivePinia(createPinia())
})
describe('useFiltersStore: defaults', () => {
it('defaults to range=7d, cmp=none, scope=all', () => {
const filters = useFiltersStore()
expect(filters.range).toBe('7d')
expect(filters.cmp).toBe('none')
expect(filters.scope).toBe('all')
})
it('derives window/span from the default range', () => {
const filters = useFiltersStore()
expect(filters.window).toBe('day')
expect(filters.span).toBe(7)
})
})
describe('useFiltersStore.setFilters', () => {
it('applies only the provided fields, leaving the rest untouched', () => {
const filters = useFiltersStore()
filters.setFilters({ range: '30d' })
expect(filters.range).toBe('30d')
expect(filters.cmp).toBe('none')
expect(filters.scope).toBe('all')
})
it('updates window/span when range changes', () => {
const filters = useFiltersStore()
filters.setFilters({ range: '12mo' })
expect(filters.window).toBe('month')
expect(filters.span).toBe(12)
})
it('applies range/cmp/scope together in one call', () => {
const filters = useFiltersStore()
filters.setFilters({ range: '14d', cmp: 'prev', scope: 'group:eng' })
expect(filters.range).toBe('14d')
expect(filters.cmp).toBe('prev')
expect(filters.scope).toBe('group:eng')
})
})
describe('useFiltersStore.cmpSpec', () => {
it('requested is false when cmp is none, even for a comparable range', () => {
const filters = useFiltersStore()
expect(filters.cmpSpec.requested).toBe(false)
})
it('requested is true when cmp is prev and the range supports it', () => {
const filters = useFiltersStore()
filters.setFilters({ cmp: 'prev' })
expect(filters.cmpSpec.available).toBe(true)
expect(filters.cmpSpec.requested).toBe(true)
expect(filters.cmpSpec.offset).toBe(7)
})
it('requested stays false when cmp is prev but the range does not support comparison', () => {
const filters = useFiltersStore()
filters.setFilters({ cmp: 'prev', range: '48h' })
expect(filters.cmpSpec.available).toBe(false)
expect(filters.cmpSpec.requested).toBe(false)
expect(filters.cmpSpec.reason).toBe('retention limit')
})
})
+54
View File
@@ -0,0 +1,54 @@
import { defineStore } from 'pinia'
import { comparison, DEFAULT_CMP, DEFAULT_RANGE, RANGE_PRESETS } from '@/lib/range'
import type { CmpMode, ComparisonResult, RangeKey } from '@/lib/range'
import type { HistoryWindow } from '@/types/api'
/** DEFAULT_SCOPE is the global filter bar's "no scope narrowing" value (redesign-plan.md section 3.1: `scope=all|group:x|user:x|provider:x`, default `all`). */
export const DEFAULT_SCOPE = 'all'
/**
* useFiltersStore holds the three GLOBAL filters every page reads from
* the address bar (redesign-plan.md section 3.1/3.3): the time range,
* whether to compare against the previous equal-length period, and an
* optional scope narrowing (a single group/user/provider, or 'all').
* Kept as one small store, not folded into stores/nav.ts, because these
* three are page-INDEPENDENT — every page's own store (spend, consumers,
* models, reliability) reads them the same way, unlike nav.ts's `params`,
* which are page-specific and opaque to this store.
*/
export const useFiltersStore = defineStore('filters', {
state: () => ({
range: DEFAULT_RANGE as RangeKey,
cmp: DEFAULT_CMP as CmpMode,
scope: DEFAULT_SCOPE,
}),
getters: {
/** window is the current range's bucket resolution (lib/range.ts's RANGE_PRESETS) — every page-store fetch reads this instead of re-deriving it from `range` itself. */
window: (state): HistoryWindow => RANGE_PRESETS[state.range].window,
/** span is the current range's bucket count. */
span: (state): number => RANGE_PRESETS[state.range].span,
/**
* cmpSpec folds `cmp` and lib/range.ts's comparison(range) into one
* read: `requested` is true only when the reader asked for a
* comparison (`cmp === 'prev'`) AND the current range actually
* supports one (comparison().available) — a page-store's fetch
* checks `requested` alone rather than re-deriving that AND itself
* every time, and a stale `cmp: 'prev'` left over from a range that
* DID support it survives a switch to one that does not without
* silently firing an invalid request.
*/
cmpSpec: (state): ComparisonResult & { requested: boolean } => {
const result = comparison(state.range)
return { ...result, requested: state.cmp === 'prev' && result.available }
},
},
actions: {
/** setFilters applies any subset of range/cmp/scope in one call — omitted fields keep their current value, the same "apply a partial update, one atomic write" convention the pre-redesign stores/history.ts's own setSelection (deleted; this store replaces it) used, and stores/spend.ts's own setSelection still follows today. */
setFilters(next: { range?: RangeKey; cmp?: CmpMode; scope?: string }): void {
if (next.range !== undefined) this.range = next.range
if (next.cmp !== undefined) this.cmp = next.cmp
if (next.scope !== undefined) this.scope = next.scope
},
},
})
-598
View File
@@ -1,598 +0,0 @@
// history.spec.ts covers useHistoryStore's model-ranking behaviour: the
// Models tab reads a different endpoint from the other three tabs, and the
// scope picker's model list is refreshed alongside whichever of the two is
// active. Both must degrade independently — a failing picker list must
// never blank a rendered chart or surface an error over it, which is the
// asymmetry these tests pin.
//
// Same harness as dashboard.spec.ts: a Pinia store is plain reactive state
// in the 'node' environment, with adminFetch (lib/api.ts) 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 { adminFetch } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import { metricsForTab, useHistoryStore } from '@/stores/history'
import type { AdminUsageModelsResponse, UsageHistoryResponse } from '@/types/api'
const mockedAdminFetch = vi.mocked(adminFetch)
function modelsResponse(models: AdminUsageModelsResponse['models']): AdminUsageModelsResponse {
return { metric: 'cost', window: 'hour', span: 24, models }
}
function seriesResponse(): UsageHistoryResponse {
return { scope: 'total', metric: 'req', window: 'hour', points: [{ bucket: '2026082012', value: 5 }] }
}
/** Routes a mocked adminFetch call by URL, so tests assert on behaviour rather than call order. */
function routeFetch(handlers: { models?: () => unknown; series?: () => unknown }): void {
mockedAdminFetch.mockImplementation((path: string) => {
if (path.startsWith('/admin/api/usage/models')) {
return Promise.resolve(handlers.models ? handlers.models() : modelsResponse([]))
}
return Promise.resolve(handlers.series ? handlers.series() : seriesResponse())
})
}
/** deferred exposes a promise's resolve function, so a test controls exactly when a mocked fetch "arrives" — the out-of-order-responses tests below depend on resolving the NEWER request before the OLDER one. */
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
/** flush drains the microtask queue — a setTimeout(0) macrotask fires only after every already-queued microtask (every chained .then/await) has run, which a plain `await Promise.resolve()` is not guaranteed to cover in one hop through fetchSeries' own multi-step await chain. */
function flush(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0))
}
describe('useHistoryStore model ranking', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockedAdminFetch.mockReset()
useAuthStore().submit('test-admin-key')
})
it('asks the history endpoint for no metric on the Models tab', () => {
// The Models tab reads the ranking endpoint instead, so requesting a
// history metric for it would be a wasted round trip per refresh.
expect(metricsForTab('models')).toEqual([])
expect(metricsForTab('tokens')).toEqual(['tokin', 'tokout'])
})
it('fetches the ranking, not a series, when the Models tab is active', async () => {
routeFetch({ models: () => modelsResponse([{ id: 'uni/qwen3.8-flash-next', value: 4_100_000 }]) })
const history = useHistoryStore()
history.tab = 'models'
await history.refresh()
expect(history.modelRanking).toEqual([{ id: 'uni/qwen3.8-flash-next', value: 4_100_000 }])
const paths = mockedAdminFetch.mock.calls.map((c) => String(c[0]))
expect(paths.some((p) => p.startsWith('/admin/api/usage/history'))).toBe(false)
})
// free-models-plan.md: a cost-default ranking hides every free model
// (its cost total is always 0) — the store now defaults to ranking by
// request count instead.
it('defaults modelMetric to "req", not "cost"', () => {
expect(useHistoryStore().modelMetric).toBe('req')
})
// free-models-plan.md: the ranking fetch always requests the per-model
// detail breakdown (requests/tokensIn/tokensOut/costMicroUsd/free), not
// just the single ranked `value` — lib/model-table-columns.ts's table
// renders every figure regardless of which metric is currently ranking.
it('always sends detail=1 on the ranking fetch, regardless of metric or prefix', 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('detail=1')
})
// The scope picker's model list (fetchModelOptions) only ever needs ids
// — it must NOT pay for the detail breakdown the ranking fetch above
// does.
it('never sends detail=1 on the picker-list fetch', async () => {
routeFetch({})
const history = useHistoryStore()
await history.fetchModelOptions()
const optionsCall = mockedAdminFetch.mock.calls.map((c) => String(c[0])).find((p) => p.includes('/usage/models'))
expect(optionsCall).not.toContain('detail=1')
})
it('ranks by the selected metric, independently of the tab', async () => {
routeFetch({})
const history = useHistoryStore()
history.tab = 'models'
history.setModelMetric('tokin')
await history.refresh()
const rankingCall = mockedAdminFetch.mock.calls
.map((c) => String(c[0]))
.find((p) => p.startsWith('/admin/api/usage/models') && p.includes('metric=tokin'))
expect(rankingCall).toBeDefined()
})
// 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()
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 () => {
routeFetch({})
const history = useHistoryStore()
await history.fetchModelOptions()
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 () => {
// Asserted directly on fetchModelOptions rather than through refresh:
// inside refresh a succeeding series fetch clears `error` afterwards
// anyway, which would let this pass even if the swallow were removed.
routeFetch({
models: () => {
throw new Error('models endpoint down')
},
})
const history = useHistoryStore()
history.error = 'pre-existing chart error'
await history.fetchModelOptions()
expect(history.error).toBe('pre-existing chart error')
expect(history.modelOptions).toEqual([])
})
// 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()
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 () => {
mockedAdminFetch.mockImplementation((path: string) => {
if (path.includes('limit=20')) return Promise.reject(new Error('ranking unavailable'))
return Promise.resolve(modelsResponse([]))
})
const history = useHistoryStore()
history.tab = 'models'
await history.refresh()
expect(history.error).toBe('ranking unavailable')
})
})
// 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
// still current.
describe('useHistoryStore: latest-request-wins', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockedAdminFetch.mockReset()
useAuthStore().submit('test-admin-key')
})
it('drops a fetchSeries response that resolves late, after a newer scope already superseded it', async () => {
const alice = deferred<UsageHistoryResponse>()
const ops = deferred<UsageHistoryResponse>()
mockedAdminFetch.mockImplementation((path: string) => {
if (path.includes('scope=user%3Aalice')) return alice.promise
if (path.includes('scope=group%3Aops')) return ops.promise
return Promise.resolve(modelsResponse([])) // fetchModelOptions' own parallel call
})
const history = useHistoryStore()
history.setScope('user:alice') // slow — resolves LAST, below
history.setScope('group:ops') // fast — resolves FIRST, below; must win regardless
ops.resolve({ scope: 'group:ops', metric: 'req', window: 'hour', points: [{ bucket: '2026082012', value: 42 }] })
await flush()
alice.resolve({ scope: 'user:alice', metric: 'req', window: 'hour', points: [{ bucket: '2026082012', value: 999 }] })
await flush()
// The stale alice response must never land, however late it arrives.
expect(history.seriesByMetric.req).toEqual([{ bucket: '2026082012', value: 42 }])
expect(history.scope).toBe('group:ops')
})
it('never re-sets `loading` to true after the CURRENT request has already cleared it (a stale request resolving late)', async () => {
const alice = deferred<UsageHistoryResponse>()
const ops = deferred<UsageHistoryResponse>()
mockedAdminFetch.mockImplementation((path: string) => {
if (path.includes('scope=user%3Aalice')) return alice.promise
if (path.includes('scope=group%3Aops')) return ops.promise
return Promise.resolve(modelsResponse([]))
})
const history = useHistoryStore()
history.setScope('user:alice')
history.setScope('group:ops')
ops.resolve({ scope: 'group:ops', metric: 'req', window: 'hour', points: [] })
await flush()
expect(history.loading).toBe(false) // the current request has already settled
alice.resolve({ scope: 'user:alice', metric: 'req', window: 'hour', points: [] })
await flush()
expect(history.loading).toBe(false) // the stale request settling later must not flip it back on
})
it('clears seriesByMetric synchronously on a window change, before the new fetch resolves — no stale bucket renders under the new window\'s labels', () => {
routeFetch({ series: () => seriesResponse() })
const history = useHistoryStore()
history.seriesByMetric = { req: [{ bucket: '2026082012', value: 5 }] }
history.setWindow('day')
expect(history.seriesByMetric).toEqual({})
})
it('clears modelRanking synchronously on a model-metric change, before the new fetch resolves', () => {
routeFetch({})
const history = useHistoryStore()
history.tab = 'models'
history.modelRanking = [{ id: 'alpha/a-model-1', value: 3 }]
// 'cost', not 'req' — the store now DEFAULTS to 'req' (free-models-
// plan.md), so setModelMetric('req') here would be a no-op early
// return rather than the actual change this test means to exercise.
history.setModelMetric('cost')
expect(history.modelRanking).toEqual([])
})
it('does not clear seriesByMetric on an auto-refresh tick (same selection) — only a selection CHANGE clears it', async () => {
routeFetch({ series: () => seriesResponse() })
const history = useHistoryStore()
await history.refresh()
expect(history.seriesByMetric.req).toHaveLength(1)
await history.refresh() // same scope/window/tab — the 30s auto-refresh's own call shape
expect(history.seriesByMetric.req).toHaveLength(1)
})
it('marks the selection loaded after its first fetch and keeps it loaded across refreshes, clearing it only on a selection change', async () => {
routeFetch({ series: () => seriesResponse() })
const history = useHistoryStore()
expect(history.loaded).toBe(false)
await history.refresh()
expect(history.loaded).toBe(true)
await history.refresh()
expect(history.loaded).toBe(true)
history.setWindow('day')
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 {
const slow = deferred<UsageHistoryResponse>()
mockedAdminFetch.mockImplementation((path: string) =>
path.startsWith('/admin/api/usage/models') ? Promise.resolve(modelsResponse([])) : slow.promise,
)
const history = useHistoryStore()
void history.refresh()
const callsBefore = mockedAdminFetch.mock.calls.length
history.startAutoRefresh()
await vi.advanceTimersByTimeAsync(90_000)
expect(mockedAdminFetch.mock.calls.length).toBe(callsBefore)
slow.resolve(seriesResponse())
await vi.advanceTimersByTimeAsync(0)
expect(history.seriesByMetric.req).toHaveLength(1)
history.stopAutoRefresh()
} finally {
vi.useRealTimers()
}
})
})
// 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: 'req' }) // 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)
})
})
-483
View File
@@ -1,483 +0,0 @@
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,
AdminUsageModelsResponse,
HistoryMetric,
HistoryWindow,
UsageHistoryPoint,
UsageHistoryResponse,
} from '@/types/api'
/**
* The chart tabs the Charts view offers. The first three are metrics over
* time (see metricsForTab below); "models" is the odd one out — a ranking
* across models for a single point in time, with its own metric picked
* separately (modelMetric below), because "which model" is the dimension
* that tab varies rather than the metric.
*/
export type ChartTab = 'requests' | 'tokens' | 'cost' | 'models'
/**
* The tabs that render a time series — every ChartTab except the ranking.
* UsageChart is typed on this rather than on ChartTab so that adding
* another ranking-style tab is a compile error there, not a chart with a
* silently missing dataset spec.
*/
export type TimeSeriesTab = Exclude<ChartTab, 'models'>
/**
* The metrics the Models ranking can rank by. Deliberately a single
* metric, unlike the stacked "tokens" time-series tab: a bar's LENGTH in a
* ranking is the comparison being made, so splitting it across two stacked
* series would make two models with different in/out mixes visually
* incomparable.
*/
export type ModelMetric = 'req' | 'tokin' | 'tokout' | 'cost'
/** MODEL_METRIC_LABEL is the ranking metric picker's display text. */
export const MODEL_METRIC_LABEL: Record<ModelMetric, string> = {
cost: 'Cost',
req: 'Requests',
tokin: 'Tokens in',
tokout: 'Tokens out',
}
/**
* 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.
*/
export const MODEL_RANKING_LIMIT = 20
/**
* How many models the SCOPE PICKER offers. Higher than the ranking's own
* limit — the picker is a lookup, not a top-N — and capped at the server's
* maximum (admin.go: usageModelsMaxLimit).
*/
const MODEL_OPTIONS_LIMIT = 100
/**
* How often the current selection refetches while the Charts view is open.
* Slower than the dashboard's 5s poll (dashboard.ts) — history buckets
* change on the timescale of the window itself, not every request.
*/
const REFRESH_MS = 30_000
/**
* WINDOW_SPAN is the bucket count requested per window: 24h at hour
* resolution, 30d at day resolution, 12mo at month resolution. Each stays
* within historyMaxSpan's own per-window ceiling (admin.go: 48/35/13), so
* every request is valid without the UI needing to know that ceiling.
*/
export const WINDOW_SPAN: Record<HistoryWindow, number> = {
hour: 24,
day: 30,
month: 12,
}
/**
* 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',
}
/**
* 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
* per call (tokin XOR tokout), and the Charts view stacks both into one bar
* chart (operator brief: "STACKED tokens-in vs tokens-out").
*/
export function metricsForTab(tab: ChartTab): HistoryMetric[] {
switch (tab) {
case 'requests':
return ['req']
case 'tokens':
return ['tokin', 'tokout']
case 'cost':
return ['cost']
case 'models':
// The Models tab reads the ranking endpoint, not the history one —
// it needs no time series, so it asks for no metric here.
return []
}
}
/**
* useHistoryStore drives the Charts view: current scope/window/tab
* selection, the fetched series for whatever metric(s) that selection
* needs, and a 30s auto-refresh of the current selection. Changing scope,
* window, or tab always triggers an immediate refetch (operator brief:
* "history fetch on selection + 30s refresh").
*/
export const useHistoryStore = defineStore('history', {
state: () => ({
/** "total" | "user:{id}" | "group:{id}" — the raw scope query value serveAdminUsageHistory expects. */
scope: 'total',
window: 'hour' as HistoryWindow,
tab: 'requests' as ChartTab,
/**
* Which metric the Models ranking ranks by — independent of `tab`.
* Defaults to 'req' (free-models-plan.md), not 'cost': a cost-default
* ranking silently hides every free model (its cost total is always
* 0), and the picker/table both need free models to be reachable
* without the reader first knowing to switch metrics.
*/
modelMetric: 'req' as ModelMetric,
seriesByMetric: {} as Partial<Record<HistoryMetric, UsageHistoryPoint[]>>,
/** The current Models-tab ranking. Empty means "nothing used in this window". */
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: '',
poller: undefined as VisibilityPoller | undefined,
/**
* seriesReqId guards fetchSeries and fetchModelRanking against
* out-of-order responses (review finding: "latest-request-wins"). The
* two are mutually exclusive per refresh() call (exactly one runs,
* picked by `tab`), so ONE shared counter is enough — a fetch started
* by an OLDER selection (scope/window/tab/modelMetric) always loses to
* whichever fetch started most recently, never to load order. Each
* fetch captures the id it was issued under before its own await, then
* only commits its result (and clears `loading`) if that id is still
* the current one when it resolves.
*/
seriesReqId: 0,
/** optionsReqId is fetchModelOptions' own independent counter (a background, parallel fetch — see that action's own doc comment for why it never touches `loading`/`error`). */
optionsReqId: 0,
}),
actions: {
setScope(scope: string): void {
if (scope === this.scope) return
this.scope = scope
// Clear the OLD selection's series before fetching the new one (review
// finding: stale data must never render under the new selection's
// labels) — done in the setter, not inside refresh()/fetchSeries(),
// so the 30s auto-refresh timer (same selection, no setter involved)
// does not blank the chart on every tick.
this.seriesByMetric = {}
this.error = ''
this.loaded = false
void this.refresh()
},
setWindow(window: HistoryWindow): void {
if (window === this.window) return
this.window = window
// 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
this.modelRanking = []
this.error = ''
this.loaded = false
void this.refresh()
},
/**
* refresh fetches whatever the CURRENT selection needs: the ranking on
* 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 (this.tab === 'models' ? this.fetchModelRanking() : this.fetchSeries())
},
async fetchSeries(): Promise<void> {
const auth = useAuthStore()
if (!auth.isAuthenticated) return
const requestId = ++this.seriesReqId
this.loading = true
try {
const span = WINDOW_SPAN[this.window]
const metrics = metricsForTab(this.tab)
const results = await Promise.all(
metrics.map((metric) =>
adminFetch<UsageHistoryResponse>(
`/admin/api/usage/history?scope=${encodeURIComponent(this.scope)}&metric=${metric}&window=${this.window}&span=${span}`,
),
),
)
// Stale: a newer selection (or the models-tab fetch) already
// superseded this one — drop the result rather than overwriting
// whatever the current selection has since fetched or cleared.
if (requestId !== this.seriesReqId) return
const next: Partial<Record<HistoryMetric, UsageHistoryPoint[]>> = {}
metrics.forEach((metric, i) => {
next[metric] = results[i]!.points
})
this.seriesByMetric = next
this.loaded = true
this.error = ''
} catch (err) {
if (requestId !== this.seriesReqId) return
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) {
return
}
this.error = err instanceof Error ? err.message : String(err)
} finally {
// Only the still-current request clears `loading` — an older,
// already-superseded fetch settling later must not flip it back to
// false while the newer one it lost to is still in flight.
if (requestId === this.seriesReqId) this.loading = false
}
},
async fetchModelRanking(): Promise<void> {
const auth = useAuthStore()
if (!auth.isAuthenticated) return
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)}` : ''
// detail=1 (free-models-plan.md): the Models tab's own ranking
// fetch always asks for the per-entry requests/tokensIn/tokensOut/
// costMicroUsd/free breakdown, not just the single ranked `value` —
// lib/model-table-columns.ts's table renders all four figures
// (plus the free badge) beside every row regardless of which
// metric is currently ranking. fetchModelOptions below stays
// detail-free: the scope picker only ever needs an id list.
const res = await adminFetch<AdminUsageModelsResponse>(
`/admin/api/usage/models?metric=${this.modelMetric}&window=${this.window}&limit=${MODEL_RANKING_LIMIT}&span=${span}${prefixParam}&detail=1`,
)
if (requestId !== this.seriesReqId) return
this.modelRanking = res.models
this.loaded = true
this.error = ''
} catch (err) {
if (requestId !== this.seriesReqId) return
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) {
return
}
this.error = err instanceof Error ? err.message : String(err)
} finally {
if (requestId === this.seriesReqId) this.loading = false
}
},
/**
* fetchModelOptions loads the picker's model list: models with at least
* 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).
*
* 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}&span=${span}`,
)
// Stale: a later window change already fired its own picker-list
// fetch — never let an older window's model list overwrite it.
if (requestId !== this.optionsReqId) return
this.modelOptions = res.models
} catch {
// 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.poller) return
this.poller = createVisibilityPoller({
intervalMs: REFRESH_MS,
tick: () => {
if (!this.loading) void this.refresh()
},
})
this.poller.start()
},
stopAutoRefresh(): void {
this.poller?.stop()
this.poller = undefined
},
},
})
+196
View File
@@ -0,0 +1,196 @@
// models.spec.ts covers useModelsStore.fetch's catalog-store integration
// (useCatalogStore.ensureLoaded, then join via lib/model-table-columns.ts's
// buildModelCatalogRows) — same mocked-adminFetch, Pinia-under-vitest
// harness as dashboard.spec.ts/reliability.spec.ts.
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 { adminFetch } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import { useCatalogStore } from '@/stores/catalog'
import { useModelsStore } from '@/stores/models'
import type { AdminCatalogResponse, AdminPerfResponse, AdminUsageModelsResponse } from '@/types/api'
const mockedAdminFetch = vi.mocked(adminFetch)
const catalogResponse: AdminCatalogResponse = {
providers: [
{
lastRefresh: '2026-01-01T00:00:00Z',
openUntil: '0001-01-01T00:00:00Z',
name: 'openai',
type: 'openai',
healthState: 'closed',
discoveryEnabled: true,
models: [{ id: 'openai/gpt-5', model: 'gpt-5', priceSource: 'builtin', inputPerMTokUsd: 1, outputPerMTokUsd: 2 }],
},
],
aliases: [],
}
const rankingResponse: AdminUsageModelsResponse = {
metric: 'cost',
window: 'day',
span: 7,
offset: 0,
detail: true,
models: [{ id: 'openai/gpt-5', value: 1, requests: 5, tokensIn: 50, tokensOut: 60, costMicroUsd: 100 }],
}
const perfResponse: AdminPerfResponse = {
kind: 'model',
window: 'day',
span: 7,
offset: 0,
latencyEnabled: true,
rows: [{ id: 'openai/gpt-5', p50Ms: 100, p95Ms: 300, count: 5, attempts: 5, failures: 0 }],
}
const providerPerfResponse: AdminPerfResponse = {
kind: 'provider',
window: 'day',
span: 7,
offset: 0,
latencyEnabled: true,
rows: [{ id: 'openai', p50Ms: 90, p95Ms: 250, count: 20, attempts: 20, failures: 1, timeouts: 0, failovers: 2 }],
}
function mockPerformance(path: string): AdminPerfResponse {
return path.includes('kind=provider') ? providerPerfResponse : perfResponse
}
describe('useModelsStore.fetch', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockedAdminFetch.mockReset()
useAuthStore().submit('test-admin-key')
})
it('ensures the catalog store is loaded, then joins ranking + performance onto it, plus provider performance', async () => {
mockedAdminFetch.mockImplementation((async (path: string) => {
if (path === '/admin/api/catalog') return catalogResponse
if (path.startsWith('/admin/api/usage/models')) return rankingResponse
if (path.startsWith('/admin/api/performance')) return mockPerformance(path)
throw new Error(`unexpected path ${path}`)
}) as typeof adminFetch)
const models = useModelsStore()
await models.fetch()
expect(models.rows).toHaveLength(1)
expect(models.rows[0]).toMatchObject({ id: 'openai/gpt-5', requests: 5, p50Ms: 100, attempts: 5 })
expect(models.providerPerf).toEqual(providerPerfResponse.rows)
expect(models.perfWindow).toEqual({ window: 'day', span: 7 })
expect(models.latencyEnabled).toBe(true)
expect(models.lastUpdated).not.toBeNull()
expect(models.error).toBe('')
})
// P1 item 1: the ranking fetch used to omit `metric` entirely, which
// admin.go's validHistoryMetric rejects as a 400 "unknown metric" — the
// previous version of this spec matched adminFetch by path PREFIX only
// (`path.startsWith('/admin/api/usage/models')`), which is why it never
// caught a missing/wrong query parameter. This test asserts the exact
// URL string.
it('requests the ranking with metric=req (not omitted, not the Spend page\'s own metric)', async () => {
let rankingUrl = ''
mockedAdminFetch.mockImplementation((async (path: string) => {
if (path === '/admin/api/catalog') return catalogResponse
if (path.startsWith('/admin/api/usage/models')) {
rankingUrl = path
return rankingResponse
}
if (path.startsWith('/admin/api/performance')) return mockPerformance(path)
throw new Error(`unexpected path ${path}`)
}) as typeof adminFetch)
const models = useModelsStore()
await models.fetch()
expect(rankingUrl).toBe('/admin/api/usage/models?window=day&span=7&limit=100&detail=1&metric=req')
})
it('reuses an already-loaded, still-fresh catalog store without re-fetching /admin/api/catalog', async () => {
const catalog = useCatalogStore()
catalog.data = catalogResponse
catalog.fetchedAt = new Date()
mockedAdminFetch.mockImplementation((async (path: string) => {
if (path.startsWith('/admin/api/usage/models')) return rankingResponse
if (path.startsWith('/admin/api/performance')) return perfResponse
throw new Error(`unexpected path ${path}`)
}) as typeof adminFetch)
const models = useModelsStore()
await models.fetch()
expect(mockedAdminFetch).not.toHaveBeenCalledWith('/admin/api/catalog')
expect(models.rows).toHaveLength(1)
})
it('records an error and leaves rows empty when the catalog itself fails to load', async () => {
mockedAdminFetch.mockImplementation((async (path: string) => {
if (path === '/admin/api/catalog') throw new Error('catalog boom')
throw new Error(`unexpected path ${path}`)
}) as typeof adminFetch)
const models = useModelsStore()
await models.fetch()
expect(models.rows).toEqual([])
expect(models.error).toContain('catalog boom')
})
// P2 item 6: fetch() used to have an `if (this.loading) return` guard,
// which silently dropped a fetch() call started during a range change
// mid-flight (a reader on Models who changes the range twice quickly
// saw the SECOND range change simply do nothing). The replacement is
// latest-request-wins (reqId), matching stores/catalog.ts's own
// convention — this test exercises the actual property that matters: a
// SLOWER, earlier fetch() resolving AFTER a faster, later one must
// never overwrite the later (more current) result with stale data.
it('discards a slower first fetch() result once a newer fetch() has already resolved (latest-request-wins)', async () => {
const staleRanking: AdminUsageModelsResponse = {
...rankingResponse,
models: [{ ...rankingResponse.models[0], id: 'openai/stale-model' }],
}
let resolveFirstRanking: ((v: AdminUsageModelsResponse) => void) | undefined
let rankingCallCount = 0
mockedAdminFetch.mockImplementation((async (path: string) => {
if (path === '/admin/api/catalog') return catalogResponse
if (path.startsWith('/admin/api/usage/models')) {
rankingCallCount += 1
// The FIRST fetch()'s own ranking call hangs until the test
// resolves it by hand, below — the SECOND (and any later)
// ranking call resolves immediately, so `second` finishes well
// before `first` does.
if (rankingCallCount === 1) return new Promise<AdminUsageModelsResponse>((resolve) => (resolveFirstRanking = resolve))
return rankingResponse
}
if (path.startsWith('/admin/api/performance')) return mockPerformance(path)
throw new Error(`unexpected path ${path}`)
}) as typeof adminFetch)
const models = useModelsStore()
const first = models.fetch()
await vi.waitFor(() => {
if (!resolveFirstRanking) throw new Error('first ranking request not issued yet')
})
const second = models.fetch()
await second
expect(models.rows[0]?.id).toBe('openai/gpt-5')
// `first` finally resolves, with STALE data — it must be discarded,
// not overwrite the already-current `second` result.
resolveFirstRanking?.(staleRanking)
await first
expect(models.rows[0]?.id).toBe('openai/gpt-5')
expect(models.loading).toBe(false)
})
})
+83
View File
@@ -0,0 +1,83 @@
import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { buildModelCatalogRows } from '@/lib/model-table-columns'
import type { ModelCatalogRow } from '@/lib/model-table-columns'
import { useCatalogStore } from '@/stores/catalog'
import { useFiltersStore } from '@/stores/filters'
import { hourOrDayWindow } from '@/stores/reliability'
import type { AdminPerfResponse, AdminPerfRow, AdminUsageModelsResponse, HistoryWindow } from '@/types/api'
/**
* useModelsStore fetches the Models page's own catalog table (redesign-
* plan.md section 3.3/3.4) — on demand, not part of the 5s dashboard poll
* (Q12 DECISIONS), refetched on mount and whenever the global range
* changes. Joins three independent responses via lib/model-table-columns.
* ts's buildModelCatalogRows: the catalog (useCatalogStore, WP-D — shared
* 5-minute-stale cache, so navigating here after Spend already paid this
* read costs nothing extra), the current range's usage ranking
* (`detail=1`, up to the server's own 100-row cap), and fleet performance
* (`kind=model`, server default: top 50 by req in span).
*
* Ranking accepts any HistoryWindow (hour/day/month — redesign-plan.md
* section 1.3.iv's metric x kind table, model scope), but performance does
* not (PerfWindow is hour/day only, types/api.ts) — reuses
* stores/reliability.ts's own hourOrDayWindow clamp for the performance
* request only, leaving the ranking request on the global filters window
* unclamped.
*/
export const useModelsStore = defineStore('models', {
state: () => ({
rows: [] as ModelCatalogRow[],
/** GET /admin/api/performance?kind=provider's own rows (fleet p50/p95, attempts/failures, timeouts, failovers per provider) — ProviderHealthPanel.vue's own doc comment. Keyed by provider name at read time, not here, so this store stays a plain array like `rows` above. */
providerPerf: [] as AdminPerfRow[],
/** The hour/day window + span hourOrDayWindow actually resolved for both performance requests — ProviderHealthPanel.vue's badge titles read this rather than filters.window/span directly whenever a month-range clamp was applied. */
perfWindow: { window: 'day' as Extract<HistoryWindow, 'hour' | 'day'>, span: 0 },
/** GET /admin/api/performance's own latencyEnabled (AdminPerfResponse) — shared by BOTH the model and provider performance requests below (the same admin.stats.latency toggle gates both), surfaced so ModelCatalogTable.vue/ProviderHealthPanel.vue can hint at it rather than silently showing every row's p50/p95 as "—". */
latencyEnabled: false,
loading: false,
lastUpdated: null as Date | null,
error: '',
/** reqId guards against an out-of-order response overwriting a newer one (latest-request-wins, stores/catalog.ts's own convention) — a range change mid-flight no longer gets silently dropped by an `if (this.loading) return` guard. */
reqId: 0,
}),
actions: {
async fetch(): Promise<void> {
const requestId = ++this.reqId
this.loading = true
try {
const catalog = useCatalogStore()
const filters = useFiltersStore()
await catalog.ensureLoaded()
if (requestId !== this.reqId) return
if (!catalog.data) {
this.error = catalog.error || 'catalog unavailable'
return
}
const perfWindow = hourOrDayWindow(filters.window, filters.span)
const [rankingRes, perfRes, providerPerfRes] = await Promise.all([
adminFetch<AdminUsageModelsResponse>(
`/admin/api/usage/models?window=${filters.window}&span=${filters.span}&limit=100&detail=1&metric=req`,
),
adminFetch<AdminPerfResponse>(`/admin/api/performance?kind=model&window=${perfWindow.window}&span=${perfWindow.span}`),
adminFetch<AdminPerfResponse>(`/admin/api/performance?kind=provider&window=${perfWindow.window}&span=${perfWindow.span}`),
])
if (requestId !== this.reqId) return
this.rows = buildModelCatalogRows(catalog.data, rankingRes, perfRes)
this.providerPerf = providerPerfRes.rows ?? []
this.perfWindow = perfWindow
this.latencyEnabled = perfRes.latencyEnabled
this.lastUpdated = new Date()
this.error = ''
} catch (err) {
if (requestId !== this.reqId) return
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) return
this.error = err instanceof Error ? err.message : String(err)
} finally {
if (requestId === this.reqId) this.loading = false
}
},
},
})
+27 -65
View File
@@ -1,16 +1,6 @@
// 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(() => {
@@ -18,69 +8,41 @@ beforeEach(() => {
})
describe('useNavStore: initial state', () => {
it('defaults activeTab to "providers" and usageQuery to empty', () => {
it('defaults page to "home" and params to an empty record', () => {
const nav = useNavStore()
expect(nav.activeTab).toBe('providers')
expect(nav.usageQuery).toBe('')
expect(nav.page).toBe('home')
expect(nav.params).toEqual({})
})
})
describe('useNavStore.goToCharts', () => {
it('sets the history scope and switches activeTab to "charts"', () => {
describe('useNavStore.goTo', () => {
it('sets page and params together', () => {
const nav = useNavStore()
const history = useHistoryStore()
nav.goToCharts('user:alice')
expect(history.scope).toBe('user:alice')
expect(nav.activeTab).toBe('charts')
nav.goTo('consumers', { user: 'alice' })
expect(nav.page).toBe('consumers')
expect(nav.params).toEqual({ user: 'alice' })
})
it('accepts every scope prefix history.ts recognizes', () => {
it('defaults params to an empty record when omitted', () => {
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)
nav.goTo('models', { model: 'openai/gpt-5' })
nav.goTo('spend')
expect(nav.page).toBe('spend')
expect(nav.params).toEqual({})
})
it('replaces, never merges, the previous page\'s params', () => {
const nav = useNavStore()
nav.goTo('consumers', { user: 'alice', view: 'directory' })
nav.goTo('consumers', { view: 'matrix' })
expect(nav.params).toEqual({ view: 'matrix' })
})
it('accepts every page id', () => {
const nav = useNavStore()
for (const page of ['home', 'spend', 'consumers', 'models', 'reliability', 'config', 'targets'] as const) {
nav.goTo(page)
expect(nav.page).toBe(page)
}
})
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/')
})
})
+27 -63
View File
@@ -1,78 +1,42 @@
import { defineStore } from 'pinia'
import type { TabValue } from '@/composables/useTabHash'
import { useHistoryStore } from '@/stores/history'
import { DEFAULT_PAGE } from '@/lib/pages'
import type { PageId } from '@/lib/pages'
/**
* 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.
* useNavStore is the redesign's single navigation store (redesign-plan.md
* section 3.3, replacing the old activeTab/usageQuery shape): which page
* is showing, and that page's own query params, as an opaque string
* record. "Opaque" is deliberate — nav.ts itself never interprets a
* param's meaning (a Spend page's `by`/`metric`/`ref`/`drill`, a
* Consumers page's `q`/`view`/`user`, ...); each page (and its own store,
* owned by that page's work package) reads `params` itself. This keeps
* nav.ts, and the shell that wraps it (App.vue, SidebarNav.vue,
* composables/useHashState.ts), free of any per-page knowledge — adding a
* new page-specific param never touches this file.
*
* `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").
* Global filters (time range, comparison, scope) are NOT part of
* `params` — those live in stores/filters.ts, page-independent by
* design (every page reads the same three).
*/
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: '',
page: DEFAULT_PAGE as PageId,
params: {} as Record<string, string>,
}),
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.
* goTo navigates to `page` with `params` as that page's complete
* param set (not merged with whatever the previous page's params
* were — a page's own params are meaningless once you've left it).
* Every EntityLink.vue click, SidebarNav.vue link, and TopList.vue
* row calls this instead of poking `page`/`params` separately, so a
* single click is always one atomic state change (and, downstream,
* one hash rewrite — composables/useHashState.ts's own watcher).
*/
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'
goTo(page: PageId, params: Record<string, string> = {}): void {
this.page = page
this.params = params
},
},
})
+352
View File
@@ -0,0 +1,352 @@
// reliability.spec.ts covers the pure helpers (hourOrDayWindow,
// errorRateSeries, seriesColor) directly, plus useReliabilityStore.fetch's
// provider-scope-list-from-dashboard-overview wiring and its zero-providers
// short-circuit — mirroring dashboard.spec.ts's own mocked-adminFetch,
// Pinia-under-vitest approach (that file's own doc comment).
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 { adminFetch } from '@/lib/api'
import { errorRateSeries, hourOrDayWindow, RELIABILITY_SERIES_COLORS, seriesColor, useReliabilityStore } from '@/stores/reliability'
import { useAuthStore } from '@/stores/auth'
import { useDashboardStore } from '@/stores/dashboard'
import type { AdminOverviewResponse, AdminPerfResponse, AdminPerfRow, AdminSeriesResponse } from '@/types/api'
const mockedAdminFetch = vi.mocked(adminFetch)
describe('hourOrDayWindow', () => {
it('passes an hour window through unchanged, with its own span', () => {
expect(hourOrDayWindow('hour', 48)).toEqual({ window: 'hour', span: 48 })
})
it('passes a day window through unchanged, with its own span', () => {
expect(hourOrDayWindow('day', 14)).toEqual({ window: 'day', span: 14 })
})
it('clamps a month window (no hour/day equivalent span) to a fixed 30-day window', () => {
expect(hourOrDayWindow('month', 6)).toEqual({ window: 'day', span: 30 })
})
})
describe('errorRateSeries', () => {
function series(entries: { scope: string; points: number[] }[]): AdminSeriesResponse {
return { metric: 'x', window: 'hour', span: entries[0]?.points.length ?? 0, offset: 0, buckets: [], series: entries, unknown: [] }
}
it('divides fail by attempt per bucket for a matching scope', () => {
const attempts = series([{ scope: 'provider:openai', points: [100, 50] }])
const fails = series([{ scope: 'provider:openai', points: [1, 5] }])
expect(errorRateSeries(attempts, fails)).toEqual([{ scope: 'provider:openai', points: [0.01, 0.1] }])
})
it('reports null (never 0) for a bucket with zero attempts', () => {
const attempts = series([{ scope: 'provider:openai', points: [0, 10] }])
const fails = series([{ scope: 'provider:openai', points: [0, 0] }])
expect(errorRateSeries(attempts, fails)).toEqual([{ scope: 'provider:openai', points: [null, 0] }])
})
it('treats a scope missing entirely from the fail series as zero failures, not a thrown error', () => {
const attempts = series([{ scope: 'provider:anthropic', points: [10] }])
const fails = series([])
expect(errorRateSeries(attempts, fails)).toEqual([{ scope: 'provider:anthropic', points: [0] }])
})
it('delegates the division itself to lib/series.ts ratioSeries verbatim (no extra clamping here)', () => {
const attempts = series([{ scope: 'provider:x', points: [5] }])
const fails = series([{ scope: 'provider:x', points: [9] }])
// Deliberately > 1 (should not happen with real counters) — asserts
// this function does not silently clamp on top of ratioSeries' own
// behavior, so the two never quietly diverge.
expect(errorRateSeries(attempts, fails)).toEqual([{ scope: 'provider:x', points: [1.8] }])
})
})
describe('seriesColor', () => {
it('cycles through RELIABILITY_SERIES_COLORS by index', () => {
expect(seriesColor(0)).toBe(RELIABILITY_SERIES_COLORS[0])
expect(seriesColor(RELIABILITY_SERIES_COLORS.length)).toBe(RELIABILITY_SERIES_COLORS[0])
expect(seriesColor(RELIABILITY_SERIES_COLORS.length + 1)).toBe(RELIABILITY_SERIES_COLORS[1])
})
})
const emptyOverview: AdminOverviewResponse = {
version: '1',
replica: 'pod-a',
instance: 'test-gateway',
warnings: [],
providers: [],
groups: [],
aliases: [],
redis: { configured: false, lastErrAt: '0001-01-01T00:00:00Z' },
cache: { enabled: false },
retry: { enabled: false },
features: { userModelStats: false, latencyStats: false, cacheStats: false, lastSeen: false, failover: false },
pricing: { override: 0, builtin: 0, litellm: 0, free: 0, unpriced: 0 },
}
function providerOverview(names: string[]): AdminOverviewResponse {
return {
...emptyOverview,
providers: names.map((name) => ({
name,
type: 'openai',
baseUrl: 'https://api.example.com',
lastRefresh: '0001-01-01T00:00:00Z',
models: [],
modelCount: 0,
modelMeta: {},
discoveryEnabled: false,
healthState: 'closed',
openUntil: '0001-01-01T00:00:00Z',
attemptsDay: 0,
failuresDay: 0,
attemptsMinute: 0,
failuresMinute: 0,
modelRates: {},
})),
}
}
describe('useReliabilityStore.fetch', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockedAdminFetch.mockReset()
useAuthStore().submit('test-admin-key')
})
it('short-circuits to empty state with no adminFetch calls when no providers are configured', async () => {
useDashboardStore().overview = emptyOverview
const reliability = useReliabilityStore()
await reliability.fetch()
expect(mockedAdminFetch).not.toHaveBeenCalled()
expect(reliability.errorRate).toEqual([])
expect(reliability.lastUpdated).not.toBeNull()
expect(reliability.error).toBe('')
})
it('fetches attempt/fail/timeout/fover per configured provider scope, plus total rej/r402, and assigns every field', async () => {
useDashboardStore().overview = providerOverview(['openai', 'anthropic'])
const reliability = useReliabilityStore()
function seriesFor(metric: string, points: number[]): AdminSeriesResponse {
return {
metric,
window: 'day',
span: points.length,
offset: 0,
buckets: points.map((_, i) => `2026010${i + 1}`),
series:
metric === 'rej' || metric === 'r402'
? [{ scope: 'total', points }]
: [
{ scope: 'provider:openai', points },
{ scope: 'provider:anthropic', points: points.map((p) => p * 2) },
],
unknown: [],
}
}
mockedAdminFetch.mockImplementation((async (path: string) => {
if (path.includes('metric=attempt')) return seriesFor('attempt', [100, 100])
if (path.includes('metric=fail')) return seriesFor('fail', [1, 2])
if (path.includes('metric=timeout')) return seriesFor('timeout', [0, 1])
if (path.includes('metric=fover')) return seriesFor('fover', [0, 0])
if (path.includes('metric=rej')) return seriesFor('rej', [3, 4])
if (path.includes('metric=r402')) return seriesFor('r402', [0, 1])
throw new Error(`unexpected path ${path}`)
}) as typeof adminFetch)
await reliability.fetch()
expect(reliability.buckets).toEqual(['20260101', '20260102'])
expect(reliability.windowUsed).toBe('day')
expect(reliability.errorRate).toEqual([
{ scope: 'provider:openai', points: [0.01, 0.02] },
{ scope: 'provider:anthropic', points: [0.01, 0.02] },
])
expect(reliability.timeouts).toHaveLength(2)
expect(reliability.failovers).toHaveLength(2)
expect(reliability.rejections).toEqual([3, 4])
expect(reliability.unpriced).toEqual([0, 1])
expect(reliability.lastUpdated).not.toBeNull()
expect(reliability.error).toBe('')
})
// P2 item 6: fetch() used to have an `if (this.loading) return` guard,
// which silently dropped a range change that started while a fetch was
// already in flight. The replacement is latest-request-wins (reqId) —
// this test exercises the property that actually matters: a SLOWER,
// earlier fetch() resolving AFTER a faster, later one must never
// overwrite the later (more current) result with stale data.
it('discards a slower first fetch() result once a newer fetch() has already resolved (latest-request-wins)', async () => {
useDashboardStore().overview = providerOverview(['openai'])
const reliability = useReliabilityStore()
function seriesFor(metric: string, scope: string, points: number[]): AdminSeriesResponse {
return { metric, window: 'day', span: points.length, offset: 0, buckets: ['20260101', '20260102'], series: [{ scope, points }], unknown: [] }
}
let resolveFirstAttempt: ((v: AdminSeriesResponse) => void) | undefined
let attemptCallCount = 0
mockedAdminFetch.mockImplementation((async (path: string) => {
if (path.includes('metric=attempt')) {
attemptCallCount += 1
// The FIRST fetch()'s own attempt call hangs until the test
// resolves it by hand, below — every LATER attempt call
// (`second`'s, and any after it) resolves immediately.
if (attemptCallCount === 1) return new Promise<AdminSeriesResponse>((resolve) => (resolveFirstAttempt = resolve))
return seriesFor('attempt', 'provider:openai', [9, 9])
}
if (path.includes('metric=fail')) return seriesFor('fail', 'provider:openai', [0, 0])
if (path.includes('metric=timeout')) return seriesFor('timeout', 'provider:openai', [0, 0])
if (path.includes('metric=fover')) return seriesFor('fover', 'provider:openai', [0, 0])
if (path.includes('metric=rej')) return seriesFor('rej', 'total', [9, 9])
if (path.includes('metric=r402')) return seriesFor('r402', 'total', [0, 0])
throw new Error(`unexpected path ${path}`)
}) as typeof adminFetch)
const first = reliability.fetch()
await vi.waitFor(() => {
if (!resolveFirstAttempt) throw new Error('first attempt request not issued yet')
})
const second = reliability.fetch()
await second
expect(reliability.rejections).toEqual([9, 9])
// `first` finally resolves, with STALE data — it must be discarded,
// not overwrite the already-current `second` result.
resolveFirstAttempt?.(seriesFor('attempt', 'provider:openai', [1, 1]))
await first
expect(reliability.rejections).toEqual([9, 9])
expect(reliability.loading).toBe(false)
})
it('records a non-auth failure message without throwing', async () => {
useDashboardStore().overview = providerOverview(['openai'])
const reliability = useReliabilityStore()
mockedAdminFetch.mockImplementation((async () => {
throw new Error('series boom')
}) as typeof adminFetch)
await reliability.fetch()
expect(reliability.error).toContain('series boom')
})
})
// fetchProviderPerf (N3 fix, verify-redesign-final.md: "performance
// series=1 for the selected provider" was never wired up).
describe('useReliabilityStore.fetchProviderPerf', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockedAdminFetch.mockReset()
useAuthStore().submit('test-admin-key')
})
function overviewWithLatency(enabled: boolean): AdminOverviewResponse {
return { ...providerOverview(['openai']), features: { ...emptyOverview.features, latencyStats: enabled } }
}
it('short-circuits with no adminFetch call when no provider is selected', async () => {
useDashboardStore().overview = overviewWithLatency(true)
const reliability = useReliabilityStore()
await reliability.fetchProviderPerf('')
expect(mockedAdminFetch).not.toHaveBeenCalled()
expect(reliability.providerPerfPoints).toEqual([])
expect(reliability.providerPerfBuckets).toEqual([])
})
it('short-circuits with no adminFetch call when admin.stats.latency is off, even with a provider selected', async () => {
useDashboardStore().overview = overviewWithLatency(false)
const reliability = useReliabilityStore()
await reliability.fetchProviderPerf('openai')
expect(mockedAdminFetch).not.toHaveBeenCalled()
expect(reliability.providerPerfLatencyEnabled).toBe(false)
})
it('fetches GET /admin/api/performance?kind=provider&series=1 for exactly the selected provider, and assigns every field', async () => {
useDashboardStore().overview = overviewWithLatency(true)
const reliability = useReliabilityStore()
const response: AdminPerfResponse = {
kind: 'provider',
window: 'day',
span: 2,
offset: 0,
buckets: ['20260101', '20260102'],
points: [
{ id: '20260101', p50Ms: 10, p95Ms: 20, count: 5, attempts: 5, failures: 1 },
{ id: '20260102', count: 3, attempts: 3, failures: 0 },
],
latencyEnabled: true,
}
mockedAdminFetch.mockImplementation((async (path: string) => {
expect(path).toContain('kind=provider')
expect(path).toContain('series=1')
expect(path).toContain('id=openai')
return response
}) as typeof adminFetch)
await reliability.fetchProviderPerf('openai')
expect(reliability.providerPerfBuckets).toEqual(['20260101', '20260102'])
expect(reliability.providerPerfWindowUsed).toBe('day')
expect(reliability.providerPerfPoints).toEqual(response.points)
expect(reliability.providerPerfLatencyEnabled).toBe(true)
expect(reliability.providerPerfError).toBe('')
})
it('discards a slower first fetchProviderPerf() result once a newer one has already resolved (latest-request-wins)', async () => {
useDashboardStore().overview = overviewWithLatency(true)
const reliability = useReliabilityStore()
function response(points: AdminPerfRow[]): AdminPerfResponse {
return { kind: 'provider', window: 'day', span: points.length, offset: 0, buckets: ['20260101', '20260102'], points, latencyEnabled: true }
}
let resolveFirst: ((v: AdminPerfResponse) => void) | undefined
let callCount = 0
mockedAdminFetch.mockImplementation((async () => {
callCount += 1
if (callCount === 1) return new Promise<AdminPerfResponse>((resolve) => (resolveFirst = resolve))
return response([{ id: '20260101', count: 9, attempts: 9, failures: 9 }])
}) as typeof adminFetch)
const first = reliability.fetchProviderPerf('openai')
await vi.waitFor(() => {
if (!resolveFirst) throw new Error('first request not issued yet')
})
const second = reliability.fetchProviderPerf('openai')
await second
expect(reliability.providerPerfPoints).toEqual([{ id: '20260101', count: 9, attempts: 9, failures: 9 }])
resolveFirst?.(response([{ id: '20260101', count: 1, attempts: 1, failures: 1 }]))
await first
expect(reliability.providerPerfPoints).toEqual([{ id: '20260101', count: 9, attempts: 9, failures: 9 }])
expect(reliability.providerPerfLoading).toBe(false)
})
it('records a non-auth failure message without throwing', async () => {
useDashboardStore().overview = overviewWithLatency(true)
const reliability = useReliabilityStore()
mockedAdminFetch.mockImplementation((async () => {
throw new Error('perf series boom')
}) as typeof adminFetch)
await reliability.fetchProviderPerf('openai')
expect(reliability.providerPerfError).toContain('perf series boom')
})
})
+227
View File
@@ -0,0 +1,227 @@
import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { seriesUrl } from '@/lib/range'
import { ratioSeries } from '@/lib/series'
import { useDashboardStore } from '@/stores/dashboard'
import { useFiltersStore } from '@/stores/filters'
import type { AdminPerfResponse, AdminPerfRow, AdminSeriesResponse, HistoryWindow } from '@/types/api'
/**
* hourOrDayWindow clamps an arbitrary HistoryWindow/span pair (usually the
* global filters store's current range) down to the hour/day-only window
* every Reliability metric accepts (redesign-plan.md section 1.3.iv's
* metric x kind matrix: provider attempt/fail/timeout/fover and total
* rej/r402 are BOTH "(h/d)" only — neither family has a month bucket at
* all, matching their own 48h/35d TTLs, section 1.2). 'hour' and 'day' pass
* through unchanged; 'month' (the 3mo/6mo/12mo global ranges) falls back to
* a fixed 30-day day-window default — a deliberate, documented choice
* rather than an arbitrary derived span, and comfortably within
* HISTORY_MAX_SPAN.day (35, lib/range.ts).
*/
export function hourOrDayWindow(window: HistoryWindow, span: number): { window: Extract<HistoryWindow, 'hour' | 'day'>; span: number } {
if (window === 'hour') return { window: 'hour', span }
if (window === 'day') return { window: 'day', span }
return { window: 'day', span: 30 }
}
/** One provider's own error-rate series — a 0-1 ratio per bucket, or null where that bucket had zero attempts (never a fabricated 0%, mirroring lib/provider-rate.ts's own providerSuccessRate convention). */
export interface ProviderRatioSeries {
scope: string
points: (number | null)[]
}
/**
* errorRateSeries joins two SAME-SHAPE AdminSeriesResponse results
* (attempt and fail, fetched with the identical scope list/window/span/
* offset) by scope, then delegates the actual per-bucket division to
* WP-D's shared lib/series.ts ratioSeries (redesign-plan.md section 3.4:
* "TimeSeriesChart for error rate per provider (lib/series.ts ratioSeries
* fail/attempt per bucket)") — this function's own job is only the
* scope-matching join AdminSeriesResponse's two independent responses
* need, not the division itself, so there is exactly one place the ratio
* math lives. A scope present in `attempts` but missing from `fails`
* (should not happen — both requests share the same scope list — but
* defensively handled) is treated as all-zero failures, matching
* ratioSeries' own "missing point reads as 0" convention.
*/
export function errorRateSeries(attempts: AdminSeriesResponse, fails: AdminSeriesResponse): ProviderRatioSeries[] {
const failByScope = new Map(fails.series.map((s) => [s.scope, s.points]))
return attempts.series.map((a) => ({
scope: a.scope,
points: ratioSeries(failByScope.get(a.scope) ?? new Array<number>(a.points.length).fill(0), a.points),
}))
}
/**
* RELIABILITY_SERIES_COLORS cycles through main.css's four existing
* --chart-* tokens (assets/main.css is owned by WP-E — this store does not
* add a fifth) to color the per-provider error-rate/timeout/failover
* overlay charts: with more than four configured providers, colors repeat
* — a known, documented limitation rather than a silent one, since adding
* a genuinely distinct fifth hue is out of this work package's file scope.
*/
export const RELIABILITY_SERIES_COLORS = ['--chart-requests', '--chart-tokens-in', '--chart-tokens-out', '--chart-cost'] as const
/** seriesColor picks one of RELIABILITY_SERIES_COLORS by index, cycling. */
export function seriesColor(index: number): string {
return RELIABILITY_SERIES_COLORS[index % RELIABILITY_SERIES_COLORS.length]
}
/** One plain (non-ratio) metric series, e.g. failovers/timeouts per provider, or the fleet-wide rej/r402 total. */
export interface CountSeries {
scope: string
points: number[]
}
/**
* useReliabilityStore fetches the Reliability page's own on-demand series
* (redesign-plan.md section 3.3/3.4) — NOT part of the 5s dashboard poll
* (Q12 DECISIONS: "new endpoints on demand or 60s"), refetched by the page
* itself on mount and whenever the global filters range changes. Provider
* scopes are read from useDashboardStore's already-polled overview
* (overview.providers) rather than a second providers listing fetch.
*/
export const useReliabilityStore = defineStore('reliability', {
state: () => ({
buckets: [] as string[],
/** The hour/day window hourOrDayWindow actually resolved for the current buckets/series — ReliabilityPage.vue's formatBucketLabel calls need this, not filters.window, whenever a month-range clamp was applied. */
windowUsed: 'day' as Extract<HistoryWindow, 'hour' | 'day'>,
/** Per-provider fail/attempt ratio, one entry per configured provider. */
errorRate: [] as ProviderRatioSeries[],
/** Per-provider timeout count series (metric `timeout`). */
timeouts: [] as CountSeries[],
/** Per-provider failover count series (metric `fover`) — a provider's OWN candidates that were failed away FROM, per accountExtras.failoverFrom's own doc comment (redesign-plan.md section 2b). */
failovers: [] as CountSeries[],
/** Fleet-wide total rejections/minute-window-denied series (metric `rej`, scope `total`). */
rejections: [] as number[],
/** Fleet-wide total 402 unpriced-refusal series (metric `r402`, scope `total`). */
unpriced: [] as number[],
loading: false,
lastUpdated: null as Date | null,
error: '',
/** reqId guards against an out-of-order response overwriting a newer one (latest-request-wins, stores/catalog.ts's own convention) — a range change mid-flight no longer gets silently dropped by an `if (this.loading) return` guard (P2 item 6). */
reqId: 0,
// --- selected-provider performance series (N3/redesign-plan.md
// section 3.3: "performance series=1 for the selected provider") ---
/** GET /admin/api/performance?kind=provider&series=1's own bucket labels for the currently selected provider — separate from `buckets`/`windowUsed` above since fetchProviderPerf runs independently of fetch() and could in principle resolve against a different window (a fast provider switch mid-range-change). */
providerPerfBuckets: [] as string[],
providerPerfWindowUsed: 'day' as Extract<HistoryWindow, 'hour' | 'day'>,
/** One AdminPerfRow per bucket (its own `id` is the bucket string, not the provider name — AdminPerfResponse's own doc comment) — p50Ms/p95Ms/failures per bucket for ReliabilityPage.vue's selected-provider chart. */
providerPerfPoints: [] as AdminPerfRow[],
/** AdminFeaturesView.latencyStats, as this SPECIFIC response reported it — ReliabilityPage.vue already reads dashboard.overview.features.latencyStats to decide whether to fetch at all, but keeping the response's own copy here avoids a stale read if the config toggles between fetches. */
providerPerfLatencyEnabled: false,
providerPerfLoading: false,
providerPerfError: '',
/** Same latest-request-wins guard as `reqId` above, scoped to fetchProviderPerf's own independent fetch lifecycle. */
providerPerfReqId: 0,
}),
actions: {
async fetch(): Promise<void> {
const requestId = ++this.reqId
this.loading = true
try {
const filters = useFiltersStore()
const dashboard = useDashboardStore()
const providerNames = (dashboard.overview?.providers ?? []).map((p) => p.name)
if (providerNames.length === 0) {
if (requestId !== this.reqId) return
this.buckets = []
this.windowUsed = hourOrDayWindow(filters.window, filters.span).window
this.errorRate = []
this.timeouts = []
this.failovers = []
this.rejections = []
this.unpriced = []
this.lastUpdated = new Date()
this.error = ''
return
}
const { window, span } = hourOrDayWindow(filters.window, filters.span)
const scopes = providerNames.map((name) => `provider:${name}`)
const [attemptRes, failRes, timeoutRes, foverRes, rejRes, r402Res] = await Promise.all([
adminFetch<AdminSeriesResponse>(seriesUrl({ scope: scopes, metric: 'attempt', window, span })),
adminFetch<AdminSeriesResponse>(seriesUrl({ scope: scopes, metric: 'fail', window, span })),
adminFetch<AdminSeriesResponse>(seriesUrl({ scope: scopes, metric: 'timeout', window, span })),
adminFetch<AdminSeriesResponse>(seriesUrl({ scope: scopes, metric: 'fover', window, span })),
adminFetch<AdminSeriesResponse>(seriesUrl({ scope: ['total'], metric: 'rej', window, span })),
adminFetch<AdminSeriesResponse>(seriesUrl({ scope: ['total'], metric: 'r402', window, span })),
])
if (requestId !== this.reqId) return
this.buckets = attemptRes.buckets
this.windowUsed = window
this.errorRate = errorRateSeries(attemptRes, failRes)
this.timeouts = timeoutRes.series
this.failovers = foverRes.series
this.rejections = rejRes.series[0]?.points ?? []
this.unpriced = r402Res.series[0]?.points ?? []
this.lastUpdated = new Date()
this.error = ''
} catch (err) {
if (requestId !== this.reqId) return
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) return
this.error = err instanceof Error ? err.message : String(err)
} finally {
if (requestId === this.reqId) this.loading = false
}
},
/**
* fetchProviderPerf (N3 fix, verify-redesign-final.md — plan §3.3's
* "performance series=1 for the selected provider" was never wired
* up) reads GET /admin/api/performance?kind=provider&series=1 for
* ONE provider — per-bucket p50/p95 latency and failure counts,
* matching this same store's hourOrDayWindow clamp. Only fetches
* when BOTH `provider` is non-empty (ReliabilityPage.vue passes ''
* for "All providers") AND admin.stats.latency is on
* (dashboard.overview.features.latencyStats) — series=1 payload is
* genuinely useless without latency (attempts/failures alone do not
* justify a second on-demand round trip Q12 would rather avoid), so
* this short-circuits to the empty state instead of firing a request
* neither the page nor the reader will use, mirroring fetch()'s own
* zero-providers short-circuit above.
*/
async fetchProviderPerf(provider: string): Promise<void> {
const requestId = ++this.providerPerfReqId
const dashboard = useDashboardStore()
const latencyStatsEnabled = dashboard.overview?.features.latencyStats ?? false
if (!provider || !latencyStatsEnabled) {
if (requestId !== this.providerPerfReqId) return
this.providerPerfBuckets = []
this.providerPerfPoints = []
this.providerPerfLatencyEnabled = false
this.providerPerfError = ''
this.providerPerfLoading = false
return
}
this.providerPerfLoading = true
try {
const filters = useFiltersStore()
const { window, span } = hourOrDayWindow(filters.window, filters.span)
const q = new URLSearchParams({ kind: 'provider', window, span: String(span), series: '1' })
q.append('id', provider)
const res = await adminFetch<AdminPerfResponse>(`/admin/api/performance?${q.toString()}`)
if (requestId !== this.providerPerfReqId) return
this.providerPerfBuckets = res.buckets ?? []
this.providerPerfWindowUsed = window
this.providerPerfPoints = res.points ?? []
this.providerPerfLatencyEnabled = res.latencyEnabled
this.providerPerfError = ''
} catch (err) {
if (requestId !== this.providerPerfReqId) return
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) return
this.providerPerfError = err instanceof Error ? err.message : String(err)
} finally {
if (requestId === this.providerPerfReqId) this.providerPerfLoading = false
}
},
},
})
+400
View File
@@ -0,0 +1,400 @@
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 { isSpendBy, isSpendMetric, MODEL_BREAKDOWN_TOP_N, useSpendStore } from '@/stores/spend'
import { useAuthStore } from '@/stores/auth'
import { useDashboardStore } from '@/stores/dashboard'
import { useFiltersStore } from '@/stores/filters'
import type { AdminOverviewResponse, AdminSeriesResponse, AdminTotalsResponse, AdminUsageModelsResponse } from '@/types/api'
const mockedAdminFetch = vi.mocked(adminFetch)
function seriesFor(scope: string[], value = 10): AdminSeriesResponse {
return {
metric: 'cost',
window: 'day',
span: 2,
offset: 0,
buckets: ['20260922', '20260923'],
series: scope.map((s) => ({ scope: s, points: [value, value] })),
unknown: [],
}
}
function rankingWith(ids: string[]): AdminUsageModelsResponse {
return {
metric: 'cost',
window: 'day',
span: 2,
detail: true,
offset: 0,
models: ids.map((id, i) => ({ id, value: 100 - i, requests: 100 - i, tokensIn: 1, tokensOut: 1, costMicroUsd: 1, free: false })),
}
}
/** mockRoutes wires adminFetch's mock by matching the route prefix — the four concerns this store fetches (models ranking, series, totals, and again totals for drilldown) all hit different admin.go routes. */
function mockRoutes(handlers: { series?: (url: string) => AdminSeriesResponse; totals?: (url: string) => AdminTotalsResponse; models?: (url: string) => AdminUsageModelsResponse }): void {
mockedAdminFetch.mockImplementation((async (path: string) => {
if (path.startsWith('/admin/api/usage/models')) return handlers.models ? handlers.models(path) : rankingWith([])
if (path.startsWith('/admin/api/usage/series')) return handlers.series ? handlers.series(path) : seriesFor(['total'], 0)
if (path.startsWith('/admin/api/usage/totals')) return handlers.totals ? handlers.totals(path) : { kind: 'group', window: 'day', span: 2, offset: 0, metrics: [], rows: [] }
throw new Error(`unexpected path ${path}`)
}) as typeof adminFetch)
}
beforeEach(() => {
setActivePinia(createPinia())
mockedAdminFetch.mockReset()
useAuthStore().submit('test-admin-key')
})
describe('SPEND_BY / SPEND_METRICS guards', () => {
it('isSpendBy accepts model/provider/group and rejects anything else', () => {
expect(isSpendBy('model')).toBe(true)
expect(isSpendBy('provider')).toBe(true)
expect(isSpendBy('group')).toBe(true)
expect(isSpendBy('bogus')).toBe(false)
})
it('isSpendMetric accepts cost/req/tokin/tokout and rejects anything else', () => {
expect(isSpendMetric('cost')).toBe(true)
expect(isSpendMetric('tokout')).toBe(true)
expect(isSpendMetric('bogus')).toBe(false)
})
})
describe('useSpendStore.fetchModelRanking', () => {
it('populates modelRanking on success', async () => {
mockRoutes({ models: () => rankingWith(['a/one', 'a/two']) })
const spend = useSpendStore()
await spend.fetchModelRanking()
expect(spend.modelRanking.map((m) => m.id)).toEqual(['a/one', 'a/two'])
})
it('is a no-op while unauthenticated', async () => {
setActivePinia(createPinia())
mockRoutes({})
const spend = useSpendStore()
await spend.fetchModelRanking()
expect(spend.modelRanking).toEqual([])
})
})
// P3 item 21: PricingHealthTable.vue's "unpriced first" ordering needs a
// ranking sorted by TRAFFIC (req), independent of whatever metric the
// Spend breakdown chart currently sorts by — sorting by `cost` (the
// default `this.metric`) cuts the top-100 ranking off BY COST, and an
// unpriced model bills $0, so it is one of the first rows a cost-sorted
// cutoff drops.
describe('useSpendStore.fetchPricingHealthRanking', () => {
it('always requests metric=req, regardless of the currently-selected Spend metric', async () => {
let requestedUrl = ''
mockRoutes({
models: (url) => {
requestedUrl = url
return rankingWith(['a/one'])
},
})
const spend = useSpendStore()
spend.$patch({ metric: 'cost' }) // deliberately NOT req
await spend.fetchPricingHealthRanking()
expect(requestedUrl).toBe('/admin/api/usage/models?metric=req&window=day&span=7&limit=100&detail=1')
expect(spend.pricingHealthRanking.map((m) => m.id)).toEqual(['a/one'])
})
})
describe('useSpendStore.fetchBreakdown: by=model, scope=all', () => {
it('fetches total + top-N model series and computes other = total - sum(top)', async () => {
mockRoutes({
models: () => rankingWith(['a/one', 'a/two']),
series: (url) => {
if (url.includes('scope=total') && !url.includes('scope=model')) return seriesFor(['total'], 100)
return seriesFor(['model:a/one', 'model:a/two'], 20)
},
})
const spend = useSpendStore()
await spend.fetchModelRanking()
await spend.fetchBreakdown()
expect(spend.totalPoints).toEqual([100, 100])
expect(spend.breakdown.map((s) => s.scope)).toEqual(['model:a/one', 'model:a/two'])
// 100 - (20 + 20) = 60 per bucket.
expect(spend.otherPoints).toEqual([60, 60])
})
it('caps the charted series at MODEL_BREAKDOWN_TOP_N even with a larger ranking', async () => {
const ids = Array.from({ length: 25 }, (_, i) => `a/model-${i}`)
mockRoutes({
models: () => rankingWith(ids),
series: (url) => {
if (url.includes('scope=model')) {
const requested = [...url.matchAll(/scope=model%3A([^&]+)/g)].map((m) => `model:${decodeURIComponent(m[1]!)}`)
expect(requested.length).toBeLessThanOrEqual(MODEL_BREAKDOWN_TOP_N)
return seriesFor(requested, 1)
}
return seriesFor(['total'], 1000)
},
})
const spend = useSpendStore()
await spend.fetchModelRanking()
await spend.fetchBreakdown()
expect(spend.breakdown).toHaveLength(MODEL_BREAKDOWN_TOP_N)
})
})
describe('useSpendStore.fetchBreakdown: by=provider, scope=all', () => {
it('sums the ranking models by provider prefix', async () => {
mockRoutes({
models: () => rankingWith(['openai/gpt-5', 'openai/gpt-5-mini', 'anthropic/claude']),
series: (url) => {
if (url.includes('scope=model')) {
return seriesFor(['model:openai/gpt-5', 'model:openai/gpt-5-mini', 'model:anthropic/claude'], 10)
}
return seriesFor(['total'], 30)
},
})
const spend = useSpendStore()
spend.setSelection({ by: 'provider' })
await spend.fetchModelRanking()
await spend.fetchBreakdown()
expect(spend.breakdown).toEqual(
expect.arrayContaining([
{ scope: 'openai', points: [20, 20] },
{ scope: 'anthropic', points: [10, 10] },
]),
)
expect(spend.otherPoints).toBeNull()
})
})
describe('useSpendStore.fetchBreakdown: by=group, scope=all', () => {
it('fetches the group id list from totals, then a series per group', async () => {
mockRoutes({
totals: () => ({ kind: 'group', window: 'day', span: 2, offset: 0, metrics: ['cost'], rows: [{ id: 'friends', values: { cost: 40 } }, { id: 'eng', values: { cost: 60 } }] }),
series: (url) => {
if (url.includes('scope=group')) return seriesFor(['group:friends', 'group:eng'], 5)
return seriesFor(['total'], 100)
},
})
const spend = useSpendStore()
spend.setSelection({ by: 'group' })
await spend.fetchModelRanking()
await spend.fetchBreakdown()
expect(spend.breakdown.map((s) => s.scope).sort()).toEqual(['group:eng', 'group:friends'])
expect(spend.otherPoints).toBeNull()
})
})
describe('useSpendStore.fetchBreakdown: scope narrowed to a single group/user', () => {
it('renders exactly one series for the narrowed scope, ignoring `by`', async () => {
useFiltersStore().setFilters({ scope: 'group:friends' })
mockRoutes({ series: () => seriesFor(['group:friends'], 42) })
const spend = useSpendStore()
await spend.fetchBreakdown()
expect(spend.breakdown).toEqual([{ scope: 'group:friends', points: [42, 42] }])
expect(spend.totalPoints).toEqual([42, 42])
expect(spend.otherPoints).toBeNull()
})
})
describe('useSpendStore.fetchBreakdown: scope narrowed to a provider', () => {
it('derives a single provider-summed series from the ranking, pre-filtered to that provider', async () => {
useFiltersStore().setFilters({ scope: 'provider:openai' })
mockRoutes({
models: () => rankingWith(['openai/gpt-5', 'anthropic/claude']),
series: (url) => {
expect(url).toContain('scope=model%3Aopenai%2Fgpt-5')
expect(url).not.toContain('anthropic')
return seriesFor(['model:openai/gpt-5'], 7)
},
})
const spend = useSpendStore()
await spend.fetchModelRanking()
await spend.fetchBreakdown()
expect(spend.breakdown).toEqual([{ scope: 'provider:openai', points: [7, 7] }])
})
})
describe('useSpendStore.fetchBreakdown: comparison', () => {
it('fetches an offset total series only when cmpSpec.requested', async () => {
useFiltersStore().setFilters({ cmp: 'prev' })
mockRoutes({
models: () => rankingWith([]),
series: (url) => {
if (url.includes('offset=')) return seriesFor(['total'], 5)
return seriesFor(['total'], 50)
},
})
const spend = useSpendStore()
await spend.fetchModelRanking()
await spend.fetchBreakdown()
expect(spend.comparisonPoints).toEqual([5, 5])
})
it('leaves comparisonPoints null when cmp is none', async () => {
mockRoutes({ models: () => rankingWith([]) })
const spend = useSpendStore()
await spend.fetchModelRanking()
await spend.fetchBreakdown()
expect(spend.comparisonPoints).toBeNull()
})
})
describe('useSpendStore.fetchBurndown', () => {
it('fetches a day-window total series spanning the current UTC day-of-month', async () => {
mockRoutes({
series: (url) => {
expect(url).toContain('window=day')
expect(url).toContain('scope=total')
return seriesFor(['total'], 3)
},
})
const spend = useSpendStore()
await spend.fetchBurndown()
expect(spend.burndownPoints).toEqual([3, 3])
})
it('reports burndownAvailable false and skips the fetch for a provider scope', async () => {
useFiltersStore().setFilters({ scope: 'provider:openai' })
mockRoutes({})
const spend = useSpendStore()
expect(spend.burndownAvailable).toBe(false)
await spend.fetchBurndown()
expect(mockedAdminFetch).not.toHaveBeenCalled()
expect(spend.burndownPoints).toEqual([])
})
it('resolves the fleet-wide budget from the sum of configured group budgets', async () => {
mockRoutes({ series: () => seriesFor(['total'], 1) })
useDashboardStore().overview = {
version: '1',
replica: 'r',
instance: 'i',
warnings: [],
providers: [],
groups: [{ name: 'friends', memberCount: 1, limits: { costPerMonthUSD: 10 } }],
aliases: [],
redis: { configured: false, lastErrAt: '0001-01-01T00:00:00Z' },
cache: { enabled: false },
retry: { enabled: false },
features: { userModelStats: false, latencyStats: false, cacheStats: false, lastSeen: false, failover: false },
pricing: { override: 0, builtin: 0, litellm: 0, free: 0, unpriced: 0 },
} satisfies AdminOverviewResponse
const spend = useSpendStore()
await spend.fetchBurndown()
expect(spend.burndownBudgetMicros).toBe(10_000_000)
})
})
describe('useSpendStore.fetchDrilldown', () => {
it('requests kind=group at the top level (drill empty)', async () => {
mockRoutes({
totals: (url) => {
expect(url).toContain('kind=group')
expect(url).not.toContain('group=')
return { kind: 'group', window: 'day', span: 2, offset: 0, metrics: ['cost'], rows: [{ id: 'friends', values: { cost: 1 } }] }
},
})
const spend = useSpendStore()
await spend.fetchDrilldown()
expect(spend.drilldownRows).toEqual([{ id: 'friends', values: { cost: 1 } }])
expect(spend.drilldownDisabled).toBe(false)
})
it('requests kind=user&group=X once drilled into a group', async () => {
mockRoutes({
totals: (url) => {
expect(url).toContain('kind=user')
expect(url).toContain('group=friends')
return { kind: 'user', window: 'day', span: 2, offset: 0, metrics: ['cost'], rows: [{ id: 'alice', values: { cost: 1 } }] }
},
})
const spend = useSpendStore()
spend.setSelection({ drill: 'group:friends' })
await spend.fetchDrilldown()
expect(spend.drilldownRows).toEqual([{ id: 'alice', values: { cost: 1 } }])
})
it('requests kind=usermodel&user=Y once drilled into a user', async () => {
mockRoutes({
totals: (url) => {
expect(url).toContain('kind=usermodel')
expect(url).toContain('user=alice')
return { kind: 'usermodel', window: 'day', span: 2, offset: 0, metrics: ['cost'], rows: [] }
},
})
const spend = useSpendStore()
spend.setSelection({ drill: 'user:alice' })
await spend.fetchDrilldown()
expect(spend.drilldownDisabled).toBe(false)
})
// P1 item 2: usermodel only accepts day/month (no hour bucket at all).
// The two tests above both pass with the default global range (7d ->
// window=day) regardless of whether a clamp exists at all — this is
// EXACTLY how the original bug (a bare, unclamped `window=hour` 400ing
// server-side) went unnoticed: the previous spec never exercised an
// hour-resolution global range against this request. This test asserts
// the EXACT window/span the URL carries, not just that it contains
// "kind=usermodel".
it('clamps an hour-resolution global range (24h) to day/month for kind=usermodel, never window=hour', async () => {
useFiltersStore().setFilters({ range: '24h' })
let requestedUrl = ''
mockRoutes({
totals: (url) => {
requestedUrl = url
return { kind: 'usermodel', window: 'day', span: 2, offset: 0, metrics: ['cost'], rows: [] }
},
})
const spend = useSpendStore()
spend.$patch({ drill: 'user:alice' })
await spend.fetchDrilldown()
expect(requestedUrl).toBe('/admin/api/usage/totals?kind=usermodel&window=day&span=7&user=alice')
})
it('sets drilldownDisabled on a 404 (admin.stats.userModel off) rather than surfacing an error', async () => {
mockedAdminFetch.mockImplementation((async (path: string) => {
if (path.startsWith('/admin/api/usage/totals')) throw new AdminApiError('user-model statistics are not enabled', 404)
throw new Error(`unexpected path ${path}`)
}) as typeof adminFetch)
// $patch, not setSelection: setSelection also fires a full refresh()
// cascade (including fetchModelRanking), which this test's mock does
// not answer — only the drilldown fetch itself is under test here.
const spend = useSpendStore()
spend.$patch({ drill: 'user:alice' })
await spend.fetchDrilldown()
expect(spend.drilldownDisabled).toBe(true)
expect(spend.error).toBe('')
})
})
describe('useSpendStore.setSelection', () => {
it('applies by/metric/drill together and triggers exactly one refresh', async () => {
mockRoutes({})
const spend = useSpendStore()
const refreshSpy = vi.spyOn(spend, 'refresh')
spend.setSelection({ by: 'provider', metric: 'req', drill: 'group:eng' })
expect(spend.by).toBe('provider')
expect(spend.metric).toBe('req')
expect(spend.drill).toBe('group:eng')
await vi.waitFor(() => expect(refreshSpy).toHaveBeenCalledTimes(1))
})
it('is a no-op (no refresh) when nothing actually changes', () => {
const spend = useSpendStore()
const refreshSpy = vi.spyOn(spend, 'refresh')
spend.setSelection({ by: 'model', metric: 'cost', drill: '' })
expect(refreshSpy).not.toHaveBeenCalled()
})
})
+495
View File
@@ -0,0 +1,495 @@
import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { currentMonthDaySpan } from '@/lib/burndown'
import { sumGroupBudgetMicros } from '@/lib/kpi'
import { createVisibilityPoller, type VisibilityPoller } from '@/lib/polling'
import { dayOrMonthWindow, seriesUrl, totalsUrl } from '@/lib/range'
import type { TotalsUrlParams } from '@/lib/range'
import { other, stackTopN, sumByProvider, type Series } from '@/lib/series'
import { useAuthStore } from '@/stores/auth'
import { useDashboardStore } from '@/stores/dashboard'
import { useFiltersStore } from '@/stores/filters'
import type { AdminSeriesResponse, AdminTotalsResponse, AdminUsageModelsResponse, HistoryMetric, HistoryWindow } from '@/types/api'
/** SPEND_BY is the Spend page's breakdown-dimension selector (redesign-plan.md section 3.1's `by` page param). */
export const SPEND_BY = ['model', 'provider', 'group'] as const
export type SpendBy = (typeof SPEND_BY)[number]
/** isSpendBy is SPEND_BY's own runtime type guard, mirroring lib/range.ts's isRangeKey — the only place a hash-restored `by` value is checked against untrusted input. */
export function isSpendBy(value: string): value is SpendBy {
return (SPEND_BY as readonly string[]).includes(value)
}
/** SPEND_METRICS is the Spend page's metric selector, in the plan's own display order (section 3.1: "metric=cost|req|tokin|tokout") — the same four values HistoryMetric already names, reused rather than redeclared. */
export const SPEND_METRICS: HistoryMetric[] = ['cost', 'req', 'tokin', 'tokout']
export const SPEND_METRIC_LABEL: Record<HistoryMetric, string> = {
cost: 'Cost',
req: 'Requests',
tokin: 'Tokens in',
tokout: 'Tokens out',
}
/** isSpendMetric guards a hash-restored `metric` param against HistoryMetric's own union. */
export function isSpendMetric(value: string): value is HistoryMetric {
return (SPEND_METRICS as readonly string[]).includes(value)
}
/** How many of the by=model breakdown's ranked models get their OWN charted series before the remainder folds into "other" (redesign-plan.md section 3.3: "series for top 10 models"). */
export const MODEL_BREAKDOWN_TOP_N = 10
/**
* MODEL_RANKING_LIMIT is the shared model-ranking fetch's own limit —
* redesign-plan.md's own usageModelsMaxLimit ceiling (section 0 ground
* truth: "limit<=100"). ONE ranking fetch at this size serves three
* different Spend cards: the by=model chart (top MODEL_BREAKDOWN_TOP_N of
* these), PricingHealthTable (every row), and CostAvoidedCard's
* reference-model picker — rather than each fetching its own.
*/
const MODEL_RANKING_LIMIT = 100
/** GET /admin/api/usage/series accepts at most this many `scope` params per request (redesign-plan.md section 1.3.iv: "scope repeated 1..100") — the by=provider/by=group breakdown fetches cap their own model/group id lists at this so a large fleet never trips the server's own 400. */
const MAX_SERIES_SCOPES = 100
/** How often the Spend page's own data (breakdown, ranking, burn-down, drilldown) re-polls while visible — slower than the 5s dashboard poll (stores/dashboard.ts), matching stores/history.ts's own REFRESH_MS for the identical reason: history buckets move on the timescale of the window itself, not every request. */
const REFRESH_MS = 60_000
/**
* useSpendStore drives the Spend page (redesign-plan.md section 3.3,
* replacing stores/history.ts): the cost/req/tokens breakdown chart (by
* model, provider, or group), the model ranking that breakdown and two
* other Spend cards (PricingHealthTable, CostAvoidedCard) all share, the
* current-month burn-down line, and the group -> user -> model attribution
* drilldown. Every fetch reads the GLOBAL filters store (range/cmp/scope)
* directly rather than taking them as action parameters — the same reason
* any Pinia store reads another store from inside an action (stores/
* dashboard.ts reads useAuthStore()) — so SpendPage.vue only needs to call
* refresh() again when a global filter OR this store's own `by`/`metric`/
* `drill` selection changes, never thread values through by hand.
*
* SCOPE NARROWING (filters.scope): when narrowed to a single group or user
* (`group:x`/`user:x`), the breakdown chart shows that ONE scope's own
* series directly — the `by` dimension has nothing left to break down once
* the reader has already picked one entity, and AttributionDrilldown below
* it is exactly the tool for descending further within it. When narrowed
* to a provider (`provider:x`), the breakdown is derived the same way
* by=provider already is (lib/series.ts's sumByProvider), pre-filtered to
* that one provider's own models — cost/req/tokin/tokout has no direct
* `provider:{name}` series scope (redesign-plan.md section 1.3.iv's metric
* x kind table). The burn-down line has no meaningful reading for a
* provider scope at all (a provider carries no configured budget) and is
* left empty in that case (`burndownAvailable` getter below) rather than a
* fabricated one.
*/
export const useSpendStore = defineStore('spend', {
state: () => ({
by: 'model' as SpendBy,
metric: 'cost' as HistoryMetric,
/** '' (top level, group list), 'group:{name}' (user list within that group), or 'user:{name}' (model list for that user) — AttributionDrilldown.vue's own breadcrumb state, mirrored from the `drill` page param (SpendPage.vue owns syncing it both ways). */
drill: '',
// --- breakdown chart (TimeSeriesChart, by/scope-driven) ---
buckets: [] as string[],
/** The charted breakdown series (top-N models, providers, or groups — or a single series when filters.scope narrows to one entity). Never includes the synthesized "other" bucket; see `otherPoints`. */
breakdown: [] as Series[],
/** by=model only: total minus the sum of the charted top-N (lib/series.ts's `other`) — null for every other `by`/scope combination, where nothing is left out of the chart. */
otherPoints: null as number[] | null,
totalPoints: [] as number[],
/** The same total, shifted back by filters.cmpSpec.offset buckets — null unless filters.cmpSpec.requested. */
comparisonPoints: null as number[] | null,
/**
* comparisonUnavailableReason (P3 item 19, Q5's "show a reason"): set
* when a comparison WAS requested (filters.cmpSpec.requested) but this
* store could not build one anyway — currently only the provider-scope
* case (fetchComparison's own doc comment: no direct series scope for
* cost/req/tokin/tokout on a `provider:{name}` scope, and doubling
* every provider-scope request through sumByProvider was not worth it
* for a reference overlay). Null whenever a comparison was not
* requested at all, or was requested and DID build successfully — a
* reader only needs this note when the dashed line they asked for is
* silently missing, not as a blanket status message.
*/
comparisonUnavailableReason: null as string | null,
// --- model ranking: the by=model chart and CostAvoidedCard's reference-model picker ---
modelRanking: [] as AdminUsageModelsResponse['models'],
/**
* pricingHealthRanking (P3 item 21) is a SEPARATE ranking, always
* sorted by `req` — PricingHealthTable.vue's own "unpriced first"
* ordering needs the models with the most TRAFFIC, not whichever
* metric the reader currently has the Spend breakdown sorted by
* (`this.metric`). With more than MODEL_RANKING_LIMIT served models,
* sorting by `cost` (the default metric) cuts the ranking off at 100
* models ranked BY COST — and an unpriced model bills $0, so it is
* one of the FIRST rows dropped by that cutoff, defeating "unpriced
* first" for the operator who most needs to see it. Sorting by `req`
* instead still has a cutoff (a model with vanishingly few requests
* AND no price could still fall outside the top 100), but it no
* longer discards a row specifically BECAUSE it is unpriced.
*/
pricingHealthRanking: [] as AdminUsageModelsResponse['models'],
// --- burn-down (BurnDownChart) ---
burndownBuckets: [] as string[],
/** Raw per-day cost (micro-USD), NOT yet cumulative — BurnDownChart.vue runs lib/burndown.ts's own `cumulative` over this, keeping the running-sum step in one place rather than duplicated between the store and the component. */
burndownPoints: [] as number[],
/** The scope's own configured cost/month budget in micro-USD, or null when none is configured (or the scope has no budget concept — see `burndownAvailable`). */
burndownBudgetMicros: null as number | null,
// --- attribution drilldown (AttributionDrilldown) ---
drilldownRows: [] as AdminTotalsResponse['rows'],
drilldownMetrics: [] as string[],
/** True once GET /admin/api/usage/totals reported this exact drill level as unavailable (usermodel with admin.stats.userModel off, adminTotalsResponse's own 404) — AttributionDrilldown.vue renders the hint naming the config key instead of an empty table. */
drilldownDisabled: false,
loading: false,
error: '',
/** breakdownReqId/rankingReqId/burndownReqId/drilldownReqId are FOUR independent latest-request-wins counters (stores/history.ts's own seriesReqId precedent), one per concern — unlike history.ts's tabs, every one of these fetches concurrently on a single Spend page render, so a single shared counter would make an in-flight ranking fetch spuriously discard a still-relevant breakdown fetch (or vice versa) merely for starting first. */
breakdownReqId: 0,
rankingReqId: 0,
pricingHealthReqId: 0,
burndownReqId: 0,
drilldownReqId: 0,
poller: undefined as VisibilityPoller | undefined,
}),
getters: {
/** burndownAvailable is false only for a provider-narrowed scope (see this store's own doc comment) — BurnDownChart.vue renders "not available for this scope" instead of an empty chart. */
burndownAvailable: (): boolean => burndownScopeParam(useFiltersStore().scope) !== null,
},
actions: {
/** setSelection applies by/metric/drill as one atomic change (stores/history.ts's own setSelection precedent) and fires exactly one refresh(), rather than each field's own setter independently clearing state and refetching. */
setSelection(next: { by?: SpendBy; metric?: HistoryMetric; drill?: string }): void {
let changed = false
if (next.by !== undefined && next.by !== this.by) {
this.by = next.by
changed = true
}
if (next.metric !== undefined && next.metric !== this.metric) {
this.metric = next.metric
changed = true
}
if (next.drill !== undefined && next.drill !== this.drill) {
this.drill = next.drill
changed = true
}
if (!changed) return
void this.refresh()
},
/**
* refresh re-fetches every section this store owns against the
* CURRENT by/metric/drill selection and the current global filters —
* the one action both the 60s poller and any selection/filter change
* call. fetchBreakdown is sequenced AFTER fetchModelRanking (not run
* in the same Promise.all) because by=model/by=provider both read
* `this.modelRanking` to decide which ids to chart — starting them
* concurrently would race fetchBreakdown against a still-in-flight (or
* still-empty, on first load) ranking. fetchBurndown/fetchDrilldown
* depend on neither and run fully in parallel with that chain.
*/
async refresh(): Promise<void> {
const auth = useAuthStore()
if (!auth.isAuthenticated) return
await Promise.all([
this.fetchModelRanking().then(() => this.fetchBreakdown()),
this.fetchPricingHealthRanking(),
this.fetchBurndown(),
this.fetchDrilldown(),
])
},
async fetchModelRanking(): Promise<void> {
const filters = useFiltersStore()
const requestId = ++this.rankingReqId
try {
const res = await adminFetch<AdminUsageModelsResponse>(
`/admin/api/usage/models?metric=${this.metric}&window=${filters.window}&span=${filters.span}&limit=${MODEL_RANKING_LIMIT}&detail=1`,
)
if (requestId !== this.rankingReqId) return
this.modelRanking = res.models
} catch (err) {
if (requestId !== this.rankingReqId) return
if (isAuthRejection(err)) return
this.error = messageOf(err)
}
},
/** fetchPricingHealthRanking is pricingHealthRanking's own fetch — always metric=req, independent of `this.metric` (see that field's own doc comment, P3 item 21). Runs in parallel with fetchModelRanking in refresh(), not chained after it: neither depends on the other's result. */
async fetchPricingHealthRanking(): Promise<void> {
const filters = useFiltersStore()
const requestId = ++this.pricingHealthReqId
try {
const res = await adminFetch<AdminUsageModelsResponse>(
`/admin/api/usage/models?metric=req&window=${filters.window}&span=${filters.span}&limit=${MODEL_RANKING_LIMIT}&detail=1`,
)
if (requestId !== this.pricingHealthReqId) return
this.pricingHealthRanking = res.models
} catch (err) {
if (requestId !== this.pricingHealthReqId) return
if (isAuthRejection(err)) return
this.error = messageOf(err)
}
},
async fetchBreakdown(): Promise<void> {
const filters = useFiltersStore()
const requestId = ++this.breakdownReqId
this.loading = true
try {
if (filters.scope === 'all') {
await this.fetchBreakdownFleetWide(filters.window, filters.span, requestId)
} else if (filters.scope.startsWith('group:') || filters.scope.startsWith('user:')) {
await this.fetchBreakdownSingleScope(filters.scope, filters.window, filters.span, requestId)
} else {
// provider:{name} — no direct cost/req/tokin/tokout series scope
// (see this store's own doc comment); derive from the model
// ranking's own ids, pre-filtered to that one provider.
await this.fetchBreakdownProviderScope(filters.scope.slice('provider:'.length), filters.window, filters.span, requestId)
}
if (requestId !== this.breakdownReqId) return
this.error = ''
if (filters.cmpSpec.requested) {
await this.fetchComparison(filters.scope, filters.window, filters.span, filters.cmpSpec.offset, requestId)
} else {
this.comparisonPoints = null
this.comparisonUnavailableReason = null
}
} catch (err) {
if (requestId !== this.breakdownReqId) return
if (isAuthRejection(err)) return
this.error = messageOf(err)
} finally {
if (requestId === this.breakdownReqId) this.loading = false
}
},
/**
* fetchBreakdownFleetWide honors `this.by` exactly (model/provider/
* group breakdown) — the ordinary, unscoped Spend view. The fleet-wide
* TOTAL is always fetched in its own single-scope request first (never
* folded into the breakdown request itself): by=provider/by=group can
* legitimately need up to MAX_SERIES_SCOPES ids on their own, and
* mixing 'total' into that same request would risk tipping it over
* the server's own scope-count cap.
*/
async fetchBreakdownFleetWide(window: HistoryWindow, span: number, requestId: number): Promise<void> {
const totalRes = await this.fetchSeries(['total'], this.metric, window, span, 0)
if (requestId !== this.breakdownReqId) return
this.buckets = totalRes.buckets
const total = totalRes.series.find((s) => s.scope === 'total')?.points ?? new Array<number>(totalRes.buckets.length).fill(0)
this.totalPoints = total
if (this.by === 'model') {
const ids = this.modelRanking.slice(0, MODEL_BREAKDOWN_TOP_N).map((m) => `model:${m.id}`)
if (ids.length === 0) {
this.breakdown = []
this.otherPoints = other(total, [])
return
}
const res = await this.fetchSeries(ids, this.metric, window, span, 0)
if (requestId !== this.breakdownReqId) return
const top = stackTopN(res.series, MODEL_BREAKDOWN_TOP_N)
this.breakdown = top
this.otherPoints = other(total, top)
return
}
if (this.by === 'provider') {
const ids = this.modelRanking.slice(0, MAX_SERIES_SCOPES).map((m) => `model:${m.id}`)
if (ids.length === 0) {
this.breakdown = []
this.otherPoints = null
return
}
const res = await this.fetchSeries(ids, this.metric, window, span, 0)
if (requestId !== this.breakdownReqId) return
this.breakdown = sumByProvider(res.series)
this.otherPoints = null
return
}
// by === 'group': one series per currently configured group, from
// GET /admin/api/usage/totals?kind=group (the authoritative group
// list) for the id list, then a real time series per group id.
const groupsRes = await adminFetch<AdminTotalsResponse>(totalsUrl({ kind: 'group', window, span, metrics: [this.metric] }))
if (requestId !== this.breakdownReqId) return
const groupIds = groupsRes.rows.slice(0, MAX_SERIES_SCOPES).map((r) => `group:${r.id}`)
if (groupIds.length === 0) {
this.breakdown = []
this.otherPoints = null
return
}
const res = await this.fetchSeries(groupIds, this.metric, window, span, 0)
if (requestId !== this.breakdownReqId) return
this.breakdown = res.series
this.otherPoints = null
},
/** fetchBreakdownSingleScope renders exactly one line — the narrowed group/user itself — ignoring `by` (nothing left to break down within one already-chosen entity). */
async fetchBreakdownSingleScope(scope: string, window: HistoryWindow, span: number, requestId: number): Promise<void> {
const res = await this.fetchSeries([scope], this.metric, window, span, 0)
if (requestId !== this.breakdownReqId) return
this.buckets = res.buckets
const points = res.series.find((s) => s.scope === scope)?.points ?? new Array<number>(res.buckets.length).fill(0)
this.breakdown = [{ scope, points }]
this.otherPoints = null
this.totalPoints = points
},
/** fetchBreakdownProviderScope sums the ranking's own model ids under this one provider (lib/series.ts's sumByProvider, applied to a pre-filtered set) — see this store's own doc comment on why a provider scope cannot use a direct series scope for cost/req/tokin/tokout. */
async fetchBreakdownProviderScope(providerName: string, window: HistoryWindow, span: number, requestId: number): Promise<void> {
const ids = this.modelRanking
.filter((m) => m.id.startsWith(`${providerName}/`))
.slice(0, MAX_SERIES_SCOPES)
.map((m) => `model:${m.id}`)
if (ids.length === 0) {
this.buckets = []
this.breakdown = []
this.otherPoints = null
this.totalPoints = []
return
}
const res = await this.fetchSeries(ids, this.metric, window, span, 0)
if (requestId !== this.breakdownReqId) return
this.buckets = res.buckets
const [summed] = sumByProvider(res.series)
const points = summed?.points ?? new Array<number>(res.buckets.length).fill(0)
this.breakdown = [{ scope: `provider:${providerName}`, points }]
this.otherPoints = null
this.totalPoints = points
},
async fetchComparison(scope: string, window: HistoryWindow, span: number, offset: number, requestId: number): Promise<void> {
const totalScope = scope === 'all' ? 'total' : scope.startsWith('provider:') ? null : scope
if (totalScope === null) {
// A provider scope's comparison line would need the same
// sumByProvider derivation as the primary series — skipped rather
// than doubling every provider-scope request; the dashed line
// simply does not render (TimeSeriesChart's own
// `comparisonDatasets` defaults to empty). Q5 requires a reason
// when a requested comparison is unavailable (P3 item 19) — this
// used to leave the reader guessing why "vs previous period" was
// pressed but no dashed line appeared.
this.comparisonPoints = null
this.comparisonUnavailableReason = 'Comparison is not available for a single-provider scope.'
return
}
const res = await this.fetchSeries([totalScope], this.metric, window, span, offset)
if (requestId !== this.breakdownReqId) return
this.comparisonPoints = res.series.find((s) => s.scope === totalScope)?.points ?? null
this.comparisonUnavailableReason = null
},
async fetchSeries(scope: string[], metric: string, window: HistoryWindow, span: number, offset: number): Promise<AdminSeriesResponse> {
return adminFetch<AdminSeriesResponse>(seriesUrl({ scope, metric, window, span, offset: offset || undefined }))
},
async fetchBurndown(): Promise<void> {
const filters = useFiltersStore()
const requestId = ++this.burndownReqId
const scope = burndownScopeParam(filters.scope)
if (scope === null) {
this.burndownBuckets = []
this.burndownPoints = []
this.burndownBudgetMicros = null
return
}
try {
const span = currentMonthDaySpan(new Date())
const res = await this.fetchSeries([scope], 'cost', 'day', span, 0)
if (requestId !== this.burndownReqId) return
this.burndownBuckets = res.buckets
this.burndownPoints = res.series.find((s) => s.scope === scope)?.points ?? new Array<number>(res.buckets.length).fill(0)
this.burndownBudgetMicros = burndownBudgetMicros(scope)
} catch (err) {
if (requestId !== this.burndownReqId) return
if (isAuthRejection(err)) return
this.error = messageOf(err)
}
},
async fetchDrilldown(): Promise<void> {
const filters = useFiltersStore()
const requestId = ++this.drilldownReqId
const level = drilldownLevel(this.drill)
try {
// usermodel only accepts day/month (redesign-plan.md section
// 1.3.v) — clamped via lib/range.ts's shared dayOrMonthWindow so
// an hour-resolution global range (24h/48h) never 400s BEFORE the
// server's own 404-when-disabled feature gate has a chance to run
// (stats_read.go: the window check runs first), which previously
// masked the "enable admin.stats.userModel" hint behind a generic
// error even when the feature really was off.
const params: TotalsUrlParams =
level.kind === 'usermodel'
? { kind: 'usermodel', ...dayOrMonthWindow(filters.window, filters.span), user: level.user }
: level.kind === 'user'
? { kind: 'user', window: filters.window, span: filters.span, group: level.group, metrics: ['req', 'tokin', 'tokout', 'cost'] }
: { kind: 'group', window: filters.window, span: filters.span, metrics: ['req', 'tokin', 'tokout', 'cost'] }
const res = await adminFetch<AdminTotalsResponse>(totalsUrl(params))
if (requestId !== this.drilldownReqId) return
this.drilldownRows = res.rows
this.drilldownMetrics = res.metrics
this.drilldownDisabled = false
} catch (err) {
if (requestId !== this.drilldownReqId) return
if (isAuthRejection(err)) return
if (err instanceof AdminApiError && err.status === 404) {
// usermodel with admin.stats.userModel off (redesign-plan.md
// section 1.3.v) — a known, named-in-the-hint state, not an
// error the reader needs to see in the status line.
this.drilldownRows = []
this.drilldownMetrics = []
this.drilldownDisabled = true
return
}
this.error = messageOf(err)
}
},
startPolling(): void {
if (this.poller) return
this.poller = createVisibilityPoller({ intervalMs: REFRESH_MS, tick: () => void this.refresh() })
this.poller.start()
},
stopPolling(): void {
this.poller?.stop()
this.poller = undefined
},
},
})
/** drilldownLevel derives AttributionDrilldown's current GET /admin/api/usage/totals request shape from the `drill` breadcrumb state (see useSpendStore's own state doc comment on `drill`). */
function drilldownLevel(drill: string): { kind: 'group' } | { kind: 'user'; group: string } | { kind: 'usermodel'; user: string } {
if (drill === '') return { kind: 'group' }
if (drill.startsWith('group:')) return { kind: 'user', group: drill.slice('group:'.length) }
return { kind: 'usermodel', user: drill.slice('user:'.length) }
}
/** burndownScopeParam maps the global scope filter to the /usage/series scope this store's burn-down line requests — null for a provider scope, which has no cost series and no budget concept (see useSpendStore's own doc comment). */
function burndownScopeParam(filtersScope: string): string | null {
if (filtersScope === 'all') return 'total'
if (filtersScope.startsWith('group:') || filtersScope.startsWith('user:')) return filtersScope
return null
}
/**
* burndownBudgetMicros resolves the configured cost/month budget (micro-
* USD) for the burn-down line's own scope: the fleet-wide sum-of-group
* budgets (Q2/DECISIONS: lib/kpi.ts's sumGroupBudgetMicros) for `total`,
* or that one group's/user's own configured costPerMonthUSD limit
* otherwise — read from stores/dashboard.ts's already-polled overview/
* usage data rather than a second network round trip.
*/
function burndownBudgetMicros(scope: string): number | null {
const dashboard = useDashboardStore()
if (scope === 'total') {
const total = sumGroupBudgetMicros(dashboard.overview?.groups ?? [])
return total > 0 ? total : null
}
if (scope.startsWith('group:')) {
const name = scope.slice('group:'.length)
const usd = dashboard.overview?.groups.find((g) => g.name === name)?.limits?.costPerMonthUSD
return usd && usd > 0 ? Math.round(usd * 1_000_000) : null
}
const name = scope.slice('user:'.length)
const usd = dashboard.usage?.users.find((u) => u.id === name)?.limits?.costPerMonthUSD
return usd && usd > 0 ? Math.round(usd * 1_000_000) : null
}
function isAuthRejection(err: unknown): boolean {
return err instanceof AdminApiError && (err.status === 401 || err.status === 403)
}
function messageOf(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}