mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-17 05:37:43 +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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user