Files
traefik-llmgateway/webui/src/stores/catalog.ts
T
lukaszraczylo 2608e01c50 refactor(webui): extract repeated dashboard elements into shared components
Markup and helpers that were copied between pages now live in one
place:
- ChartCard, StatDisabledAlert, StatItem, DetailField, BudgetRatioBars,
  LabeledSelect, TargetPicker and LoadStateView components
- lib/scope.ts, lib/columns.ts and lib/chart-palette.ts
- messageOf, isAuthRejection, and the USD to micro-USD conversion
  helpers
- one monthly cost projection, one date formatter, and the
  useCopyFeedback composable

Behaviour is unchanged. Before and after screenshots of every page
match at 390 and 1440 px. Empty-state text now comes from the shared
EmptyState component, and the user run-out date uses the short date
format. admin_assets_gen.go is regenerated.

Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
2026-09-24 06:04:46 +01:00

99 lines
4.2 KiB
TypeScript

import { defineStore } from 'pinia'
import { adminFetch, isAuthRejection, messageOf } 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 (isAuthRejection(err)) {
return
}
this.error = messageOf(err)
} finally {
if (requestId === this.reqId) this.loading = false
}
},
},
})