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,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()
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user