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
+82 -4
View File
@@ -77,13 +77,31 @@ export const WINDOW_SPAN: Record<HistoryWindow, number> = {
month: 12,
}
/** WINDOW_LABEL is the switcher's own display text for each window. */
/** WINDOW_LABEL is the switcher's own display text for each window, for the three TIME-SERIES tabs (requests/tokens/cost) — a real span of buckets, matching WINDOW_SPAN above. */
export const WINDOW_LABEL: Record<HistoryWindow, string> = {
hour: '24h',
day: '30d',
month: '12mo',
}
/**
* MODEL_WINDOW_LABEL is the Models tab's OWN window-switcher text — that
* tab shares the same three-way Tabs control as the time-series tabs
* (ChartsView.vue), but reads a fundamentally different query: modelTotals
* (limits.go) returns ONE counter at window's CURRENT bucket, never a span
* of WINDOW_SPAN buckets. Labeling that control "24h" when it actually
* means "since the top of the current UTC hour" understates how little
* data is behind it — at 14:05 UTC, "24h" would visually promise a full
* day while the ranking covers five minutes. "(UTC)" is explicit rather
* than implied, matching formatBucketLabel's own hour-bucket fix
* (lib/format.ts) — bucketFor (limits.go) always buckets in UTC.
*/
export const MODEL_WINDOW_LABEL: Record<HistoryWindow, string> = {
hour: 'This hour (UTC)',
day: 'Today (UTC)',
month: 'This month (UTC)',
}
/**
* metricsForTab maps a chart tab to the metric(s) it needs. "tokens" is the
* one tab needing two: GET /admin/api/usage/history takes a single metric
@@ -126,28 +144,65 @@ export const useHistoryStore = defineStore('history', {
/** Models with non-zero traffic, for the scope picker's model entries. */
modelOptions: [] as AdminUsageModelEntry[],
loading: false,
/** True once the current selection's first fetch committed; cleared by the setters. Drives the "Loading…" line so background refreshes don't flash it over rendered data. */
loaded: false,
error: '',
timer: undefined as ReturnType<typeof setInterval> | undefined,
/**
* seriesReqId guards fetchSeries and fetchModelRanking against
* out-of-order responses (review finding: "latest-request-wins"). The
* two are mutually exclusive per refresh() call (exactly one runs,
* picked by `tab`), so ONE shared counter is enough — a fetch started
* by an OLDER selection (scope/window/tab/modelMetric) always loses to
* whichever fetch started most recently, never to load order. Each
* fetch captures the id it was issued under before its own await, then
* only commits its result (and clears `loading`) if that id is still
* the current one when it resolves.
*/
seriesReqId: 0,
/** optionsReqId is fetchModelOptions' own independent counter (a background, parallel fetch — see that action's own doc comment for why it never touches `loading`/`error`). */
optionsReqId: 0,
}),
actions: {
setScope(scope: string): void {
if (scope === this.scope) return
this.scope = scope
// Clear the OLD selection's series before fetching the new one (review
// finding: stale data must never render under the new selection's
// labels) — done in the setter, not inside refresh()/fetchSeries(),
// so the 30s auto-refresh timer (same selection, no setter involved)
// does not blank the chart on every tick.
this.seriesByMetric = {}
this.error = ''
this.loaded = false
void this.refresh()
},
setWindow(window: HistoryWindow): void {
if (window === this.window) return
this.window = window
// Window changes both the time-series bucket resolution AND the
// Models ranking's single current bucket — clear both.
this.seriesByMetric = {}
this.modelRanking = []
this.error = ''
this.loaded = false
void this.refresh()
},
setTab(tab: ChartTab): void {
if (tab === this.tab) return
this.tab = tab
this.seriesByMetric = {}
this.modelRanking = []
this.error = ''
this.loaded = false
void this.refresh()
},
setModelMetric(metric: ModelMetric): void {
if (metric === this.modelMetric) return
this.modelMetric = metric
this.modelRanking = []
this.error = ''
this.loaded = false
void this.refresh()
},
/**
@@ -162,6 +217,7 @@ export const useHistoryStore = defineStore('history', {
async fetchSeries(): Promise<void> {
const auth = useAuthStore()
if (!auth.isAuthenticated) return
const requestId = ++this.seriesReqId
this.loading = true
try {
const span = WINDOW_SPAN[this.window]
@@ -173,38 +229,51 @@ export const useHistoryStore = defineStore('history', {
),
),
)
// Stale: a newer selection (or the models-tab fetch) already
// superseded this one — drop the result rather than overwriting
// whatever the current selection has since fetched or cleared.
if (requestId !== this.seriesReqId) return
const next: Partial<Record<HistoryMetric, UsageHistoryPoint[]>> = {}
metrics.forEach((metric, i) => {
next[metric] = results[i]!.points
})
this.seriesByMetric = next
this.loaded = true
this.error = ''
} catch (err) {
if (requestId !== this.seriesReqId) return
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) {
return
}
this.error = err instanceof Error ? err.message : String(err)
} finally {
this.loading = false
// Only the still-current request clears `loading` — an older,
// already-superseded fetch settling later must not flip it back to
// false while the newer one it lost to is still in flight.
if (requestId === this.seriesReqId) this.loading = false
}
},
async fetchModelRanking(): Promise<void> {
const auth = useAuthStore()
if (!auth.isAuthenticated) return
const requestId = ++this.seriesReqId
this.loading = true
try {
const res = await adminFetch<AdminUsageModelsResponse>(
`/admin/api/usage/models?metric=${this.modelMetric}&window=${this.window}&limit=${MODEL_RANKING_LIMIT}`,
)
if (requestId !== this.seriesReqId) return
this.modelRanking = res.models
this.loaded = true
this.error = ''
} catch (err) {
if (requestId !== this.seriesReqId) return
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) {
return
}
this.error = err instanceof Error ? err.message : String(err)
} finally {
this.loading = false
if (requestId === this.seriesReqId) this.loading = false
}
},
/**
@@ -219,10 +288,14 @@ export const useHistoryStore = defineStore('history', {
async fetchModelOptions(): Promise<void> {
const auth = useAuthStore()
if (!auth.isAuthenticated) return
const requestId = ++this.optionsReqId
try {
const res = await adminFetch<AdminUsageModelsResponse>(
`/admin/api/usage/models?metric=req&window=${this.window}&limit=${MODEL_OPTIONS_LIMIT}`,
)
// Stale: a later window change already fired its own picker-list
// fetch — never let an older window's model list overwrite it.
if (requestId !== this.optionsReqId) return
this.modelOptions = res.models
} catch {
// Intentionally ignored — see the doc comment above.
@@ -230,7 +303,12 @@ export const useHistoryStore = defineStore('history', {
},
startAutoRefresh(): void {
if (this.timer !== undefined) return
this.timer = setInterval(() => void this.refresh(), REFRESH_MS)
// Skip a tick while the previous fetch is still in flight: each fetch
// bumps seriesReqId, so an unconditional tick would supersede (and
// discard) any request slower than REFRESH_MS, forever.
this.timer = setInterval(() => {
if (!this.loading) void this.refresh()
}, REFRESH_MS)
},
stopAutoRefresh(): void {
if (this.timer === undefined) return