fix(webui): keep charts consistent with the current selection

Chart and ranking fetches carry a request id, and a superseded response
is dropped. Changing a selection clears the old data at once, so old
numbers never show under new labels or units. The auto-refresh tick
skips while a fetch is still in flight. "Loading..." shows only until
the first result for a selection arrives. Dashboard polls do not
overlap.

Users in several groups are counted and searched in every group. The
Models tab labels its windows as the current UTC bucket. Hour labels
are marked UTC. The Members column sorts numerically. Target access
shows "All groups" for null and "No groups" for []. The UI shows the
server's own error message. Cost axis ticks use the currency
formatter. npm run dev proxies /admin/api. Nine unused shadcn
components are removed.

admin_assets_gen.go is regenerated from the new build.

Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
2026-09-23 09:44:32 +01:00
parent 45b650410d
commit 227200316d
38 changed files with 604 additions and 229 deletions
+25
View File
@@ -134,4 +134,29 @@ describe('useDashboardStore.refresh', () => {
expect(dashboard.error).toBe('')
})
// Review finding: the 5s poll interval had no in-flight guard, so a round
// trip slower than POLL_MS could stack a second refresh() on top of the
// first — twice the requests, free to resolve in either order.
it('skips a refresh() call that starts while one is already in flight', async () => {
let resolveOverview!: (v: AdminOverviewResponse) => void
mockedAdminFetch.mockImplementation((async (path: string) => {
if (path === '/admin/api/overview') return new Promise<AdminOverviewResponse>((resolve) => (resolveOverview = resolve))
if (path === '/admin/api/usage') return emptyUsage
if (path === '/admin/api/targets') return emptyTargets
throw new Error(`unexpected path ${path}`)
}) as typeof adminFetch)
const dashboard = useDashboardStore()
const first = dashboard.refresh() // overview never resolves yet — still "in flight"
const second = dashboard.refresh() // must skip entirely, not queue behind the first
resolveOverview(emptyOverview)
await Promise.all([first, second])
// One overview call for the first refresh(), none for the skipped
// second one — not two.
const overviewCalls = mockedAdminFetch.mock.calls.filter((c) => c[0] === '/admin/api/overview')
expect(overviewCalls).toHaveLength(1)
})
})
+22 -6
View File
@@ -25,12 +25,33 @@ export const useDashboardStore = defineStore('dashboard', {
lastUpdated: null as Date | null,
error: '',
timer: undefined as ReturnType<typeof setInterval> | undefined,
/**
* refreshing guards against overlapping polls (review finding): the 5s
* setInterval has no in-flight check on its own, so a round trip that
* takes longer than POLL_MS (a slow Redis behind /admin/api/overview,
* say) would otherwise stack a second refresh() on top of the first —
* two requests per section in flight at once, free to resolve in
* either order and briefly show older data after the newer one already
* landed. A tick that finds a refresh still in flight simply skips —
* the next tick (or the already-in-flight one finishing) catches up,
* so the dashboard never resorts to comparing response ordering.
*/
refreshing: false,
}),
actions: {
async refresh(): Promise<void> {
const auth = useAuthStore()
if (!auth.isAuthenticated) return
if (this.refreshing) return
this.refreshing = true
try {
await this.doRefresh()
} finally {
this.refreshing = false
}
},
/** doRefresh is refresh()'s actual body, split out so the in-flight guard above wraps it in one place rather than every early-return branch below needing its own `finally`. */
async doRefresh(): Promise<void> {
const [overviewResult, usageResult, targetsResult] = await Promise.allSettled([
adminFetch<AdminOverviewResponse>('/admin/api/overview'),
adminFetch<AdminUsageResponse>('/admin/api/usage'),
@@ -80,10 +101,5 @@ export const useDashboardStore = defineStore('dashboard', {
void this.refresh()
this.timer = setInterval(() => void this.refresh(), POLL_MS)
},
stopPolling(): void {
if (this.timer === undefined) return
clearInterval(this.timer)
this.timer = undefined
},
},
})
+140
View File
@@ -41,6 +41,20 @@ function routeFetch(handlers: { models?: () => unknown; series?: () => unknown }
})
}
/** 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())
@@ -148,3 +162,129 @@ describe('useHistoryStore model ranking', () => {
expect(history.error).toBe('ranking unavailable')
})
})
// 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 }]
history.setModelMetric('req')
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('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()
}
})
})
+82 -4
View File
@@ -77,13 +77,31 @@ export const WINDOW_SPAN: Record<HistoryWindow, number> = {
month: 12,
}
/** WINDOW_LABEL is the switcher's own display text for each window. */
/** WINDOW_LABEL is the switcher's own display text for each window, for the three TIME-SERIES tabs (requests/tokens/cost) — a real span of buckets, matching WINDOW_SPAN above. */
export const WINDOW_LABEL: Record<HistoryWindow, string> = {
hour: '24h',
day: '30d',
month: '12mo',
}
/**
* MODEL_WINDOW_LABEL is the Models tab's OWN window-switcher text — that
* tab shares the same three-way Tabs control as the time-series tabs
* (ChartsView.vue), but reads a fundamentally different query: modelTotals
* (limits.go) returns ONE counter at window's CURRENT bucket, never a span
* of WINDOW_SPAN buckets. Labeling that control "24h" when it actually
* means "since the top of the current UTC hour" understates how little
* data is behind it — at 14:05 UTC, "24h" would visually promise a full
* day while the ranking covers five minutes. "(UTC)" is explicit rather
* than implied, matching formatBucketLabel's own hour-bucket fix
* (lib/format.ts) — bucketFor (limits.go) always buckets in UTC.
*/
export const MODEL_WINDOW_LABEL: Record<HistoryWindow, string> = {
hour: 'This hour (UTC)',
day: 'Today (UTC)',
month: 'This month (UTC)',
}
/**
* 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
@@ -126,28 +144,65 @@ export const useHistoryStore = defineStore('history', {
/** Models with non-zero traffic, for the scope picker's model entries. */
modelOptions: [] as AdminUsageModelEntry[],
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: '',
timer: undefined as ReturnType<typeof setInterval> | 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 both the time-series bucket resolution AND the
// Models ranking's single current bucket — clear both.
this.seriesByMetric = {}
this.modelRanking = []
this.error = ''
this.loaded = false
void this.refresh()
},
setTab(tab: ChartTab): void {
if (tab === this.tab) return
this.tab = tab
this.seriesByMetric = {}
this.modelRanking = []
this.error = ''
this.loaded = false
void this.refresh()
},
setModelMetric(metric: ModelMetric): void {
if (metric === this.modelMetric) return
this.modelMetric = metric
this.modelRanking = []
this.error = ''
this.loaded = false
void this.refresh()
},
/**
@@ -162,6 +217,7 @@ export const useHistoryStore = defineStore('history', {
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]
@@ -173,38 +229,51 @@ export const useHistoryStore = defineStore('history', {
),
),
)
// 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 {
this.loading = false
// 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 res = await adminFetch<AdminUsageModelsResponse>(
`/admin/api/usage/models?metric=${this.modelMetric}&window=${this.window}&limit=${MODEL_RANKING_LIMIT}`,
)
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 {
this.loading = false
if (requestId === this.seriesReqId) this.loading = false
}
},
/**
@@ -219,10 +288,14 @@ export const useHistoryStore = defineStore('history', {
async fetchModelOptions(): Promise<void> {
const auth = useAuthStore()
if (!auth.isAuthenticated) return
const requestId = ++this.optionsReqId
try {
const res = await adminFetch<AdminUsageModelsResponse>(
`/admin/api/usage/models?metric=req&window=${this.window}&limit=${MODEL_OPTIONS_LIMIT}`,
)
// 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.
@@ -230,7 +303,12 @@ export const useHistoryStore = defineStore('history', {
},
startAutoRefresh(): void {
if (this.timer !== undefined) return
this.timer = setInterval(() => void this.refresh(), REFRESH_MS)
// Skip a tick while the previous fetch is still in flight: each fetch
// bumps seriesReqId, so an unconditional tick would supersede (and
// discard) any request slower than REFRESH_MS, forever.
this.timer = setInterval(() => {
if (!this.loading) void this.refresh()
}, REFRESH_MS)
},
stopAutoRefresh(): void {
if (this.timer === undefined) return