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
+6
View File
@@ -24,3 +24,9 @@ testdata/ export-ignore
.github/ export-ignore
*_test.go export-ignore
.gitattributes export-ignore
# webui/ is the Vue admin panel's SOURCE (Vite/npm project) — CI and a
# release build never need node: admin_assets_gen.go (repo root, `make
# admin-ui`'s committed output) is the only artifact the plugin build
# actually depends on, exactly like vendor/ above is safe to drop because
# nothing at the repo root imports it directly.
webui/ export-ignore
+6
View File
@@ -3,6 +3,12 @@
tools/yaegi-check/llmgw-yaegi-check
dist/
# webui/'s build output (dist/, matched above) and npm dependency tree —
# neither is source: dist/ is regenerated by `vite build`, node_modules/ by
# `npm ci`. admin_assets_gen.go (repo root, `make admin-ui`'s other output)
# is the only artifact from webui/ that is actually committed.
webui/node_modules/
# Rendered from integration/traefik/dynamic.yml.tmpl by `make integration-up`
# (INTEGRATION_REAL_BASEURL substitution) — generated, not source. A whole
# directory (not a single file): Traefik's directory-based file provider
+10
View File
@@ -56,6 +56,15 @@ const (
// needs, and copying its "go.yaml.in"-style nested module dirs into a
// module-less GOPATH tree would otherwise confuse Yaegi's own import
// resolution) have nothing to do with the plugin package Yaegi imports.
// webui/ (Vue admin-panel task) is excluded for the same reason vendor/
// is: its own go.mod already walls it off from the repo root's `go
// build`/`vet`/`test ./...`, and its node_modules tree — hundreds of MB,
// including a stray bundled .go file
// (node_modules/flatted/golang/pkg/flatted.go) — would otherwise both
// balloon copyRepoSource's wall time and risk Yaegi tripping over code
// this repo neither wrote nor can build. The plugin only ever imports the
// generated admin_assets_gen.go at the repo root, never anything under
// webui/ itself.
var excludedTopLevelDirs = map[string]bool{
"tools": true,
"integration": true,
@@ -63,6 +72,7 @@ var excludedTopLevelDirs = map[string]bool{
"docs": true,
".git": true,
"vendor": true,
"webui": true,
}
func main() {
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+40
View File
@@ -0,0 +1,40 @@
# Admin panel (webui)
Source for the traefik-llmgateway read-only admin dashboard: Vue 3 +
TypeScript + Pinia + Tailwind v4 + shadcn-vue + Chart.js, built with Vite.
See the repo root [README.md's Admin section](../README.md#admin) for what
the dashboard shows and how its API/auth works, and the
[Development section](../README.md#development) for `make admin-ui` — the
only way this directory's output (`../admin_assets_gen.go`) is meant to be
regenerated. Nothing here runs at request time: the plugin serves the
built, baked-in output, never `webui/` itself.
## Local development
```sh
npm ci
npm run dev # Vite dev server — /admin/api/* calls need a real
# gateway to fetch from; point one at the same origin or
# accept the "refresh failed" state while iterating on
# layout/styling alone.
npm run build # what `make admin-ui` runs, plus webui/generate.mjs
```
## Structure
- `src/stores/` — Pinia: `auth` (the sessionStorage key gate), `dashboard`
(Overview/Usage, 5s poll), `history` (Charts, fetch-on-selection + 30s
refresh).
- `src/components/` — `AuthGate`, `OverviewView`, `UsageView`,
`ChartsView`/`UsageChart`, `UsageTable`; `ui/` holds the shadcn-vue
primitives this panel actually uses (Card, Table, Tabs, Button, Input,
Alert, Select, Badge) — nothing installed and unused.
- `src/lib/` — `api.ts` (the `/admin/api/*` fetch wrapper), `format.ts`,
`key-storage.ts`, `chart-setup.ts`.
- `src/types/api.ts` — TypeScript mirrors of admin.go's JSON response
shapes (`adminOverviewResponse`, `adminUsageResponse`,
`usageHistoryResponse`).
- `generate.mjs` — reads `dist/` after `vite build` and writes
`../admin_assets_gen.go`; see its own header comment for why every
asset is base64-encoded rather than embedded as a raw string literal.
+26
View File
@@ -0,0 +1,26 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "reka-nova",
"font": "inter",
"typescript": true,
"tailwind": {
"config": "",
"css": "src/assets/main.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"pointer": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"composables": "@/composables"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+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()
+12
View File
@@ -0,0 +1,12 @@
// This go.mod exists only to wall webui/ off as a separate Go module
// boundary: without it, the repo root's `go build ./...`/`go vet ./...`/
// `go test ./...` (and any tool honoring the same `./...` pattern, e.g.
// golangci-lint) would descend into webui/node_modules — an npm
// dependency tree that happens to bundle a stray .go file
// (node_modules/flatted/golang/pkg/flatted/flatted.go) — and choke on
// code this repo neither wrote nor can build. Go's module resolution
// stops `./...` at a nested go.mod by design, so this one file is the
// entire fix; nothing here is ever built or imported by anything.
module github.com/lukaszraczylo/traefik-llmgateway/webui-nodemodules-boundary
go 1.22
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- Inline data: URI, not a file under public/: admin.go's asset map only
ever holds content-hashed files under /admin/assets/*, and the admin
CSP's img-src allows 'self' plus data: for exactly this reason — no
extra unhashed-asset route is needed for one small icon. -->
<link
rel="icon"
type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect width='24' height='24' rx='5' fill='%230072b2'/%3E%3Cpath d='M6 16.5 10 8l3 6 2-3.5 3 6' stroke='%23fff' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LLM Gateway - Admin</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+6935
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "webui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.3.1",
"@fortawesome/free-solid-svg-icons": "^7.3.1",
"@fortawesome/vue-fontawesome": "^3.3.3",
"@vueuse/core": "^14.4.0",
"chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"pinia": "^4.0.3",
"reka-ui": "^2.10.3",
"shadcn-vue": "^2.8.2",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"vue": "^3.5.40",
"vue-chartjs": "^5.3.4"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^24.13.3",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/tsconfig": "^0.9.1",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.2",
"vite": "^8.2.0",
"vue-tsc": "^3.3.8"
}
}
+63
View File
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { faCircleExclamation } from '@fortawesome/free-solid-svg-icons'
import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
import AuthGate from '@/components/AuthGate.vue'
import OverviewView from '@/components/OverviewView.vue'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import UsageView from '@/components/UsageView.vue'
import { useAuthStore } from '@/stores/auth'
import { useDashboardStore } from '@/stores/dashboard'
// Lazy: Chart.js (UsageChart.vue's dependency, pulled in transitively) is
// the single largest piece of this bundle, and most admin sessions open on
// Overview/Usage. Splitting it into its own chunk keeps the initial load
// lean; it fetches once, on first visit to the Charts tab.
const ChartsView = defineAsyncComponent(() => import('@/components/ChartsView.vue'))
const auth = useAuthStore()
const dashboard = useDashboardStore()
const activeView = ref<'overview' | 'usage' | 'charts'>('overview')
const statusText = computed<string>(() => {
if (dashboard.error) return `refresh failed: ${dashboard.error}`
if (dashboard.lastUpdated) return `last updated ${dashboard.lastUpdated.toLocaleTimeString()}`
return 'loading...'
})
onMounted(() => {
dashboard.startPolling()
})
</script>
<template>
<div class="mx-auto flex max-w-6xl flex-col gap-6 px-4 py-8">
<header class="flex flex-col gap-1">
<h1 class="text-xl font-semibold tracking-tight">LLM Gateway &mdash; Admin</h1>
<p class="flex items-center gap-1.5 text-sm text-muted-foreground">
<FontAwesomeIcon v-if="dashboard.error" :icon="faCircleExclamation" class="size-3.5 text-destructive" />
<span :class="dashboard.error ? 'text-destructive' : undefined">{{ statusText }}</span>
</p>
</header>
<AuthGate v-if="!auth.isAuthenticated" />
<Tabs v-else v-model="activeView">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="usage">Usage</TabsTrigger>
<TabsTrigger value="charts">Charts</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<OverviewView />
</TabsContent>
<TabsContent value="usage">
<UsageView />
</TabsContent>
<TabsContent value="charts">
<ChartsView />
</TabsContent>
</Tabs>
</div>
</template>
+131
View File
@@ -0,0 +1,131 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn-vue/tailwind.css";
/*
* Theme tokens (design note, not slop-by-default): shadcn-vue's `init`
* scaffolds an achromatic "stone" palette (chroma 0 everywhere), a Google
* Fonts Inter import, and a Geist Variable heading font. All three are
* rewritten below:
*
* - No Google Fonts import, no Geist. The admin CSP (admin.go) ships
* style-src 'self' with no external host allowed, and the panel must load
* zero external assets per the operator brief — a network font would
* silently fail under that CSP anyway. Headings and body both use the
* platform's own UI font stack; numeric/tabular data (usage tables, chart
* axes) uses the platform monospace stack for column alignment.
* - A deliberate signature color (deep teal, hue ~200) replaces the
* grayscale-everything default and the reflexive indigo/purple accent.
* - Chart series use the Okabe & Ito (2008) colorblind-safe qualitative
* palette rather than picking hues by eye — distinguishable under
* protanopia, deuteranopia, and tritanopia alike.
* - Dark mode is driven by `prefers-color-scheme`, not a `.dark` class
* toggle: this panel has no theme switcher, so the OS setting is the only
* signal there is. Tailwind v4's default `dark:` variant already means
* "@media (prefers-color-scheme: dark)" — shadcn-vue's init overrides it
* to a `.dark`-class variant for apps with a manual theme toggle; that
* override is dropped here so any `dark:` utility (ours or a generated
* ui/ primitive's) follows the OS setting, same as the CSS variables below.
*/
@theme inline {
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Roboto Mono", Consolas, monospace;
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--color-chart-requests: var(--chart-requests);
--color-chart-tokens-in: var(--chart-tokens-in);
--color-chart-tokens-out: var(--chart-tokens-out);
--color-chart-cost: var(--chart-cost);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-heading: var(--font-sans);
}
:root {
--background: oklch(0.99 0.004 75);
--foreground: oklch(0.19 0.012 75);
--card: oklch(0.995 0.003 75);
--card-foreground: oklch(0.19 0.012 75);
--popover: oklch(0.995 0.003 75);
--popover-foreground: oklch(0.19 0.012 75);
--primary: oklch(0.46 0.09 200);
--primary-foreground: oklch(0.98 0.006 200);
--secondary: oklch(0.95 0.006 75);
--secondary-foreground: oklch(0.22 0.01 75);
--muted: oklch(0.95 0.006 75);
--muted-foreground: oklch(0.48 0.012 75);
--accent: oklch(0.93 0.02 200);
--accent-foreground: oklch(0.28 0.05 200);
--destructive: oklch(0.55 0.2 25);
--border: oklch(0.9 0.007 75);
--input: oklch(0.9 0.007 75);
--ring: oklch(0.55 0.09 200);
--radius: 0.375rem;
/* Chart series — Okabe & Ito (2008) colorblind-safe qualitative set. */
--chart-requests: #009e73;
--chart-tokens-in: #0072b2;
--chart-tokens-out: #e69f00;
--chart-cost: #d55e00;
}
@media (prefers-color-scheme: dark) {
:root {
--background: oklch(0.17 0.007 75);
--foreground: oklch(0.95 0.005 75);
--card: oklch(0.21 0.009 75);
--card-foreground: oklch(0.95 0.005 75);
--popover: oklch(0.21 0.009 75);
--popover-foreground: oklch(0.95 0.005 75);
--primary: oklch(0.74 0.11 200);
--primary-foreground: oklch(0.16 0.012 200);
--secondary: oklch(0.27 0.011 75);
--secondary-foreground: oklch(0.93 0.005 75);
--muted: oklch(0.27 0.011 75);
--muted-foreground: oklch(0.68 0.012 75);
--accent: oklch(0.32 0.035 200);
--accent-foreground: oklch(0.91 0.03 200);
--destructive: oklch(0.68 0.19 25);
--border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 16%);
--ring: oklch(0.62 0.1 200);
/* Same hues, most already legible on a dark ground at this
saturation — Okabe-Ito was designed for slide/print use on light
and dark backgrounds alike. */
--chart-requests: #16b891;
--chart-tokens-in: #4098d7;
--chart-tokens-out: #f0ac1f;
--chart-cost: #e8763a;
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
@apply font-sans;
font-variant-numeric: tabular-nums;
}
}
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { faKey } from '@fortawesome/free-solid-svg-icons'
import { ref } from 'vue'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const keyInput = ref('')
function submit(): void {
const value = keyInput.value
keyInput.value = ''
auth.submit(value)
}
</script>
<template>
<div class="mx-auto flex min-h-[70vh] max-w-md flex-col justify-center px-4">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<FontAwesomeIcon :icon="faKey" class="size-4 text-primary" />
Admin key required
</CardTitle>
<CardDescription>
Enter an admin API key. The browser keeps it only in this tab's session storage: it is
never written to disk and is sent only to this page's own /admin/api/* requests.
</CardDescription>
</CardHeader>
<CardContent class="flex flex-col gap-4">
<form class="flex flex-col gap-3" @submit.prevent="submit">
<Input
v-model="keyInput"
type="password"
autocomplete="off"
placeholder="API key"
aria-label="Admin API key"
/>
<Button type="submit" class="w-full">Continue</Button>
</form>
<Alert v-if="auth.error" variant="destructive">
<AlertTitle>Authentication failed</AlertTitle>
<AlertDescription>{{ auth.error }}</AlertDescription>
</Alert>
</CardContent>
</Card>
</div>
</template>
+90
View File
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import UsageChart from '@/components/UsageChart.vue'
import { useDashboardStore } from '@/stores/dashboard'
import { type ChartTab, useHistoryStore, WINDOW_LABEL } from '@/stores/history'
import type { HistoryWindow } from '@/types/api'
const dashboard = useDashboardStore()
const history = useHistoryStore()
const scopeOptions = computed(() => {
const users = (dashboard.usage?.users ?? []).map((u) => ({ value: `user:${u.id}`, label: u.id }))
const groups = (dashboard.usage?.groups ?? []).map((g) => ({ value: `group:${g.id}`, label: g.id }))
return [{ value: 'total', label: 'Total (all traffic)' }, ...groups, ...users]
})
const windows: HistoryWindow[] = ['hour', 'day', 'month']
function onTabChange(value: string | number): void {
history.setTab(value as ChartTab)
}
function onScopeChange(value: unknown): void {
if (typeof value === 'string') history.setScope(value)
}
onMounted(() => {
void history.fetchSeries()
history.startAutoRefresh()
})
onUnmounted(() => {
history.stopAutoRefresh()
})
// The scope list is only known once the Usage response has loaded — if the
// previously-selected scope disappears (a user/group config change), fall
// back to the total scope rather than requesting a now-unknown id.
watch(scopeOptions, (options) => {
if (!options.some((o) => o.value === history.scope)) {
history.setScope('total')
}
})
</script>
<template>
<Card>
<CardHeader class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Usage charts</CardTitle>
<div class="flex flex-wrap items-center gap-3">
<Select :model-value="history.scope" @update:model-value="onScopeChange">
<SelectTrigger class="w-56">
<SelectValue placeholder="Scope" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="opt in scopeOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
<Tabs :model-value="history.window" @update:model-value="(v) => history.setWindow(v as HistoryWindow)">
<TabsList>
<TabsTrigger v-for="w in windows" :key="w" :value="w">{{ WINDOW_LABEL[w] }}</TabsTrigger>
</TabsList>
</Tabs>
</div>
</CardHeader>
<CardContent class="flex flex-col gap-4">
<Tabs :model-value="history.tab" @update:model-value="onTabChange">
<TabsList>
<TabsTrigger value="requests">Requests</TabsTrigger>
<TabsTrigger value="tokens">Tokens</TabsTrigger>
<TabsTrigger value="cost">Cost</TabsTrigger>
</TabsList>
</Tabs>
<p v-if="history.error" class="text-sm text-destructive">{{ history.error }}</p>
<UsageChart :tab="history.tab" :window="history.window" :series-by-metric="history.seriesByMetric" />
</CardContent>
</Card>
</template>
+148
View File
@@ -0,0 +1,148 @@
<script setup lang="ts">
import { faCircleCheck, faCircleXmark, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
import { computed } from 'vue'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Table,
TableBody,
TableCell,
TableEmpty,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { formatAgo, formatTimestamp } from '@/lib/format'
import { useDashboardStore } from '@/stores/dashboard'
const dashboard = useDashboardStore()
const overview = computed(() => dashboard.overview)
</script>
<template>
<div class="flex flex-col gap-6">
<div class="grid gap-4 sm:grid-cols-3">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<FontAwesomeIcon
:icon="overview?.redis.configured ? faCircleCheck : faCircleXmark"
:class="overview?.redis.configured ? 'text-chart-requests' : 'text-muted-foreground'"
class="size-3.5"
/>
Redis
</CardTitle>
</CardHeader>
<CardContent class="flex flex-col gap-1 text-sm">
<p class="font-medium">{{ overview?.redis.configured ? 'Configured' : 'Not configured' }}</p>
<p v-if="overview?.redis.lastErr" class="flex items-start gap-1.5 text-destructive">
<FontAwesomeIcon :icon="faTriangleExclamation" class="mt-0.5 size-3.5 shrink-0" />
<span>{{ overview.redis.lastErr }}{{ formatAgo(overview.redis.lastErrAt) }}</span>
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<FontAwesomeIcon
:icon="overview?.cache.enabled ? faCircleCheck : faCircleXmark"
:class="overview?.cache.enabled ? 'text-chart-requests' : 'text-muted-foreground'"
class="size-3.5"
/>
Response cache
</CardTitle>
</CardHeader>
<CardContent class="text-sm">
<p class="font-medium">{{ overview?.cache.enabled ? 'Enabled' : 'Disabled' }}</p>
<p v-if="overview?.cache.enabled" class="text-muted-foreground">ttl {{ overview.cache.ttl }}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<FontAwesomeIcon
:icon="overview?.retry.enabled ? faCircleCheck : faCircleXmark"
:class="overview?.retry.enabled ? 'text-chart-requests' : 'text-muted-foreground'"
class="size-3.5"
/>
Retry
</CardTitle>
</CardHeader>
<CardContent class="text-sm">
<p class="font-medium">{{ overview?.retry.enabled ? 'Enabled' : 'Disabled' }}</p>
<p v-if="overview?.retry.enabled" class="text-muted-foreground">
attempts {{ overview.retry.attempts }}, backoff {{ overview.retry.backoff }}
</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Providers</CardTitle>
<CardDescription>Every configured upstream and its last discovery refresh.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Base URL</TableHead>
<TableHead class="text-right">Models</TableHead>
<TableHead>Last refresh</TableHead>
<TableHead>Last error</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableEmpty v-if="!overview?.providers.length" :colspan="6" class="text-muted-foreground">
none
</TableEmpty>
<TableRow v-for="p in overview?.providers" :key="p.name">
<TableCell class="font-medium">{{ p.name }}</TableCell>
<TableCell class="text-muted-foreground">{{ p.type }}</TableCell>
<TableCell class="text-muted-foreground">{{ p.baseUrl }}</TableCell>
<TableCell class="text-right tabular-nums">{{ p.modelCount }}</TableCell>
<TableCell class="text-muted-foreground">{{ formatTimestamp(p.lastRefresh) }}</TableCell>
<TableCell class="text-destructive">{{ p.lastErr ?? '' }}</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Model aliases</CardTitle>
<CardDescription>Operator-defined alias &rarr; target model mappings.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Alias</TableHead>
<TableHead>Target</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableEmpty v-if="!overview?.aliases.length" :colspan="2" class="text-muted-foreground">
none configured
</TableEmpty>
<TableRow v-for="a in overview?.aliases" :key="a.alias">
<TableCell class="font-medium">{{ a.alias }}</TableCell>
<TableCell class="text-muted-foreground">{{ a.target }}</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</template>
+107
View File
@@ -0,0 +1,107 @@
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js'
import { computed } from 'vue'
import { Bar } from 'vue-chartjs'
import '@/lib/chart-setup'
import { useThemeColors } from '@/composables/useThemeColors'
import { formatBucketLabel } from '@/lib/format'
import type { ChartTab } from '@/stores/history'
import type { HistoryMetric, HistoryWindow, UsageHistoryPoint } from '@/types/api'
const props = defineProps<{
tab: ChartTab
window: HistoryWindow
seriesByMetric: Partial<Record<HistoryMetric, UsageHistoryPoint[]>>
}>()
const { foreground, mutedForeground, border } = useThemeColors()
/** One dataset spec per tab: which metric(s), its label, and its token color. */
const DATASET_SPECS: Record<ChartTab, { metric: HistoryMetric; label: string; color: string }[]> = {
requests: [{ metric: 'req', label: 'Requests', color: '--chart-requests' }],
tokens: [
{ metric: 'tokin', label: 'Tokens in', color: '--chart-tokens-in' },
{ metric: 'tokout', label: 'Tokens out', color: '--chart-tokens-out' },
],
cost: [{ metric: 'cost', label: 'Cost (USD)', color: '--chart-cost' }],
}
/** cost is stored as micro-USD (limits.go's account/history convention) — every other metric is already the display unit. */
function toDisplayValue(metric: HistoryMetric, raw: number): number {
return metric === 'cost' ? raw / 1_000_000 : raw
}
const labels = computed<string[]>(() => {
const specs = DATASET_SPECS[props.tab]
const points = props.seriesByMetric[specs[0]!.metric] ?? []
return points.map((p) => formatBucketLabel(p.bucket, props.window))
})
/** resolveToken reads one of main.css's --chart-* custom properties from :root, live — so a chart already on screen repaints on a `prefers-color-scheme` flip exactly like useThemeColors does for axis/legend text. */
function resolveToken(cssVar: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim()
}
const chartData = computed<ChartData<'bar'>>(() => {
// Touch the theme refs so this computed re-runs (and re-reads the CSS
// custom properties via resolveToken) on a `prefers-color-scheme` flip —
// resolveToken's own getComputedStyle read is not itself reactive.
void foreground.value
return {
labels: labels.value,
datasets: DATASET_SPECS[props.tab].map((spec) => ({
label: spec.label,
backgroundColor: resolveToken(spec.color),
data: (props.seriesByMetric[spec.metric] ?? []).map((p) => toDisplayValue(spec.metric, p.value)),
})),
}
})
const stacked = computed<boolean>(() => DATASET_SPECS[props.tab].length > 1)
const chartOptions = computed<ChartOptions<'bar'>>(() => ({
responsive: true,
maintainAspectRatio: false,
animation: false,
scales: {
x: {
stacked: stacked.value,
grid: { color: border.value, display: false },
ticks: { color: mutedForeground.value },
},
y: {
// Zero-based, honest axis (dataviz rule): a bar chart's y-axis never
// starts anywhere but 0, so bar length always means what it looks
// like it means.
stacked: stacked.value,
beginAtZero: true,
grid: { color: border.value },
ticks: {
color: mutedForeground.value,
callback: (value) => (props.tab === 'cost' ? `$${value}` : String(value)),
},
},
},
plugins: {
legend: {
display: stacked.value,
labels: { color: foreground.value },
},
tooltip: {
callbacks: {
label: (ctx) => {
const value = ctx.parsed.y ?? 0
return `${ctx.dataset.label}: ${props.tab === 'cost' ? `$${value.toFixed(4)}` : value}`
},
},
},
},
}))
</script>
<template>
<div class="h-72 w-full">
<Bar :data="chartData" :options="chartOptions" />
</div>
</template>
+85
View File
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { formatCost, formatLimits } from '@/lib/format'
import {
Table,
TableBody,
TableCell,
TableEmpty,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import type { AdminUsageEntryView } from '@/types/api'
/**
* UsageTable renders one scope kind's usage rows (users or groups) — the
* shared table shape both need (vue.md: extract before you paste it
* twice). The second column differs per kind: a user's row shows the group
* it belongs to; a group's row shows its member count (looked up from the
* Overview response, which is the only place member counts live).
*/
defineProps<{
idLabel: string
entries: AdminUsageEntryView[]
secondaryColumnLabel: string
secondaryValue: (entry: AdminUsageEntryView) => string
}>()
</script>
<template>
<Table>
<TableHeader>
<TableRow>
<TableHead>{{ idLabel }}</TableHead>
<TableHead>{{ secondaryColumnLabel }}</TableHead>
<TableHead>Limits</TableHead>
<TableHead class="text-right">req/min</TableHead>
<TableHead class="text-right">req/day</TableHead>
<TableHead class="text-right">tokIn/day</TableHead>
<TableHead class="text-right">tokOut/day</TableHead>
<TableHead class="text-right">tokIn/month</TableHead>
<TableHead class="text-right">tokOut/month</TableHead>
<TableHead class="text-right">cost/day</TableHead>
<TableHead class="text-right">cost/month</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableEmpty v-if="entries.length === 0" :colspan="11" class="text-muted-foreground">
none
</TableEmpty>
<TableRow
v-for="entry in entries"
:key="entry.id"
:class="entry.storeDown ? 'bg-destructive/10' : undefined"
>
<TableCell class="font-medium">{{ entry.id }}</TableCell>
<TableCell class="text-muted-foreground">{{ secondaryValue(entry) }}</TableCell>
<TableCell class="text-muted-foreground">{{ formatLimits(entry.limits) }}</TableCell>
<TableCell class="text-right tabular-nums">
{{ entry.storeDown ? '?' : entry.requestsPerMinute }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ entry.storeDown ? '?' : entry.requestsPerDay }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ entry.storeDown ? '?' : entry.tokensInPerDay }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ entry.storeDown ? '?' : entry.tokensOutPerDay }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ entry.storeDown ? '?' : entry.tokensInPerMonth }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ entry.storeDown ? '?' : entry.tokensOutPerMonth }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ entry.storeDown ? '?' : formatCost(entry.costPerDayMicroUsd) }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ entry.storeDown ? '?' : formatCost(entry.costPerMonthMicroUsd) }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</template>
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import UsageTable from '@/components/UsageTable.vue'
import { formatCost } from '@/lib/format'
import { useDashboardStore } from '@/stores/dashboard'
import type { AdminUsageEntryView } from '@/types/api'
const dashboard = useDashboardStore()
const usage = computed(() => dashboard.usage)
/** memberCounts maps a group name to its member count — only the Overview response carries it. */
const memberCounts = computed<Record<string, number>>(() => {
const out: Record<string, number> = {}
for (const g of dashboard.overview?.groups ?? []) out[g.name] = g.memberCount
return out
})
function groupNameOf(entry: AdminUsageEntryView): string {
return entry.groupName ?? ''
}
function memberCountOf(entry: AdminUsageEntryView): string {
const count = memberCounts.value[entry.id]
return count === undefined ? '' : String(count)
}
</script>
<template>
<div class="flex flex-col gap-6">
<Card v-if="usage">
<CardHeader>
<CardTitle>Total</CardTitle>
<CardDescription>Usage summed across every user and group combined.</CardDescription>
</CardHeader>
<CardContent class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
<div>
<p class="text-muted-foreground">Requests/day</p>
<p class="text-lg font-semibold tabular-nums">{{ usage.total.requestsPerDay }}</p>
</div>
<div>
<p class="text-muted-foreground">Tokens in/day</p>
<p class="text-lg font-semibold tabular-nums">{{ usage.total.tokensInPerDay }}</p>
</div>
<div>
<p class="text-muted-foreground">Tokens out/day</p>
<p class="text-lg font-semibold tabular-nums">{{ usage.total.tokensOutPerDay }}</p>
</div>
<div>
<p class="text-muted-foreground">Cost/day</p>
<p class="text-lg font-semibold tabular-nums">{{ formatCost(usage.total.costPerDayMicroUsd) }}</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Groups</CardTitle>
</CardHeader>
<CardContent>
<UsageTable
id-label="Name"
secondary-column-label="Members"
:entries="usage?.groups ?? []"
:secondary-value="memberCountOf"
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Users</CardTitle>
</CardHeader>
<CardContent>
<UsageTable
id-label="Name"
secondary-column-label="Group"
:entries="usage?.users ?? []"
:secondary-value="groupNameOf"
/>
</CardContent>
</Card>
</div>
</template>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { AlertVariants } from '.'
import { cn } from '@/lib/utils'
import { alertVariants } from '.'
const props = defineProps<{
class?: HTMLAttributes['class']
variant?: AlertVariants['variant']
}>()
</script>
<template>
<div
data-slot="alert"
:class="cn(alertVariants({ variant }), props.class)"
role="alert"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="alert-action"
:class="cn('absolute top-2 right-2', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="alert-description"
:class="cn('text-muted-foreground text-sm text-balance md:text-pretty [&_p:not(:last-child)]:mb-4 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="alert-title"
:class="cn('font-medium group-has-[>svg]/alert:col-start-2 cn-font-heading [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground', props.class)"
>
<slot />
</div>
</template>
+21
View File
@@ -0,0 +1,21 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Alert } from './Alert.vue'
export { default as AlertAction } from './AlertAction.vue'
export { default as AlertDescription } from './AlertDescription.vue'
export { default as AlertTitle } from './AlertTitle.vue'
export const alertVariants = cva('grid gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*=size-])]:size-4 group/alert relative w-full', {
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive: 'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current',
},
},
defaultVariants: {
variant: 'default',
},
})
export type AlertVariants = VariantProps<typeof alertVariants>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { BadgeVariants } from '.'
import { reactiveOmit } from '@vueuse/core'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { badgeVariants } from '.'
const props = defineProps<PrimitiveProps & {
variant?: BadgeVariants['variant']
class?: HTMLAttributes['class']
}>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<Primitive
data-slot="badge"
:data-variant="variant"
:class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps"
>
<slot />
</Primitive>
</template>
+24
View File
@@ -0,0 +1,24 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Badge } from './Badge.vue'
export const badgeVariants = cva(
'h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
destructive: 'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20',
outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
link: 'text-primary underline-offset-4 hover:underline',
},
},
defaultVariants: {
variant: 'default',
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
as: 'button',
})
</script>
<template>
<Primitive
data-slot="button"
:data-variant="variant"
:data-size="size"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>
+35
View File
@@ -0,0 +1,35 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Button } from './Button.vue'
export const buttonVariants = cva(
'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*=size-])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
outline: 'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost: 'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
destructive: 'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
'default': 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
'xs': 'h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3',
'sm': 'h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3.5',
'lg': 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
'icon': 'size-8',
'icon-xs': 'size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*=size-])]:size-3',
'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<{
class?: HTMLAttributes['class']
size?: 'default' | 'sm'
}>(), {
size: 'default',
})
</script>
<template>
<div
data-slot="card"
:data-size="size"
:class="cn('ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-action"
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-content"
:class="cn('px-4 group-data-[size=sm]/card:px-3', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-description"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-footer"
:class="cn('bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-header"
:class="cn('gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-title"
:class="cn('text-base leading-snug font-medium group-data-[size=sm]/card:text-sm cn-font-heading', props.class)"
>
<slot />
</div>
</template>
+7
View File
@@ -0,0 +1,7 @@
export { default as Card } from './Card.vue'
export { default as CardAction } from './CardAction.vue'
export { default as CardContent } from './CardContent.vue'
export { default as CardDescription } from './CardDescription.vue'
export { default as CardFooter } from './CardFooter.vue'
export { default as CardHeader } from './CardHeader.vue'
export { default as CardTitle } from './CardTitle.vue'
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number
class?: HTMLAttributes['class']
}>()
const emits = defineEmits<{
(e: 'update:modelValue', payload: string | number): void
}>()
const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>
<template>
<input
v-model="modelValue"
data-slot="input"
:class="cn(
'dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)"
>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Input } from './Input.vue'
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { SelectRootEmits, SelectRootProps } from 'reka-ui'
import { SelectRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<SelectRootProps>()
const emits = defineEmits<SelectRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<SelectRoot
v-slot="slotProps"
data-slot="select"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</SelectRoot>
</template>
@@ -0,0 +1,58 @@
<script setup lang="ts">
import type { SelectContentEmits, SelectContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
SelectContent,
SelectPortal,
SelectViewport,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
import { SelectScrollDownButton, SelectScrollUpButton } from '.'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>(),
{
position: 'item-aligned',
align: 'center',
},
)
const emits = defineEmits<SelectContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SelectPortal>
<SelectContent
data-slot="select-content"
:data-align-trigger="position === 'item-aligned'"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn(
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-lg shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 cn-menu-translucent relative z-50 max-h-(--reka-select-content-available-height) origin-(--reka-select-content-transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none',
position === 'popper'
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class,
)
"
>
<SelectScrollUpButton />
<SelectViewport
:data-position="position"
:class="cn(
'data-[position=popper]:h-(--reka-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--reka-select-trigger-width)',
)"
>
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { SelectGroupProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { SelectGroup } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectGroupProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<SelectGroup
data-slot="select-group"
v-bind="delegatedProps"
:class="cn('scroll-my-1 p-1', props.class)"
>
<slot />
</SelectGroup>
</template>
@@ -0,0 +1,46 @@
<script setup lang="ts">
import type { SelectItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { faCheck } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { reactiveOmit } from '@vueuse/core'
import {
SelectItem,
SelectItemIndicator,
SelectItemText,
useForwardProps,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectItemProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectItem
data-slot="select-item"
v-bind="forwardedProps"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*=size-])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)
"
>
<span class="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectItemIndicator>
<slot name="indicator-icon">
<FontAwesomeIcon :icon="faCheck" class="pointer-events-none size-3.5" />
</slot>
</SelectItemIndicator>
</span>
<SelectItemText>
<slot />
</SelectItemText>
</SelectItem>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { SelectItemTextProps } from 'reka-ui'
import { SelectItemText } from 'reka-ui'
const props = defineProps<SelectItemTextProps>()
</script>
<template>
<SelectItemText
data-slot="select-item-text"
v-bind="props"
>
<slot />
</SelectItemText>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { SelectLabelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { SelectLabel } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes['class'] }>()
</script>
<template>
<SelectLabel
data-slot="select-label"
:class="cn('text-muted-foreground px-1.5 py-1 text-xs', props.class)"
>
<slot />
</SelectLabel>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { SelectScrollDownButtonProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { faChevronDown } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { reactiveOmit } from '@vueuse/core'
import { SelectScrollDownButton, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectScrollDownButton
data-slot="select-scroll-down-button"
v-bind="forwardedProps"
:class="cn('bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4', props.class)"
>
<slot>
<FontAwesomeIcon :icon="faChevronDown" class="size-4" />
</slot>
</SelectScrollDownButton>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { SelectScrollUpButtonProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { faChevronUp } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { reactiveOmit } from '@vueuse/core'
import { SelectScrollUpButton, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectScrollUpButton
data-slot="select-scroll-up-button"
v-bind="forwardedProps"
:class="cn('bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4', props.class)"
>
<slot>
<FontAwesomeIcon :icon="faChevronUp" class="size-4" />
</slot>
</SelectScrollUpButton>
</template>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { SelectSeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { SelectSeparator } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<SelectSeparator
data-slot="select-separator"
v-bind="delegatedProps"
:class="cn('bg-border -mx-1 my-1 h-px pointer-events-none', props.class)"
/>
</template>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { SelectTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { faChevronDown } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { reactiveOmit } from '@vueuse/core'
import { SelectIcon, SelectTrigger, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<SelectTriggerProps & { class?: HTMLAttributes['class'], size?: 'sm' | 'default' }>(),
{ size: 'default' },
)
const delegatedProps = reactiveOmit(props, 'class', 'size')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectTrigger
data-slot="select-trigger"
:data-size="size"
v-bind="forwardedProps"
:class="cn(
'border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*=size-])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
<slot />
<SelectIcon as-child>
<FontAwesomeIcon :icon="faChevronDown" class="text-muted-foreground size-4 pointer-events-none" />
</SelectIcon>
</SelectTrigger>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { SelectValueProps } from 'reka-ui'
import { SelectValue } from 'reka-ui'
const props = defineProps<SelectValueProps>()
</script>
<template>
<SelectValue
data-slot="select-value"
v-bind="props"
>
<slot />
</SelectValue>
</template>
+11
View File
@@ -0,0 +1,11 @@
export { default as Select } from './Select.vue'
export { default as SelectContent } from './SelectContent.vue'
export { default as SelectGroup } from './SelectGroup.vue'
export { default as SelectItem } from './SelectItem.vue'
export { default as SelectItemText } from './SelectItemText.vue'
export { default as SelectLabel } from './SelectLabel.vue'
export { default as SelectScrollDownButton } from './SelectScrollDownButton.vue'
export { default as SelectScrollUpButton } from './SelectScrollUpButton.vue'
export { default as SelectSeparator } from './SelectSeparator.vue'
export { default as SelectTrigger } from './SelectTrigger.vue'
export { default as SelectValue } from './SelectValue.vue'
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div data-slot="table-container" class="relative w-full overflow-x-auto">
<table data-slot="table" :class="cn('w-full caption-bottom text-sm', props.class)">
<slot />
</table>
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<tbody
data-slot="table-body"
:class="cn('[&_tr:last-child]:border-0', props.class)"
>
<slot />
</tbody>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<caption
data-slot="table-caption"
:class="cn('text-muted-foreground mt-4 text-sm', props.class)"
>
<slot />
</caption>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<td
data-slot="table-cell"
:class="cn('p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0', props.class)"
>
<slot />
</td>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { cn } from '@/lib/utils'
import TableCell from './TableCell.vue'
import TableRow from './TableRow.vue'
const props = withDefaults(defineProps<{
class?: HTMLAttributes['class']
colspan?: number
}>(), {
colspan: 1,
})
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<TableRow>
<TableCell
:class="
cn(
'p-4 whitespace-nowrap align-middle text-sm text-foreground',
props.class,
)
"
v-bind="delegatedProps"
>
<div class="flex items-center justify-center py-10">
<slot />
</div>
</TableCell>
</TableRow>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<tfoot
data-slot="table-footer"
:class="cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)"
>
<slot />
</tfoot>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<th
data-slot="table-head"
:class="cn('text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0', props.class)"
>
<slot />
</th>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<thead
data-slot="table-header"
:class="cn('[&_tr]:border-b', props.class)"
>
<slot />
</thead>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<tr
data-slot="table-row"
:class="cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors has-aria-expanded:bg-muted/50', props.class)"
>
<slot />
</tr>
</template>
+9
View File
@@ -0,0 +1,9 @@
export { default as Table } from './Table.vue'
export { default as TableBody } from './TableBody.vue'
export { default as TableCaption } from './TableCaption.vue'
export { default as TableCell } from './TableCell.vue'
export { default as TableEmpty } from './TableEmpty.vue'
export { default as TableFooter } from './TableFooter.vue'
export { default as TableHead } from './TableHead.vue'
export { default as TableHeader } from './TableHeader.vue'
export { default as TableRow } from './TableRow.vue'
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { TabsRootEmits, TabsRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { TabsRoot, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<TabsRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<TabsRootEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<TabsRoot
v-slot="slotProps"
data-slot="tabs"
:data-orientation="forwarded.orientation || 'horizontal'"
v-bind="forwarded"
:class="cn('gap-2 group/tabs flex data-horizontal:flex-col', props.class)"
>
<slot v-bind="slotProps" />
</TabsRoot>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { TabsContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { TabsContent } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<TabsContentProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<TabsContent
data-slot="tabs-content"
:class="cn('text-sm flex-1 outline-none', props.class)"
v-bind="delegatedProps"
>
<slot />
</TabsContent>
</template>
+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { TabsListProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { TabsListVariants } from '.'
import { reactiveOmit } from '@vueuse/core'
import { TabsList } from 'reka-ui'
import { cn } from '@/lib/utils'
import { tabsListVariants } from '.'
const props = withDefaults(defineProps<TabsListProps & {
class?: HTMLAttributes['class']
variant?: TabsListVariants['variant']
}>(), {
variant: 'default',
})
const delegatedProps = reactiveOmit(props, 'class', 'variant')
</script>
<template>
<TabsList
data-slot="tabs-list"
:data-variant="variant"
v-bind="delegatedProps"
:class="cn(tabsListVariants({ variant }), props.class)"
>
<slot />
</TabsList>
</template>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { TabsTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { TabsTrigger, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<TabsTriggerProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<TabsTrigger
data-slot="tabs-trigger"
:class="cn(
'gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg:not([class*=size-])]:size-4 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0',
'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent',
'data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground',
'after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100',
props.class,
)"
v-bind="forwardedProps"
>
<slot />
</TabsTrigger>
</template>
+24
View File
@@ -0,0 +1,24 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Tabs } from './Tabs.vue'
export { default as TabsContent } from './TabsContent.vue'
export { default as TabsList } from './TabsList.vue'
export { default as TabsTrigger } from './TabsTrigger.vue'
export const tabsListVariants = cva(
'rounded-lg p-0.75 group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list inline-flex w-fit items-center justify-center text-muted-foreground group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col',
{
variants: {
variant: {
default: 'bg-muted',
line: 'gap-1 bg-transparent',
},
},
defaultVariants: {
variant: 'default',
},
},
)
export type TabsListVariants = VariantProps<typeof tabsListVariants>
+41
View File
@@ -0,0 +1,41 @@
import { onBeforeUnmount, onMounted, ref } from 'vue'
/**
* useThemeColors exposes the current computed values of the design
* tokens Chart.js needs to draw axis/grid/legend text (main.css) — Chart.js
* paints to a <canvas>, so it cannot pick up CSS variables the way a DOM
* element does; the caller must read them and hand them to Chart.js as
* plain color strings. Every color literal in main.css is oklch()/hex,
* both valid canvas fillStyle values in current evergreen browsers (the
* only ones this admin panel targets — see vite.config.ts's
* modulePreload note for the same reasoning), so no conversion is needed.
*
* Re-reads on a `prefers-color-scheme` change so an open Charts view
* repaints if the OS theme flips while it's on screen — main.css's own
* dark-mode block is a plain media query, not a class toggle, so this is
* the only way a chart already on screen learns about it.
*/
export function useThemeColors() {
const foreground = ref('')
const mutedForeground = ref('')
const border = ref('')
function sync(): void {
const style = getComputedStyle(document.documentElement)
foreground.value = style.getPropertyValue('--foreground').trim()
mutedForeground.value = style.getPropertyValue('--muted-foreground').trim()
border.value = style.getPropertyValue('--border').trim()
}
let media: MediaQueryList | undefined
onMounted(() => {
sync()
media = window.matchMedia('(prefers-color-scheme: dark)')
media.addEventListener('change', sync)
})
onBeforeUnmount(() => {
media?.removeEventListener('change', sync)
})
return { foreground, mutedForeground, border }
}
+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))
}
+11
View File
@@ -0,0 +1,11 @@
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from '@/App.vue'
import '@/assets/main.css'
const app = createApp(App)
app.use(createPinia())
app.component('FontAwesomeIcon', FontAwesomeIcon)
app.mount('#app')
+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
},
},
})
+103
View File
@@ -0,0 +1,103 @@
// Shared response shapes for the three /admin/api/* routes (admin.go). Kept
// as one module so every store/component imports the identical types rather
// than re-declaring them (vue.md: "share types once, import everywhere").
/** Mirrors Go's LimitsConfig (llmgateway.go) — every field is 0/omitted when unlimited. */
export interface LimitsConfig {
requestsPerMinute?: number
requestsPerDay?: number
tokensPerDay?: number
tokensPerMonth?: number
costPerDayUSD?: number
costPerMonthUSD?: number
}
export interface AdminProviderView {
name: string
type: string
baseUrl: string
lastRefresh: string
lastErr?: string
modelCount: number
}
export interface AdminRedisView {
lastErrAt: string
lastErr?: string
configured: boolean
}
export interface AdminCacheView {
ttl?: string
enabled: boolean
}
export interface AdminRetryView {
backoff?: string
enabled: boolean
attempts?: number
}
export interface AdminGroupView {
limits?: LimitsConfig
name: string
memberCount: number
}
export interface AdminAliasView {
alias: string
target: string
}
/** GET /admin/api/overview (admin.go: adminOverviewResponse). */
export interface AdminOverviewResponse {
version: string
providers: AdminProviderView[]
groups: AdminGroupView[]
aliases: AdminAliasView[]
redis: AdminRedisView
cache: AdminCacheView
retry: AdminRetryView
}
export interface AdminUsageEntryView {
limits?: LimitsConfig
kind: string
id: string
groupName?: string
requestsPerMinute: number
requestsPerDay: number
tokensInPerDay: number
tokensOutPerDay: number
tokensInPerMonth: number
tokensOutPerMonth: number
costPerDayMicroUsd: number
costPerMonthMicroUsd: number
storeDown?: boolean
}
/** GET /admin/api/usage (admin.go: adminUsageResponse). */
export interface AdminUsageResponse {
users: AdminUsageEntryView[]
groups: AdminUsageEntryView[]
total: AdminUsageEntryView
}
/** The four metric values GET /admin/api/usage/history accepts. */
export type HistoryMetric = 'req' | 'tokin' | 'tokout' | 'cost'
/** The three window values GET /admin/api/usage/history accepts. */
export type HistoryWindow = 'hour' | 'day' | 'month'
export interface UsageHistoryPoint {
bucket: string
value: number
}
/** GET /admin/api/usage/history (admin.go: usageHistoryResponse). */
export interface UsageHistoryResponse {
scope: string
metric: string
window: string
points: UsageHistoryPoint[]
}
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"paths": {
"@/*": ["./src/*"]
},
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+12
View File
@@ -0,0 +1,12 @@
{
"files": [],
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+31
View File
@@ -0,0 +1,31 @@
import { fileURLToPath, URL } from 'node:url'
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
// admin.go serves this build's index.html at GET /admin and every hashed
// asset at GET /admin/assets/{hashedname} — the base must match so the
// emitted index.html's own <script>/<link> references resolve correctly
// regardless of what path the browser first loaded the page from.
base: '/admin/',
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
// No inline bootstrap script in the emitted index.html (admin.go's CSP
// ships script-src 'self' with no 'unsafe-inline' — see the Go-side
// asset-serving comment for the full rationale): Vite's module-preload
// polyfill is otherwise inlined as a <script> tag, which that CSP would
// block. Disabling it leaves only <link rel="modulepreload"> hints,
// which need no script-src allowance at all. The admin panel only ever
// targets current evergreen browsers (native ES module support), so the
// polyfill has nothing to do here anyway.
modulePreload: { polyfill: false },
},
})