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
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env node
// generate.mjs reads webui/dist/ (the output of `vite build`) and writes
// ../admin_assets_gen.go — the baked-in static assets GET /admin and
// GET /admin/assets/{hashedname} serve (admin.go's serveAdminPage /
// serveAdminAsset). Run via `make admin-ui`, never by hand against a stale
// dist/.
//
// Encoding choice (documented once, here, rather than re-argued at every
// call site): every asset body is emitted as a base64 string literal,
// decoded once via base64.StdEncoding at package-var initialization
// (encoding/base64 — stdlib, Yaegi-safe). This was picked over emitting
// each asset as a Go raw-string (backtick) literal because a Vite
// production build's minified JS routinely CONTAINS backtick characters
// (template literals) and other bytes (control characters in source maps,
// non-ASCII in string/comment content) that would need per-byte escaping
// to embed safely as Go source text. Base64's alphabet is a fixed,
// printable 65-character set — every input byte round-trips through it
// with no escaping logic to get wrong, at the well-known cost of ~33%
// size inflation. At this bundle's size (a few hundred KB), that
// inflation is a non-issue; see the Makefile's admin-ui target for the
// measured before/after byte count this trades against.
import { createHash } from 'node:crypto'
import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
import { dirname, extname, join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
const DIST_DIR = join(SCRIPT_DIR, 'dist')
const OUT_PATH = join(SCRIPT_DIR, '..', 'admin_assets_gen.go')
const ASSETS_SUBDIR = 'assets'
/** Content-Type per file extension — every type webui/'s build actually emits, plus common source-map/font fallbacks for a future dependency change. */
const CONTENT_TYPES = {
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
}
function contentTypeFor(filePath) {
return CONTENT_TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream'
}
/** walk lists every regular file under dir, recursively, as paths relative to dir (forward-slash separated). */
function walk(dir, base = dir) {
const out = []
for (const entry of readdirSync(dir)) {
const full = join(dir, entry)
if (statSync(full).isDirectory()) {
out.push(...walk(full, base))
} else {
out.push(relative(base, full).split('\\').join('/'))
}
}
return out
}
/** goStringLiteral renders a Go double-quoted string literal for s, escaping the handful of characters that require it. base64 output never needs this (its alphabet is a strict subset of what a Go string literal permits unescaped), but this stays generic rather than assuming that forever. */
function goStringLiteral(s) {
return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}
/**
* goBase64Expr renders base64Body (a base64 string) as a Go expression
* calling decodeAdminAsset, split into LINE_WIDTH-character chunks joined
* with `+` — a single 400KB-wide line is valid Go but painful for humans
* and diff tools alike; chunking keeps every line reviewable.
*/
const LINE_WIDTH = 120
function goBase64Expr(base64Body) {
if (base64Body.length === 0) return 'decodeAdminAsset("")'
const chunks = []
for (let i = 0; i < base64Body.length; i += LINE_WIDTH) {
chunks.push(goStringLiteral(base64Body.slice(i, i + LINE_WIDTH)))
}
return `decodeAdminAsset(\n\t\t${chunks.join(' +\n\t\t')},\n\t)`
}
function main() {
let distFiles
try {
distFiles = walk(DIST_DIR)
} catch (err) {
console.error(`generate.mjs: cannot read ${DIST_DIR} — run \`vite build\` first: ${err.message}`)
process.exitCode = 1
return
}
if (distFiles.length === 0) {
console.error(`generate.mjs: ${DIST_DIR} is empty — run \`vite build\` first`)
process.exitCode = 1
return
}
const indexRel = 'index.html'
if (!distFiles.includes(indexRel)) {
console.error(`generate.mjs: ${DIST_DIR} has no top-level index.html`)
process.exitCode = 1
return
}
const assetEntries = []
for (const rel of distFiles) {
if (rel === indexRel) continue
if (!rel.startsWith(`${ASSETS_SUBDIR}/`)) {
// admin.go's serveAdminAsset only ever looks a filename up under
// /admin/assets/ — a build output file living anywhere else (e.g. a
// future public/ static file copied to dist/'s top level) would
// silently 404 forever if this generator didn't fail on it first.
console.error(`generate.mjs: unexpected dist/ file outside ${ASSETS_SUBDIR}/: ${rel}`)
process.exitCode = 1
return
}
const name = rel.slice(ASSETS_SUBDIR.length + 1)
const body = readFileSync(join(DIST_DIR, rel))
assetEntries.push({ name, contentType: contentTypeFor(name), base64: body.toString('base64') })
}
assetEntries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
const indexBody = readFileSync(join(DIST_DIR, indexRel))
const indexBase64 = indexBody.toString('base64')
const sourceDigest = createHash('sha256')
for (const f of distFiles.slice().sort()) {
sourceDigest.update(f)
sourceDigest.update(readFileSync(join(DIST_DIR, f)))
}
const lines = []
lines.push('// Code generated by webui/generate.mjs; DO NOT EDIT.')
lines.push('//')
lines.push('// Source: webui/dist/ (vite build output), digest ' + sourceDigest.digest('hex').slice(0, 16) + '.')
lines.push('// Regenerate via `make admin-ui` after any webui/ source change.')
lines.push('//')
lines.push('// Every asset body below is base64-encoded and decoded once at package-var')
lines.push('// initialization (encoding/base64, stdlib — Yaegi-safe). See generate.mjs\'s')
lines.push('// own header comment for why base64 was chosen over a raw-string literal.')
lines.push('')
lines.push('package traefikllmgateway')
lines.push('')
lines.push('import "encoding/base64"')
lines.push('')
lines.push('// adminAsset is one baked static file: its HTTP Content-Type and decoded body.')
lines.push('type adminAsset struct {')
lines.push('\tcontentType string')
lines.push('\tbody []byte')
lines.push('}')
lines.push('')
lines.push('// decodeAdminAsset base64-decodes one embedded asset body. Every literal')
lines.push('// passed to it below was produced by this exact encoding (base64.StdEncoding)')
lines.push('// in webui/generate.mjs, so a decode error here can only mean this generated')
lines.push('// file is corrupt — a build-time invariant violation, not a reachable runtime')
lines.push('// condition — so it panics rather than threading an error return through')
lines.push('// every var initializer below.')
lines.push('func decodeAdminAsset(encoded string) []byte {')
lines.push('\tdata, err := base64.StdEncoding.DecodeString(encoded)')
lines.push('\tif err != nil {')
lines.push('\t\tpanic("admin_assets_gen.go: corrupt embedded asset: " + err.Error())')
lines.push('\t}')
lines.push('\treturn data')
lines.push('}')
lines.push('')
lines.push('// adminIndexHTML is GET /admin\'s response body (admin.go\'s serveAdminPage) —')
lines.push('// the built Vue app\'s HTML shell.')
lines.push(`var adminIndexHTML = ${goBase64Expr(indexBase64)}`)
lines.push('')
lines.push('// adminAssets maps each hashed filename under /admin/assets/ (admin.go\'s')
lines.push('// serveAdminAsset) to its content.')
lines.push('var adminAssets = map[string]adminAsset{')
for (const { name, contentType, base64 } of assetEntries) {
lines.push(`\t${goStringLiteral(name)}: {`)
lines.push(`\t\tcontentType: ${goStringLiteral(contentType)},`)
lines.push(`\t\tbody: ${goBase64Expr(base64)},`)
lines.push('\t},')
}
lines.push('}')
lines.push('')
const goSource = lines.join('\n')
writeFileSync(OUT_PATH, goSource)
const totalBytes = distFiles.reduce((sum, f) => sum + statSync(join(DIST_DIR, f)).size, 0)
console.log(`generate.mjs: wrote ${OUT_PATH}`)
console.log(`generate.mjs: ${distFiles.length} dist/ files, ${totalBytes} bytes raw -> ${goSource.length} bytes generated Go source`)
}
main()