mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-14 05:18:54 +00:00
docs: vue admin panel, usage charts, stats exclusion
README.md: ## Admin rewritten for the Vue rebuild (webui/, new
GET /admin/assets/{hashedname} route, tightened CSP with no more
'unsafe-inline', usage-charts description) and the stats-exclusion
directive ("Admin traffic is never counted" replaces "Poll counts
against quota"). ## Development gains `make admin-ui` plus a note
that Node is needed only for that one target.
docs/superpowers/specs/2026-08-20-llmgateway-v02-design.md §4: an
amendment block documents the Vue rewrite (new route, tightened CSP,
Charts view) following this doc's established
controller-approved-amendment convention; the Accounting bullet is
amended for the stats-exclusion operator directive, with the original
wording struck through rather than deleted.
This commit is contained in:
@@ -738,34 +738,48 @@ adapter call — minus response caching and token/cost accounting (below).
|
||||
## Admin
|
||||
|
||||
A read-only dashboard and JSON API for operational visibility — providers,
|
||||
groups, and per-user/per-group usage against configured limits. No
|
||||
mutation of any kind; config stays owned by GitOps/Traefik as usual. Off
|
||||
by default (`admin.enabled: false`, or the `admin` block omitted
|
||||
entirely): the four routes below are not registered at all, and a
|
||||
request to any of them falls through to the plugin's existing
|
||||
404/`passthroughUnknown` handling.
|
||||
groups, model aliases, and per-user/per-group/total usage against
|
||||
configured limits, plus usage charts. No mutation of any kind; config
|
||||
stays owned by GitOps/Traefik as usual. Off by default (`admin.enabled:
|
||||
false`, or the `admin` block omitted entirely): none of the routes below
|
||||
are registered at all, and a request to any of them falls through to the
|
||||
plugin's existing 404/`passthroughUnknown` handling.
|
||||
|
||||
The dashboard itself is a small Vue 3 + Pinia + Chart.js single-page app
|
||||
([`webui/`](webui/)), built once with `vite build` and baked into the
|
||||
plugin as generated Go source (`admin_assets_gen.go` — see
|
||||
[Development](#development)); nothing under `webui/` is needed at runtime
|
||||
or in CI.
|
||||
|
||||
- **Enabling it**: set `admin.enabled: true`, then flag at least one user
|
||||
`admin: true` (`UserConfig.admin`) so someone can actually reach
|
||||
`/admin/api/*`. An admin user is otherwise ordinary — their own keys,
|
||||
group, and limits still apply, including to the admin routes
|
||||
themselves.
|
||||
- **`GET /admin`** serves the dashboard's HTML/CSS/JS shell. This route is
|
||||
deliberately served **with no authentication at all** once
|
||||
group, and limits still apply everywhere except the admin routes
|
||||
themselves (see "Admin traffic is never counted" below).
|
||||
- **`GET /admin`** serves the built app's `index.html` shell, and
|
||||
**`GET /admin/assets/{hashedname}`** serves its content-hashed JS/CSS.
|
||||
Both are deliberately served **with no authentication at all** once
|
||||
`admin.enabled` is true (disabled still falls through to the same
|
||||
404/`passthroughUnknown` handling described above): a browser navigating
|
||||
straight to the URL has no way to attach a custom `Authorization` or
|
||||
`x-api-key` header, so gating the page itself would make it unreachable
|
||||
from a browser in the first place. This is safe because the shell
|
||||
carries **zero data of its own** — every value is fetched client-side
|
||||
from the JSON routes below, which stay fully gated.
|
||||
- **Browser key-entry flow**: the page's script reads an admin API key
|
||||
from the current tab's `sessionStorage`. Absent a stored key, or on any
|
||||
`401`/`403` from a JSON route, it shows an inline key-entry form
|
||||
instead of the dashboard. A submitted key is kept **only in
|
||||
`sessionStorage`** — never a cookie, `localStorage`, or any persistent
|
||||
store — so it disappears when the tab closes, and it is sent only as
|
||||
the `x-api-key` header on this page's own `/admin/api/*` fetches.
|
||||
from a browser in the first place. This is safe because the shell and
|
||||
its assets carry **zero data of their own** — every value is fetched
|
||||
client-side from the JSON routes below, which stay fully gated — and the
|
||||
built JS/CSS is exactly what `vite build` emits from `webui/`, publicly
|
||||
inspectable by any admin dashboard user already. Each asset response
|
||||
carries a long-lived `Cache-Control: public, max-age=31536000,
|
||||
immutable` (the hash in the filename changes whenever the content does,
|
||||
so a cached response never goes stale); an unrecognized asset name is a
|
||||
`404` in the same error envelope every other unknown route returns.
|
||||
- **Browser key-entry flow**: the page reads an admin API key from the
|
||||
current tab's `sessionStorage` (a Pinia store owns this). Absent a
|
||||
stored key, or on any `401`/`403` from a JSON route, it shows an inline
|
||||
key-entry form instead of the dashboard. A submitted key is kept **only
|
||||
in `sessionStorage`** — never a cookie, `localStorage`, or any
|
||||
persistent store — so it disappears when the tab closes, and it is sent
|
||||
only as the `x-api-key` header on this page's own `/admin/api/*`
|
||||
fetches.
|
||||
- **`GET /admin/api/overview`** and **`GET /admin/api/usage`** share one
|
||||
gate: unauthenticated → 401, authenticated non-admin → 403, admin →
|
||||
serve. `overview` returns the provider list, groups, the configured
|
||||
@@ -780,10 +794,12 @@ request to any of them falls through to the plugin's existing
|
||||
`tokensOutPerMonth`, `costPerDayMicroUsd`, `costPerMonthMicroUsd` —
|
||||
alongside their configured limits, plus one extra `total` row: the
|
||||
synthetic all-traffic scope (see [Limits and accounting](
|
||||
#limits-and-accounting)), limits always `null`.
|
||||
#limits-and-accounting)), limits always `null`. The dashboard's Overview
|
||||
and Usage views poll both every 5 seconds while open.
|
||||
- **`GET /admin/api/usage/history`** returns a bucketed series for one
|
||||
scope/metric/window — the data source for per-user/per-group/total
|
||||
usage charts. Query parameters:
|
||||
scope/metric/window — the data source for the dashboard's Charts view
|
||||
(per-user/per-group/total, stacked tokens-in/tokens-out, with a
|
||||
24h/30d/12mo window switcher). Query parameters:
|
||||
- `scope`: `user:{id}`, `group:{id}`, or the literal `total`.
|
||||
- `metric`: `req`, `tokin`, `tokout`, or `cost`.
|
||||
- `window`: `hour`, `day`, or `month`.
|
||||
@@ -797,34 +813,41 @@ request to any of them falls through to the plugin's existing
|
||||
the configured store being unreachable is `503` (a chart must never
|
||||
read an outage as "zero usage"). Response shape:
|
||||
`{"scope","metric","window","points":[{"bucket":"2026082114","value":123},...]}`.
|
||||
Unlike the other two JSON routes, this one **never counts request
|
||||
statistics** — it skips `checkAndCount` entirely, ahead of the same
|
||||
change landing for every `/admin/api/*` route in a later change. It is
|
||||
therefore also **unthrottled**: no `requestsPerMinute`/`requestsPerDay`
|
||||
limit ever applies to it, so its only trust boundary is the admin gate
|
||||
itself (a valid admin key). An admin key holder can poll it as fast as
|
||||
they like.
|
||||
The Charts view fetches this once per selection change, plus a 30s
|
||||
auto-refresh of the current selection.
|
||||
- **What's exposed**: provider names, types, base URLs (with any
|
||||
userinfo/query string stripped before it's ever echoed), model counts,
|
||||
discovery status, group/user names, membership, limits, and live usage
|
||||
counters. **Never exposed**: API keys (not even digests), provider
|
||||
keys, the Redis password, or users-file path contents — every
|
||||
secret-bearing field is redacted from every response.
|
||||
- **Poll counts against quota**: the dashboard's own JavaScript polls
|
||||
`overview` and `usage` every 5 seconds while open. Each poll is a real
|
||||
authenticated request and increments the admin user's own request
|
||||
counters exactly like any other route (the `usage/history` route above
|
||||
is the one exception) — leaving a dashboard tab open against a tightly
|
||||
limited admin user can itself exhaust their
|
||||
`requestsPerMinute`/`requestsPerDay` budget.
|
||||
- **`GET /admin` counts nothing**: it is unauthenticated, so there is no
|
||||
identified user to count a request against.
|
||||
- **Admin traffic is never counted**: none of the three `/admin/api/*`
|
||||
JSON routes call `checkAndCount` — admin polling never moves any
|
||||
user's or group's `requestsPerMinute`/`requestsPerDay` counters, and
|
||||
usage statistics reflect real LLM traffic only (operator directive: an
|
||||
admin dashboard tab left open, however aggressively it polls, must
|
||||
never itself distort the numbers it displays). The direct consequence:
|
||||
an admin's own `requestsPerMinute`/`requestsPerDay` limit, if
|
||||
configured, is never enforced against admin-route traffic either — an
|
||||
admin key holder can poll any of the three routes as fast as they like.
|
||||
This is an accepted trade-off, not an oversight: these are admin-gated,
|
||||
cheap reads, and an admin holder polling aggressively is a
|
||||
self-inflicted, not a shared, resource cost. `GET /admin` and
|
||||
`GET /admin/assets/*` count nothing either, for the simpler reason that
|
||||
they are unauthenticated — there is no identified user to count a
|
||||
request against.
|
||||
- **Response headers**: all three JSON routes set
|
||||
`X-Content-Type-Options: nosniff` and `Cache-Control: no-store`, and all
|
||||
four routes share one `Content-Security-Policy` header
|
||||
(`default-src 'none'; script-src 'unsafe-inline'; style-src
|
||||
'unsafe-inline'; connect-src 'self'`) — the dashboard loads no external
|
||||
asset of any kind and works in an air-gapped cluster.
|
||||
`X-Content-Type-Options: nosniff` and `Cache-Control: no-store`; every
|
||||
hashed asset sets `X-Content-Type-Options: nosniff` and its own
|
||||
immutable `Cache-Control` (above). `GET /admin` and the three JSON
|
||||
routes share one `Content-Security-Policy` header (`default-src 'none';
|
||||
script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'
|
||||
data:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`) —
|
||||
no `'unsafe-inline'`, since the built app has no inline script or style
|
||||
of any kind, only external `/admin/assets/*` references; `img-src`
|
||||
additionally allows `data:` for the page's inlined favicon. The
|
||||
dashboard loads no external asset of any kind and works in an
|
||||
air-gapped cluster.
|
||||
|
||||
## MCP and A2A
|
||||
|
||||
@@ -1048,12 +1071,20 @@ uses `directory:` rather than `filename:`.
|
||||
make test # go test ./... -count=1
|
||||
make lint # gofmt -l . && go vet ./...
|
||||
make yaegi-check # interprets the plugin with Yaegi, exercises .traefik.yml's testData
|
||||
make admin-ui # rebuilds webui/ (npm ci && vite build) and regenerates
|
||||
# admin_assets_gen.go — needed only after a webui/ source change
|
||||
make integration # docker-compose: real Traefik, real Redis, mock providers; brings the
|
||||
# stack up, waits for it, runs the suite, always tears it down after
|
||||
make integration-keep # same run, but leaves the stack up afterward for debugging —
|
||||
# tear it down yourself with `make integration-down` when done
|
||||
```
|
||||
|
||||
`make admin-ui` needs Node (developed against v22 / npm 11) but nothing
|
||||
else does: `admin_assets_gen.go` is committed, so `make test`/`make
|
||||
lint`/`make yaegi-check`/`make integration`/CI never invoke Node at all.
|
||||
Regenerate it after editing anything under `webui/`, then commit both the
|
||||
`webui/` change and the regenerated `admin_assets_gen.go` together.
|
||||
|
||||
Also run before committing (not wired into `make lint`, since it needs a
|
||||
separate binary): `golangci-lint run ./...` and
|
||||
`golangci-lint run --enable=gosec ./...` (gosec applies to `_test.go`
|
||||
|
||||
Reference in New Issue
Block a user