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
+68 -119
View File
@@ -1,148 +1,97 @@
import { computed, onMounted, onUnmounted, watch } from 'vue'
import {
buildHash,
parseChartsParams,
parseEventsParams,
parseHashState,
parseUsageParams,
} from '@/lib/hash-state'
import { useEventsStore } from '@/stores/events'
import { useHistoryStore } from '@/stores/history'
import { buildHash, parseGlobalParams, parseHashState, parsePageParams } from '@/lib/hash-state'
import { DEFAULT_CMP, DEFAULT_RANGE } from '@/lib/range'
import { DEFAULT_SCOPE, useFiltersStore } from '@/stores/filters'
import { useNavStore } from '@/stores/nav'
import type { ChartTab, ModelMetric } from '@/stores/history'
import type { HistoryWindow } from '@/types/api'
/** The five defaults every per-tab param omits from the built hash when the field is already at its default value (buildHash's own "caller decides what's default" contract, lib/hash-state.ts). */
const DEFAULT_WINDOW: HistoryWindow = 'hour'
const DEFAULT_CHART_TAB: ChartTab = 'requests'
/**
* DEFAULT_METRIC mirrors stores/history.ts's own initial `modelMetric`
* state (free-models-plan.md: 'req', not 'cost' — a cost-default ranking
* hides every free model). An omitted `metric` hash param, or one this
* build does not recognize (parseChartsParams' own independent-field
* validation, lib/hash-state.ts), falls back to this value here; an
* explicit `metric=cost` in the hash still parses and applies normally.
*/
const DEFAULT_METRIC: ModelMetric = 'req'
const DEFAULT_SCOPE = 'total'
const DEFAULT_MODEL_FILTER = ''
/**
* useHashState (F9, dashboard-plan.md) is the single composable that keeps
* the URL hash and every store it names in sync, both ways:
* useHashState (redesign-plan.md section 3.1) is the single composable
* that keeps the URL hash and the two shell stores it drives — stores/
* filters.ts (range/cmp/scope) and stores/nav.ts (page/params) — in sync,
* both ways:
*
* - On creation, it parses the CURRENT hash exactly once and applies it to
* stores/nav.ts (activeTab, usageQuery), stores/history.ts (Charts'
* scope/window/tab/modelMetric/modelFilter), and stores/events.ts (kindFilter/
* userFilter) — "on load: read hash -> restore state" for all three, not
* just the active tab the old useTabHash() composable covered.
* - A single computed re-derives the hash from whichever store the
* CURRENT nav.activeTab reads from, and writes it back via
* history.replaceState whenever it changes — never pushState (one
* browser-history entry per admin session, not per click) and never a
* bare `location.hash =` assignment (which would also jump scroll
* position to any element whose id happens to match).
* - On creation, it parses the CURRENT hash exactly once and applies it:
* the three global filters to filters.setFilters, and the page plus
* its own opaque param record to nav.goTo. Every per-page STORE
* (spend/consumers/models/reliability, each owned by its own work
* package) is responsible for reading nav.params itself when its page
* mounts — this composable never touches a page-specific store, by
* design (stores/nav.ts's own doc comment).
* - A single computed re-derives the hash from filters' current state
* plus nav.page/nav.params, and writes it back via history.
* replaceState whenever it changes — never pushState (one browser-
* history entry per admin session, not per click) and never a bare
* `location.hash =` assignment (which would also jump scroll position
* to any element whose id happens to match).
* - It listens for the browser's own `hashchange` event (back/forward
* navigation, or a hand-edited URL) and re-parses from scratch,
* reapplying the same restore path as the initial read.
*
* MUST be called from App.vue's setup, before the `v-if="!auth.
* isAuthenticated"` branch (dashboard-plan.md's own Risks section:
* "load-with-hash-then-authenticate") — every store action this touches
* (history.setScope, etc.) is safe to call before a key is stored (each
* one's own fetch no-ops on an unauthenticated auth store), so the hash's
* selection is captured immediately and is already in place the moment
* the reader authenticates, rather than being lost because this composable
* was never constructed while the AuthGate was showing.
* isAuthenticated"` branch — filters.setFilters/nav.goTo are plain
* synchronous state writes, safe to call before an admin key is stored,
* so the hash's selection is already restored by the time the reader
* authenticates.
*
* Guarded for a `window`-less environment (vitest's node test
* environment, vite.config.ts) the same way useTabHash's old
* implementation was: every DOM/history/location access sits behind one
* hasWindow check computed once. Being a real Vue lifecycle-hook user
* (onMounted/onUnmounted) exercised through App.vue in the real browser,
* not unit-tested directly — lib/hash-state.ts's own pure functions are
* what hash-state.spec.ts covers.
* environment, vite.config.ts): every DOM/history/location access sits
* behind one hasWindow check computed once. Exercised through App.vue in
* the real browser, not unit-tested directly — lib/hash-state.ts's own
* pure functions are what hash-state.spec.ts covers.
*/
export function useHashState(): void {
const filters = useFiltersStore()
const nav = useNavStore()
const history = useHistoryStore()
const events = useEventsStore()
function applyFromHash(hash: string): void {
const { tab, query } = parseHashState(hash)
nav.activeTab = tab
if (tab === 'charts') {
// P7 review fix: this used to apply ONLY the params present in the
// hash, so a PARTIAL Charts hash on hashchange (back/forward
// navigation, or the reader hand-editing the URL down to a bare
// "#charts") left every OMITTED field exactly as it was — buildHash
// itself treats an omitted param as "use the default" (its own doc
// comment), but this restore path never actually reset anything to
// that default, so the address bar and the rendered chart silently
// disagreed. Every field is now applied on EVERY call — explicitly
// falling back to its own default when the hash omits it — so a
// partial or bare hash genuinely resets whatever it doesn't name —
// including `filter` (P7 review follow-up): the Models tab's
// provider-prefix filter is a hash param now (lib/hash-state.ts's
// ChartsHashParams.filter), so it falls back to
// DEFAULT_MODEL_FILTER exactly like every other field here, rather
// than being left over from whatever the store already had.
//
// P12 review fix: applied through ONE history.setSelection() call
// (batches the state writes and fires exactly one refresh(), plus
// one fetchModelOptions() only when window is part of the change)
// instead of up to four separate setWindow/setTab/setModelMetric/
// setScope calls, each of which used to fire its OWN refresh() —
// every fetch but the last immediately discarded.
const p = parseChartsParams(query)
history.setSelection({
window: p.window ?? DEFAULT_WINDOW,
tab: p.tab ?? DEFAULT_CHART_TAB,
modelMetric: p.metric ?? DEFAULT_METRIC,
scope: p.scope ?? DEFAULT_SCOPE,
modelFilter: p.filter ?? DEFAULT_MODEL_FILTER,
})
} else if (tab === 'usage') {
const p = parseUsageParams(query)
nav.usageQuery = p.q ?? ''
} else if (tab === 'events') {
const p = parseEventsParams(query)
events.setKindFilter(p.kind ?? '')
events.setUserFilter(p.user ?? '')
}
const { page, query } = parseHashState(hash)
const globals = parseGlobalParams(query)
filters.setFilters({
range: globals.range ?? DEFAULT_RANGE,
cmp: globals.cmp ?? DEFAULT_CMP,
scope: globals.scope ?? DEFAULT_SCOPE,
})
nav.goTo(page, parsePageParams(query))
}
const hasWindow = typeof window !== 'undefined'
if (hasWindow) applyFromHash(window.location.hash)
const currentHash = computed<string>(() => {
if (nav.activeTab === 'charts') {
const params: Record<string, string> = {}
if (history.window !== DEFAULT_WINDOW) params.window = history.window
if (history.tab !== DEFAULT_CHART_TAB) params.tab = history.tab
if (history.modelMetric !== DEFAULT_METRIC) params.metric = history.modelMetric
if (history.scope !== DEFAULT_SCOPE) params.scope = history.scope
if (history.modelFilter !== DEFAULT_MODEL_FILTER) params.filter = history.modelFilter
return buildHash(nav.activeTab, params)
}
if (nav.activeTab === 'usage') {
return buildHash(nav.activeTab, nav.usageQuery ? { q: nav.usageQuery } : {})
}
if (nav.activeTab === 'events') {
const params: Record<string, string> = {}
if (events.kindFilter) params.kind = events.kindFilter
if (events.userFilter) params.user = events.userFilter
return buildHash(nav.activeTab, params)
}
return buildHash(nav.activeTab, {})
const params: Record<string, string> = {}
if (filters.range !== DEFAULT_RANGE) params.range = filters.range
if (filters.cmp !== DEFAULT_CMP) params.cmp = filters.cmp
if (filters.scope !== DEFAULT_SCOPE) params.scope = filters.scope
// Page params are merged in AFTER the three global keys, so the
// built query string always lists range/cmp/scope first — readable,
// and matches redesign-plan.md section 3.1's own example ordering.
// No current page uses a param named range/cmp/scope (see stores/
// nav.ts's own doc comment on `params` staying opaque), so this
// merge never actually overwrites one of the three above in
// practice; it is a plain Object.assign, not a defensive filter.
Object.assign(params, nav.params)
return buildHash(nav.page, params)
})
watch(currentHash, (hash) => {
if (!hasWindow) return
if (window.location.hash === hash) return
window.history.replaceState(null, '', hash)
})
// `immediate: true` (P3 item 24): applyFromHash already normalized
// filters/nav state from window.location.hash above, synchronously,
// before this watch is even registered — so currentHash's FIRST
// computed value is already the canonical hash for whatever the reader
// arrived with. Without `immediate`, that first value is never written
// back until some LATER state change happens to fire the watch, so a
// legacy or garbage hash (`#charts`, `#nope?range=30d`) stayed in the
// address bar — correct app state, stale URL — until the reader
// happened to touch a filter.
watch(
currentHash,
(hash) => {
if (!hasWindow) return
if (window.location.hash === hash) return
window.history.replaceState(null, '', hash)
},
{ immediate: true },
)
function onHashChange(): void {
applyFromHash(window.location.hash)
+22 -15
View File
@@ -11,7 +11,7 @@ import { createVisibilityPoller, type VisibilityPoller } from '@/lib/polling'
const NOW_TICK_MS = 60_000
export interface UseNow {
/** The shared clock — every time-dependent read on the Usage tab (CostForecastCard's elapsedFraction, usageColumns()'s own `now` parameter, UsageView.vue's groupCostProjection) reads THIS ref, never its own `new Date()`. */
/** The shared clock — every time-dependent read on the Consumers page (CostForecastCard's elapsedFraction, usageColumns()'s own `now` parameter, ConsumerDirectory.vue's groupCostProjection, UserDetail.vue's runOutDate) reads THIS ref, never its own `new Date()`. */
now: Ref<Date>
/** Idempotent, mirrors createVisibilityPoller's own start() — call from the owning component's onMounted. */
start: () => void
@@ -20,9 +20,12 @@ export interface UseNow {
}
/**
* useNow (P1) is the ONE reactive clock every time-dependent read on the
* Usage tab shares, instead of each freezing its own `new Date()` at mount
* or re-deriving a fresh one on every render:
* useNow (P1) is the ONE reactive clock every time-dependent read across
* the admin UI shares (today: SpendPage.vue's cost forecast, UserDetail.vue's
* run-out date, ConsumerDirectory.vue's group cost projection — see this
* file's own UseNow doc comment for the full current-caller list), instead
* of each freezing its own `new Date()` at mount or re-deriving a fresh one
* on every render:
*
* - CostForecastCard.vue used `withDefaults(..., { now: () => new Date() })`
* — Vue caches a prop's default once per component instance
@@ -30,12 +33,14 @@ export interface UseNow {
* instant the card happened to mount, never advancing again.
* - usage-columns.ts's usageColumns() takes `now` as a plain parameter,
* evaluated once per call — UsageTable.vue's `computed(() =>
* usageColumns(...))` and UsageView.vue's headless Groups toolbar
* columns both called it with no reactive dependency on the current
* time, so neither ever recomputed either.
* - UsageView.vue's groupCostProjection called `new Date()` fresh on every
* invocation instead, so the group grid could disagree with the above
* two on what "now" even was, mid-render.
* usageColumns(...))` and the pre-redesign Usage tab's own headless
* Groups toolbar columns (UsageView.vue, deleted) both called it with no
* reactive dependency on the current time, so neither ever recomputed
* either.
* - The pre-redesign Usage tab's own groupCostProjection (UsageView.vue,
* deleted) called `new Date()` fresh on every invocation instead, so the
* group grid could disagree with the above two on what "now" even was,
* mid-render.
*
* Driven by the shared createVisibilityPoller (lib/polling.ts — the same
* primitive every store's own poll timer uses): ticks once immediately on
@@ -43,11 +48,13 @@ export interface UseNow {
* hidden and catching up the moment it is looked at again (and, per P13,
* never ticking at all if started while already hidden).
*
* This composable does NOT call onMounted/onUnmounted itself — the caller
* (UsageView.vue) owns its lifecycle explicitly via the returned start/stop,
* the same reason lib/polling.ts itself takes no lifecycle-hook dependency:
* it keeps this composable callable, and testable, outside a mounted
* component.
* This composable does NOT call onMounted/onUnmounted itself — each of its
* three current callers (SpendPage.vue, UserDetail.vue,
* ConsumerDirectory.vue) owns its OWN clock instance's lifecycle
* explicitly via the returned start/stop (`onMounted(startClock)` /
* `onUnmounted(stopClock)`), the same reason lib/polling.ts itself takes
* no lifecycle-hook dependency: it keeps this composable callable, and
* testable, outside a mounted component.
*/
export function useNow(intervalMs = NOW_TICK_MS): UseNow {
const now = ref(new Date())
+4 -2
View File
@@ -24,8 +24,10 @@ describe('useSearchQuery', () => {
// F9 (hash-state): a caller can supply the ref this composable reads and
// writes instead of a private local one, so the query round-trips
// through an external owner (nav.usageQuery, UsageView.vue) rather than
// being lost on remount.
// through an external owner (e.g. ConsumerDirectory.vue's own
// `nav.params.q`-backed computed, or EventsView.vue's store-backed
// `userFilter` — useSearchQuery.ts's own doc comment) rather than being
// lost on remount.
describe('with an external ref', () => {
it('reads and derives from the external ref\'s current value, rather than starting blank', () => {
const external = ref('preset')
+22 -16
View File
@@ -3,14 +3,15 @@ import { computed, ref } from 'vue'
/**
* useSearchQuery is the one raw-query → {normalized, hasQuery} adapter
* every search filter in this panel uses — ProvidersView's model search,
* UsageView's user/group search, ChartsView's scope-picker search all had
* their own copy of this identical query/normalizedQuery/hasQuery
* pairing; extracted so it exists once (vue.md: "if you've written it
* twice, you owe an abstraction"). Callers still own their own matching
* logic entirely (ProvidersView's modelMatches/providerMatches,
* lib/usage-search.ts's helpers) — this only normalizes the raw input the
* same way every call site already agreed on: trimmed, lowercased.
* every search filter in this panel uses — ProviderHealthPanel's
* model/provider search, ConsumerDirectory's user/group search
* (lib/usage-search.ts), EventsView's user/group filter, TargetsView's
* target search, each with its own matching logic but the identical
* query/normalizedQuery/hasQuery pairing; extracted so it exists once
* (vue.md: "if you've written it twice, you owe an abstraction"). Callers
* still own their own matching logic entirely — this only normalizes the
* raw input the same way every call site already agreed on: trimmed,
* lowercased.
*
* No `clear()` here — clearing is just setting `query.value = ''`, and
* every caller already gets that for free from SearchInput.vue's own
@@ -19,14 +20,19 @@ import { computed, ref } from 'vue'
* caller needs.
*
* `external` (F9, hash-state) lets a caller supply the ref this composable
* reads and writes instead of owning a private local one — UsageView.vue
* binds `nav.usageQuery` (stores/nav.ts) so the Usage tab's own search
* text round-trips through the URL hash (lib/hash-state.ts's `q` param)
* the same way ChartsView's scope/window/tab selection already does.
* Every OTHER caller (ProvidersView, ChartsView's own scope-picker search,
* EventsView) omits it and keeps its private, hash-independent ref exactly
* as before — passing `external` changes nothing about `normalized`/
* `hasQuery`'s own derivation, only where the raw value itself lives.
* reads and writes instead of owning a private local one, so that ref's
* own search text round-trips through the URL hash. Two current callers
* pass it: ConsumerDirectory.vue binds a local `computed` get/set over
* `nav.params.q` directly (stores/nav.ts's own `params` is opaque, so the
* page owns interpreting/writing its own `q` key — see that computed's
* own doc comment for why this replaced an earlier, page-specific
* `nav.usageQuery` special case), and EventsView.vue binds
* `storeToRefs(events).userFilter`, which composables/useHashState.ts
* reads/writes directly against the same hash. Every OTHER caller
* (ProviderHealthPanel's model/provider search, TargetsView's target
* search) omits it and keeps its private, hash-independent ref instead —
* passing `external` changes nothing about `normalized`/`hasQuery`'s own
* derivation, only where the raw value itself lives.
*/
export function useSearchQuery(external?: Ref<string>) {
const query = external ?? ref('')
-49
View File
@@ -1,49 +0,0 @@
import { describe, expect, it } from 'vitest'
import { hashToTab, tabToHash } from './useTabHash'
const TAB_VALUES = ['providers', 'usage', 'charts', 'targets'] as const
describe('hashToTab', () => {
it('maps a recognized hash (with leading #) to its tab value', () => {
expect(hashToTab('#usage', TAB_VALUES, 'providers')).toBe('usage')
})
it('maps a recognized hash without a leading # too (defensive: a caller might pass window.location.hash.slice(1) already)', () => {
expect(hashToTab('charts', TAB_VALUES, 'providers')).toBe('charts')
})
it('falls back to defaultTab for an empty hash (absent, load with no hash at all)', () => {
expect(hashToTab('', TAB_VALUES, 'providers')).toBe('providers')
})
it('falls back to defaultTab for a bare "#" (empty fragment)', () => {
expect(hashToTab('#', TAB_VALUES, 'providers')).toBe('providers')
})
it('falls back to defaultTab for an unrecognized hash (hand-edited URL)', () => {
expect(hashToTab('#nonexistent-tab', TAB_VALUES, 'providers')).toBe('providers')
})
it('is case-sensitive: a differently-cased match still falls back to defaultTab', () => {
expect(hashToTab('#Providers', TAB_VALUES, 'providers')).toBe('providers')
})
it('every valid tab round-trips to itself', () => {
for (const tab of TAB_VALUES) {
expect(hashToTab(`#${tab}`, TAB_VALUES, 'providers')).toBe(tab)
}
})
})
describe('tabToHash', () => {
it('prefixes the tab value with "#"', () => {
expect(tabToHash('targets')).toBe('#targets')
})
it('round-trips through hashToTab back to the same tab', () => {
for (const tab of TAB_VALUES) {
expect(hashToTab(tabToHash(tab), TAB_VALUES, 'providers')).toBe(tab)
}
})
})
-52
View File
@@ -1,52 +0,0 @@
// Feature C (v0.22) introduced hashToTab/tabToHash below, plus a
// useTabHash() composable wrapping them. F9 (dashboard-plan.md) supersedes
// that composable with useHashState() (composables/useHashState.ts):
// App.vue now needs a single hash that carries BOTH the active tab AND
// each tab's own query-string state (scope/window/tab/metric for Charts, q
// for Usage, kind/user for Events — lib/hash-state.ts), not the tab alone,
// so useTabHash() itself was removed as the now-superseded, unreachable
// half of this file. hashToTab/tabToHash stay: useHashState.ts still
// builds the OUTER "#<tab>" portion of the combined hash directly on top
// of these same two pure functions, and this module's own spec exercises
// them directly (vite.config.ts's `test` block runs in a Node environment
// — no `window`, no component mount).
//
// TAB_VALUES/TabValue also live here — the single source of truth for the
// app's tab union (vue.md: "share types once, import everywhere"):
// stores/nav.ts's `activeTab`, composables/useHashState.ts, and App.vue's
// own Tabs all import the SAME union rather than each redeclaring it.
export const TAB_VALUES = ['providers', 'usage', 'charts', 'targets', 'events'] as const
export type TabValue = (typeof TAB_VALUES)[number]
/**
* hashToTab maps a raw location.hash string (with or without its leading
* "#") to one of validTabs. An absent, empty, or unrecognized hash
* returns defaultTab — never throws, and never returns a value outside
* validTabs. Matching is a plain string comparison: a tab value is
* already a fixed lowercase identifier (App.vue's TabsTrigger value=
* strings), never re-cased anywhere in this pipeline.
*
* Two intentional URL-normalization quirks (folded review minors, v0.22
* review round), both worth naming rather than leaving implicit:
*
* 1. Case sensitivity: "#Providers" does NOT match "providers" — it falls
* back to defaultTab like any other unrecognized hash, rather than
* being normalized case-insensitively. A URL a person typed or edited
* by hand with different casing is treated as invalid, not corrected.
* 2. An invalid or absent hash is normalized only in memory (this
* function's own return value), never in the address bar itself:
* useHashState's initial read (composables/useHashState.ts) never
* calls history.replaceState on its own — only a LATER state change
* does (see its own doc comment) — so loading "#bogus" shows the
* Providers tab while the address bar still reads "#bogus" until the
* reader switches tabs, or something else changes, at least once.
*/
export function hashToTab<T extends string>(hash: string, validTabs: readonly T[], defaultTab: T): T {
const raw = hash.startsWith('#') ? hash.slice(1) : hash
return (validTabs as readonly string[]).includes(raw) ? (raw as T) : defaultTab
}
/** tabToHash is hashToTab's inverse: the URL hash (leading "#" included) one tab value maps to. */
export function tabToHash(tab: string): string {
return `#${tab}`
}