Files
traefik-llmgateway/webui/src/components/UsageChart.vue
T
lukaszraczylo 655bbcbf2c feat(webui): vue admin panel source
Vue 3 + TypeScript + Pinia + Tailwind v4 + shadcn-vue + Chart.js admin
panel source under webui/, built with Vite. Views: Overview (providers,
redis/cache/retry status, model aliases), Usage (per-user/per-group/total
tables with tokensIn/tokensOut/cost + limits), Charts (scope selector,
req/tokens(stacked in-out)/cost metric tabs, 24h/30d/12mo window
switcher, fed by GET /admin/api/usage/history). Pinia stores: auth
(sessionStorage key gate, x-api-key header, 401/403 re-prompt),
dashboard (5s poll), history (fetch-on-selection + 30s refresh).

Design: deliberate neutral warm-neutral palette with a signature teal
accent (no default Inter/purple-gradient/glassmorphism), real
typographic hierarchy via the platform UI/mono font stacks (zero
external assets - no Google Fonts), dark+light via prefers-color-scheme
(no class toggle, no theme switcher). Charts: zero-based bars, honest
axes, Okabe-Ito colorblind-safe series, tokens-in/out as a
distinguishable stacked pair.

vite.config.ts: base '/admin/' (matches admin.go's route layout) and
build.modulePreload.polyfill:false (no inline bootstrap script, so the
tightened admin CSP needs no 'unsafe-inline'). Code-split: Chart.js
(ChartsView) loads lazily, not in the initial bundle.

generate.mjs reads dist/ after `vite build` and writes
../admin_assets_gen.go (not committed by this change) - base64-encodes
every asset, decoded once at package-var init; documented rationale in
the script's own header comment.

Hygiene: webui/go.mod walls webui/ off from the repo root's `go build`/
`vet`/`test ./...` (npm's node_modules bundles a stray .go file that
would otherwise be swept in); tools/yaegi-check/main.go excludes webui/
from its GOPATH copy for the same reason. webui/ export-ignored
(.gitattributes) from release source tarballs; webui/node_modules/
gitignored. tsconfig.app.json/tsconfig.node.json's JSONC comments
dropped (TS itself is fine with either; the global pre-commit JSON
validator is not) - no config values changed.
2026-08-21 09:21:41 +01:00

108 lines
3.7 KiB
Vue

<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js'
import { computed } from 'vue'
import { Bar } from 'vue-chartjs'
import '@/lib/chart-setup'
import { useThemeColors } from '@/composables/useThemeColors'
import { formatBucketLabel } from '@/lib/format'
import type { ChartTab } from '@/stores/history'
import type { HistoryMetric, HistoryWindow, UsageHistoryPoint } from '@/types/api'
const props = defineProps<{
tab: ChartTab
window: HistoryWindow
seriesByMetric: Partial<Record<HistoryMetric, UsageHistoryPoint[]>>
}>()
const { foreground, mutedForeground, border } = useThemeColors()
/** One dataset spec per tab: which metric(s), its label, and its token color. */
const DATASET_SPECS: Record<ChartTab, { metric: HistoryMetric; label: string; color: string }[]> = {
requests: [{ metric: 'req', label: 'Requests', color: '--chart-requests' }],
tokens: [
{ metric: 'tokin', label: 'Tokens in', color: '--chart-tokens-in' },
{ metric: 'tokout', label: 'Tokens out', color: '--chart-tokens-out' },
],
cost: [{ metric: 'cost', label: 'Cost (USD)', color: '--chart-cost' }],
}
/** cost is stored as micro-USD (limits.go's account/history convention) — every other metric is already the display unit. */
function toDisplayValue(metric: HistoryMetric, raw: number): number {
return metric === 'cost' ? raw / 1_000_000 : raw
}
const labels = computed<string[]>(() => {
const specs = DATASET_SPECS[props.tab]
const points = props.seriesByMetric[specs[0]!.metric] ?? []
return points.map((p) => formatBucketLabel(p.bucket, props.window))
})
/** resolveToken reads one of main.css's --chart-* custom properties from :root, live — so a chart already on screen repaints on a `prefers-color-scheme` flip exactly like useThemeColors does for axis/legend text. */
function resolveToken(cssVar: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim()
}
const chartData = computed<ChartData<'bar'>>(() => {
// Touch the theme refs so this computed re-runs (and re-reads the CSS
// custom properties via resolveToken) on a `prefers-color-scheme` flip —
// resolveToken's own getComputedStyle read is not itself reactive.
void foreground.value
return {
labels: labels.value,
datasets: DATASET_SPECS[props.tab].map((spec) => ({
label: spec.label,
backgroundColor: resolveToken(spec.color),
data: (props.seriesByMetric[spec.metric] ?? []).map((p) => toDisplayValue(spec.metric, p.value)),
})),
}
})
const stacked = computed<boolean>(() => DATASET_SPECS[props.tab].length > 1)
const chartOptions = computed<ChartOptions<'bar'>>(() => ({
responsive: true,
maintainAspectRatio: false,
animation: false,
scales: {
x: {
stacked: stacked.value,
grid: { color: border.value, display: false },
ticks: { color: mutedForeground.value },
},
y: {
// Zero-based, honest axis (dataviz rule): a bar chart's y-axis never
// starts anywhere but 0, so bar length always means what it looks
// like it means.
stacked: stacked.value,
beginAtZero: true,
grid: { color: border.value },
ticks: {
color: mutedForeground.value,
callback: (value) => (props.tab === 'cost' ? `$${value}` : String(value)),
},
},
},
plugins: {
legend: {
display: stacked.value,
labels: { color: foreground.value },
},
tooltip: {
callbacks: {
label: (ctx) => {
const value = ctx.parsed.y ?? 0
return `${ctx.dataset.label}: ${props.tab === 'cost' ? `$${value.toFixed(4)}` : value}`
},
},
},
},
}))
</script>
<template>
<div class="h-72 w-full">
<Bar :data="chartData" :options="chartOptions" />
</div>
</template>