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:
2026-08-21 09:21:41 +01:00
parent 47b22d2d7f
commit 655bbcbf2c
81 changed files with 9694 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { formatCost, formatLimits } from '@/lib/format'
import {
Table,
TableBody,
TableCell,
TableEmpty,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import type { AdminUsageEntryView } from '@/types/api'
/**
* UsageTable renders one scope kind's usage rows (users or groups) — the
* shared table shape both need (vue.md: extract before you paste it
* twice). The second column differs per kind: a user's row shows the group
* it belongs to; a group's row shows its member count (looked up from the
* Overview response, which is the only place member counts live).
*/
defineProps<{
idLabel: string
entries: AdminUsageEntryView[]
secondaryColumnLabel: string
secondaryValue: (entry: AdminUsageEntryView) => string
}>()
</script>
<template>
<Table>
<TableHeader>
<TableRow>
<TableHead>{{ idLabel }}</TableHead>
<TableHead>{{ secondaryColumnLabel }}</TableHead>
<TableHead>Limits</TableHead>
<TableHead class="text-right">req/min</TableHead>
<TableHead class="text-right">req/day</TableHead>
<TableHead class="text-right">tokIn/day</TableHead>
<TableHead class="text-right">tokOut/day</TableHead>
<TableHead class="text-right">tokIn/month</TableHead>
<TableHead class="text-right">tokOut/month</TableHead>
<TableHead class="text-right">cost/day</TableHead>
<TableHead class="text-right">cost/month</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableEmpty v-if="entries.length === 0" :colspan="11" class="text-muted-foreground">
none
</TableEmpty>
<TableRow
v-for="entry in entries"
:key="entry.id"
:class="entry.storeDown ? 'bg-destructive/10' : undefined"
>
<TableCell class="font-medium">{{ entry.id }}</TableCell>
<TableCell class="text-muted-foreground">{{ secondaryValue(entry) }}</TableCell>
<TableCell class="text-muted-foreground">{{ formatLimits(entry.limits) }}</TableCell>
<TableCell class="text-right tabular-nums">
{{ entry.storeDown ? '?' : entry.requestsPerMinute }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ entry.storeDown ? '?' : entry.requestsPerDay }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ entry.storeDown ? '?' : entry.tokensInPerDay }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ entry.storeDown ? '?' : entry.tokensOutPerDay }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ entry.storeDown ? '?' : entry.tokensInPerMonth }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ entry.storeDown ? '?' : entry.tokensOutPerMonth }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ entry.storeDown ? '?' : formatCost(entry.costPerDayMicroUsd) }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ entry.storeDown ? '?' : formatCost(entry.costPerMonthMicroUsd) }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</template>