Every broker serves a dashboard on its own port. There is nothing to deploy: the
built single-page application is compiled into the binary with rust_embed from
server/webapp/dist, and the bytes are read out of the executable at request time.
There is no QUEEN_STATIC_DIR. No runtime override exists, no directory is read
from disk, and pointing a volume at the container changes nothing. If you need a
different dashboard, you rebuild the binary.
How it is served
The dashboard is the router’s fallback, registered after every real route, so it can never shadow an API path. The fallback resolves in three steps:
- If the path is under
/api/or/auth/(or is exactly/apior/auth), or the method is anything other thanGETorHEAD, it answers a JSON 404:{"error":"Not Found","code":"no_such_route"}. The three/authpaths the broker really serves are registered routes, so they never reach the fallback. - Otherwise, if the path matches an embedded asset, that asset is returned with a content type guessed from its extension.
- Otherwise
index.htmlis returned, so client-side routes like/queues/orderswork on a hard reload. If the dashboard was not bundled at build time, this is a plain404 Not Found.
Step 1 exists because the fallback matches any method. Without it, a call to an
endpoint that does not exist came back 200 text/html, which every JSON client,
including the dashboard’s own HTTP layer, read as success. A phantom endpoint now
fails loudly.
Assets are served with a content type and nothing else: no Cache-Control, no
ETag, no compression negotiation. For a handful of operators that is fine. If the
dashboard is on a hot path, put a reverse proxy or CDN in front and let it add
caching.
The same server/webapp/dist directory is embedded by queen_proxy as well, so a
proxy and the broker behind it serve the identical bundle.
Who the dashboard thinks you are
The dashboard boots by calling GET /auth/me and derives every permission from
that one payload: it never guesses a role from a response code, and it never
sniffs which binary is serving it. /auth/me is answered by whoever serves the
bundle, and that is the whole difference between the two deployments:
Behind queen_proxy, /auth/me is the proxy’s cookie-authenticated session
endpoint: real users, per-cluster roles, an operator flag, and a login page. The
broker never sees these paths.
Broker-direct with auth off (JWT_ENABLED=false, the default), the broker
answers /auth/me itself with a fixed standalone identity: a single synthetic
cluster named local, the admin role on it, and the operator capability live.
Whoever runs the broker is the operator of that cell, so every view including
System works. The payload also says standalone: true, which is the one thing
the shell reads to hide the session controls: no cluster selector, because there
is nothing to switch to, and no sign-out, because there is no session to end.
Nothing here grants anything: with auth off the API was already open to anyone
who can reach the port, and the broker’s boot log says so. The dashboard adds
visibility, not privilege.
Broker-direct with auth on (JWT_ENABLED=true), the dashboard cannot work:
its HTTP layer sends no bearer token, and the broker holds no user accounts and
no browser sessions to authenticate one. The broker answers /auth/me with
401 {"code":"auth_required"}, the app’s login redirect lands on /auth/login,
and the broker serves a static page there saying exactly that: use the proxy,
or run with auth off. The page is terminal, so there is no redirect loop.
POST /auth/logout exists as a no-op ({"ok":true}) so a hand-typed call gets
an answer instead of a JSON 404. All three routes are public in the
authorization table; /auth/me enforces the auth state itself, which is why the
explanation above works without a token.
Every view is tenant-scoped except one, and the exception is labelled on screen.
| View | Path | What it reads |
|---|---|---|
| Dashboard | / |
the resource overview, namespaces and tasks: the tenant’s totals and key rates |
| Queue Operations | /operations |
per-queue throughput, lag and consumer health |
| Queues | /queues |
the queue list, with per-queue detail and delete |
| Queue Detail | /queues/:queueName |
one queue’s configuration and current status |
| Consumer Groups | /consumers |
groups, their lag, the lagging list, subscription changes, seek and delete |
| Messages | /messages |
message listing and single-message inspection, plus delete |
| Traces | /traces |
trace names that actually exist, and the timeline for one |
| Analytics | /analytics |
per-queue status over time, queue lag and queue ops time series, retention volume |
| Dead Letter | /dlq |
the dead-letter listing, with purge |
| System | /system |
cell-level: host CPU and memory, worker metrics, PostgreSQL internals, the disk spool, the maintenance switch |
Three properties of this UI are worth knowing before you read a number off it.
The System view is not about your tenant. Host resources, PostgreSQL internals and the file-buffer state cover the whole cell: every tenant on it. The view says so on screen, and behind a proxy it answers only for an operator principal. Reading a cell figure as a tenant figure is the specific mistake the shell is built to prevent.
The dead-letter view inspects and purges. It has no replay control. An earlier version of this UI had a retry button that posted to a path the broker did not route; the request fell through to the static fallback and reported success while doing nothing. The control was removed rather than left lying.
An unknown is not a zero. Panels carry explicit loading, error and
last-updated state, and a failed call renders as a failure instead of a confident
0. Refresh is a single shared ticker rather than per-panel timers, so what you
see across a page is one moment.
Rebuilding it
The dashboard source is app/: Vue 3, Vite, Tailwind, Chart.js. It needs Node 22
or newer.
Run the first two steps from app/ and the last from server/.
-
Install dependencies.
npm install -
Build. The output goes straight to
server/webapp/dist, which is the one artifact both Rust binaries embed.npm run build -
Rebuild whichever binary serves it, because the bytes are baked in.
cargo build --release
For iterating on the UI, npm run dev serves it on port 4000 and proxies /api,
/auth, /health and /metrics to a queen_proxy development cell on port
6711. Setting QUEEN_DEV_UPSTREAM=http://localhost:6632 points it straight at a
broker instead: that is the standalone mode, with its fixed operator identity
(no sessions, no tenancy, no role checks, no rate limiting). Default to the
proxy upstream unless standalone behaviour is the thing being worked on, because
the proxy side is where a permission or a 429 can prove the UI wrong.
QUEEN_APP_BASE sets the router and asset base if the bundle is mounted under a
path prefix.