mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
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:
+12
-12
File diff suppressed because one or more lines are too long
+7
-1
@@ -41,6 +41,12 @@ type TabValue = (typeof TAB_VALUES)[number]
|
||||
// authenticated transition with no extra wiring here.
|
||||
const activeView = useTabHash<TabValue>({ validTabs: TAB_VALUES, defaultTab: 'providers' })
|
||||
|
||||
// statusText reads dashboard.error/lastUpdated unconditionally — it is
|
||||
// correct only while the template's own `v-if="auth.isAuthenticated"` below
|
||||
// keeps it off-screen otherwise. Without that gate a 401 (which resolves
|
||||
// before touching error/lastUpdated) left the PRIOR "last updated ..." or
|
||||
// "refresh failed: ..." line showing right under the auth gate, and a first
|
||||
// load with no stored key showed "loading..." forever (review finding).
|
||||
const statusText = computed<string>(() => {
|
||||
if (dashboard.error) return `refresh failed: ${dashboard.error}`
|
||||
if (dashboard.lastUpdated) return `last updated ${dashboard.lastUpdated.toLocaleTimeString()}`
|
||||
@@ -70,7 +76,7 @@ onMounted(() => {
|
||||
<FontAwesomeIcon :icon="faGithub" class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<p class="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<p v-if="auth.isAuthenticated" class="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<FontAwesomeIcon v-if="dashboard.error" :icon="faCircleExclamation" class="size-3.5 text-destructive" />
|
||||
<span :class="dashboard.error ? 'text-destructive' : undefined">{{ statusText }}</span>
|
||||
</p>
|
||||
|
||||
@@ -58,6 +58,14 @@
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
/* Powers the `font-heading` utility Tailwind v4 auto-generates from any
|
||||
--font-* @theme token — CardTitle.vue and AlertTitle.vue apply it (a
|
||||
review fix: they previously wrote the unrecognized class
|
||||
`cn-font-heading`, matching nothing this token or Tailwind's engine
|
||||
generates, so it rendered as a plain no-op string with zero styling
|
||||
effect). Same font as body today; the token exists so a future
|
||||
distinct heading face is a one-line change here, not a find-replace
|
||||
across every component that uses it. */
|
||||
--font-heading: var(--font-sans);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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))),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
+15
-4
@@ -231,6 +231,17 @@ export interface AdminUsageEntryView {
|
||||
kind: string
|
||||
id: string
|
||||
groupName?: string
|
||||
/**
|
||||
* The user's full member group list (multi-group support, admin.go's
|
||||
* adminUsageEntryView.Groups) — set only on a user entry, same "user-only"
|
||||
* convention groupName above already follows. groupName keeps the FIRST
|
||||
* group for back-compat; groups is the complete membership. Every
|
||||
* group-membership check (search matching, the members table, the Group
|
||||
* column) must read `entry.groups ?? [entry.groupName]`, never groupName
|
||||
* alone — a multi-group user otherwise silently drops out of every group
|
||||
* but their first.
|
||||
*/
|
||||
groups?: string[]
|
||||
/**
|
||||
* The group's configured access lists (admin.go: adminUsageEntryView's
|
||||
* Providers/Models/MCPServers/Agents) — set only on a group entry (kind
|
||||
@@ -357,14 +368,14 @@ export interface AdminTargetHealthView {
|
||||
* One configured MCP server's or A2A agent's row in GET /admin/api/targets
|
||||
* (admin.go: adminTargetView). Access is the group names actually allowed
|
||||
* to reach this target, computed server-side via the same matching authz
|
||||
* enforcement uses — undefined/empty means every configured group can
|
||||
* reach it ("empty meaning all", mirroring AdminUsageEntryView's own
|
||||
* providers/models/mcpServers/agents convention above).
|
||||
* enforcement uses — null/undefined means every configured group can
|
||||
* reach it, [] means no configured group can, and a non-empty list names
|
||||
* the groups that can (admin.go adminTargetView.Access).
|
||||
*/
|
||||
export interface AdminTargetView {
|
||||
name: string
|
||||
url: string
|
||||
access?: string[]
|
||||
access?: string[] | null
|
||||
counters: AdminTargetCountersView
|
||||
/** See AdminTargetHealthView's own doc comment (feat/target-health) — always present, unlike access above. */
|
||||
health: AdminTargetHealthView
|
||||
|
||||
@@ -32,6 +32,24 @@ export default defineConfig({
|
||||
// polyfill has nothing to do here anyway.
|
||||
modulePreload: { polyfill: false },
|
||||
},
|
||||
server: {
|
||||
// `npm run dev` serves this app at :5173 (or whatever Vite picks), but
|
||||
// every fetch('/admin/api/...') (lib/api.ts) is a same-origin, relative
|
||||
// path — without a proxy it hits Vite itself, which has no such route
|
||||
// and answers with a 404 or the index.html SPA fallback, so the dev
|
||||
// build can never actually load data. Proxying just the API path (not
|
||||
// '/admin' itself) leaves Vite serving this project's own index.html/
|
||||
// HMR client at '/admin/' as usual. The target is env-configurable
|
||||
// (ADMIN_API_PROXY_TARGET) rather than hardcoded, since which gateway
|
||||
// instance a developer is pointed at varies by machine; the fallback
|
||||
// matches this repo's own `run-local`/integration default port.
|
||||
proxy: {
|
||||
'/admin/api': {
|
||||
target: process.env.ADMIN_API_PROXY_TARGET ?? 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
// Deliberately narrow: lib/'s pure modules (search-expand.ts,
|
||||
// usage-search.ts) plus, since review round 2 (v0.21), stores/
|
||||
|
||||
Reference in New Issue
Block a user