mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-15 05:29:28 +00:00
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.
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user