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
+42
View File
@@ -0,0 +1,42 @@
import { defineStore } from 'pinia'
import { clearStoredKey, getStoredKey, setStoredKey } from '@/lib/key-storage'
/**
* useAuthStore owns the admin API key gate: sessionStorage-backed key
* entry, sent as the `x-api-key` header on every /admin/api/* fetch
* (lib/api.ts). GET /admin itself needs no auth (admin.go serves the shell
* unauthenticated) — this store only gates the JSON routes.
*/
export const useAuthStore = defineStore('auth', {
state: () => ({
apiKey: getStoredKey(),
/** Set after a 401/403 (lib/api.ts) or on a still-empty submit. */
error: '',
}),
getters: {
isAuthenticated: (state): boolean => state.apiKey !== '',
},
actions: {
/** submit stores a newly entered key and clears any prior error. */
submit(key: string): void {
if (!key) return
this.apiKey = key
this.error = ''
setStoredKey(key)
},
/**
* reject clears the key and shows message — called by lib/api.ts on a
* 401/403. Guards against the late-401 race the vanilla-JS page this
* replaces also guarded against: adminFetch only calls reject() when
* the failing request's own key still matches what is currently
* stored, so a stale in-flight request for a key the user has since
* replaced can never clobber the newer, possibly-valid key.
*/
reject(message: string): void {
this.apiKey = ''
this.error = message
clearStoredKey()
},
},
})
+58
View File
@@ -0,0 +1,58 @@
import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import type { AdminOverviewResponse, AdminUsageResponse } from '@/types/api'
/** How often the Overview/Usage views poll (matches the replaced vanilla-JS page). */
const POLL_MS = 5000
/**
* useDashboardStore polls GET /admin/api/overview and GET /admin/api/usage
* together every POLL_MS, feeding the Overview and Usage views. A single
* store (not two) because both routes are always fetched together — the
* old vanilla-JS page's own Promise.all([overview, usage]) pattern.
*/
export const useDashboardStore = defineStore('dashboard', {
state: () => ({
overview: null as AdminOverviewResponse | null,
usage: null as AdminUsageResponse | null,
lastUpdated: null as Date | null,
error: '',
timer: undefined as ReturnType<typeof setInterval> | undefined,
}),
actions: {
async refresh(): Promise<void> {
const auth = useAuthStore()
if (!auth.isAuthenticated) return
try {
const [overview, usage] = await Promise.all([
adminFetch<AdminOverviewResponse>('/admin/api/overview'),
adminFetch<AdminUsageResponse>('/admin/api/usage'),
])
this.overview = overview
this.usage = usage
this.lastUpdated = new Date()
this.error = ''
} catch (err) {
// A 401/403 already rejected the key (lib/api.ts) — the AuthGate
// takes over the view, nothing left to report here.
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) {
return
}
this.error = err instanceof Error ? err.message : String(err)
}
},
/** startPolling is idempotent and safe to call before a key is stored — refresh() no-ops until then. */
startPolling(): void {
if (this.timer !== undefined) return
void this.refresh()
this.timer = setInterval(() => void this.refresh(), POLL_MS)
},
stopPolling(): void {
if (this.timer === undefined) return
clearInterval(this.timer)
this.timer = undefined
},
},
})
+126
View File
@@ -0,0 +1,126 @@
import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import type { HistoryMetric, HistoryWindow, UsageHistoryPoint, UsageHistoryResponse } from '@/types/api'
/** The three chart tabs the Charts view offers — see metricsForTab below. */
export type ChartTab = 'requests' | 'tokens' | 'cost'
/**
* How often the current selection refetches while the Charts view is open.
* Slower than the dashboard's 5s poll (dashboard.ts) — history buckets
* change on the timescale of the window itself, not every request.
*/
const REFRESH_MS = 30_000
/**
* WINDOW_SPAN is the bucket count requested per window: 24h at hour
* resolution, 30d at day resolution, 12mo at month resolution. Each stays
* within historyMaxSpan's own per-window ceiling (admin.go: 48/35/13), so
* every request is valid without the UI needing to know that ceiling.
*/
export const WINDOW_SPAN: Record<HistoryWindow, number> = {
hour: 24,
day: 30,
month: 12,
}
/** WINDOW_LABEL is the switcher's own display text for each window. */
export const WINDOW_LABEL: Record<HistoryWindow, string> = {
hour: '24h',
day: '30d',
month: '12mo',
}
/**
* metricsForTab maps a chart tab to the metric(s) it needs. "tokens" is the
* one tab needing two: GET /admin/api/usage/history takes a single metric
* per call (tokin XOR tokout), and the Charts view stacks both into one bar
* chart (operator brief: "STACKED tokens-in vs tokens-out").
*/
export function metricsForTab(tab: ChartTab): HistoryMetric[] {
switch (tab) {
case 'requests':
return ['req']
case 'tokens':
return ['tokin', 'tokout']
case 'cost':
return ['cost']
}
}
/**
* useHistoryStore drives the Charts view: current scope/window/tab
* selection, the fetched series for whatever metric(s) that selection
* needs, and a 30s auto-refresh of the current selection. Changing scope,
* window, or tab always triggers an immediate refetch (operator brief:
* "history fetch on selection + 30s refresh").
*/
export const useHistoryStore = defineStore('history', {
state: () => ({
/** "total" | "user:{id}" | "group:{id}" — the raw scope query value serveAdminUsageHistory expects. */
scope: 'total',
window: 'hour' as HistoryWindow,
tab: 'requests' as ChartTab,
seriesByMetric: {} as Partial<Record<HistoryMetric, UsageHistoryPoint[]>>,
loading: false,
error: '',
timer: undefined as ReturnType<typeof setInterval> | undefined,
}),
actions: {
setScope(scope: string): void {
if (scope === this.scope) return
this.scope = scope
void this.fetchSeries()
},
setWindow(window: HistoryWindow): void {
if (window === this.window) return
this.window = window
void this.fetchSeries()
},
setTab(tab: ChartTab): void {
if (tab === this.tab) return
this.tab = tab
void this.fetchSeries()
},
async fetchSeries(): Promise<void> {
const auth = useAuthStore()
if (!auth.isAuthenticated) return
this.loading = true
try {
const span = WINDOW_SPAN[this.window]
const metrics = metricsForTab(this.tab)
const results = await Promise.all(
metrics.map((metric) =>
adminFetch<UsageHistoryResponse>(
`/admin/api/usage/history?scope=${encodeURIComponent(this.scope)}&metric=${metric}&window=${this.window}&span=${span}`,
),
),
)
const next: Partial<Record<HistoryMetric, UsageHistoryPoint[]>> = {}
metrics.forEach((metric, i) => {
next[metric] = results[i]!.points
})
this.seriesByMetric = next
this.error = ''
} catch (err) {
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) {
return
}
this.error = err instanceof Error ? err.message : String(err)
} finally {
this.loading = false
}
},
startAutoRefresh(): void {
if (this.timer !== undefined) return
this.timer = setInterval(() => void this.fetchSeries(), REFRESH_MS)
},
stopAutoRefresh(): void {
if (this.timer === undefined) return
clearInterval(this.timer)
this.timer = undefined
},
},
})