Skip to content

The built-in dashboard

The Vue dashboard is compiled into the binary: what each view shows, how the fallback route serves it, and how to rebuild it from app/.

Updated View as Markdown

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:

  1. If the path is under /api/ or /auth/ (or is exactly /api or /auth), or the method is anything other than GET or HEAD, it answers a JSON 404: {"error":"Not Found","code":"no_such_route"}. The three /auth paths the broker really serves are registered routes, so they never reach the fallback.
  2. Otherwise, if the path matches an embedded asset, that asset is returned with a content type guessed from its extension.
  3. Otherwise index.html is returned, so client-side routes like /queues/orders work on a hard reload. If the dashboard was not bundled at build time, this is a plain 404 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/.

  1. Install dependencies.

    npm install
  2. Build. The output goes straight to server/webapp/dist, which is the one artifact both Rust binaries embed.

    npm run build
  3. 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close