Files
traefik-llmgateway/webui/src/pages/SpendPage.vue
T
lukaszraczylo cf308624af 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
2026-09-23 23:02:22 +01:00

383 lines
18 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, onUnmounted, watch } from 'vue'
import AttributionDrilldown from '@/components/AttributionDrilldown.vue'
import BurnDownChart from '@/components/BurnDownChart.vue'
import CostAvoidedCard from '@/components/CostAvoidedCard.vue'
import CostForecastCard from '@/components/CostForecastCard.vue'
import ErrorState from '@/components/ErrorState.vue'
import PricingHealthTable from '@/components/PricingHealthTable.vue'
import SkeletonChart from '@/components/SkeletonChart.vue'
import TimeSeriesChart from '@/components/TimeSeriesChart.vue'
import type { TimeSeriesComparisonDataset, TimeSeriesDataset } from '@/components/TimeSeriesChart.vue'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useNow } from '@/composables/useNow'
import { cacheSavingsStatus } from '@/lib/cache-savings'
import { formatBucketLabel, formatCompactCount, formatCost } from '@/lib/format'
import { loadState } from '@/lib/load-state'
import { useCatalogStore } from '@/stores/catalog'
import { useDashboardStore } from '@/stores/dashboard'
import { useFiltersStore } from '@/stores/filters'
import { useNavStore } from '@/stores/nav'
import {
isSpendBy,
isSpendMetric,
SPEND_BY,
SPEND_METRIC_LABEL,
SPEND_METRICS,
useSpendStore,
} from '@/stores/spend'
import type { SpendBy } from '@/stores/spend'
import type { HistoryMetric, LimitsConfig } from '@/types/api'
const SPEND_BY_LABEL: Record<SpendBy, string> = { model: 'By model', provider: 'By provider', group: 'By group' }
const nav = useNavStore()
const filters = useFiltersStore()
const dashboard = useDashboardStore()
const catalog = useCatalogStore()
const spend = useSpendStore()
const { now, start: startClock, stop: stopClock } = useNow()
// --- page params (redesign-plan.md section 3.1: by/metric/ref/drill) ---
const by = computed<SpendBy>(() => (nav.params.by && isSpendBy(nav.params.by) ? nav.params.by : 'model'))
const metric = computed<HistoryMetric>(() => (nav.params.metric && isSpendMetric(nav.params.metric) ? nav.params.metric : 'cost'))
const refParam = computed<string>(() => nav.params.ref ?? '')
const drillParam = computed<string>(() => nav.params.drill ?? '')
/** updateParams merges one changed field over the current by/metric/ref/drill and writes the whole set back via nav.goTo (which REPLACES, never merges, stores/nav.ts's own doc comment) — a key is included only when it differs from its default, keeping the hash as clean as every other page's own param convention. */
function updateParams(next: Partial<{ by: SpendBy; metric: HistoryMetric; ref: string; drill: string }>): void {
const merged = {
by: next.by ?? by.value,
metric: next.metric ?? metric.value,
ref: next.ref ?? refParam.value,
drill: next.drill ?? drillParam.value,
}
const params: Record<string, string> = {}
if (merged.by !== 'model') params.by = merged.by
if (merged.metric !== 'cost') params.metric = merged.metric
if (merged.ref !== '') params.ref = merged.ref
if (merged.drill !== '') params.drill = merged.drill
nav.goTo('spend', params)
}
function onByChange(value: string | number): void {
if (typeof value === 'string' && isSpendBy(value)) updateParams({ by: value })
}
function onMetricUpdate(value: unknown): void {
if (typeof value === 'string' && isSpendMetric(value)) updateParams({ metric: value })
}
function onRefUpdate(value: string): void {
updateParams({ ref: value })
}
function onDrillUpdate(value: string): void {
updateParams({ drill: value })
}
// The breakdown-dimension selector has nothing left to choose once the
// global scope filter has already narrowed to one group/user — the chart
// then shows exactly that one entity's own line regardless of `by` (see
// stores/spend.ts's own doc comment on scope narrowing).
const byDisabled = computed<boolean>(() => filters.scope !== 'all')
// --- sync page params + global filters into the store ---
watch([by, metric, drillParam], ([b, m, d]) => {
spend.setSelection({ by: b, metric: m, drill: d })
})
watch(
() => [filters.range, filters.cmp, filters.scope],
() => void spend.refresh(),
)
onMounted(() => {
spend.$patch({ by: by.value, metric: metric.value, drill: drillParam.value })
void catalog.ensureLoaded()
spend.startPolling() // ticks once immediately — the page's initial fetch.
startClock()
})
onUnmounted(() => {
spend.stopPolling()
stopClock()
})
// --- breakdown chart ---
/**
* BREAKDOWN_PALETTE cycles a colorblind-safe (Okabe & Ito, 2008) 8-color
* qualitative set across the breakdown chart's per-model/provider/group
* series. The first four reuse main.css's own existing --chart-* tokens
* (already that same palette's requests/tokens-in/tokens-out/cost
* members); the remaining four (--chart-series-a..d) extend it, defined
* alongside them in assets/main.css — main.css's original four tokens
* were sized for four FIXED semantic metrics, not an open-ended
* breakdown series count, so this page needed four more, but every chart
* color token lives in the one design-tokens file (Tailwind-only rule),
* never a component-local <style> block. Beyond 8 series (e.g. a fleet
* with more than 8 configured groups) colors repeat — an accepted
* qualitative-palette limit, not a bug: legend labels remain the
* disambiguator past that point, the same tradeoff any categorical chart
* palette makes once it runs out of distinguishable hues.
*/
const BREAKDOWN_PALETTE = [
'--chart-requests',
'--chart-tokens-in',
'--chart-tokens-out',
'--chart-cost',
'--chart-series-a',
'--chart-series-b',
'--chart-series-c',
'--chart-series-d',
]
function paletteColor(index: number): string {
return BREAKDOWN_PALETTE[index % BREAKDOWN_PALETTE.length]!
}
/** breakdownLabel strips a series' own scope-kind prefix ("model:"/"group:") for the chart legend — sumByProvider's own output already has no prefix (lib/series.ts). */
function breakdownLabel(scope: string): string {
if (scope.startsWith('model:')) return scope.slice('model:'.length)
if (scope.startsWith('group:')) return scope.slice('group:'.length)
if (scope.startsWith('provider:')) return scope.slice('provider:'.length)
return scope
}
// The breakdown chart's own window (filters.window) decides the bucket
// label format (lib/format.ts's formatBucketLabel), same as every other
// chart in this panel.
const breakdownLabels = computed<string[]>(() => spend.buckets.map((b) => formatBucketLabel(b, filters.window)))
const breakdownDatasets = computed<TimeSeriesDataset[]>(() => {
const charted: TimeSeriesDataset[] = spend.breakdown.map((s, i) => ({
label: breakdownLabel(s.scope),
data: s.points,
color: paletteColor(i),
}))
if (spend.otherPoints) charted.push({ label: 'Other', data: spend.otherPoints, color: '--muted-foreground' })
return charted
})
// This IS a real "vs previous period" comparison (Q5) — unlike
// BurnDownChart.vue's own flat budget reference line, which reuses the
// same comparisonDatasets prop but opts OUT of the suffix (P2 item 5).
const comparisonDatasets = computed<TimeSeriesComparisonDataset[]>(() =>
spend.comparisonPoints ? [{ label: 'Total', data: spend.comparisonPoints, color: '--foreground', labelSuffix: ' (previous period)' }] : [],
)
const valueFormatter = computed<(value: number) => string>(() => (metric.value === 'cost' ? formatCost : formatCompactCount))
const totalInRange = computed<number>(() => spend.totalPoints.reduce((a, b) => a + b, 0))
// --- forecast / burn-down scope derivation (Q2/DECISIONS: fleet budget = sum of group costPerMonthUSD) ---
const scopeMtdMicros = computed<number>(() => {
if (filters.scope === 'all') return dashboard.usage?.total.costPerMonthMicroUsd ?? 0
if (filters.scope.startsWith('group:')) {
const name = filters.scope.slice('group:'.length)
return dashboard.usage?.groups.find((g) => g.id === name)?.costPerMonthMicroUsd ?? 0
}
if (filters.scope.startsWith('user:')) {
const name = filters.scope.slice('user:'.length)
return dashboard.usage?.users.find((u) => u.id === name)?.costPerMonthMicroUsd ?? 0
}
return 0
})
const scopeLimits = computed<LimitsConfig | undefined>(() => {
if (filters.scope === 'all') {
const groups = dashboard.overview?.groups ?? []
const budgeted = groups.filter((g) => (g.limits?.costPerMonthUSD ?? 0) > 0)
if (budgeted.length === 0) return undefined
const totalUsd = budgeted.reduce((sum, g) => sum + (g.limits?.costPerMonthUSD ?? 0), 0)
return { costPerMonthUSD: totalUsd }
}
if (filters.scope.startsWith('group:')) {
const name = filters.scope.slice('group:'.length)
return dashboard.overview?.groups.find((g) => g.name === name)?.limits
}
if (filters.scope.startsWith('user:')) {
const name = filters.scope.slice('user:'.length)
return dashboard.usage?.users.find((u) => u.id === name)?.limits
}
return undefined
})
const forecastAvailable = computed<boolean>(() => filters.scope === 'all' || filters.scope.startsWith('group:') || filters.scope.startsWith('user:'))
// --- attribution drilldown gating ---
const userModelStatsEnabled = computed<boolean>(() => dashboard.overview?.features.userModelStats ?? false)
const cacheStatsEnabled = computed<boolean>(() => dashboard.overview?.features.cacheStats ?? false)
/**
* cacheSavingsStatusResult (N2 fix, verify-redesign-final.md — replaces
* the P3-item-15 note this superseded, which described an already-fixed
* backend bug): lib/cache-savings.ts's own doc comment has the full,
* CURRENT backend-behaviour explanation this page passes down as plain
* props. `available` gates whether CostAvoidedCard.vue renders a dollar
* figure at all (false for an hour-window range — the backend never
* writes a per-hour cache-savings bucket, so there is no number to sum,
* not a genuine $0) or a caveat-annotated figure (month-window ranges,
* capped at the day counter's own 35-day retention).
*/
const cacheSavingsStatusResult = computed(() => cacheSavingsStatus(filters.window))
const cacheSavingsAvailable = computed<boolean>(() => cacheSavingsStatusResult.value.available)
const cacheSavingsNote = computed<string | null>(() => cacheSavingsStatusResult.value.note)
// --- skeleton/error states (verify-ui-states.md #7: "Spend and Reliability
// pages got no skeletons or ErrorState") -----------------------------------
/** breakdownState gates the main breakdown chart's own skeleton/error — hasData is bucket presence (a real response landed), not "every value is non-zero", so a genuinely zero-traffic range still reads 'ready' and the chart draws its own honest zero line. BurnDownChart.vue computes its own identical state internally now (it owns its own Card/CardHeader, verify-ui-states.md fix) — SpendPage.vue just forwards spend.burndownLoading/spend.burndownError as props. */
const breakdownState = computed(() => loadState({ loading: spend.loading, hasData: spend.buckets.length > 0, error: spend.breakdownError }))
/**
* retryPricingHealthTable (verify-ui-states-2.md #9 fix) is
* PricingHealthTable's own Retry action — the table joins
* spend.pricingHealthRanking with catalog.modelsById, so a failure whose
* ROOT CAUSE is the catalog fetch (catalog.error, shown alongside
* spend.pricingHealthError below) never gets fixed by re-running
* spend.fetchPricingHealthRanking alone. Always re-runs the pricing fetch;
* additionally re-runs catalog.fetch() when the catalog is the one
* currently failing.
*/
function retryPricingHealthTable(): Promise<void> {
const tasks: Promise<void>[] = [spend.fetchPricingHealthRanking()]
if (catalog.error) tasks.push(catalog.fetch())
return Promise.all(tasks).then(() => undefined)
}
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-wrap items-center gap-3">
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-muted-foreground">Breakdown</span>
<Tabs :model-value="by" @update:model-value="onByChange">
<TabsList>
<TabsTrigger v-for="key in SPEND_BY" :key="key" :value="key" :disabled="byDisabled">
{{ SPEND_BY_LABEL[key] }}
</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div class="flex flex-col gap-1">
<label id="spend-metric-label" class="text-xs font-medium text-muted-foreground">Metric</label>
<Select :model-value="metric" @update:model-value="onMetricUpdate">
<SelectTrigger aria-labelledby="spend-metric-label" class="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="m in SPEND_METRICS" :key="m" :value="m">{{ SPEND_METRIC_LABEL[m] }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="text-right">
<p class="text-xs font-medium text-muted-foreground">{{ SPEND_METRIC_LABEL[metric] }} in range</p>
<p class="text-2xl font-semibold tabular-nums">{{ valueFormatter(totalInRange) }}</p>
</div>
</div>
<p v-if="byDisabled" class="text-sm text-muted-foreground">
Showing <span class="font-mono text-xs">{{ filters.scope }}</span> directly — clear the scope filter to break spend down by
model, provider, or group.
</p>
<!-- verify-ui-states-2.md #3: spend.breakdownError still renders via
the breakdown chart's own ErrorState below when there is no data at all
(breakdownState === 'error') — never a second, duplicate line for that
case (the item-8-style duplicate-error bug this fix set also corrected
for ModelsPage.vue). verify-ui-states-3.md NEW-1 adds the ONE case
ErrorState can't cover: a background refetch (poll tick, or a
metric/by/range/scope change) fails while a PREVIOUS successful result
is still on screen (breakdownState === 'ready', loadState's own 'ready'
precedence) — the failure was otherwise invisible. See the paragraph
below the chart. -->
<!-- P3 item 23: catalog.error was never rendered — PricingHealthTable/
CostAvoidedCard both depend on catalog.modelsById and silently showed
empty/"no priced model" with no indication the catalog fetch itself
had failed. -->
<p v-if="catalog.error" class="text-sm break-words text-destructive">{{ catalog.error }}</p>
<!-- verify-ui-states-3.md pre-existing fix: fetchModelRanking's own
failure has its own field (rankingError), never cross-cleared by a
later, unrelated fetchBreakdown success — shown unconditionally next to
the chart since nothing else surfaces it (by=model then silently renders
100% "Other" off a still-empty ranking). -->
<p v-if="spend.rankingError" class="text-sm break-words text-destructive">{{ spend.rankingError }}</p>
<!-- P3 item 19 / Q5: a requested-but-unavailable comparison (a
single-provider scope) used to leave the "vs previous period" toggle
pressed with no dashed line and no explanation. -->
<p v-if="filters.cmpSpec.requested && spend.comparisonUnavailableReason" class="text-sm text-muted-foreground">
{{ spend.comparisonUnavailableReason }}
</p>
<SkeletonChart v-if="breakdownState === 'skeleton'" />
<ErrorState v-else-if="breakdownState === 'error'" :message="spend.breakdownError" :on-retry="spend.refresh" />
<TimeSeriesChart
v-else
:labels="breakdownLabels"
:datasets="breakdownDatasets"
:comparison-datasets="comparisonDatasets"
stacked
:value-formatter="valueFormatter"
:ariaLabel="`${SPEND_METRIC_LABEL[metric]} breakdown ${SPEND_BY_LABEL[by].toLowerCase()} over time`"
/>
<!-- verify-ui-states-3.md NEW-1: a background refetch failure (poll
tick, or a metric/by/range/scope change) while the PREVIOUS selection's
data is still on screen — breakdownState stays 'ready' (loadState's own
precedence), so the ErrorState above never mounts. Without this line the
failure was invisible: the chart kept the old series under the new
labels/formatter with no indication anything had gone wrong. -->
<p v-if="spend.breakdownError && breakdownState === 'ready'" class="text-sm break-words text-destructive">{{ spend.breakdownError }}</p>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<BurnDownChart
v-if="spend.burndownAvailable"
:buckets="spend.burndownBuckets"
:points="spend.burndownPoints"
:budget-micros="spend.burndownBudgetMicros"
:now="now"
:loading="spend.burndownLoading"
:error="spend.burndownError"
:on-retry="spend.refresh"
/>
<CostForecastCard v-if="forecastAvailable" :mtd-micros="scopeMtdMicros" :limits="scopeLimits" :now="now" />
</div>
<CostAvoidedCard
:models="spend.modelRanking"
:catalog="catalog.modelsById"
:ref-model="refParam"
:cache-stats-enabled="cacheStatsEnabled"
:cache-savings-available="cacheSavingsAvailable"
:cache-savings-note="cacheSavingsNote"
@update:ref-model="onRefUpdate"
/>
<PricingHealthTable
:models="spend.pricingHealthRanking"
:catalog="catalog.modelsById"
:loading="spend.pricingHealthLoading"
:loaded="spend.pricingHealthLoaded"
:error="spend.pricingHealthError || catalog.error"
:on-retry="retryPricingHealthTable"
/>
<AttributionDrilldown
:rows="spend.drilldownRows"
:metrics="spend.drilldownMetrics"
:drill="drillParam"
:disabled="spend.drilldownDisabled"
:user-model-stats-enabled="userModelStatsEnabled"
:window="filters.window"
:span="filters.span"
:loading="spend.drilldownLoading"
:loaded="spend.drilldownLoaded"
:error="spend.drilldownError"
:on-retry="spend.fetchDrilldown"
@update:drill="onDrillUpdate"
/>
</div>
</template>