mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-16 05:37:28 +00:00
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
147 lines
4.7 KiB
TypeScript
147 lines
4.7 KiB
TypeScript
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)
|
|
})
|
|
})
|