mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-16 05:37:28 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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()
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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).
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
@@ -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: '',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user