refactor(webui): extract repeated dashboard elements into shared components

Markup and helpers that were copied between pages now live in one
place:
- ChartCard, StatDisabledAlert, StatItem, DetailField, BudgetRatioBars,
  LabeledSelect, TargetPicker and LoadStateView components
- lib/scope.ts, lib/columns.ts and lib/chart-palette.ts
- messageOf, isAuthRejection, and the USD to micro-USD conversion
  helpers
- one monthly cost projection, one date formatter, and the
  useCopyFeedback composable

Behaviour is unchanged. Before and after screenshots of every page
match at 390 and 1440 px. Empty-state text now comes from the shared
EmptyState component, and the user run-out date uses the short date
format. admin_assets_gen.go is regenerated.

Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
2026-09-24 06:04:46 +01:00
parent ce39b8eb37
commit 2608e01c50
67 changed files with 2008 additions and 1136 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { adminFetch, isAuthRejection, messageOf } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import type { AdminCatalogModel, AdminCatalogResponse } from '@/types/api'
@@ -86,10 +86,10 @@ export const useCatalogStore = defineStore('catalog', {
this.error = ''
} catch (err) {
if (requestId !== this.reqId) return
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) {
if (isAuthRejection(err)) {
return
}
this.error = err instanceof Error ? err.message : String(err)
this.error = messageOf(err)
} finally {
if (requestId === this.reqId) this.loading = false
}
+3 -3
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { adminFetch, isAuthRejection, messageOf } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import type { AdminConfigResponse } from '@/types/api'
@@ -29,8 +29,8 @@ export const useConfigStore = defineStore('config', {
this.data = await adminFetch<AdminConfigResponse>('/admin/api/config')
this.error = ''
} catch (err) {
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) return
this.error = err instanceof Error ? err.message : String(err)
if (isAuthRejection(err)) return
this.error = messageOf(err)
} finally {
this.loading = false
}
+5 -7
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { AdminApiError, adminFetch, isAuthRejection, messageOf } from '@/lib/api'
import { dayOrMonthWindow, seriesUrl, totalsUrl } from '@/lib/range'
import { useAuthStore } from '@/stores/auth'
import type { AdminConsumersResponse, AdminSeriesResponse, AdminTotalsResponse, HistoryWindow } from '@/types/api'
@@ -63,7 +63,7 @@ function emptyDetail(): UserDetailState {
/** 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)
return messageOf(result.reason)
}
/**
@@ -102,8 +102,8 @@ export const useConsumersStore = defineStore('consumers', {
this.error = ''
this.fetchedAt = Date.now()
} catch (err) {
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) return
this.error = err instanceof Error ? err.message : String(err)
if (isAuthRejection(err)) return
this.error = messageOf(err)
} finally {
this.loading = false
}
@@ -184,9 +184,7 @@ export const useConsumersStore = defineStore('consumers', {
// superseded this one (latest-request-wins) — drop this result.
if (this.detail[userId]?.reqId !== requestId) return
const authRejected = [reqResult, costResult, modelResult].some(
(r) => r.status === 'rejected' && r.reason instanceof AdminApiError && (r.reason.status === 401 || r.reason.status === 403),
)
const authRejected = [reqResult, costResult, modelResult].some((r) => r.status === 'rejected' && isAuthRejection(r.reason))
if (authRejected) return
const modelTotalsUnavailable =
+3 -7
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { adminFetch, isAuthRejection, messageOf } from '@/lib/api'
import { createVisibilityPoller, type VisibilityPoller } from '@/lib/polling'
import { useAuthStore } from '@/stores/auth'
import { useToastsStore } from '@/stores/toasts'
@@ -98,18 +98,14 @@ export const useDashboardStore = defineStore('dashboard', {
// A 401/403 on ANY section already rejected the key (lib/api.ts) —
// the AuthGate takes over the whole view in that case, matching the
// pre-existing single-Promise.all behavior; nothing left to report.
const authRejected = failures.some(
({ reason }) => reason instanceof AdminApiError && (reason.status === 401 || reason.status === 403),
)
const authRejected = failures.some(({ reason }) => isAuthRejection(reason))
if (authRejected) return
// A partial failure still advances lastUpdated: at least one
// section genuinely has fresh data, even though `error` (below)
// still surfaces that something is degraded.
if (failures.length < 3) this.lastUpdated = new Date()
this.error = failures
.map(({ label, reason }) => `${label}: ${reason instanceof Error ? reason.message : String(reason)}`)
.join('; ')
this.error = failures.map(({ label, reason }) => `${label}: ${messageOf(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
+3 -3
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { adminFetch, isAuthRejection, messageOf } from '@/lib/api'
import { createVisibilityPoller, type VisibilityPoller } from '@/lib/polling'
import { useAuthStore } from '@/stores/auth'
import { useToastsStore } from '@/stores/toasts'
@@ -87,8 +87,8 @@ export const useEventsStore = defineStore('events', {
// 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)
if (isAuthRejection(err)) return
this.error = messageOf(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.
+3 -3
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { adminFetch, isAuthRejection, messageOf } from '@/lib/api'
import { buildModelCatalogRows } from '@/lib/model-table-columns'
import type { ModelCatalogRow } from '@/lib/model-table-columns'
import { useCatalogStore } from '@/stores/catalog'
@@ -73,8 +73,8 @@ export const useModelsStore = defineStore('models', {
this.error = ''
} catch (err) {
if (requestId !== this.reqId) return
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) return
this.error = err instanceof Error ? err.message : String(err)
if (isAuthRejection(err)) return
this.error = messageOf(err)
} finally {
if (requestId === this.reqId) this.loading = false
}
+5 -11
View File
@@ -1,8 +1,10 @@
// reliability.spec.ts covers the pure helpers (hourOrDayWindow,
// errorRateSeries, seriesColor) directly, plus useReliabilityStore.fetch's
// errorRateSeries) directly, plus useReliabilityStore.fetch's
// provider-scope-list-from-dashboard-overview wiring and its zero-providers
// short-circuit — mirroring dashboard.spec.ts's own mocked-adminFetch,
// Pinia-under-vitest approach (that file's own doc comment).
// Pinia-under-vitest approach (that file's own doc comment). seriesColor/
// CHART_SERIES_COLORS live in, and are covered by, lib/chart-palette.spec.ts
// — this store no longer re-exports them (reuse-audit.md F13).
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -13,7 +15,7 @@ vi.mock('@/lib/api', async () => {
})
import { adminFetch } from '@/lib/api'
import { errorRateSeries, hourOrDayWindow, RELIABILITY_SERIES_COLORS, seriesColor, useReliabilityStore } from '@/stores/reliability'
import { errorRateSeries, hourOrDayWindow, useReliabilityStore } from '@/stores/reliability'
import { useAuthStore } from '@/stores/auth'
import { useDashboardStore } from '@/stores/dashboard'
import type { AdminOverviewResponse, AdminPerfResponse, AdminPerfRow, AdminSeriesResponse } from '@/types/api'
@@ -67,14 +69,6 @@ describe('errorRateSeries', () => {
})
})
describe('seriesColor', () => {
it('cycles through RELIABILITY_SERIES_COLORS by index', () => {
expect(seriesColor(0)).toBe(RELIABILITY_SERIES_COLORS[0])
expect(seriesColor(RELIABILITY_SERIES_COLORS.length)).toBe(RELIABILITY_SERIES_COLORS[0])
expect(seriesColor(RELIABILITY_SERIES_COLORS.length + 1)).toBe(RELIABILITY_SERIES_COLORS[1])
})
})
const emptyOverview: AdminOverviewResponse = {
version: '1',
replica: 'pod-a',
+5 -20
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { adminFetch, isAuthRejection, messageOf } from '@/lib/api'
import { seriesUrl } from '@/lib/range'
import { ratioSeries } from '@/lib/series'
import { useDashboardStore } from '@/stores/dashboard'
@@ -53,21 +53,6 @@ export function errorRateSeries(attempts: AdminSeriesResponse, fails: AdminSerie
}))
}
/**
* RELIABILITY_SERIES_COLORS cycles through main.css's four existing
* --chart-* tokens (assets/main.css is owned by WP-E — this store does not
* add a fifth) to color the per-provider error-rate/timeout/failover
* overlay charts: with more than four configured providers, colors repeat
* — a known, documented limitation rather than a silent one, since adding
* a genuinely distinct fifth hue is out of this work package's file scope.
*/
export const RELIABILITY_SERIES_COLORS = ['--chart-requests', '--chart-tokens-in', '--chart-tokens-out', '--chart-cost'] as const
/** seriesColor picks one of RELIABILITY_SERIES_COLORS by index, cycling. */
export function seriesColor(index: number): string {
return RELIABILITY_SERIES_COLORS[index % RELIABILITY_SERIES_COLORS.length]
}
/** One plain (non-ratio) metric series, e.g. failovers/timeouts per provider, or the fleet-wide rej/r402 total. */
export interface CountSeries {
scope: string
@@ -179,8 +164,8 @@ export const useReliabilityStore = defineStore('reliability', {
this.error = ''
} catch (err) {
if (requestId !== this.reqId) return
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) return
this.error = err instanceof Error ? err.message : String(err)
if (isAuthRejection(err)) return
this.error = messageOf(err)
} finally {
if (requestId === this.reqId) this.loading = false
}
@@ -232,8 +217,8 @@ export const useReliabilityStore = defineStore('reliability', {
this.providerPerfError = ''
} catch (err) {
if (requestId !== this.providerPerfReqId) return
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) return
this.providerPerfError = err instanceof Error ? err.message : String(err)
if (isAuthRejection(err)) return
this.providerPerfError = messageOf(err)
} finally {
if (requestId === this.providerPerfReqId) this.providerPerfLoading = false
}
+13 -33
View File
@@ -1,12 +1,13 @@
import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { AdminApiError, adminFetch, isAuthRejection, messageOf } from '@/lib/api'
import { currentMonthDaySpan } from '@/lib/burndown'
import { sumGroupBudgetMicros } from '@/lib/kpi'
import { createVisibilityPoller, type VisibilityPoller } from '@/lib/polling'
import { dayOrMonthWindow, seriesUrl, totalsUrl } from '@/lib/range'
import type { TotalsUrlParams } from '@/lib/range'
import { drilldownLevel, scopeBudgetLimits } from '@/lib/scope'
import { other, stackTopN, sumByProvider, type Series } from '@/lib/series'
import { usdToMicros } from '@/lib/usage-bars'
import { useAuthStore } from '@/stores/auth'
import { useDashboardStore } from '@/stores/dashboard'
import { useFiltersStore } from '@/stores/filters'
@@ -618,13 +619,6 @@ export const useSpendStore = defineStore('spend', {
},
})
/** drilldownLevel derives AttributionDrilldown's current GET /admin/api/usage/totals request shape from the `drill` breadcrumb state (see useSpendStore's own state doc comment on `drill`). */
function drilldownLevel(drill: string): { kind: 'group' } | { kind: 'user'; group: string } | { kind: 'usermodel'; user: string } {
if (drill === '') return { kind: 'group' }
if (drill.startsWith('group:')) return { kind: 'user', group: drill.slice('group:'.length) }
return { kind: 'usermodel', user: drill.slice('user:'.length) }
}
/** burndownScopeParam maps the global scope filter to the /usage/series scope this store's burn-down line requests — null for a provider scope, which has no cost series and no budget concept (see useSpendStore's own doc comment). */
function burndownScopeParam(filtersScope: string): string | null {
if (filtersScope === 'all') return 'total'
@@ -634,32 +628,18 @@ function burndownScopeParam(filtersScope: string): string | null {
/**
* burndownBudgetMicros resolves the configured cost/month budget (micro-
* USD) for the burn-down line's own scope: the fleet-wide sum-of-group
* budgets (Q2/DECISIONS: lib/kpi.ts's sumGroupBudgetMicros) for `total`,
* or that one group's/user's own configured costPerMonthUSD limit
* otherwise — read from stores/dashboard.ts's already-polled overview/
* usage data rather than a second network round trip.
* USD) for the burn-down line's own scope, via lib/scope.ts's shared
* scopeBudgetLimits (reuse-audit.md F5 — the fleet-wide sum-of-group-
* budgets 'total' case and the per-group/user lookup both used to be
* reimplemented here a third time) — read from stores/dashboard.ts's
* already-polled overview/usage data rather than a second network round
* trip. `burndownScopeParam`'s own 'total' sentinel is scopeBudgetLimits'
* "unscoped root" case (same as filters.scope's 'all'); a provider scope
* never reaches here (burndownScopeParam itself returns null for one).
*/
function burndownBudgetMicros(scope: string): number | null {
const dashboard = useDashboardStore()
if (scope === 'total') {
const total = sumGroupBudgetMicros(dashboard.overview?.groups ?? [])
return total > 0 ? total : null
}
if (scope.startsWith('group:')) {
const name = scope.slice('group:'.length)
const usd = dashboard.overview?.groups.find((g) => g.name === name)?.limits?.costPerMonthUSD
return usd && usd > 0 ? Math.round(usd * 1_000_000) : null
}
const name = scope.slice('user:'.length)
const usd = dashboard.usage?.users.find((u) => u.id === name)?.limits?.costPerMonthUSD
return usd && usd > 0 ? Math.round(usd * 1_000_000) : null
const usd = scopeBudgetLimits(scope, dashboard.overview?.groups ?? [], dashboard.usage?.users ?? [])?.costPerMonthUSD
return usd && usd > 0 ? usdToMicros(usd) : null
}
function isAuthRejection(err: unknown): boolean {
return err instanceof AdminApiError && (err.status === 401 || err.status === 403)
}
function messageOf(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}