mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-13 04:58:50 +00:00
feat(webui): vue admin panel source
Vue 3 + TypeScript + Pinia + Tailwind v4 + shadcn-vue + Chart.js admin panel source under webui/, built with Vite. Views: Overview (providers, redis/cache/retry status, model aliases), Usage (per-user/per-group/total tables with tokensIn/tokensOut/cost + limits), Charts (scope selector, req/tokens(stacked in-out)/cost metric tabs, 24h/30d/12mo window switcher, fed by GET /admin/api/usage/history). Pinia stores: auth (sessionStorage key gate, x-api-key header, 401/403 re-prompt), dashboard (5s poll), history (fetch-on-selection + 30s refresh). Design: deliberate neutral warm-neutral palette with a signature teal accent (no default Inter/purple-gradient/glassmorphism), real typographic hierarchy via the platform UI/mono font stacks (zero external assets - no Google Fonts), dark+light via prefers-color-scheme (no class toggle, no theme switcher). Charts: zero-based bars, honest axes, Okabe-Ito colorblind-safe series, tokens-in/out as a distinguishable stacked pair. vite.config.ts: base '/admin/' (matches admin.go's route layout) and build.modulePreload.polyfill:false (no inline bootstrap script, so the tightened admin CSP needs no 'unsafe-inline'). Code-split: Chart.js (ChartsView) loads lazily, not in the initial bundle. generate.mjs reads dist/ after `vite build` and writes ../admin_assets_gen.go (not committed by this change) - base64-encodes every asset, decoded once at package-var init; documented rationale in the script's own header comment. Hygiene: webui/go.mod walls webui/ off from the repo root's `go build`/ `vet`/`test ./...` (npm's node_modules bundles a stray .go file that would otherwise be swept in); tools/yaegi-check/main.go excludes webui/ from its GOPATH copy for the same reason. webui/ export-ignored (.gitattributes) from release source tarballs; webui/node_modules/ gitignored. tsconfig.app.json/tsconfig.node.json's JSONC comments dropped (TS itself is fine with either; the global pre-commit JSON validator is not) - no config values changed.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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?
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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>
|
||||
Generated
+6935
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 — 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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 → 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Input } from './Input.vue'
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
/** AdminApiError carries the HTTP status alongside the message, so callers
|
||||
* can tell an auth failure (already handled by adminFetch itself) apart
|
||||
* from any other fetch failure without re-parsing the message string. */
|
||||
export class AdminApiError extends Error {
|
||||
readonly status: number
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.name = 'AdminApiError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* adminFetch issues a GET against one of the three /admin/api/* routes with
|
||||
* the stored admin key attached as `x-api-key`, and decodes the JSON body.
|
||||
*
|
||||
* A 401/403 response rejects the stored key (authStore.reject) and throws
|
||||
* an AdminApiError — callers that only care about real failures (a stopped
|
||||
* poll timer should not spam the status line with "unauthorized" on every
|
||||
* tick once the gate is already showing) can catch and check `status`.
|
||||
*/
|
||||
export async function adminFetch<T>(path: string): Promise<T> {
|
||||
const auth = useAuthStore()
|
||||
const key = auth.apiKey
|
||||
const headers: HeadersInit = key ? { 'x-api-key': key } : {}
|
||||
|
||||
const resp = await fetch(path, { credentials: 'same-origin', headers })
|
||||
|
||||
if (resp.status === 401 || resp.status === 403) {
|
||||
// Late-401 guard: only reject the key that is STILL the one stored —
|
||||
// see authStore.reject's own doc comment for why.
|
||||
if (auth.apiKey === key) {
|
||||
auth.reject('invalid key, or not an admin')
|
||||
}
|
||||
throw new AdminApiError(`${path}: HTTP ${resp.status}`, resp.status)
|
||||
}
|
||||
if (!resp.ok) {
|
||||
throw new AdminApiError(`${path}: HTTP ${resp.status}`, resp.status)
|
||||
}
|
||||
return (await resp.json()) as T
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Chart,
|
||||
Legend,
|
||||
LinearScale,
|
||||
Tooltip,
|
||||
} from 'chart.js'
|
||||
|
||||
// Registered once at module load (ES module caching makes this a true
|
||||
// singleton regardless of how many components import it) — only the bar
|
||||
// chart pieces this panel actually uses, not chart.js's full auto-registry,
|
||||
// to keep the baked-in bundle lean.
|
||||
Chart.register(BarController, BarElement, CategoryScale, LinearScale, Legend, Tooltip)
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { HistoryWindow, LimitsConfig } from '@/types/api'
|
||||
|
||||
const ZERO_TIME = '0001-01-01T00:00:00Z'
|
||||
|
||||
/** formatCost renders a micro-USD integer (adminUsageEntryView's convention) as "$1.2345". */
|
||||
export function formatCost(micros: number): string {
|
||||
return `$${(micros / 1_000_000).toFixed(4)}`
|
||||
}
|
||||
|
||||
/** formatAgo renders an ISO timestamp as "(Ns ago)", or "" for the unset zero-time sentinel. */
|
||||
export function formatAgo(iso: string | undefined): string {
|
||||
if (!iso || iso === ZERO_TIME) return ''
|
||||
const then = new Date(iso).getTime()
|
||||
if (Number.isNaN(then)) return ''
|
||||
const secs = Math.max(0, Math.round((Date.now() - then) / 1000))
|
||||
return ` (${secs}s ago)`
|
||||
}
|
||||
|
||||
/** formatTimestamp renders an ISO timestamp for display, or "never" for the unset zero-time sentinel. */
|
||||
export function formatTimestamp(iso: string | undefined): string {
|
||||
if (!iso || iso === ZERO_TIME) return 'never'
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
return d.toLocaleString()
|
||||
}
|
||||
|
||||
/** formatLimits renders a LimitsConfig as a short comma-joined summary, or "none" when unset. */
|
||||
export function formatLimits(limits: LimitsConfig | undefined): string {
|
||||
if (!limits) return 'none'
|
||||
const parts: string[] = []
|
||||
if (limits.requestsPerMinute) parts.push(`req/min ${limits.requestsPerMinute}`)
|
||||
if (limits.requestsPerDay) parts.push(`req/day ${limits.requestsPerDay}`)
|
||||
if (limits.tokensPerDay) parts.push(`tok/day ${limits.tokensPerDay}`)
|
||||
if (limits.tokensPerMonth) parts.push(`tok/month ${limits.tokensPerMonth}`)
|
||||
if (limits.costPerDayUSD) parts.push(`cost/day $${limits.costPerDayUSD}`)
|
||||
if (limits.costPerMonthUSD) parts.push(`cost/month $${limits.costPerMonthUSD}`)
|
||||
return parts.length ? parts.join(', ') : 'none'
|
||||
}
|
||||
|
||||
/**
|
||||
* historyBucketFormat maps each history window to the fixed-width bucket
|
||||
* key format serveAdminUsageHistory echoes back (limits.go's windowKey:
|
||||
* hour="2006010215", day="20060102", month="200601" — Go reference-time
|
||||
* layouts, all digits, no separators).
|
||||
*/
|
||||
export function formatBucketLabel(bucket: string, window: HistoryWindow): string {
|
||||
switch (window) {
|
||||
case 'hour': {
|
||||
const hour = bucket.slice(8, 10)
|
||||
return `${hour}:00`
|
||||
}
|
||||
case 'day': {
|
||||
const year = Number(bucket.slice(0, 4))
|
||||
const month = Number(bucket.slice(4, 6))
|
||||
const day = Number(bucket.slice(6, 8))
|
||||
return new Date(year, month - 1, day).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
case 'month': {
|
||||
const year = Number(bucket.slice(0, 4))
|
||||
const month = Number(bucket.slice(4, 6))
|
||||
return new Date(year, month - 1, 1).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// sessionStorage helpers for the admin API key (auth.go / admin.go's
|
||||
// browser key-entry flow, ported from the replaced vanilla-JS admin page):
|
||||
// the key lives only in this tab's sessionStorage — never a cookie, never
|
||||
// localStorage — so it disappears when the tab closes and is sent only to
|
||||
// this page's own /admin/api/* fetches. Every call is wrapped: a browser
|
||||
// with storage disabled (private mode, some embedded webviews) must degrade
|
||||
// to "key not remembered across reload", never throw.
|
||||
|
||||
const KEY_STORAGE = 'llmgwAdminKey'
|
||||
|
||||
export function getStoredKey(): string {
|
||||
try {
|
||||
return sessionStorage.getItem(KEY_STORAGE) ?? ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function setStoredKey(key: string): void {
|
||||
try {
|
||||
sessionStorage.setItem(KEY_STORAGE, key)
|
||||
} catch {
|
||||
// storage unavailable — the key still lives in the Pinia store for the
|
||||
// rest of this page load, only cross-reload persistence is lost.
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredKey(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(KEY_STORAGE)
|
||||
} catch {
|
||||
// storage unavailable, nothing to clear
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ClassValue } from "clsx"
|
||||
import { clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -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')
|
||||
@@ -0,0 +1,42 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { clearStoredKey, getStoredKey, setStoredKey } from '@/lib/key-storage'
|
||||
|
||||
/**
|
||||
* useAuthStore owns the admin API key gate: sessionStorage-backed key
|
||||
* entry, sent as the `x-api-key` header on every /admin/api/* fetch
|
||||
* (lib/api.ts). GET /admin itself needs no auth (admin.go serves the shell
|
||||
* unauthenticated) — this store only gates the JSON routes.
|
||||
*/
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
apiKey: getStoredKey(),
|
||||
/** Set after a 401/403 (lib/api.ts) or on a still-empty submit. */
|
||||
error: '',
|
||||
}),
|
||||
getters: {
|
||||
isAuthenticated: (state): boolean => state.apiKey !== '',
|
||||
},
|
||||
actions: {
|
||||
/** submit stores a newly entered key and clears any prior error. */
|
||||
submit(key: string): void {
|
||||
if (!key) return
|
||||
this.apiKey = key
|
||||
this.error = ''
|
||||
setStoredKey(key)
|
||||
},
|
||||
/**
|
||||
* reject clears the key and shows message — called by lib/api.ts on a
|
||||
* 401/403. Guards against the late-401 race the vanilla-JS page this
|
||||
* replaces also guarded against: adminFetch only calls reject() when
|
||||
* the failing request's own key still matches what is currently
|
||||
* stored, so a stale in-flight request for a key the user has since
|
||||
* replaced can never clobber the newer, possibly-valid key.
|
||||
*/
|
||||
reject(message: string): void {
|
||||
this.apiKey = ''
|
||||
this.error = message
|
||||
clearStoredKey()
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { AdminApiError, adminFetch } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { AdminOverviewResponse, AdminUsageResponse } from '@/types/api'
|
||||
|
||||
/** How often the Overview/Usage views poll (matches the replaced vanilla-JS page). */
|
||||
const POLL_MS = 5000
|
||||
|
||||
/**
|
||||
* useDashboardStore polls GET /admin/api/overview and GET /admin/api/usage
|
||||
* together every POLL_MS, feeding the Overview and Usage views. A single
|
||||
* store (not two) because both routes are always fetched together — the
|
||||
* old vanilla-JS page's own Promise.all([overview, usage]) pattern.
|
||||
*/
|
||||
export const useDashboardStore = defineStore('dashboard', {
|
||||
state: () => ({
|
||||
overview: null as AdminOverviewResponse | null,
|
||||
usage: null as AdminUsageResponse | null,
|
||||
lastUpdated: null as Date | null,
|
||||
error: '',
|
||||
timer: undefined as ReturnType<typeof setInterval> | undefined,
|
||||
}),
|
||||
actions: {
|
||||
async refresh(): Promise<void> {
|
||||
const auth = useAuthStore()
|
||||
if (!auth.isAuthenticated) return
|
||||
try {
|
||||
const [overview, usage] = await Promise.all([
|
||||
adminFetch<AdminOverviewResponse>('/admin/api/overview'),
|
||||
adminFetch<AdminUsageResponse>('/admin/api/usage'),
|
||||
])
|
||||
this.overview = overview
|
||||
this.usage = usage
|
||||
this.lastUpdated = new Date()
|
||||
this.error = ''
|
||||
} catch (err) {
|
||||
// A 401/403 already rejected the key (lib/api.ts) — the AuthGate
|
||||
// takes over the view, nothing left to report here.
|
||||
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) {
|
||||
return
|
||||
}
|
||||
this.error = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
},
|
||||
/** startPolling is idempotent and safe to call before a key is stored — refresh() no-ops until then. */
|
||||
startPolling(): void {
|
||||
if (this.timer !== undefined) return
|
||||
void this.refresh()
|
||||
this.timer = setInterval(() => void this.refresh(), POLL_MS)
|
||||
},
|
||||
stopPolling(): void {
|
||||
if (this.timer === undefined) return
|
||||
clearInterval(this.timer)
|
||||
this.timer = undefined
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { AdminApiError, adminFetch } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { HistoryMetric, HistoryWindow, UsageHistoryPoint, UsageHistoryResponse } from '@/types/api'
|
||||
|
||||
/** The three chart tabs the Charts view offers — see metricsForTab below. */
|
||||
export type ChartTab = 'requests' | 'tokens' | 'cost'
|
||||
|
||||
/**
|
||||
* How often the current selection refetches while the Charts view is open.
|
||||
* Slower than the dashboard's 5s poll (dashboard.ts) — history buckets
|
||||
* change on the timescale of the window itself, not every request.
|
||||
*/
|
||||
const REFRESH_MS = 30_000
|
||||
|
||||
/**
|
||||
* WINDOW_SPAN is the bucket count requested per window: 24h at hour
|
||||
* resolution, 30d at day resolution, 12mo at month resolution. Each stays
|
||||
* within historyMaxSpan's own per-window ceiling (admin.go: 48/35/13), so
|
||||
* every request is valid without the UI needing to know that ceiling.
|
||||
*/
|
||||
export const WINDOW_SPAN: Record<HistoryWindow, number> = {
|
||||
hour: 24,
|
||||
day: 30,
|
||||
month: 12,
|
||||
}
|
||||
|
||||
/** WINDOW_LABEL is the switcher's own display text for each window. */
|
||||
export const WINDOW_LABEL: Record<HistoryWindow, string> = {
|
||||
hour: '24h',
|
||||
day: '30d',
|
||||
month: '12mo',
|
||||
}
|
||||
|
||||
/**
|
||||
* metricsForTab maps a chart tab to the metric(s) it needs. "tokens" is the
|
||||
* one tab needing two: GET /admin/api/usage/history takes a single metric
|
||||
* per call (tokin XOR tokout), and the Charts view stacks both into one bar
|
||||
* chart (operator brief: "STACKED tokens-in vs tokens-out").
|
||||
*/
|
||||
export function metricsForTab(tab: ChartTab): HistoryMetric[] {
|
||||
switch (tab) {
|
||||
case 'requests':
|
||||
return ['req']
|
||||
case 'tokens':
|
||||
return ['tokin', 'tokout']
|
||||
case 'cost':
|
||||
return ['cost']
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* useHistoryStore drives the Charts view: current scope/window/tab
|
||||
* selection, the fetched series for whatever metric(s) that selection
|
||||
* needs, and a 30s auto-refresh of the current selection. Changing scope,
|
||||
* window, or tab always triggers an immediate refetch (operator brief:
|
||||
* "history fetch on selection + 30s refresh").
|
||||
*/
|
||||
export const useHistoryStore = defineStore('history', {
|
||||
state: () => ({
|
||||
/** "total" | "user:{id}" | "group:{id}" — the raw scope query value serveAdminUsageHistory expects. */
|
||||
scope: 'total',
|
||||
window: 'hour' as HistoryWindow,
|
||||
tab: 'requests' as ChartTab,
|
||||
seriesByMetric: {} as Partial<Record<HistoryMetric, UsageHistoryPoint[]>>,
|
||||
loading: false,
|
||||
error: '',
|
||||
timer: undefined as ReturnType<typeof setInterval> | undefined,
|
||||
}),
|
||||
actions: {
|
||||
setScope(scope: string): void {
|
||||
if (scope === this.scope) return
|
||||
this.scope = scope
|
||||
void this.fetchSeries()
|
||||
},
|
||||
setWindow(window: HistoryWindow): void {
|
||||
if (window === this.window) return
|
||||
this.window = window
|
||||
void this.fetchSeries()
|
||||
},
|
||||
setTab(tab: ChartTab): void {
|
||||
if (tab === this.tab) return
|
||||
this.tab = tab
|
||||
void this.fetchSeries()
|
||||
},
|
||||
async fetchSeries(): Promise<void> {
|
||||
const auth = useAuthStore()
|
||||
if (!auth.isAuthenticated) return
|
||||
this.loading = true
|
||||
try {
|
||||
const span = WINDOW_SPAN[this.window]
|
||||
const metrics = metricsForTab(this.tab)
|
||||
const results = await Promise.all(
|
||||
metrics.map((metric) =>
|
||||
adminFetch<UsageHistoryResponse>(
|
||||
`/admin/api/usage/history?scope=${encodeURIComponent(this.scope)}&metric=${metric}&window=${this.window}&span=${span}`,
|
||||
),
|
||||
),
|
||||
)
|
||||
const next: Partial<Record<HistoryMetric, UsageHistoryPoint[]>> = {}
|
||||
metrics.forEach((metric, i) => {
|
||||
next[metric] = results[i]!.points
|
||||
})
|
||||
this.seriesByMetric = next
|
||||
this.error = ''
|
||||
} catch (err) {
|
||||
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) {
|
||||
return
|
||||
}
|
||||
this.error = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
startAutoRefresh(): void {
|
||||
if (this.timer !== undefined) return
|
||||
this.timer = setInterval(() => void this.fetchSeries(), REFRESH_MS)
|
||||
},
|
||||
stopAutoRefresh(): void {
|
||||
if (this.timer === undefined) return
|
||||
clearInterval(this.timer)
|
||||
this.timer = undefined
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"files": [],
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user