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

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

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

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

Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
2026-09-23 16:41:01 +01:00
parent e8abb91dc8
commit ee49467e64
127 changed files with 11226 additions and 3414 deletions
+98
View File
@@ -0,0 +1,98 @@
import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import type { AdminCatalogModel, AdminCatalogResponse } from '@/types/api'
/**
* How long a fetched catalog stays fresh before ensureLoaded() will fetch
* again (redesign-plan.md section 3.3: "catalog (/catalog on demand,
* 5-minute stale)"). GET /admin/api/catalog reads the registry snapshot —
* no live counter round trip (admin_catalog.go's own doc comment) — so it
* changes only on a discovery refresh or a config reload, far slower than
* the 5s overview poll; every page that needs it (Spend's pricing health
* and cost-avoided reference picker, WP-F's Models catalog table) can
* treat a 5-minute-old copy as current rather than re-fetching on every
* mount.
*/
const STALE_MS = 5 * 60 * 1000
/**
* useCatalogStore fetches GET /admin/api/catalog on demand (never part of
* the 5s dashboard poll, Q12/DECISIONS) and indexes it by canonical
* "provider/model" id (admin_catalog.go's providerModelScopeID — the SAME
* id AdminUsageModelEntry.id and AdminSeriesResponse's `model:{canonical}`
* scope already use, so every consumer joins against it with a plain map
* lookup, no id reformatting).
*
* Lands FIRST in WP-D (redesign-plan.md section 4): WP-F's Models page
* (ModelCatalogTable.vue, ProviderHealthPanel.vue) depends on this store
* existing before it can build against it.
*/
export const useCatalogStore = defineStore('catalog', {
state: () => ({
data: null as AdminCatalogResponse | null,
fetchedAt: null as Date | null,
loading: false,
error: '',
/** reqId guards against an out-of-order response overwriting a newer one — the same latest-request-wins convention every other store's own fetch action follows (stores/dashboard.ts, stores/spend.ts). */
reqId: 0,
}),
getters: {
/** stale is true before the first successful fetch, or once STALE_MS has elapsed since it — ensureLoaded's own refetch condition, also readable by a caller that wants to show "catalog data may be out of date" copy. */
stale(state): boolean {
if (!state.fetchedAt) return true
return Date.now() - state.fetchedAt.getTime() > STALE_MS
},
/**
* modelsById indexes every provider's models by canonical id — the one
* join key every consumer (PricingHealthTable, CostAvoidedCard's
* reference-model picker, WP-F's ModelCatalogTable) needs, computed
* once per fetch (a Pinia getter is cached per state reference) rather
* than every consumer re-scanning `data.providers` itself.
*/
modelsById(state): Map<string, AdminCatalogModel> {
const map = new Map<string, AdminCatalogModel>()
for (const provider of state.data?.providers ?? []) {
for (const model of provider.models) map.set(model.id, model)
}
return map
},
},
actions: {
/**
* ensureLoaded fetches only when the catalog has never loaded, or has
* gone stale (STALE_MS) — every page that reads catalog data calls
* this from its own onMounted rather than unconditionally fetching, so
* navigating between Spend/Models repeatedly does not re-pay this read
* every time. `force` bypasses the staleness check (a reader-initiated
* "refresh catalog" action, if one is ever added).
*/
async ensureLoaded(force = false): Promise<void> {
if (!force && this.data && !this.stale) return
await this.fetch()
},
async fetch(): Promise<void> {
const auth = useAuthStore()
if (!auth.isAuthenticated) return
const requestId = ++this.reqId
this.loading = true
try {
const res = await adminFetch<AdminCatalogResponse>('/admin/api/catalog')
// Stale: a newer fetch() call already superseded this one.
if (requestId !== this.reqId) return
this.data = res
this.fetchedAt = new Date()
this.error = ''
} catch (err) {
if (requestId !== this.reqId) return
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) {
return
}
this.error = err instanceof Error ? err.message : String(err)
} finally {
if (requestId === this.reqId) this.loading = false
}
},
},
})