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
+22 -2
View File
@@ -19,6 +19,7 @@ import { useDashboardStore } from '@/stores/dashboard'
import {
type ChartTab,
MODEL_METRIC_LABEL,
MODEL_WINDOW_LABEL,
type ModelMetric,
type TimeSeriesTab,
useHistoryStore,
@@ -80,6 +81,17 @@ const scopeOptions = computed(() => {
const windows: HistoryWindow[] = ['hour', 'day', 'month']
const modelMetrics: ModelMetric[] = ['cost', 'req', 'tokin', 'tokout']
/**
* windowLabel picks the right window-tab text for whichever query is
* actually active: the Models tab reads a single current bucket (review
* finding — MODEL_WINDOW_LABEL's own doc comment, stores/history.ts), the
* other three tabs read a real span (WINDOW_LABEL). Same Tabs control
* either way — only the label changes.
*/
function windowLabel(window: HistoryWindow): string {
return history.tab === 'models' ? MODEL_WINDOW_LABEL[window] : WINDOW_LABEL[window]
}
/**
* UsageChart only ever renders on a time-series tab (the template's v-else
* below), but a `v-else` narrows nothing for the type checker — this does.
@@ -121,7 +133,13 @@ watch(scopeOptions, (options) => {
<CardHeader class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Usage charts</CardTitle>
<div class="flex flex-wrap items-center gap-3">
<SearchInput v-model="scopeQuery" placeholder="Filter users, groups or models" class="w-56" />
<!-- The Models tab has no scope picker to filter (its own Select branch below is the ranking-metric picker instead) — a visible search box that silently does nothing is worse than no box at all. -->
<SearchInput
v-if="history.tab !== 'models'"
v-model="scopeQuery"
placeholder="Filter users, groups or models"
class="w-56"
/>
<Select
v-if="history.tab === 'models'"
@@ -151,7 +169,7 @@ watch(scopeOptions, (options) => {
<Tabs :model-value="history.window" @update:model-value="(v) => history.setWindow(v as HistoryWindow)">
<TabsList>
<TabsTrigger v-for="w in windows" :key="w" :value="w">{{ WINDOW_LABEL[w] }}</TabsTrigger>
<TabsTrigger v-for="w in windows" :key="w" :value="w">{{ windowLabel(w) }}</TabsTrigger>
</TabsList>
</Tabs>
</div>
@@ -167,6 +185,8 @@ watch(scopeOptions, (options) => {
</Tabs>
<p v-if="history.error" class="text-sm text-destructive">{{ history.error }}</p>
<!-- Only until the current selection's first fetch lands (history.loaded) — background refreshes never flash it over rendered data. -->
<p v-else-if="history.loading && !history.loaded" class="text-sm text-muted-foreground">Loading…</p>
<template v-if="history.tab === 'models'">
<p class="text-sm text-muted-foreground">
Models with usage in the selected window, ranked by
+7 -2
View File
@@ -5,7 +5,7 @@ import { Bar } from 'vue-chartjs'
import '@/lib/chart-setup'
import { useThemeColors } from '@/composables/useThemeColors'
import { formatCompactCount, formatExactInt } from '@/lib/format'
import { formatCompactCount, formatCost, formatExactInt } from '@/lib/format'
import { MODEL_METRIC_LABEL, type ModelMetric } from '@/stores/history'
import type { AdminUsageModelEntry } from '@/types/api'
@@ -78,7 +78,12 @@ const chartOptions = computed<ChartOptions<'bar'>>(() => ({
grid: { color: border.value },
ticks: {
color: mutedForeground.value,
callback: (value) => (props.metric === 'cost' ? `$${value}` : formatCompactCount(Number(value))),
// Reuses formatCost, same rationale as UsageChart.vue's identical
// fix: a raw tick float interpolated directly into "$${value}" can
// print unrounded binary-float noise; formatCost's .toFixed(4)
// resolves it, and is the same formatter every other cost figure
// in this app already renders through.
callback: (value) => (props.metric === 'cost' ? formatCost(Number(value) * 1_000_000) : formatCompactCount(Number(value))),
},
},
y: {
+12 -4
View File
@@ -5,7 +5,7 @@ import { Bar } from 'vue-chartjs'
import '@/lib/chart-setup'
import { useThemeColors } from '@/composables/useThemeColors'
import { formatBucketLabel, formatCompactCount, formatExactInt } from '@/lib/format'
import { formatBucketLabel, formatCompactCount, formatCost, formatExactInt } from '@/lib/format'
import type { TimeSeriesTab } from '@/stores/history'
import type { HistoryMetric, HistoryWindow, UsageHistoryPoint } from '@/types/api'
@@ -109,9 +109,17 @@ const chartOptions = computed<ChartOptions<'bar'>>(() => ({
color: mutedForeground.value,
// Axis ticks stay compact (feature v0.23 addendum) — no room for
// the exact figure on an axis label; the tooltip below carries
// it. Cost keeps its existing money format, unaffected: a
// dollar figure is not a "count" this formatter is meant for.
callback: (value) => (props.tab === 'cost' ? `$${value}` : formatCompactCount(Number(value))),
// it. Cost reuses formatCost (the same formatter cost/day and
// cost/month already render through elsewhere in the app) rather
// than interpolating the raw tick float directly — Chart.js hands
// this callback whatever value it picked for gridline placement,
// which for a non-terminating division (e.g. cost/3) prints as
// "0.30000000000000004" without it. formatCost takes micro-USD, so
// the already-converted display value (toDisplayValue above) is
// scaled back up before the call — .toFixed(4) inside it then
// resolves any such float noise the same way the tooltip below
// already does by hand.
callback: (value) => (props.tab === 'cost' ? formatCost(Number(value) * 1_000_000) : formatCompactCount(Number(value))),
},
},
},
+5 -2
View File
@@ -30,8 +30,9 @@ const memberCounts = computed<Record<string, number>>(() => {
return out
})
/** groupNameOf renders a user's FULL group membership (multi-group support — entry.groups ?? [entry.groupName], never groupName alone, see AdminUsageEntryView.groups' own doc comment), comma-joined for the Group column. */
function groupNameOf(entry: AdminUsageEntryView): string {
return entry.groupName ?? ''
return (entry.groups ?? (entry.groupName === undefined ? [] : [entry.groupName])).join(', ')
}
function memberCountOf(entry: AdminUsageEntryView): string {
const count = memberCounts.value[entry.id]
@@ -129,7 +130,9 @@ const filteredUsers = computed<AdminUsageEntryView[]>(() => {
groups.filter((g) => groupNameMatches(g, normalizedQuery.value)).map((g) => g.id),
)
return all.filter(
(u) => userMatches(u, normalizedQuery.value) || (u.groupName !== undefined && nameMatchedGroupIds.has(u.groupName)),
(u) =>
userMatches(u, normalizedQuery.value) ||
(u.groups ?? [u.groupName]).some((g) => g !== undefined && nameMatchedGroupIds.has(g)),
)
})
/** usersEmptyMessage mirrors groupsEmptyMessage's three-way pattern (see its own doc comment). */
@@ -1,17 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="alert-action"
:class="cn('absolute top-2 right-2', props.class)"
>
<slot />
</div>
</template>
+1 -1
View File
@@ -10,7 +10,7 @@ const props = defineProps<{
<template>
<div
data-slot="alert-title"
:class="cn('font-medium group-has-[>svg]/alert:col-start-2 cn-font-heading [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground', props.class)"
:class="cn('font-medium group-has-[>svg]/alert:col-start-2 font-heading [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground', props.class)"
>
<slot />
</div>
-1
View File
@@ -2,7 +2,6 @@ import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Alert } from './Alert.vue'
export { default as AlertAction } from './AlertAction.vue'
export { default as AlertDescription } from './AlertDescription.vue'
export { default as AlertTitle } from './AlertTitle.vue'
@@ -1,17 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-action"
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
>
<slot />
</div>
</template>
@@ -1,17 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-footer"
:class="cn('bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center', props.class)"
>
<slot />
</div>
</template>
+1 -1
View File
@@ -10,7 +10,7 @@ const props = defineProps<{
<template>
<div
data-slot="card-title"
:class="cn('text-base leading-snug font-medium group-data-[size=sm]/card:text-sm cn-font-heading', props.class)"
:class="cn('text-base leading-snug font-medium group-data-[size=sm]/card:text-sm font-heading', props.class)"
>
<slot />
</div>
-2
View File
@@ -1,7 +1,5 @@
export { default as Card } from './Card.vue'
export { default as CardAction } from './CardAction.vue'
export { default as CardContent } from './CardContent.vue'
export { default as CardDescription } from './CardDescription.vue'
export { default as CardFooter } from './CardFooter.vue'
export { default as CardHeader } from './CardHeader.vue'
export { default as CardTitle } from './CardTitle.vue'
@@ -1,21 +0,0 @@
<script setup lang="ts">
import type { SelectGroupProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { SelectGroup } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectGroupProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<SelectGroup
data-slot="select-group"
v-bind="delegatedProps"
:class="cn('scroll-my-1 p-1', props.class)"
>
<slot />
</SelectGroup>
</template>
@@ -1,15 +0,0 @@
<script setup lang="ts">
import type { SelectItemTextProps } from 'reka-ui'
import { SelectItemText } from 'reka-ui'
const props = defineProps<SelectItemTextProps>()
</script>
<template>
<SelectItemText
data-slot="select-item-text"
v-bind="props"
>
<slot />
</SelectItemText>
</template>
@@ -1,17 +0,0 @@
<script setup lang="ts">
import type { SelectLabelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { SelectLabel } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes['class'] }>()
</script>
<template>
<SelectLabel
data-slot="select-label"
:class="cn('text-muted-foreground px-1.5 py-1 text-xs', props.class)"
>
<slot />
</SelectLabel>
</template>
@@ -1,19 +0,0 @@
<script setup lang="ts">
import type { SelectSeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { SelectSeparator } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<SelectSeparator
data-slot="select-separator"
v-bind="delegatedProps"
:class="cn('bg-border -mx-1 my-1 h-px pointer-events-none', props.class)"
/>
</template>
-4
View File
@@ -1,11 +1,7 @@
export { default as Select } from './Select.vue'
export { default as SelectContent } from './SelectContent.vue'
export { default as SelectGroup } from './SelectGroup.vue'
export { default as SelectItem } from './SelectItem.vue'
export { default as SelectItemText } from './SelectItemText.vue'
export { default as SelectLabel } from './SelectLabel.vue'
export { default as SelectScrollDownButton } from './SelectScrollDownButton.vue'
export { default as SelectScrollUpButton } from './SelectScrollUpButton.vue'
export { default as SelectSeparator } from './SelectSeparator.vue'
export { default as SelectTrigger } from './SelectTrigger.vue'
export { default as SelectValue } from './SelectValue.vue'
@@ -1,17 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<caption
data-slot="table-caption"
:class="cn('text-muted-foreground mt-4 text-sm', props.class)"
>
<slot />
</caption>
</template>
@@ -1,17 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<tfoot
data-slot="table-footer"
:class="cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)"
>
<slot />
</tfoot>
</template>
-2
View File
@@ -1,9 +1,7 @@
export { default as Table } from './Table.vue'
export { default as TableBody } from './TableBody.vue'
export { default as TableCaption } from './TableCaption.vue'
export { default as TableCell } from './TableCell.vue'
export { default as TableEmpty } from './TableEmpty.vue'
export { default as TableFooter } from './TableFooter.vue'
export { default as TableHead } from './TableHead.vue'
export { default as TableHeader } from './TableHeader.vue'
export { default as TableRow } from './TableRow.vue'