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
+78
View File
@@ -0,0 +1,78 @@
// api.spec.ts covers adminFetch's own error handling (lib/api.ts) — unlike
// history.spec.ts/dashboard.spec.ts, which mock this module entirely to test
// their OWN callers, this file mocks the global `fetch` instead so
// adminFetch's real body runs. Same 'node' vitest environment as those
// files; useAuthStore's sessionStorage access degrades harmlessly (see
// key-storage.ts's own try/catch doc comment) with no DOM present.
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AdminApiError, adminFetch } from './api'
import { useAuthStore } from '@/stores/auth'
function jsonResponse(status: number, body: unknown): Response {
return {
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(body),
} as Response
}
describe('adminFetch', () => {
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().submit('test-admin-key')
vi.stubGlobal('fetch', vi.fn())
})
it('resolves the decoded JSON body on a 2xx response', async () => {
vi.mocked(fetch).mockResolvedValue(jsonResponse(200, { hello: 'world' }))
await expect(adminFetch('/admin/api/overview')).resolves.toEqual({ hello: 'world' })
})
// Review finding: a failed response's own error envelope (errors.go's
// writeOAIError: {"error":{"message","type","code"}}) was previously
// discarded entirely — every failure surfaced as a bare
// "/admin/api/...: HTTP 503", regardless of what the server actually said
// went wrong.
it('surfaces the server error envelope\'s message instead of a bare "HTTP <status>"', async () => {
vi.mocked(fetch).mockResolvedValue(
jsonResponse(503, { error: { message: 'usage history store unavailable', type: 'server_error', code: '503' } }),
)
await expect(adminFetch('/admin/api/usage/history')).rejects.toMatchObject({
message: 'usage history store unavailable',
status: 503,
})
})
it('falls back to the path/status text when the failed body is not the expected envelope shape', async () => {
vi.mocked(fetch).mockResolvedValue(jsonResponse(500, { unexpected: 'shape' }))
await expect(adminFetch('/admin/api/overview')).rejects.toMatchObject({
message: '/admin/api/overview: HTTP 500',
status: 500,
})
})
it('falls back to the path/status text when the failed body is not valid JSON at all (a proxy error page)', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
status: 502,
json: () => Promise.reject(new SyntaxError('Unexpected token < in JSON')),
} as Response)
await expect(adminFetch('/admin/api/overview')).rejects.toMatchObject({
message: '/admin/api/overview: HTTP 502',
status: 502,
})
})
it('still rejects the stored key on a 401, with the server message carried on the thrown error', async () => {
vi.mocked(fetch).mockResolvedValue(jsonResponse(401, { error: { message: 'invalid key' } }))
const auth = useAuthStore()
await expect(adminFetch('/admin/api/overview')).rejects.toBeInstanceOf(AdminApiError)
expect(auth.isAuthenticated).toBe(false)
expect(auth.error).toBe('invalid key, or not an admin')
})
})
+28 -9
View File
@@ -13,6 +13,25 @@ export class AdminApiError extends Error {
}
}
/**
* serverErrorMessage reads the server's own error envelope off a failed
* response (errors.go's writeOAIError: {"error":{"message","type","code"}}),
* so a 503 "usage history store unavailable" or a 404 "unknown user id"
* reaches the UI instead of a bare "/admin/api/...: HTTP 503". Falls back to
* that plain path/status text whenever the body is not that shape — an
* empty body, a proxy error page, or any other non-JSON response — so a
* malformed error body can never itself throw out of adminFetch.
*/
async function serverErrorMessage(resp: Response, path: string): Promise<string> {
const fallback = `${path}: HTTP ${resp.status}`
try {
const body = (await resp.json()) as { error?: { message?: string } }
return body.error?.message || fallback
} catch {
return fallback
}
}
/**
* adminFetch issues a GET against one of the three /admin/api/* routes with
* the stored admin key attached as `x-api-key`, and decodes the JSON body.
@@ -29,16 +48,16 @@ export async function adminFetch<T>(path: string): Promise<T> {
const resp = await fetch(path, { credentials: 'same-origin', headers })
if (resp.status === 401 || resp.status === 403) {
// Late-401 guard: only reject the key that is STILL the one stored —
// see authStore.reject's own doc comment for why.
if (auth.apiKey === key) {
auth.reject('invalid key, or not an admin')
}
throw new AdminApiError(`${path}: HTTP ${resp.status}`, resp.status)
}
if (!resp.ok) {
throw new AdminApiError(`${path}: HTTP ${resp.status}`, resp.status)
const message = await serverErrorMessage(resp, path)
if (resp.status === 401 || resp.status === 403) {
// Late-401 guard: only reject the key that is STILL the one stored —
// see authStore.reject's own doc comment for why.
if (auth.apiKey === key) {
auth.reject('invalid key, or not an admin')
}
}
throw new AdminApiError(message, resp.status)
}
return (await resp.json()) as T
}
+27 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { formatCompactCount, formatContextWindow, formatElapsedAgo, formatLatencyMs, formatModelCostHover, formatUntil, refreshDetailLabel, refreshLabel, ZERO_TIME } from './format'
import { formatBucketLabel, formatCompactCount, formatContextWindow, formatElapsedAgo, formatLatencyMs, formatModelCostHover, formatUntil, refreshDetailLabel, refreshLabel, ZERO_TIME } from './format'
describe('refreshLabel', () => {
it('reads "discovery off" when discovery is disabled, regardless of lastRefresh', () => {
@@ -182,6 +182,32 @@ describe('formatLatencyMs', () => {
})
})
// History chart bucket labels (limits.go's windowKey: hour/day/month
// fixed-width digit strings, bucketFor always formats in UTC). Review
// finding: the hour case previously rendered its bucket's bare hour digits
// with no timezone indicator, reading as local time to a reader outside
// UTC even though bucketFor never buckets in local time.
describe('formatBucketLabel', () => {
it('labels an hour bucket with an explicit "UTC" suffix, not a bare local-looking hour', () => {
expect(formatBucketLabel('2026082114', 'hour')).toBe('14:00 UTC')
})
it('labels a day bucket as a calendar date, no timezone suffix needed', () => {
// toLocaleDateString(undefined, ...) follows the RUNNER's own default
// locale (e.g. "Aug 21" under en-US, "21 Aug" under en-GB) — the
// expectation is computed the identical way, not hardcoded, so this
// stays locale-independent rather than pinning one CI environment's
// ordering.
const want = new Date(2026, 7, 21).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
expect(formatBucketLabel('20260821', 'day')).toBe(want)
})
it('labels a month bucket as a calendar month/year, no timezone suffix needed', () => {
const want = new Date(2026, 7, 1).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })
expect(formatBucketLabel('202608', 'month')).toBe(want)
})
})
// Target health (feat/target-health): formatElapsedAgo is formatAgo's bare
// "Ns ago" counterpart — no parentheses — so a caller can compose it into
// either "unhealthy (Ns ago)" or "Ns ago via probe" without stripping
+7 -1
View File
@@ -303,8 +303,14 @@ export function formatLimits(limits: LimitsConfig | undefined): string {
export function formatBucketLabel(bucket: string, window: HistoryWindow): string {
switch (window) {
case 'hour': {
// bucketFor (limits.go) always buckets in UTC (t.UTC() before
// formatting) — labeling the bare hour digits with no timezone reads
// as local time to an operator outside UTC (14:xx local shows as
// "12:00" for a UTC+2 reader). Explicit "UTC" suffix, not a
// local-time conversion: day/month buckets are calendar dates with
// no such ambiguity, so only the hour case needs this.
const hour = bucket.slice(8, 10)
return `${hour}:00`
return `${hour}:00 UTC`
}
case 'day': {
const year = Number(bucket.slice(0, 4))
+28
View File
@@ -156,3 +156,31 @@ describe('targetColumns: health column', () => {
expect(vnode.children?.default()).toBe('unknown')
})
})
function accessCellOf(access: AdminTargetView['access']): unknown {
const col = targetColumns().find((c) => c.id === 'access')
const cell = col?.cell as (ctx: { row: { original: AdminTargetView } }) => unknown
return cell({ row: { original: { ...testTarget({ state: 'unknown', consecutiveFailures: 0 }), access } } })
}
describe('targetColumns: access column', () => {
it('renders "All groups" for null and absent access', () => {
for (const access of [null, undefined]) {
const node = accessCellOf(access)
expect(isVNode(node) && node.children).toBe('All groups')
}
})
it('renders "No groups" for an empty access list, not "All groups"', () => {
const node = accessCellOf([])
expect(isVNode(node) && node.children).toBe('No groups')
})
it('renders one Badge per group for a non-empty access list', () => {
const node = accessCellOf(['a', 'b'])
expect(isVNode(node)).toBe(true)
const children = (node as { children: unknown[] }).children
expect(children).toHaveLength(2)
expect(children.every((c) => isVNode(c) && c.type === Badge)).toBe(true)
})
})
+3 -1
View File
@@ -151,7 +151,9 @@ export function targetColumns(): ColumnDef<AdminTargetView, unknown>[] {
accessorFn: (t) => (t.access ?? []).join(', '),
cell: ({ row }) => {
const access = row.original.access
if (!access?.length) return h('span', { class: 'text-muted-foreground' }, 'All groups')
// null/absent = every group; [] = no group (admin.go adminTargetView.Access).
if (access == null) return h('span', { class: 'text-muted-foreground' }, 'All groups')
if (access.length === 0) return h('span', { class: 'text-muted-foreground' }, 'No groups')
return h(
'div',
{ class: 'flex flex-wrap gap-1.5' },
+11
View File
@@ -70,6 +70,17 @@ describe('usageColumns: sorting stays on raw numeric values', () => {
expect(rendered).toBe('?')
})
// Review finding: TanStack's getAutoSortingFn only inspects rows 11+
// (RowSorting.js's own `flatRows.slice(10)`) to auto-detect numeric vs.
// string sorting, so a Groups table with 10 or fewer rows silently fell
// back to `basic` (plain string a > b comparison) for the Members
// column, sorting "10" before "9". An explicit sortingFn sidesteps that
// row-count-dependent auto-detection entirely.
it('the secondary (Members/Group) column declares an explicit sortingFn, not left to row-count-dependent auto-detection', () => {
const col = columns.find((c) => c.id === 'secondary')
expect(col?.sortingFn).toBe('alphanumeric')
})
it('sorting a set of entries by reqDay orders numerically, not lexicographically (the bug this separation prevents)', () => {
const col = columns.find((c) => c.id === 'reqDay')
const accessor = accessorFnOf(col)
+12
View File
@@ -70,6 +70,18 @@ export function usageColumns(
id: 'secondary',
header: secondaryColumnLabel,
accessorFn: secondaryValue,
// 'alphanumeric' (not the default auto-detected sortingFn): this
// column's value is a plain string everywhere EXCEPT the Groups
// toolbar, where UsageView.vue passes memberCountOf — a numeric count
// rendered as a string. TanStack's getAutoSortingFn only inspects
// rows 11+ (flatRows.slice(10)) to decide numeric vs basic, so a
// groups table with 10 or fewer rows silently fell back to `basic`
// (plain a > b string comparison), sorting "10" before "9".
// 'alphanumeric' handles both cases correctly regardless of row
// count: numeric substrings compare numerically, and it degrades
// gracefully for the plain-text group-name case (secondaryValue on
// the Users table).
sortingFn: 'alphanumeric',
cell: ({ row }) => h('span', { class: 'text-muted-foreground' }, secondaryValue(row.original)),
},
{
+20 -2
View File
@@ -3,12 +3,13 @@ import { describe, expect, it } from 'vitest'
import { groupMatches, groupNameMatches, matchingMembersOfGroup, membersOfGroup, userMatches } from './usage-search'
import type { AdminUsageEntryView } from '@/types/api'
/** entry builds a minimal AdminUsageEntryView — only id/groupName matter to this module, the rest are never read. */
function entry(id: string, groupName?: string): AdminUsageEntryView {
/** entry builds a minimal AdminUsageEntryView — only id/groupName/groups matter to this module, the rest are never read. */
function entry(id: string, groupName?: string, groups?: string[]): AdminUsageEntryView {
return {
kind: groupName === undefined ? 'group' : 'user',
id,
groupName,
groups,
requestsPerMinute: 0,
requestsPerDay: 0,
tokensInPerDay: 0,
@@ -52,6 +53,23 @@ describe('membersOfGroup', () => {
expect(membersOfGroup(teamA, users)).toEqual([alice, bob])
expect(membersOfGroup(teamB, users)).toEqual([carol])
})
// Multi-group support (review finding 4): a user's full membership lives
// in `groups` (admin.go's adminUsageEntryView.Groups); groupName alone
// only ever carries the FIRST one. Before this fix, membersOfGroup's
// `groupName === group.id` join silently dropped a multi-group user from
// every group but their first.
it('joins a multi-group user into EVERY one of their member groups via `groups`, not just groupName', () => {
const dave = entry('dave', 'team-a', ['team-a', 'team-b'])
const roster = [alice, bob, carol, dave]
expect(membersOfGroup(teamA, roster)).toEqual([alice, bob, dave])
expect(membersOfGroup(teamB, roster)).toEqual([carol, dave])
})
it('falls back to [groupName] when `groups` is absent (a single-group user, or a fixture predating the field)', () => {
expect(membersOfGroup(teamA, users)).toEqual([alice, bob])
})
})
describe('matchingMembersOfGroup', () => {
+13 -10
View File
@@ -24,19 +24,22 @@ export function groupNameMatches(group: Pick<AdminUsageEntryView, 'id'>, query:
}
/**
* membersOfGroup: group's own member user rows, joined on
* `groupName === group.id` — a user's row already carries its own group
* name (admin.go's adminUsageEntryView.GroupName, set in buildAdminUsage),
* so this is a plain client-side join against the already-polled
* GET /admin/api/usage response, no extra fetch. UsageView.vue calls this
* directly (replacing its own former local membersOf helper);
* matchingMembersOfGroup below builds on it too, which is how
* ChartsView.vue's groupMatches call ends up depending on this same join
* — indirectly, through matchingMembersOfGroup — without calling
* membersOfGroup: group's own member user rows, joined on group.id being
* one of the user's own member groups — a user's row carries its full
* membership in `groups` (admin.go's adminUsageEntryView.Groups, multi-group
* support) and falls back to the single-group `groupName` for a user
* predating that field (or a mock/fixture that only sets groupName). Never
* `groupName` alone: a multi-group user would otherwise silently drop out
* of every group but their first. This is a plain client-side join against
* the already-polled GET /admin/api/usage response, no extra fetch.
* UsageView.vue calls this directly (replacing its own former local
* membersOf helper); matchingMembersOfGroup below builds on it too, which
* is how ChartsView.vue's groupMatches call ends up depending on this same
* join — indirectly, through matchingMembersOfGroup — without calling
* membersOfGroup itself.
*/
export function membersOfGroup(group: Pick<AdminUsageEntryView, 'id'>, users: AdminUsageEntryView[]): AdminUsageEntryView[] {
return users.filter((u) => u.groupName === group.id)
return users.filter((u) => (u.groups ?? [u.groupName]).includes(group.id))
}
/** matchingMembersOfGroup: group's own members that themselves match query. */