mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-16 05:37:28 +00:00
feat(webui): vue admin panel source
Vue 3 + TypeScript + Pinia + Tailwind v4 + shadcn-vue + Chart.js admin panel source under webui/, built with Vite. Views: Overview (providers, redis/cache/retry status, model aliases), Usage (per-user/per-group/total tables with tokensIn/tokensOut/cost + limits), Charts (scope selector, req/tokens(stacked in-out)/cost metric tabs, 24h/30d/12mo window switcher, fed by GET /admin/api/usage/history). Pinia stores: auth (sessionStorage key gate, x-api-key header, 401/403 re-prompt), dashboard (5s poll), history (fetch-on-selection + 30s refresh). Design: deliberate neutral warm-neutral palette with a signature teal accent (no default Inter/purple-gradient/glassmorphism), real typographic hierarchy via the platform UI/mono font stacks (zero external assets - no Google Fonts), dark+light via prefers-color-scheme (no class toggle, no theme switcher). Charts: zero-based bars, honest axes, Okabe-Ito colorblind-safe series, tokens-in/out as a distinguishable stacked pair. vite.config.ts: base '/admin/' (matches admin.go's route layout) and build.modulePreload.polyfill:false (no inline bootstrap script, so the tightened admin CSP needs no 'unsafe-inline'). Code-split: Chart.js (ChartsView) loads lazily, not in the initial bundle. generate.mjs reads dist/ after `vite build` and writes ../admin_assets_gen.go (not committed by this change) - base64-encodes every asset, decoded once at package-var init; documented rationale in the script's own header comment. Hygiene: webui/go.mod walls webui/ off from the repo root's `go build`/ `vet`/`test ./...` (npm's node_modules bundles a stray .go file that would otherwise be swept in); tools/yaegi-check/main.go excludes webui/ from its GOPATH copy for the same reason. webui/ export-ignored (.gitattributes) from release source tarballs; webui/node_modules/ gitignored. tsconfig.app.json/tsconfig.node.json's JSONC comments dropped (TS itself is fine with either; the global pre-commit JSON validator is not) - no config values changed.
This commit is contained in:
@@ -24,3 +24,9 @@ testdata/ export-ignore
|
|||||||
.github/ export-ignore
|
.github/ export-ignore
|
||||||
*_test.go export-ignore
|
*_test.go export-ignore
|
||||||
.gitattributes 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
|
tools/yaegi-check/llmgw-yaegi-check
|
||||||
dist/
|
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`
|
# Rendered from integration/traefik/dynamic.yml.tmpl by `make integration-up`
|
||||||
# (INTEGRATION_REAL_BASEURL substitution) — generated, not source. A whole
|
# (INTEGRATION_REAL_BASEURL substitution) — generated, not source. A whole
|
||||||
# directory (not a single file): Traefik's directory-based file provider
|
# 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
|
// needs, and copying its "go.yaml.in"-style nested module dirs into a
|
||||||
// module-less GOPATH tree would otherwise confuse Yaegi's own import
|
// module-less GOPATH tree would otherwise confuse Yaegi's own import
|
||||||
// resolution) have nothing to do with the plugin package Yaegi imports.
|
// 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{
|
var excludedTopLevelDirs = map[string]bool{
|
||||||
"tools": true,
|
"tools": true,
|
||||||
"integration": true,
|
"integration": true,
|
||||||
@@ -63,6 +72,7 @@ var excludedTopLevelDirs = map[string]bool{
|
|||||||
"docs": true,
|
"docs": true,
|
||||||
".git": true,
|
".git": true,
|
||||||
"vendor": true,
|
"vendor": true,
|
||||||
|
"webui": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
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