feat(webui): make the dashboard responsive and add loading and empty states

Every tab row scrolls sideways instead of being clipped by its card.
On phones this hid tabs, such as the Config change helper's last forms
at 360-390px. The login button has a larger touch target, and long
names are truncated with a tooltip.

Loading and empty states:
- Skeletons show only while a selection has no data yet, never over
  data already on screen during a refresh.
- Empty states name the next step to take.
- Error states offer a Retry button that shows progress, and each card
  keeps its own error.
- Toasts confirm copies and key generation, and report background
  failures once per streak through polite and assertive live regions.

Other changes:
- The access matrix switches between Models, MCP servers and Agents,
  and says "none configured" only once its data has loaded.
- Home ranks the top 5 users by cost, requests or tokens. It requests
  only the needed metrics and warns before a range exceeds the
  server's key cap.
- MCP and agent deep links accept a bare target name.

admin_assets_gen.go is regenerated.

Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
2026-09-23 23:02:22 +01:00
parent 95a3a0e235
commit cf308624af
57 changed files with 3621 additions and 446 deletions
+135 -3
View File
@@ -108,7 +108,8 @@ describe('useConsumersStore.fetchUserDetail', () => {
expect(paths.some((p) => p.includes('scope=user%3Aalice') && p.includes('metric=req'))).toBe(true)
expect(paths.some((p) => p.includes('scope=user%3Aalice') && p.includes('metric=cost'))).toBe(true)
expect(store.detail.alice.loading).toBe(false)
expect(store.detail.alice.error).toBe('')
expect(store.detail.alice.reqError).toBe('')
expect(store.detail.alice.costError).toBe('')
expect(store.detail.alice.reqSeries).toEqual(emptySeries)
expect(store.detail.alice.costSeries).toEqual(emptySeries)
})
@@ -181,8 +182,139 @@ describe('useConsumersStore.fetchUserDetail', () => {
mockedAdminFetch.mockRejectedValue(new Error('boom'))
await store.fetchUserDetail('bob', 'day', 7, false)
expect(store.detail.alice.error).toBe('')
expect(store.detail.alice.reqError).toBe('')
expect(store.detail.alice.costError).toBe('')
expect(store.detail.alice.reqSeries).toEqual(emptySeries)
expect(store.detail.bob.error).toBe('boom')
expect(store.detail.bob.reqError).toBe('boom')
expect(store.detail.bob.costError).toBe('boom')
})
// verify-ui-states.md #4: a failure in only ONE of the three parallel
// requests must set only THAT field's own error, never a shared field
// the req/cost charts both read — an unrelated modelTotals failure used
// to replace two perfectly valid, already-loaded charts with ErrorState.
it('scopes each request\'s failure to its own error field, never a shared one', async () => {
mockedAdminFetch.mockImplementation((async (path: string) => {
if (path.includes('kind=usermodel')) throw new Error('usermodel boom')
return emptySeries
}) as typeof adminFetch)
const store = useConsumersStore()
await store.fetchUserDetail('alice', 'day', 7, true)
expect(store.detail.alice.reqError).toBe('')
expect(store.detail.alice.costError).toBe('')
expect(store.detail.alice.modelError).toBe('usermodel boom')
expect(store.detail.alice.reqSeries).toEqual(emptySeries)
expect(store.detail.alice.costSeries).toEqual(emptySeries)
})
// verify-ui-states.md #4: re-fetching the SAME range (a retry, or the
// featuresUserModelStats-triggered follow-up UserDetail.vue's own watch
// fires) must keep the previous series in `detail` while `loading` is
// true, so the caller's own `loading && !reqSeries` skeleton condition
// does not flip back on over data already on screen — this is also
// exactly what makes re-opening a cached user show its data immediately.
it('keeps the previous series while loading is true for a SAME-range refetch', async () => {
mockedAdminFetch.mockResolvedValue(emptySeries)
const store = useConsumersStore()
await store.fetchUserDetail('alice', 'day', 7, false)
expect(store.detail.alice.reqSeries).toEqual(emptySeries)
// Second call never resolves within this assertion window — read
// `detail.alice` synchronously right after firing it, before either
// of the two (req, cost) in-flight fetches settle, to inspect the
// mid-flight state. Both pending calls share this one mock
// implementation, so every resolver gets collected and released.
const resolvers: (() => void)[] = []
mockedAdminFetch.mockImplementation(
() =>
new Promise((resolve) => {
resolvers.push(() => resolve(emptySeries))
}),
)
const inFlight = store.fetchUserDetail('alice', 'day', 7, false)
expect(store.detail.alice.loading).toBe(true)
expect(store.detail.alice.reqSeries).toEqual(emptySeries)
resolvers.forEach((resolve) => resolve())
await inFlight
})
// verify-ui-states.md #4: a genuine RANGE change for the same user
// clears the previous range's own series before the new fetch starts,
// so a stale (wrong-range) chart never sits under the new heading.
it('clears the previous series when the range genuinely changes', async () => {
mockedAdminFetch.mockResolvedValue(emptySeries)
const store = useConsumersStore()
await store.fetchUserDetail('alice', 'day', 7, false)
expect(store.detail.alice.reqSeries).toEqual(emptySeries)
const resolvers: (() => void)[] = []
mockedAdminFetch.mockImplementation(
() =>
new Promise((resolve) => {
resolvers.push(() => resolve(emptySeries))
}),
)
const inFlight = store.fetchUserDetail('alice', 'day', 14, false)
expect(store.detail.alice.loading).toBe(true)
expect(store.detail.alice.reqSeries).toBeNull()
expect(store.detail.alice.costSeries).toBeNull()
resolvers.forEach((resolve) => resolve())
await inFlight
})
// verify-ui-states-2.md #7: a genuine RANGE change must also clear the
// PREVIOUS range's own per-section errors, not just its series/totals —
// otherwise loadState's own "error beats loading" precedence keeps the
// old range's ErrorState on screen under the new range's heading instead
// of the skeleton the cleared reqSeries/costSeries/modelTotals is meant
// to produce.
it('clears reqError/costError/modelError when the range genuinely changes', async () => {
mockedAdminFetch.mockImplementation((async (path: string) => {
if (path.includes('kind=usermodel')) throw new Error('usermodel boom')
throw new Error('series boom')
}) as typeof adminFetch)
const store = useConsumersStore()
await store.fetchUserDetail('alice', 'day', 7, true)
expect(store.detail.alice.reqError).toBe('series boom')
expect(store.detail.alice.costError).toBe('series boom')
expect(store.detail.alice.modelError).toBe('usermodel boom')
const resolvers: (() => void)[] = []
mockedAdminFetch.mockImplementation(
() =>
new Promise((resolve) => {
resolvers.push(() => resolve(emptySeries))
}),
)
const inFlight = store.fetchUserDetail('alice', 'day', 14, true)
expect(store.detail.alice.reqError).toBe('')
expect(store.detail.alice.costError).toBe('')
expect(store.detail.alice.modelError).toBe('')
resolvers.forEach((resolve) => resolve())
await inFlight
})
it('keeps the previous errors on a SAME-range refetch (a retry) — only a genuine range change clears them', async () => {
mockedAdminFetch.mockRejectedValue(new Error('boom'))
const store = useConsumersStore()
await store.fetchUserDetail('alice', 'day', 7, false)
expect(store.detail.alice.reqError).toBe('boom')
const resolvers: (() => void)[] = []
mockedAdminFetch.mockImplementation(
() =>
new Promise((resolve) => {
resolvers.push(() => resolve(emptySeries))
}),
)
const inFlight = store.fetchUserDetail('alice', 'day', 7, false)
expect(store.detail.alice.reqError).toBe('boom')
resolvers.forEach((resolve) => resolve())
await inFlight
})
})
+76 -19
View File
@@ -8,10 +8,27 @@ import type { AdminConsumersResponse, AdminSeriesResponse, AdminTotalsResponse,
/** REFRESH_STALE_MS is how long a previously-fetched /admin/api/consumers response is trusted before ConsumersPage.vue's mount triggers a fresh fetch — this endpoint reflects the operator's own config (users/groups/access lists), which changes on a config edit, not every 5s like the polled dashboard store. A 60s staleness window (matches Q12's "new endpoints on demand or 60s" DECISION) means switching between pages within the Consumers tab never re-fetches on every mount, while a long-open session still notices a config reload eventually. */
const REFRESH_STALE_MS = 60_000
/** UserDetailState is one user's on-demand detail — UserDetail.vue's own data (timeline series, per-model totals), fetched only when that user's row is actually opened, keyed by user id so switching between two already-opened users never re-fetches. */
/**
* UserDetailState is one user's on-demand detail — UserDetail.vue's own
* data (timeline series, per-model totals), fetched only when that
* user's row is actually opened, keyed by user id so switching between
* two already-opened users never re-fetches.
*
* `reqError`/`costError`/`modelError` are THREE independent fields
* (verify-ui-states.md #4 fix — previously one shared `error`, joined
* from all three requests): a failure in only ONE of the three
* Promise.allSettled calls (say, the usermodel totals) used to set that
* one shared field, and UserDetail.vue's template read it for BOTH the
* "Requests over time" and "Cost over time" cards — an unrelated
* modelTotals failure replaced two perfectly valid, already-loaded
* charts with ErrorState. Each chart/table now reads only its own field.
*/
export interface UserDetailState {
loading: boolean
error: string
reqError: string
costError: string
/** '' whenever modelTotalsUnavailable is true (a 404 is an expected, named state — modelTotalsUnavailable below, not a generic error) or the fetch was skipped (userModelStatsEnabled false). */
modelError: string
reqSeries: AdminSeriesResponse | null
costSeries: AdminSeriesResponse | null
/** null while userModelStats is off (no fetch attempted), the 404 case (see modelTotalsUnavailable), or before the first fetch resolves. */
@@ -21,22 +38,29 @@ export interface UserDetailState {
fetchedAt: number | null
/** reqId guards against an out-of-order response overwriting a newer one (latest-request-wins) — two ranges in flight for the same user can otherwise land out of order. */
reqId: number
/** The (window, span) this state's OWN reqSeries/costSeries/modelTotals were fetched for, or null before the first fetch ever starts — fetchUserDetail's own "is this a genuine range change, not just a same-range refetch" check (verify-ui-states.md #4 fix), see that action's own doc comment. */
window: HistoryWindow | null
span: number | null
}
function emptyDetail(): UserDetailState {
return {
loading: false,
error: '',
reqError: '',
costError: '',
modelError: '',
reqSeries: null,
costSeries: null,
modelTotals: null,
modelTotalsUnavailable: false,
fetchedAt: null,
reqId: 0,
window: null,
span: null,
}
}
/** settledError reads one Error message off a rejected PromiseSettledResult, or '' for a fulfilled one — fetchUserDetail's own small helper for building a combined error message out of whichever of its three independent requests failed. */
/** settledError reads one Error message off a rejected PromiseSettledResult, or '' for a fulfilled one. */
function settledError(result: PromiseSettledResult<unknown>): string {
if (result.status !== 'rejected') return ''
return result.reason instanceof Error ? result.reason.message : String(result.reason)
@@ -58,10 +82,20 @@ export const useConsumersStore = defineStore('consumers', {
detail: {} as Record<string, UserDetailState>,
}),
actions: {
/** fetchConsumers fetches GET /admin/api/consumers unconditionally — callers that want the staleness guard use ensureConsumers instead. */
/**
* fetchConsumers fetches GET /admin/api/consumers unconditionally —
* callers that want the staleness guard use ensureConsumers instead.
* The `this.loading` in-flight guard (verify-ui-states.md #11 fix)
* matches stores/dashboard.ts's/stores/events.ts's own identical
* guard on their refresh() — without it, AccessMatrix.vue's own
* ErrorState Retry button (wired directly to this action) could fire
* a second overlapping request on a rapid double-click, free to
* resolve in either order.
*/
async fetchConsumers(): Promise<void> {
const auth = useAuthStore()
if (!auth.isAuthenticated) return
if (this.loading) return
this.loading = true
try {
this.data = await adminFetch<AdminConsumersResponse>('/admin/api/consumers')
@@ -106,7 +140,34 @@ export const useConsumersStore = defineStore('consumers', {
if (!auth.isAuthenticated) return
const existing = this.detail[userId] ?? emptyDetail()
const requestId = existing.reqId + 1
this.detail[userId] = { ...existing, loading: true, reqId: requestId }
// A genuine RANGE change for this same user (verify-ui-states.md #4
// fix) clears the previous range's own series/totals before the
// fetch starts — UserDetail.vue's own skeleton condition (`loading
// && !reqSeries`) then shows a skeleton instead of the WRONG
// range's numbers sitting under the new heading until the fresh
// response lands. A SAME-range refetch (re-opening this cached
// user, a retry, or the featuresUserModelStats-triggered follow-up
// UserDetail.vue's own watch fires) is NOT a selection change —
// `existing.window === null` (never fetched before) also takes this
// branch, which is harmless since there is nothing to clear yet.
const rangeChanged = existing.window !== null && (existing.window !== window || existing.span !== span)
this.detail[userId] = {
...existing,
loading: true,
reqId: requestId,
reqSeries: rangeChanged ? null : existing.reqSeries,
costSeries: rangeChanged ? null : existing.costSeries,
modelTotals: rangeChanged ? null : existing.modelTotals,
// verify-ui-states-2.md #7: a genuine range change also clears the
// PREVIOUS range's own per-section errors — without this, an old
// range's ErrorState (loadState's own "error beats loading"
// precedence) stayed on screen under the new range's heading until
// the fresh response landed, instead of the skeleton a cleared
// reqSeries/costSeries/modelTotals is meant to produce.
reqError: rangeChanged ? '' : existing.reqError,
costError: rangeChanged ? '' : existing.costError,
modelError: rangeChanged ? '' : existing.modelError,
}
const clamped = dayOrMonthWindow(window, span)
const [reqResult, costResult, modelResult] = await Promise.allSettled([
@@ -132,27 +193,23 @@ export const useConsumersStore = defineStore('consumers', {
modelResult.status === 'rejected' && modelResult.reason instanceof AdminApiError && modelResult.reason.status === 404
const current = this.detail[userId] ?? emptyDetail()
// A 404 on the usermodel call is a known, EXPECTED state (the
// feature is off server-side) — surfaced via modelTotalsUnavailable
// instead, never folded into the generic error message. Deduped
// (Set) — the req and cost calls commonly fail with the IDENTICAL
// message (e.g. the same network error, or the same 503 "usage
// store unavailable"), and repeating it twice reads as noise, not
// two distinct problems.
const errorMessages = new Set(
[settledError(reqResult), settledError(costResult), modelTotalsUnavailable ? '' : settledError(modelResult)].filter(Boolean),
)
const combinedError = Array.from(errorMessages).join('; ')
this.detail[userId] = {
loading: false,
error: combinedError,
// Per-section errors (verify-ui-states.md #4 fix) — see this
// state's own doc comment. A 404 on the usermodel call is a
// known, EXPECTED state (the feature is off server-side),
// surfaced via modelTotalsUnavailable instead, never as an error.
reqError: settledError(reqResult),
costError: settledError(costResult),
modelError: modelTotalsUnavailable ? '' : settledError(modelResult),
reqSeries: reqResult.status === 'fulfilled' ? reqResult.value : current.reqSeries,
costSeries: costResult.status === 'fulfilled' ? costResult.value : current.costSeries,
modelTotals: modelResult.status === 'fulfilled' ? modelResult.value : modelTotalsUnavailable ? null : current.modelTotals,
modelTotalsUnavailable,
fetchedAt: Date.now(),
reqId: requestId,
window,
span,
}
},
},
+27
View File
@@ -3,6 +3,7 @@ import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { createVisibilityPoller, type VisibilityPoller } from '@/lib/polling'
import { useAuthStore } from '@/stores/auth'
import { useToastsStore } from '@/stores/toasts'
import type { AdminOverviewResponse, AdminTargetsResponse, AdminUsageResponse } from '@/types/api'
/** How often the Providers/Usage/Targets views poll (matches the replaced vanilla-JS page). */
@@ -39,6 +40,8 @@ export const useDashboardStore = defineStore('dashboard', {
* so the dashboard never resorts to comparing response ordering.
*/
refreshing: false,
/** toastedForFailure guards states-plan.md item 3's "one toast per failure streak until it recovers" — set once a background-refresh-failure toast has fired for the CURRENT ongoing failure streak, reset the moment a refresh fully succeeds again (failures.length === 0 in doRefresh). */
toastedForFailure: false,
}),
actions: {
async refresh(): Promise<void> {
@@ -54,6 +57,16 @@ export const useDashboardStore = defineStore('dashboard', {
},
/** 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> {
// Captured BEFORE this tick's results are assigned below — overview/
// usage/targets are never cleared on a failed fetch (only ever
// assigned on a FULFILLED result), so this is a true "did the reader
// already have something on screen before this attempt" read, not
// just "did the previous refresh fully succeed" (a first attempt
// that partially succeeds still advances lastUpdated below, which
// would otherwise make a same-tick check on `lastUpdated` see this
// attempt's own partial success instead of the PRIOR state).
const hadDataBefore = this.overview !== null || this.usage !== null || this.targets !== null
const [overviewResult, usageResult, targetsResult] = await Promise.allSettled([
adminFetch<AdminOverviewResponse>('/admin/api/overview'),
adminFetch<AdminUsageResponse>('/admin/api/usage'),
@@ -78,6 +91,7 @@ export const useDashboardStore = defineStore('dashboard', {
if (failures.length === 0) {
this.lastUpdated = new Date()
this.error = ''
this.toastedForFailure = false
return
}
@@ -96,6 +110,19 @@ export const useDashboardStore = defineStore('dashboard', {
this.error = failures
.map(({ label, reason }) => `${label}: ${reason instanceof Error ? reason.message : String(reason)}`)
.join('; ')
// states-plan.md item 3: a background refresh failure while data is
// ALREADY shown gets a toast instead of wiping content (the header's
// own inline `error` text already covers that case too, but a toast
// surfaces it even when the reader is looking at a different page
// than the one showing the stale data) — never fired on a genuine
// first-load failure (hadDataBefore false), which is an ErrorState's
// job instead, and never repeated on every tick of an ONGOING
// failure streak (toastedForFailure).
if (hadDataBefore && !this.toastedForFailure) {
useToastsStore().push({ kind: 'error', message: `Dashboard refresh failed: ${this.error}` })
}
this.toastedForFailure = true
},
/**
* startPolling is idempotent and safe to call before a key is stored —
+17
View File
@@ -3,6 +3,7 @@ import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { createVisibilityPoller, type VisibilityPoller } from '@/lib/polling'
import { useAuthStore } from '@/stores/auth'
import { useToastsStore } from '@/stores/toasts'
import type { AdminEventsResponse, AdminEventView } from '@/types/api'
/** How often the Events view polls while mounted — same cadence as the Providers/Usage/Targets dashboard poll (stores/dashboard.ts's own POLL_MS): a rate-limit/upstream event is exactly the kind of thing an operator wants to see within a few seconds, not thirty. */
@@ -57,6 +58,8 @@ export const useEventsStore = defineStore('events', {
poller: undefined as VisibilityPoller | undefined,
/** Mirrors dashboard.ts's own in-flight guard: a round trip slower than POLL_MS must never stack a second refresh() on top of the first. */
refreshing: false,
/** toastedForFailure mirrors dashboard.ts's own flag: states-plan.md item 3's "one toast per failure streak per store until it recovers". */
toastedForFailure: false,
}),
actions: {
async refresh(): Promise<void> {
@@ -64,6 +67,12 @@ export const useEventsStore = defineStore('events', {
if (!auth.isAuthenticated) return
if (this.refreshing) return
this.refreshing = true
// Captured before this tick's own result lands — see dashboard.ts's
// doRefresh's identical hadDataBefore doc comment: `error` alone is
// stale-friendly (a previous failure leaves it set), but `lastUpdated`
// is null exactly until the FIRST successful fetch ever completes,
// regardless of how many failures came before it.
const hadDataBefore = this.lastUpdated !== null
try {
const res = await adminFetch<AdminEventsResponse>(`/admin/api/events?limit=${EVENTS_LIMIT}`)
this.events = res.events
@@ -73,12 +82,20 @@ export const useEventsStore = defineStore('events', {
this.degraded = res.degraded ?? false
this.lastUpdated = new Date()
this.error = ''
this.toastedForFailure = false
} catch (err) {
// A 401/403 already rejected the key (lib/api.ts) — the AuthGate
// takes over the whole view, matching dashboard.ts's own
// identical carve-out; nothing left to report here.
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) return
this.error = err instanceof Error ? err.message : String(err)
// states-plan.md item 3 — see dashboard.ts's doRefresh for the
// full reasoning: only when the reader already had a successful
// fetch to show, and only once per ongoing failure streak.
if (hadDataBefore && !this.toastedForFailure) {
useToastsStore().push({ kind: 'error', message: `Events refresh failed: ${this.error}` })
}
this.toastedForFailure = true
} finally {
this.refreshing = false
}
+19
View File
@@ -132,6 +132,25 @@ describe('useReliabilityStore.fetch', () => {
expect(reliability.error).toBe('')
})
// Live-preview fix: dashboard.overview being null means the fleet's
// provider list is UNKNOWN, not genuinely zero — fetch() must not resolve
// as "loaded, empty" (the bug above's own short-circuit used to do this
// BEFORE dashboard.overview had ever landed too), or ReliabilityPage.vue's
// per-provider charts briefly render an empty grid on a cold reload
// instead of staying on their skeleton.
it('no-ops with no adminFetch calls and leaves state untouched when dashboard.overview has not loaded yet', async () => {
// useDashboardStore().overview defaults to null — never assigned here.
const reliability = useReliabilityStore()
await reliability.fetch()
expect(mockedAdminFetch).not.toHaveBeenCalled()
expect(reliability.buckets).toEqual([])
expect(reliability.lastUpdated).toBeNull()
expect(reliability.error).toBe('')
expect(reliability.loading).toBe(false)
})
it('fetches attempt/fail/timeout/fover per configured provider scope, plus total rej/r402, and assigns every field', async () => {
useDashboardStore().overview = providerOverview(['openai', 'anthropic'])
const reliability = useReliabilityStore()
+17 -2
View File
@@ -119,12 +119,27 @@ export const useReliabilityStore = defineStore('reliability', {
}),
actions: {
async fetch(): Promise<void> {
const dashboard = useDashboardStore()
/**
* dashboard.overview not having loaded yet (live-preview fix) means
* the fleet's provider list is UNKNOWN, not genuinely zero — a
* no-op here, rather than falling into the zero-providers
* short-circuit below, which used to resolve `lastUpdated`/`buckets`
* as "loaded, empty" before the dashboard's own first poll had even
* landed, briefly rendering an empty chart grid on a cold reload
* instead of a skeleton. ReliabilityPage.vue's own watch on
* `dashboard.overview?.providers.length` re-fires this fetch the
* moment overview actually loads, so nothing is lost by skipping it
* here — this.loading/buckets/error are left exactly as they were.
*/
const overview = dashboard.overview
if (overview === null) return
const requestId = ++this.reqId
this.loading = true
try {
const filters = useFiltersStore()
const dashboard = useDashboardStore()
const providerNames = (dashboard.overview?.providers ?? []).map((p) => p.name)
const providerNames = overview.providers.map((p) => p.name)
if (providerNames.length === 0) {
if (requestId !== this.reqId) return
+307 -1
View File
@@ -86,6 +86,44 @@ describe('useSpendStore.fetchModelRanking', () => {
})
})
// verify-ui-states-3.md pre-existing fix: fetchModelRanking's failures used
// to fold into breakdownError, which a later, unrelated fetchBreakdown
// success then cleared unconditionally — silently wiping a real ranking
// failure. rankingError is its own field, never touched by fetchBreakdown.
describe('useSpendStore.fetchModelRanking: rankingError', () => {
it('sets rankingError on failure, and a later breakdown success never clears it', async () => {
mockRoutes({
models: () => {
throw new Error('ranking boom')
},
series: () => seriesFor(['total'], 5),
})
const spend = useSpendStore()
await spend.fetchModelRanking()
expect(spend.rankingError).toBe('ranking boom')
expect(spend.breakdownError).toBe('')
await spend.fetchBreakdown() // succeeds even off a still-empty ranking (by=model with 0 ids just charts nothing)
expect(spend.breakdownError).toBe('')
expect(spend.rankingError).toBe('ranking boom') // NOT cross-cleared
})
it('clears rankingError on the next successful ranking fetch', async () => {
mockRoutes({
models: () => {
throw new Error('ranking boom')
},
})
const spend = useSpendStore()
await spend.fetchModelRanking()
expect(spend.rankingError).toBe('ranking boom')
mockRoutes({ models: () => rankingWith(['a/one']) })
await spend.fetchModelRanking()
expect(spend.rankingError).toBe('')
})
})
// P3 item 21: PricingHealthTable.vue's "unpriced first" ordering needs a
// ranking sorted by TRAFFIC (req), independent of whatever metric the
// Spend breakdown chart currently sorts by — sorting by `cost` (the
@@ -251,6 +289,59 @@ describe('useSpendStore.fetchBreakdown: comparison', () => {
})
})
// verify-ui-states-3.md NEW-1: a genuine selection change (by, metric,
// window, span, or scope) clears `buckets` SYNCHRONOUSLY, before the new
// fetch resolves — SpendPage.vue's breakdownState (hasData: buckets.length
// > 0) must flip to 'skeleton' immediately, never keep the OLD selection's
// series on screen mislabeled under the NEW selection. A same-selection
// refetch (a poll tick, or a Retry) must leave `buckets` alone.
describe('useSpendStore.fetchBreakdown: NEW-1 buckets-clear-on-selection-change', () => {
it('clears buckets synchronously on a genuine selection change, before the fetch resolves', async () => {
mockRoutes({ models: () => rankingWith([]), series: () => seriesFor(['total'], 5) })
const spend = useSpendStore()
await spend.fetchModelRanking()
await spend.fetchBreakdown()
expect(spend.buckets).toEqual(['20260922', '20260923'])
spend.$patch({ metric: 'req' }) // a genuine selection change
const pending = spend.fetchBreakdown()
expect(spend.buckets).toEqual([]) // cleared before the network call even starts
await pending
expect(spend.buckets).toEqual(['20260922', '20260923']) // repopulated once the new selection's fetch lands
})
it('leaves buckets on screen across a same-selection refetch (a poll tick)', async () => {
mockRoutes({ models: () => rankingWith([]), series: () => seriesFor(['total'], 5) })
const spend = useSpendStore()
await spend.fetchModelRanking()
await spend.fetchBreakdown()
expect(spend.buckets).toEqual(['20260922', '20260923'])
const pending = spend.fetchBreakdown() // same by/metric/window/span/scope
expect(spend.buckets).toEqual(['20260922', '20260923']) // NOT cleared
await pending
expect(spend.buckets).toEqual(['20260922', '20260923'])
})
it('a same-selection refetch failure sets breakdownError but leaves buckets on screen (the "ready" state the inline error line in SpendPage.vue depends on)', async () => {
mockRoutes({ models: () => rankingWith([]), series: () => seriesFor(['total'], 5) })
const spend = useSpendStore()
await spend.fetchModelRanking()
await spend.fetchBreakdown()
expect(spend.buckets.length).toBeGreaterThan(0)
expect(spend.breakdownError).toBe('')
mockedAdminFetch.mockImplementation((async (path: string) => {
if (path.startsWith('/admin/api/usage/series')) throw new Error('breakdown boom')
return rankingWith([])
}) as typeof adminFetch)
await spend.fetchBreakdown()
expect(spend.breakdownError).toBe('breakdown boom')
expect(spend.buckets.length).toBeGreaterThan(0) // still on screen: loadState reads 'ready', not 'error'
})
})
describe('useSpendStore.fetchBurndown', () => {
it('fetches a day-window total series spanning the current UTC day-of-month', async () => {
mockRoutes({
@@ -297,6 +388,32 @@ describe('useSpendStore.fetchBurndown', () => {
})
})
// verify-ui-states-3.md NEW-2: the scope===null (provider-scope) branch
// must also reset burndownError/burndownLoading, not just buckets/points/
// budget — otherwise a stale error/loading from a PREVIOUS scope's failed
// fetch survives a detour through a provider scope (chart hidden) and
// reappears once the reader switches back to a scope with a chart.
describe('useSpendStore.fetchBurndown: NEW-2 provider-scope reset', () => {
it('resets burndownError and burndownLoading when scope narrows to a provider', async () => {
mockRoutes({
series: () => {
throw new Error('burndown boom')
},
})
const spend = useSpendStore()
await spend.fetchBurndown() // fails against the default 'all' scope
expect(spend.burndownError).toBe('burndown boom')
useFiltersStore().setFilters({ scope: 'provider:openai' })
await spend.fetchBurndown() // burndownAvailable is now false -> scope===null branch
expect(spend.burndownError).toBe('')
expect(spend.burndownLoading).toBe(false)
expect(spend.burndownBuckets).toEqual([])
expect(spend.burndownPoints).toEqual([])
expect(spend.burndownBudgetMicros).toBeNull()
})
})
describe('useSpendStore.fetchDrilldown', () => {
it('requests kind=group at the top level (drill empty)', async () => {
mockRoutes({
@@ -375,7 +492,7 @@ describe('useSpendStore.fetchDrilldown', () => {
spend.$patch({ drill: 'user:alice' })
await spend.fetchDrilldown()
expect(spend.drilldownDisabled).toBe(true)
expect(spend.error).toBe('')
expect(spend.drilldownError).toBe('')
})
})
@@ -398,3 +515,192 @@ describe('useSpendStore.setSelection', () => {
expect(refreshSpy).not.toHaveBeenCalled()
})
})
// verify-ui-states-2.md #3: breakdownError/pricingHealthError/drilldownError/
// burndownError replace one shared `error` field — each leg's own failure
// must never surface under, or get silently cleared by, an unrelated leg.
describe('useSpendStore: per-request error fields', () => {
it('a pricing-health failure sets only pricingHealthError, leaving the other three legs at \'\'', async () => {
mockRoutes({
models: (url) => {
if (url.includes('metric=req')) throw new Error('pricing boom')
return rankingWith([])
},
})
const spend = useSpendStore()
await spend.refresh()
expect(spend.pricingHealthError).toBe('pricing boom')
expect(spend.breakdownError).toBe('')
expect(spend.drilldownError).toBe('')
expect(spend.burndownError).toBe('')
})
it('a drilldown failure sets only drilldownError, leaving the other three legs at \'\'', async () => {
mockRoutes({
totals: () => {
throw new Error('drilldown boom')
},
})
const spend = useSpendStore()
await spend.refresh()
expect(spend.drilldownError).toBe('drilldown boom')
expect(spend.breakdownError).toBe('')
expect(spend.pricingHealthError).toBe('')
expect(spend.burndownError).toBe('')
})
it('a later breakdown success never clears an already-set pricingHealthError', async () => {
mockRoutes({
models: (url) => {
if (url.includes('metric=req')) throw new Error('pricing boom')
return rankingWith([])
},
})
const spend = useSpendStore()
await spend.refresh()
expect(spend.pricingHealthError).toBe('pricing boom')
mockRoutes({}) // every route now succeeds
await spend.fetchBreakdown()
expect(spend.breakdownError).toBe('')
expect(spend.pricingHealthError).toBe('pricing boom')
})
it('retrying only the failing leg clears only that leg\'s own error', async () => {
mockRoutes({
models: (url) => {
if (url.includes('metric=req')) throw new Error('pricing boom')
return rankingWith([])
},
})
const spend = useSpendStore()
await spend.refresh()
expect(spend.pricingHealthError).toBe('pricing boom')
mockRoutes({}) // the network recovers
await spend.fetchPricingHealthRanking()
expect(spend.pricingHealthError).toBe('')
})
})
// verify-ui-states-2.md #2: pricingHealthLoaded/drilldownLoaded are "has a
// fetch completed for the CURRENT selection", reset only on a genuine
// selection change (window/span for pricing health; window/span/drill for
// the drilldown) — never on a same-selection refetch (a 60s poll tick),
// which must not flash a skeleton back on over an already-settled result.
describe('useSpendStore: pricingHealthLoaded / drilldownLoaded', () => {
it('pricingHealthLoaded flips true on a successful fetch, even with zero ranked models', async () => {
mockRoutes({ models: () => rankingWith([]) })
const spend = useSpendStore()
expect(spend.pricingHealthLoaded).toBe(false)
await spend.fetchPricingHealthRanking()
expect(spend.pricingHealthLoaded).toBe(true)
})
it('pricingHealthLoaded stays true across a same-range refetch, and resets before a genuine range change resolves', async () => {
mockRoutes({ models: () => rankingWith(['a/one']) })
const spend = useSpendStore()
await spend.fetchPricingHealthRanking()
expect(spend.pricingHealthLoaded).toBe(true)
// Same range again (a poll tick): must read true DURING the fetch too
// — a caller (PricingHealthTable) checking mid-flight must not see a
// skeleton flash for an unchanged selection.
let sawLoadedDuringSameRangeRefetch: boolean | undefined
mockedAdminFetch.mockImplementation((async () => {
sawLoadedDuringSameRangeRefetch = spend.pricingHealthLoaded
return rankingWith(['a/one'])
}) as typeof adminFetch)
await spend.fetchPricingHealthRanking()
expect(sawLoadedDuringSameRangeRefetch).toBe(true)
// A genuine range change (span 7 -> 30, still window=day) DOES reset
// it before the new fetch resolves.
useFiltersStore().setFilters({ range: '30d' })
let sawLoadedDuringRangeChange: boolean | undefined
mockedAdminFetch.mockImplementation((async () => {
sawLoadedDuringRangeChange = spend.pricingHealthLoaded
return rankingWith(['a/one'])
}) as typeof adminFetch)
await spend.fetchPricingHealthRanking()
expect(sawLoadedDuringRangeChange).toBe(false)
})
it('drilldownLoaded flips true on a successful fetch and on a 404 (a settled, known-disabled state)', async () => {
mockRoutes({ totals: () => ({ kind: 'group', window: 'day', span: 2, offset: 0, metrics: ['cost'], rows: [] }) })
const spend = useSpendStore()
expect(spend.drilldownLoaded).toBe(false)
await spend.fetchDrilldown()
expect(spend.drilldownLoaded).toBe(true)
mockedAdminFetch.mockImplementation((async (path: string) => {
if (path.startsWith('/admin/api/usage/totals')) throw new AdminApiError('user-model statistics are not enabled', 404)
throw new Error(`unexpected path ${path}`)
}) as typeof adminFetch)
const spend2 = useSpendStore()
spend2.$patch({ drill: 'user:alice' })
await spend2.fetchDrilldown()
expect(spend2.drilldownDisabled).toBe(true)
expect(spend2.drilldownLoaded).toBe(true)
})
it('drilldownLoaded resets when `drill` changes, not on a same-selection refetch', async () => {
mockRoutes({ totals: () => ({ kind: 'group', window: 'day', span: 2, offset: 0, metrics: ['cost'], rows: [] }) })
const spend = useSpendStore()
await spend.fetchDrilldown()
expect(spend.drilldownLoaded).toBe(true)
let sawLoadedDuringSameDrillRefetch: boolean | undefined
mockedAdminFetch.mockImplementation((async () => {
sawLoadedDuringSameDrillRefetch = spend.drilldownLoaded
return { kind: 'group', window: 'day', span: 2, offset: 0, metrics: ['cost'], rows: [] }
}) as typeof adminFetch)
await spend.fetchDrilldown()
expect(sawLoadedDuringSameDrillRefetch).toBe(true)
spend.$patch({ drill: 'group:friends' })
let sawLoadedDuringDrillChange: boolean | undefined
mockedAdminFetch.mockImplementation((async () => {
sawLoadedDuringDrillChange = spend.drilldownLoaded
return { kind: 'user', window: 'day', span: 2, offset: 0, metrics: ['cost'], rows: [] }
}) as typeof adminFetch)
await spend.fetchDrilldown()
expect(sawLoadedDuringDrillChange).toBe(false)
})
})
// verify-ui-states-2.md #10: an overlapping poll-tick refresh() and a
// selection/filter-change refresh() can resolve in either order — only the
// LATEST refresh call may clear `loading`.
describe('useSpendStore.refresh: overlapping calls', () => {
it('an earlier refresh finishing after a newer one has started does not clear loading', async () => {
const spend = useSpendStore()
vi.spyOn(spend, 'fetchModelRanking').mockResolvedValue(undefined)
const pending: (() => void)[] = []
const controlled = (): Promise<void> => new Promise((resolve) => pending.push(resolve))
vi.spyOn(spend, 'fetchBreakdown').mockImplementation(controlled)
vi.spyOn(spend, 'fetchPricingHealthRanking').mockImplementation(controlled)
vi.spyOn(spend, 'fetchBurndown').mockImplementation(controlled)
vi.spyOn(spend, 'fetchDrilldown').mockImplementation(controlled)
const firstRefresh = spend.refresh()
await vi.waitFor(() => expect(pending.length).toBe(4))
const firstPending = pending.splice(0, pending.length)
const secondRefresh = spend.refresh()
await vi.waitFor(() => expect(pending.length).toBe(4))
const secondPending = pending.splice(0, pending.length)
// Resolve only the FIRST (now stale) refresh's own legs.
firstPending.forEach((resolve) => resolve())
await firstRefresh
expect(spend.loading).toBe(true) // the second, newer refresh is still in flight
secondPending.forEach((resolve) => resolve())
await secondRefresh
expect(spend.loading).toBe(false)
})
})
+187 -17
View File
@@ -144,13 +144,95 @@ export const useSpendStore = defineStore('spend', {
drilldownDisabled: false,
loading: false,
error: '',
/**
* breakdownError/pricingHealthError/drilldownError/burndownError
* (verify-ui-states-2.md #3 fix) replace a single shared `error` field
* — that field was cleared ONLY on fetchBreakdown's own success (never
* by the other three legs), so a pricing-health/drilldown/burndown
* failure that happened to land before a later, unrelated breakdown
* success got silently wiped (a false "no traffic" reading over a real
* failure), or, the other direction, an already-cleared error from one
* leg could sit stale under a DIFFERENT card that loaded fine. Each
* card below (SpendPage.vue) now reads and retries only its own field.
* fetchModelRanking's own failures used to fold into `breakdownError`
* too — see `rankingError` below (verify-ui-states-3.md pre-existing
* fix) for why that was also wrong and what replaced it.
*/
breakdownError: '',
/**
* rankingError (verify-ui-states-3.md pre-existing-bug fix) is
* fetchModelRanking's OWN error field. It used to fold into
* `breakdownError`, which a SUBSEQUENT fetchBreakdown success then
* cleared unconditionally (line below), silently wiping a real ranking
* failure the moment breakdown itself finished fetching — even a
* breakdown built from that same still-empty/stale ranking (a cold
* by=model load then renders 100% "Other" with no error in sight).
* Never touched by fetchBreakdown, so SpendPage.vue can surface it next
* to the breakdown chart independently, with no cross-clearing.
*/
rankingError: '',
pricingHealthError: '',
drilldownError: '',
burndownError: '',
/**
* pricingHealthLoading/drilldownLoading/burndownLoading (verify-ui-
* states.md #3/#7): PricingHealthTable.vue, AttributionDrilldown.vue,
* and BurnDownChart's own SpendPage.vue wrapper each need their OWN
* in-flight signal — `loading` above only wraps fetchBreakdown (the
* by=model/provider/group chart), not these three, which run in
* parallel via refresh()'s own Promise.all — so reusing `loading` for
* any of them would read false (or wrongly true) independent of
* whether THEIR OWN fetch is actually in flight.
*/
pricingHealthLoading: false,
drilldownLoading: false,
burndownLoading: false,
/**
* pricingHealthLoaded/drilldownLoaded (verify-ui-states-2.md #2 fix)
* are "has a fetch completed for the CURRENT selection" —
* PricingHealthTable.vue's/AttributionDrilldown.vue's own `loaded`
* prop — set once their own fetch settles into a genuine result
* (success, or a known-disabled 404 for the drilldown), and reset the
* moment the selection they depend on changes (see
* pricingHealthWindow/pricingHealthSpan and
* drilldownWindow/drilldownSpan/drilldownDrill below), so a
* genuinely empty table still reads as settled/empty across the 60s
* poll's own `loading` flips instead of flashing a skeleton on every
* tick.
*/
pricingHealthLoaded: false,
drilldownLoaded: false,
/** The (window, span) pricingHealthRanking was last fetched FOR — fetchPricingHealthRanking's own "did the selection this ranking depends on actually change" check, resetting pricingHealthLoaded below exactly when it did. */
pricingHealthWindow: null as HistoryWindow | null,
pricingHealthSpan: null as number | null,
/** The (window, span, drill) drilldownRows were last fetched FOR — fetchDrilldown's own equivalent of the field above. */
drilldownWindow: null as HistoryWindow | null,
drilldownSpan: null as number | null,
drilldownDrillFetchedFor: null as string | null,
/**
* The (by, metric, window, span, scope) `buckets` was last fetched FOR
* (verify-ui-states-3.md NEW-1) — fetchBreakdown's own equivalent of
* the two field groups above. A genuine SELECTION change clears
* `buckets` synchronously, before the fetch starts, so SpendPage.vue's
* breakdownState (hasData: buckets.length > 0) flips to 'skeleton'
* immediately instead of leaving the PREVIOUS selection's series on
* screen mislabeled under the NEW selection's metric/by/range/scope. A
* same-selection refetch (a 60s poll tick, or a Retry) leaves `buckets`
* alone — loadState's own 'ready' precedence (lib/load-state.ts).
*/
breakdownByFetchedFor: null as SpendBy | null,
breakdownMetricFetchedFor: null as HistoryMetric | null,
breakdownWindow: null as HistoryWindow | null,
breakdownSpan: null as number | null,
breakdownScope: null as string | null,
/** breakdownReqId/rankingReqId/burndownReqId/drilldownReqId are FOUR independent latest-request-wins counters (stores/history.ts's own seriesReqId precedent), one per concern — unlike history.ts's tabs, every one of these fetches concurrently on a single Spend page render, so a single shared counter would make an in-flight ranking fetch spuriously discard a still-relevant breakdown fetch (or vice versa) merely for starting first. */
breakdownReqId: 0,
rankingReqId: 0,
pricingHealthReqId: 0,
burndownReqId: 0,
drilldownReqId: 0,
/** refreshReqId (verify-ui-states-2.md #10 fix) is refresh()'s own latest-request-wins counter over `this.loading` — an overlapping poll-tick refresh and a selection/filter-change refresh can otherwise resolve in either order, and the FIRST to finish clears `loading` while the SECOND is still in flight. */
refreshReqId: 0,
poller: undefined as VisibilityPoller | undefined,
}),
getters: {
@@ -186,16 +268,37 @@ export const useSpendStore = defineStore('spend', {
* concurrently would race fetchBreakdown against a still-in-flight (or
* still-empty, on first load) ranking. fetchBurndown/fetchDrilldown
* depend on neither and run fully in parallel with that chain.
*
* `this.loading` is now owned HERE, wrapping the whole
* fetchModelRanking-then-fetchBreakdown chain (verify-ui-states.md
* live-preview fix), not just fetchBreakdown's own body: SpendPage.
* vue's breakdownState (lib/load-state.ts) reads `spend.loading` to
* decide skeleton-vs-empty for the breakdown chart, and with the flag
* previously scoped to fetchBreakdown alone, it stayed FALSE for the
* entire fetchModelRanking leg — live verification caught the
* breakdown chart rendering as a bare empty grid (loadState's 'empty'
* branch, not 'skeleton') for that whole first leg on every load.
*/
async refresh(): Promise<void> {
const auth = useAuthStore()
if (!auth.isAuthenticated) return
await Promise.all([
this.fetchModelRanking().then(() => this.fetchBreakdown()),
this.fetchPricingHealthRanking(),
this.fetchBurndown(),
this.fetchDrilldown(),
])
// verify-ui-states-2.md #10: latest-request-wins over `this.loading`
// itself — a poll tick and a selection/filter-change refresh can
// overlap and resolve in either order; only the LATEST refresh call
// is allowed to clear the flag, so an earlier, still-settling one
// finishing first never clears it out from under the later one.
const requestId = ++this.refreshReqId
this.loading = true
try {
await Promise.all([
this.fetchModelRanking().then(() => this.fetchBreakdown()),
this.fetchPricingHealthRanking(),
this.fetchBurndown(),
this.fetchDrilldown(),
])
} finally {
if (requestId === this.refreshReqId) this.loading = false
}
},
async fetchModelRanking(): Promise<void> {
const filters = useFiltersStore()
@@ -206,32 +309,69 @@ export const useSpendStore = defineStore('spend', {
)
if (requestId !== this.rankingReqId) return
this.modelRanking = res.models
this.rankingError = ''
} catch (err) {
if (requestId !== this.rankingReqId) return
if (isAuthRejection(err)) return
this.error = messageOf(err)
// Own field, never folded into breakdownError — see this store's
// own doc comment on rankingError (verify-ui-states-3.md
// pre-existing fix).
this.rankingError = messageOf(err)
}
},
/** fetchPricingHealthRanking is pricingHealthRanking's own fetch — always metric=req, independent of `this.metric` (see that field's own doc comment, P3 item 21). Runs in parallel with fetchModelRanking in refresh(), not chained after it: neither depends on the other's result. */
async fetchPricingHealthRanking(): Promise<void> {
const filters = useFiltersStore()
// verify-ui-states-2.md #2: a genuine SELECTION change (this ranking
// depends only on window/span, not `by`/`metric`/`drill`/scope)
// resets pricingHealthLoaded so the new selection's own skeleton
// shows — a background poll tick (same window/span) leaves it alone.
if (this.pricingHealthWindow !== filters.window || this.pricingHealthSpan !== filters.span) {
this.pricingHealthLoaded = false
}
this.pricingHealthWindow = filters.window
this.pricingHealthSpan = filters.span
const requestId = ++this.pricingHealthReqId
this.pricingHealthLoading = true
try {
const res = await adminFetch<AdminUsageModelsResponse>(
`/admin/api/usage/models?metric=req&window=${filters.window}&span=${filters.span}&limit=${MODEL_RANKING_LIMIT}&detail=1`,
)
if (requestId !== this.pricingHealthReqId) return
this.pricingHealthRanking = res.models
this.pricingHealthError = ''
this.pricingHealthLoaded = true
} catch (err) {
if (requestId !== this.pricingHealthReqId) return
if (isAuthRejection(err)) return
this.error = messageOf(err)
this.pricingHealthError = messageOf(err)
} finally {
if (requestId === this.pricingHealthReqId) this.pricingHealthLoading = false
}
},
async fetchBreakdown(): Promise<void> {
const filters = useFiltersStore()
// verify-ui-states-3.md NEW-1: a genuine SELECTION change (by,
// metric, window, span, or scope) clears `buckets` synchronously,
// BEFORE the fetch starts — flips breakdownState to 'skeleton'
// immediately instead of leaving the OLD selection's series on
// screen mislabeled under the NEW selection. A same-selection
// refetch (a 60s poll tick, or a Retry) leaves `buckets` alone.
if (
this.breakdownByFetchedFor !== this.by ||
this.breakdownMetricFetchedFor !== this.metric ||
this.breakdownWindow !== filters.window ||
this.breakdownSpan !== filters.span ||
this.breakdownScope !== filters.scope
) {
this.buckets = []
}
this.breakdownByFetchedFor = this.by
this.breakdownMetricFetchedFor = this.metric
this.breakdownWindow = filters.window
this.breakdownSpan = filters.span
this.breakdownScope = filters.scope
const requestId = ++this.breakdownReqId
this.loading = true
try {
if (filters.scope === 'all') {
await this.fetchBreakdownFleetWide(filters.window, filters.span, requestId)
@@ -244,7 +384,7 @@ export const useSpendStore = defineStore('spend', {
await this.fetchBreakdownProviderScope(filters.scope.slice('provider:'.length), filters.window, filters.span, requestId)
}
if (requestId !== this.breakdownReqId) return
this.error = ''
this.breakdownError = ''
if (filters.cmpSpec.requested) {
await this.fetchComparison(filters.scope, filters.window, filters.span, filters.cmpSpec.offset, requestId)
} else {
@@ -254,9 +394,7 @@ export const useSpendStore = defineStore('spend', {
} catch (err) {
if (requestId !== this.breakdownReqId) return
if (isAuthRejection(err)) return
this.error = messageOf(err)
} finally {
if (requestId === this.breakdownReqId) this.loading = false
this.breakdownError = messageOf(err)
}
},
/**
@@ -383,8 +521,17 @@ export const useSpendStore = defineStore('spend', {
this.burndownBuckets = []
this.burndownPoints = []
this.burndownBudgetMicros = null
// verify-ui-states-3.md NEW-2: also reset burndownError/Loading —
// an in-flight burndown for a PREVIOUS scope has its own `finally`
// fail the reqId check below (burndownReqId was just bumped
// above), so without this its stale error/loading would otherwise
// survive a detour through a provider scope (chart hidden) and
// reappear once the reader switches back to a scope with a chart.
this.burndownError = ''
this.burndownLoading = false
return
}
this.burndownLoading = true
try {
const span = currentMonthDaySpan(new Date())
const res = await this.fetchSeries([scope], 'cost', 'day', span, 0)
@@ -392,16 +539,30 @@ export const useSpendStore = defineStore('spend', {
this.burndownBuckets = res.buckets
this.burndownPoints = res.series.find((s) => s.scope === scope)?.points ?? new Array<number>(res.buckets.length).fill(0)
this.burndownBudgetMicros = burndownBudgetMicros(scope)
this.burndownError = ''
} catch (err) {
if (requestId !== this.burndownReqId) return
if (isAuthRejection(err)) return
this.error = messageOf(err)
this.burndownError = messageOf(err)
} finally {
if (requestId === this.burndownReqId) this.burndownLoading = false
}
},
async fetchDrilldown(): Promise<void> {
const filters = useFiltersStore()
// verify-ui-states-2.md #2: a genuine SELECTION change (window,
// span, or the drill breadcrumb itself) resets drilldownLoaded so
// the new selection's own skeleton shows — a background poll tick
// (same three values) leaves it alone.
if (this.drilldownWindow !== filters.window || this.drilldownSpan !== filters.span || this.drilldownDrillFetchedFor !== this.drill) {
this.drilldownLoaded = false
}
this.drilldownWindow = filters.window
this.drilldownSpan = filters.span
this.drilldownDrillFetchedFor = this.drill
const requestId = ++this.drilldownReqId
const level = drilldownLevel(this.drill)
this.drilldownLoading = true
try {
// usermodel only accepts day/month (redesign-plan.md section
// 1.3.v) — clamped via lib/range.ts's shared dayOrMonthWindow so
@@ -421,19 +582,28 @@ export const useSpendStore = defineStore('spend', {
this.drilldownRows = res.rows
this.drilldownMetrics = res.metrics
this.drilldownDisabled = false
this.drilldownError = ''
this.drilldownLoaded = true
} catch (err) {
if (requestId !== this.drilldownReqId) return
if (isAuthRejection(err)) return
if (err instanceof AdminApiError && err.status === 404) {
// usermodel with admin.stats.userModel off (redesign-plan.md
// section 1.3.v) — a known, named-in-the-hint state, not an
// error the reader needs to see in the status line.
// error the reader needs to see in the status line. Still a
// SETTLED fetch for this selection (AttributionDrilldown.vue's
// own hint Alert takes precedence over skeleton/error/empty
// regardless), so drilldownLoaded is set here too.
this.drilldownRows = []
this.drilldownMetrics = []
this.drilldownDisabled = true
this.drilldownError = ''
this.drilldownLoaded = true
return
}
this.error = messageOf(err)
this.drilldownError = messageOf(err)
} finally {
if (requestId === this.drilldownReqId) this.drilldownLoading = false
}
},
startPolling(): void {
+130
View File
@@ -0,0 +1,130 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MAX_VISIBLE_TOASTS, TOAST_TIMEOUT_MS, useToastsStore } from './toasts'
beforeEach(() => {
setActivePinia(createPinia())
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('useToastsStore.push', () => {
it('enqueues a toast with the default timeout for its kind', () => {
const toasts = useToastsStore()
toasts.push({ kind: 'success', message: 'Copied' })
expect(toasts.toasts).toHaveLength(1)
expect(toasts.toasts[0]).toMatchObject({ kind: 'success', message: 'Copied', timeoutMs: TOAST_TIMEOUT_MS.success })
})
it('error toasts default to the longer 6s timeout', () => {
const toasts = useToastsStore()
toasts.push({ kind: 'error', message: 'Export failed' })
expect(toasts.toasts[0].timeoutMs).toBe(6000)
expect(TOAST_TIMEOUT_MS.error).toBe(6000)
})
it('success/info toasts default to the shorter 3s timeout', () => {
const toasts = useToastsStore()
toasts.push({ kind: 'success', message: 'a' })
toasts.push({ kind: 'info', message: 'b' })
expect(toasts.toasts[0].timeoutMs).toBe(3000)
expect(toasts.toasts[1].timeoutMs).toBe(3000)
})
it('accepts a per-push timeoutMs override', () => {
const toasts = useToastsStore()
toasts.push({ kind: 'success', message: 'a', timeoutMs: 100 })
expect(toasts.toasts[0].timeoutMs).toBe(100)
})
it('assigns each toast a distinct, increasing id', () => {
const toasts = useToastsStore()
toasts.push({ kind: 'success', message: 'a' })
toasts.push({ kind: 'success', message: 'b' })
expect(toasts.toasts[1].id).toBeGreaterThan(toasts.toasts[0].id)
})
it('auto-dismisses after its own timeoutMs', () => {
const toasts = useToastsStore()
toasts.push({ kind: 'success', message: 'a', timeoutMs: 1000 })
expect(toasts.toasts).toHaveLength(1)
vi.advanceTimersByTime(999)
expect(toasts.toasts).toHaveLength(1)
vi.advanceTimersByTime(1)
expect(toasts.toasts).toHaveLength(0)
})
it('dedupes an identical kind+message pushed consecutively', () => {
const toasts = useToastsStore()
toasts.push({ kind: 'error', message: 'refresh failed' })
toasts.push({ kind: 'error', message: 'refresh failed' })
expect(toasts.toasts).toHaveLength(1)
})
it('does NOT dedupe the same message once a different toast came in between', () => {
const toasts = useToastsStore()
toasts.push({ kind: 'error', message: 'refresh failed' })
toasts.push({ kind: 'info', message: 'other' })
toasts.push({ kind: 'error', message: 'refresh failed' })
expect(toasts.toasts).toHaveLength(3)
})
it('does NOT dedupe the same message text across different kinds', () => {
const toasts = useToastsStore()
toasts.push({ kind: 'error', message: 'x' })
toasts.push({ kind: 'success', message: 'x' })
expect(toasts.toasts).toHaveLength(2)
})
it('caps visible toasts at MAX_VISIBLE_TOASTS, dropping the oldest first (FIFO)', () => {
const toasts = useToastsStore()
toasts.push({ kind: 'info', message: '1' })
toasts.push({ kind: 'info', message: '2' })
toasts.push({ kind: 'info', message: '3' })
toasts.push({ kind: 'info', message: '4' })
expect(MAX_VISIBLE_TOASTS).toBe(3)
expect(toasts.toasts).toHaveLength(3)
expect(toasts.toasts.map((t) => t.message)).toEqual(['2', '3', '4'])
})
it('cancels a dropped toast\'s own timer so it cannot fire after eviction', () => {
const toasts = useToastsStore()
toasts.push({ kind: 'info', message: '1', timeoutMs: 500 })
toasts.push({ kind: 'info', message: '2', timeoutMs: 500 })
toasts.push({ kind: 'info', message: '3', timeoutMs: 500 })
toasts.push({ kind: 'info', message: '4', timeoutMs: 500 }) // drops '1'
expect(toasts.toasts.map((t) => t.message)).toEqual(['2', '3', '4'])
vi.advanceTimersByTime(500)
expect(toasts.toasts).toHaveLength(0)
})
})
describe('useToastsStore.dismiss', () => {
it('removes the named toast immediately', () => {
const toasts = useToastsStore()
toasts.push({ kind: 'success', message: 'a' })
const id = toasts.toasts[0].id
toasts.dismiss(id)
expect(toasts.toasts).toHaveLength(0)
})
it('cancels the pending auto-dismiss timer, so it does not double-fire on an id that no longer exists', () => {
const toasts = useToastsStore()
toasts.push({ kind: 'success', message: 'a', timeoutMs: 1000 })
const id = toasts.toasts[0].id
toasts.dismiss(id)
// Nothing should throw when the (canceled) timer would otherwise have fired.
expect(() => vi.advanceTimersByTime(2000)).not.toThrow()
expect(toasts.toasts).toHaveLength(0)
})
it('is a no-op for an unknown id', () => {
const toasts = useToastsStore()
toasts.push({ kind: 'success', message: 'a' })
toasts.dismiss(999)
expect(toasts.toasts).toHaveLength(1)
})
})
+85
View File
@@ -0,0 +1,85 @@
import { defineStore } from 'pinia'
/** ToastKind is one toast's visual/urgency tier — 'error' gets the longer default timeout (TOAST_TIMEOUT_MS) and an assertive aria-live region in ToastViewport.vue; 'success'/'info' share the shorter default and a polite region. */
export type ToastKind = 'success' | 'error' | 'info'
/** One toast entry ToastViewport.vue renders — `id` is this store's own monotonic counter, never reused, so a dismiss/timer always targets the exact entry it was scheduled for even after other toasts have come and gone. */
export interface Toast {
id: number
kind: ToastKind
message: string
timeoutMs: number
}
/** TOAST_TIMEOUT_MS is each kind's default auto-dismiss delay (states-plan.md item 3: "auto-dismiss default 3s success/info, 6s error") — a caller may override per-push via the optional `timeoutMs` argument. */
export const TOAST_TIMEOUT_MS: Record<ToastKind, number> = {
success: 3000,
info: 3000,
error: 6000,
}
/** MAX_VISIBLE_TOASTS caps how many toasts ToastViewport.vue shows at once (states-plan.md item 3: "max 3 visible") — pushing a 4th silently drops the OLDEST (FIFO), matching a notification stack's usual behavior: the newest, most relevant toast always stays. */
export const MAX_VISIBLE_TOASTS = 3
export interface PushToastInput {
kind: ToastKind
message: string
/** Overrides TOAST_TIMEOUT_MS[kind] for this one toast. */
timeoutMs?: number
}
/**
* useToastsStore is the panel's one in-house toast queue (states-plan.md
* item 3) — copy-to-clipboard, CSV export, snippet copy, API key
* generation, and background-refresh-failure callers all push through
* this single store; ToastViewport.vue (mounted once in App.vue) is the
* only reader. Kept deliberately tiny — no external toast library, this
* panel's whole toast surface is "a short queue with auto-dismiss
* timers" — rather than pulling in a dependency for it.
*/
export const useToastsStore = defineStore('toasts', {
state: () => ({
toasts: [] as Toast[],
nextId: 1,
/** timers is keyed by toast id, not held on the Toast object itself — a setTimeout handle is not serializable/comparable state a component template should ever touch. */
timers: {} as Record<number, ReturnType<typeof setTimeout>>,
}),
actions: {
/**
* push enqueues one toast, deduping it against the MOST RECENTLY
* pushed toast only (states-plan.md item 3: "dedupe identical
* consecutive messages") — an identical kind+message pushed again
* later, after other toasts have appeared in between, is NOT a
* duplicate of the earlier one and is shown again (e.g. the same
* background-refresh-failure message recurring after a brief
* recovery is worth re-surfacing, not silently swallowed forever).
*/
push({ kind, message, timeoutMs }: PushToastInput): void {
const mostRecent = this.toasts[this.toasts.length - 1]
if (mostRecent && mostRecent.kind === kind && mostRecent.message === message) return
const id = this.nextId++
const toast: Toast = { id, kind, message, timeoutMs: timeoutMs ?? TOAST_TIMEOUT_MS[kind] }
this.toasts.push(toast)
if (this.toasts.length > MAX_VISIBLE_TOASTS) {
const dropped = this.toasts.shift()
if (dropped) this.clearTimer(dropped.id)
}
this.timers[id] = setTimeout(() => this.dismiss(id), toast.timeoutMs)
},
/** dismiss removes one toast by id (the Retry/close button's own click handler, or a timer firing) and clears its pending auto-dismiss timer, if any. */
dismiss(id: number): void {
this.clearTimer(id)
this.toasts = this.toasts.filter((t) => t.id !== id)
},
/** clearTimer cancels and forgets one toast's pending setTimeout — shared by dismiss() and push()'s own over-capacity eviction, so a dropped or dismissed toast never fires its timer against an id no longer in `toasts`. */
clearTimer(id: number): void {
const timer = this.timers[id]
if (timer === undefined) return
clearTimeout(timer)
delete this.timers[id]
},
},
})