mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-17 05:37:43 +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
|
## Admin
|
||||||
|
|
||||||
A read-only dashboard and JSON API for operational visibility — providers,
|
A read-only dashboard and JSON API for operational visibility — providers,
|
||||||
groups, and per-user/per-group usage against configured limits. No
|
groups, model aliases, and per-user/per-group/total usage against
|
||||||
mutation of any kind; config stays owned by GitOps/Traefik as usual. Off
|
configured limits, plus usage charts. No mutation of any kind; config
|
||||||
by default (`admin.enabled: false`, or the `admin` block omitted
|
stays owned by GitOps/Traefik as usual. Off by default (`admin.enabled:
|
||||||
entirely): the four routes below are not registered at all, and a
|
false`, or the `admin` block omitted entirely): none of the routes below
|
||||||
request to any of them falls through to the plugin's existing
|
are registered at all, and a request to any of them falls through to the
|
||||||
404/`passthroughUnknown` handling.
|
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
|
- **Enabling it**: set `admin.enabled: true`, then flag at least one user
|
||||||
`admin: true` (`UserConfig.admin`) so someone can actually reach
|
`admin: true` (`UserConfig.admin`) so someone can actually reach
|
||||||
`/admin/api/*`. An admin user is otherwise ordinary — their own keys,
|
`/admin/api/*`. An admin user is otherwise ordinary — their own keys,
|
||||||
group, and limits still apply, including to the admin routes
|
group, and limits still apply everywhere except the admin routes
|
||||||
themselves.
|
themselves (see "Admin traffic is never counted" below).
|
||||||
- **`GET /admin`** serves the dashboard's HTML/CSS/JS shell. This route is
|
- **`GET /admin`** serves the built app's `index.html` shell, and
|
||||||
deliberately served **with no authentication at all** once
|
**`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
|
`admin.enabled` is true (disabled still falls through to the same
|
||||||
404/`passthroughUnknown` handling described above): a browser navigating
|
404/`passthroughUnknown` handling described above): a browser navigating
|
||||||
straight to the URL has no way to attach a custom `Authorization` or
|
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
|
`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
|
from a browser in the first place. This is safe because the shell and
|
||||||
carries **zero data of its own** — every value is fetched client-side
|
its assets carry **zero data of their own** — every value is fetched
|
||||||
from the JSON routes below, which stay fully gated.
|
client-side from the JSON routes below, which stay fully gated — and the
|
||||||
- **Browser key-entry flow**: the page's script reads an admin API key
|
built JS/CSS is exactly what `vite build` emits from `webui/`, publicly
|
||||||
from the current tab's `sessionStorage`. Absent a stored key, or on any
|
inspectable by any admin dashboard user already. Each asset response
|
||||||
`401`/`403` from a JSON route, it shows an inline key-entry form
|
carries a long-lived `Cache-Control: public, max-age=31536000,
|
||||||
instead of the dashboard. A submitted key is kept **only in
|
immutable` (the hash in the filename changes whenever the content does,
|
||||||
`sessionStorage`** — never a cookie, `localStorage`, or any persistent
|
so a cached response never goes stale); an unrecognized asset name is a
|
||||||
store — so it disappears when the tab closes, and it is sent only as
|
`404` in the same error envelope every other unknown route returns.
|
||||||
the `x-api-key` header on this page's own `/admin/api/*` fetches.
|
- **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
|
- **`GET /admin/api/overview`** and **`GET /admin/api/usage`** share one
|
||||||
gate: unauthenticated → 401, authenticated non-admin → 403, admin →
|
gate: unauthenticated → 401, authenticated non-admin → 403, admin →
|
||||||
serve. `overview` returns the provider list, groups, the configured
|
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` —
|
`tokensOutPerMonth`, `costPerDayMicroUsd`, `costPerMonthMicroUsd` —
|
||||||
alongside their configured limits, plus one extra `total` row: the
|
alongside their configured limits, plus one extra `total` row: the
|
||||||
synthetic all-traffic scope (see [Limits and accounting](
|
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
|
- **`GET /admin/api/usage/history`** returns a bucketed series for one
|
||||||
scope/metric/window — the data source for per-user/per-group/total
|
scope/metric/window — the data source for the dashboard's Charts view
|
||||||
usage charts. Query parameters:
|
(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`.
|
- `scope`: `user:{id}`, `group:{id}`, or the literal `total`.
|
||||||
- `metric`: `req`, `tokin`, `tokout`, or `cost`.
|
- `metric`: `req`, `tokin`, `tokout`, or `cost`.
|
||||||
- `window`: `hour`, `day`, or `month`.
|
- `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
|
the configured store being unreachable is `503` (a chart must never
|
||||||
read an outage as "zero usage"). Response shape:
|
read an outage as "zero usage"). Response shape:
|
||||||
`{"scope","metric","window","points":[{"bucket":"2026082114","value":123},...]}`.
|
`{"scope","metric","window","points":[{"bucket":"2026082114","value":123},...]}`.
|
||||||
Unlike the other two JSON routes, this one **never counts request
|
The Charts view fetches this once per selection change, plus a 30s
|
||||||
statistics** — it skips `checkAndCount` entirely, ahead of the same
|
auto-refresh of the current selection.
|
||||||
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.
|
|
||||||
- **What's exposed**: provider names, types, base URLs (with any
|
- **What's exposed**: provider names, types, base URLs (with any
|
||||||
userinfo/query string stripped before it's ever echoed), model counts,
|
userinfo/query string stripped before it's ever echoed), model counts,
|
||||||
discovery status, group/user names, membership, limits, and live usage
|
discovery status, group/user names, membership, limits, and live usage
|
||||||
counters. **Never exposed**: API keys (not even digests), provider
|
counters. **Never exposed**: API keys (not even digests), provider
|
||||||
keys, the Redis password, or users-file path contents — every
|
keys, the Redis password, or users-file path contents — every
|
||||||
secret-bearing field is redacted from every response.
|
secret-bearing field is redacted from every response.
|
||||||
- **Poll counts against quota**: the dashboard's own JavaScript polls
|
- **Admin traffic is never counted**: none of the three `/admin/api/*`
|
||||||
`overview` and `usage` every 5 seconds while open. Each poll is a real
|
JSON routes call `checkAndCount` — admin polling never moves any
|
||||||
authenticated request and increments the admin user's own request
|
user's or group's `requestsPerMinute`/`requestsPerDay` counters, and
|
||||||
counters exactly like any other route (the `usage/history` route above
|
usage statistics reflect real LLM traffic only (operator directive: an
|
||||||
is the one exception) — leaving a dashboard tab open against a tightly
|
admin dashboard tab left open, however aggressively it polls, must
|
||||||
limited admin user can itself exhaust their
|
never itself distort the numbers it displays). The direct consequence:
|
||||||
`requestsPerMinute`/`requestsPerDay` budget.
|
an admin's own `requestsPerMinute`/`requestsPerDay` limit, if
|
||||||
- **`GET /admin` counts nothing**: it is unauthenticated, so there is no
|
configured, is never enforced against admin-route traffic either — an
|
||||||
identified user to count a request against.
|
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
|
- **Response headers**: all three JSON routes set
|
||||||
`X-Content-Type-Options: nosniff` and `Cache-Control: no-store`, and all
|
`X-Content-Type-Options: nosniff` and `Cache-Control: no-store`; every
|
||||||
four routes share one `Content-Security-Policy` header
|
hashed asset sets `X-Content-Type-Options: nosniff` and its own
|
||||||
(`default-src 'none'; script-src 'unsafe-inline'; style-src
|
immutable `Cache-Control` (above). `GET /admin` and the three JSON
|
||||||
'unsafe-inline'; connect-src 'self'`) — the dashboard loads no external
|
routes share one `Content-Security-Policy` header (`default-src 'none';
|
||||||
asset of any kind and works in an air-gapped cluster.
|
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
|
## MCP and A2A
|
||||||
|
|
||||||
@@ -1048,12 +1071,20 @@ uses `directory:` rather than `filename:`.
|
|||||||
make test # go test ./... -count=1
|
make test # go test ./... -count=1
|
||||||
make lint # gofmt -l . && go vet ./...
|
make lint # gofmt -l . && go vet ./...
|
||||||
make yaegi-check # interprets the plugin with Yaegi, exercises .traefik.yml's testData
|
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
|
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
|
# 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 —
|
make integration-keep # same run, but leaves the stack up afterward for debugging —
|
||||||
# tear it down yourself with `make integration-down` when done
|
# 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
|
Also run before committing (not wired into `make lint`, since it needs a
|
||||||
separate binary): `golangci-lint run ./...` and
|
separate binary): `golangci-lint run ./...` and
|
||||||
`golangci-lint run --enable=gosec ./...` (gosec applies to `_test.go`
|
`golangci-lint run --enable=gosec ./...` (gosec applies to `_test.go`
|
||||||
|
|||||||
Reference in New Issue
Block a user