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
+44
View File
@@ -0,0 +1,44 @@
import { useAuthStore } from '@/stores/auth'
/** AdminApiError carries the HTTP status alongside the message, so callers
* can tell an auth failure (already handled by adminFetch itself) apart
* from any other fetch failure without re-parsing the message string. */
export class AdminApiError extends Error {
readonly status: number
constructor(message: string, status: number) {
super(message)
this.name = 'AdminApiError'
this.status = status
}
}
/**
* adminFetch issues a GET against one of the three /admin/api/* routes with
* the stored admin key attached as `x-api-key`, and decodes the JSON body.
*
* A 401/403 response rejects the stored key (authStore.reject) and throws
* an AdminApiError — callers that only care about real failures (a stopped
* poll timer should not spam the status line with "unauthorized" on every
* tick once the gate is already showing) can catch and check `status`.
*/
export async function adminFetch<T>(path: string): Promise<T> {
const auth = useAuthStore()
const key = auth.apiKey
const headers: HeadersInit = key ? { 'x-api-key': key } : {}
const resp = await fetch(path, { credentials: 'same-origin', headers })
if (resp.status === 401 || resp.status === 403) {
// Late-401 guard: only reject the key that is STILL the one stored —
// see authStore.reject's own doc comment for why.
if (auth.apiKey === key) {
auth.reject('invalid key, or not an admin')
}
throw new AdminApiError(`${path}: HTTP ${resp.status}`, resp.status)
}
if (!resp.ok) {
throw new AdminApiError(`${path}: HTTP ${resp.status}`, resp.status)
}
return (await resp.json()) as T
}
+15
View File
@@ -0,0 +1,15 @@
import {
BarController,
BarElement,
CategoryScale,
Chart,
Legend,
LinearScale,
Tooltip,
} from 'chart.js'
// Registered once at module load (ES module caching makes this a true
// singleton regardless of how many components import it) — only the bar
// chart pieces this panel actually uses, not chart.js's full auto-registry,
// to keep the baked-in bundle lean.
Chart.register(BarController, BarElement, CategoryScale, LinearScale, Legend, Tooltip)
+70
View File
@@ -0,0 +1,70 @@
import type { HistoryWindow, LimitsConfig } from '@/types/api'
const ZERO_TIME = '0001-01-01T00:00:00Z'
/** formatCost renders a micro-USD integer (adminUsageEntryView's convention) as "$1.2345". */
export function formatCost(micros: number): string {
return `$${(micros / 1_000_000).toFixed(4)}`
}
/** formatAgo renders an ISO timestamp as "(Ns ago)", or "" for the unset zero-time sentinel. */
export function formatAgo(iso: string | undefined): string {
if (!iso || iso === ZERO_TIME) return ''
const then = new Date(iso).getTime()
if (Number.isNaN(then)) return ''
const secs = Math.max(0, Math.round((Date.now() - then) / 1000))
return ` (${secs}s ago)`
}
/** formatTimestamp renders an ISO timestamp for display, or "never" for the unset zero-time sentinel. */
export function formatTimestamp(iso: string | undefined): string {
if (!iso || iso === ZERO_TIME) return 'never'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleString()
}
/** formatLimits renders a LimitsConfig as a short comma-joined summary, or "none" when unset. */
export function formatLimits(limits: LimitsConfig | undefined): string {
if (!limits) return 'none'
const parts: string[] = []
if (limits.requestsPerMinute) parts.push(`req/min ${limits.requestsPerMinute}`)
if (limits.requestsPerDay) parts.push(`req/day ${limits.requestsPerDay}`)
if (limits.tokensPerDay) parts.push(`tok/day ${limits.tokensPerDay}`)
if (limits.tokensPerMonth) parts.push(`tok/month ${limits.tokensPerMonth}`)
if (limits.costPerDayUSD) parts.push(`cost/day $${limits.costPerDayUSD}`)
if (limits.costPerMonthUSD) parts.push(`cost/month $${limits.costPerMonthUSD}`)
return parts.length ? parts.join(', ') : 'none'
}
/**
* historyBucketFormat maps each history window to the fixed-width bucket
* key format serveAdminUsageHistory echoes back (limits.go's windowKey:
* hour="2006010215", day="20060102", month="200601" — Go reference-time
* layouts, all digits, no separators).
*/
export function formatBucketLabel(bucket: string, window: HistoryWindow): string {
switch (window) {
case 'hour': {
const hour = bucket.slice(8, 10)
return `${hour}:00`
}
case 'day': {
const year = Number(bucket.slice(0, 4))
const month = Number(bucket.slice(4, 6))
const day = Number(bucket.slice(6, 8))
return new Date(year, month - 1, day).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
})
}
case 'month': {
const year = Number(bucket.slice(0, 4))
const month = Number(bucket.slice(4, 6))
return new Date(year, month - 1, 1).toLocaleDateString(undefined, {
month: 'short',
year: 'numeric',
})
}
}
}
+34
View File
@@ -0,0 +1,34 @@
// sessionStorage helpers for the admin API key (auth.go / admin.go's
// browser key-entry flow, ported from the replaced vanilla-JS admin page):
// the key lives only in this tab's sessionStorage — never a cookie, never
// localStorage — so it disappears when the tab closes and is sent only to
// this page's own /admin/api/* fetches. Every call is wrapped: a browser
// with storage disabled (private mode, some embedded webviews) must degrade
// to "key not remembered across reload", never throw.
const KEY_STORAGE = 'llmgwAdminKey'
export function getStoredKey(): string {
try {
return sessionStorage.getItem(KEY_STORAGE) ?? ''
} catch {
return ''
}
}
export function setStoredKey(key: string): void {
try {
sessionStorage.setItem(KEY_STORAGE, key)
} catch {
// storage unavailable — the key still lives in the Pinia store for the
// rest of this page load, only cross-reload persistence is lost.
}
}
export function clearStoredKey(): void {
try {
sessionStorage.removeItem(KEY_STORAGE)
} catch {
// storage unavailable, nothing to clear
}
}
+7
View File
@@ -0,0 +1,7 @@
import type { ClassValue } from "clsx"
import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}