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