mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-16 05:37: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,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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user