Dashboard
@portqueue/dashboard is a pluggable React ops dashboard — queues, jobs, schedules, instances and
live stats. Mount it on your existing HTTP server (Express, Koa,
Fastify, NestJS or raw Node), or serve it from the CLI with
no app changes at all.
Pages
Navigation lives in a left sidebar (it collapses behind a burger button on small screens), with links to these docs and the GitHub repo pinned at its foot, and the live-updates control and the light/dark toggle in the top bar. Live updates refresh the current page's data every 15 seconds and pause with the tab hidden; the control freezes them, and a refresh button fetches on demand either way. Every page opens with a full-width subheader carrying its title, a one-line description, and the page's actions.
The screenshots below follow the theme you are reading in — switch it in the top-right and they swap with the rest of the site. Click any of them to see it full size.
Overview & queue details
The overview opens on activity cards — active, pending, failed and completed — above a table of every queue with its state, usage and per-status counts, pause/resume on each row, and the name linking through to the queue itself.
A queue's own page narrows the same cards to that queue and lists its jobs: paged and searchable,
with per-row retry, cancel and remove plus bulk actions over a selection. Pause/resume, Add job
and (for a queue no running process declares) Remove queue sit in the subheader. The Add job
form mirrors addJob, so its timing fields are the same seconds the API takes.
Job details
One job's full record — queue, status, priority, attempts, the instance that processed it, lock,
idempotency key and schedule — beside its data, return value, failure message and a lifecycle
timeline, followed by every lifecycle timestamp including the ones the job never reached.
Retry, clone, cancel and remove sit in the subheader. Two of them leave the page: a clone opens the new job's (or schedule's) own page, and a removal returns to the queue. A running job is never removed — cancel it first.
Instances list & details
One row per registered instance: liveness, the CPU and memory of that Node process at its last heartbeat, jobs processed, and pause/resume. Both percentages are process-local — CPU a share of the cores available to it, memory a share of V8's heap limit — and turn amber from 75% and red from 85%. Rows only appear for servers that set an instance key.
Each key opens its own page: the same health as cards, plus the jobs that instance claimed, with pause/resume and remove (available once it has been unseen for 30 minutes). A job's Processed on instance links here too.
Schedules list & details
Recurring definitions across every queue — cadence, timezone, next and last run — with per-schedule enable/disable and bulk unschedule. Disabling keeps the definition and stops it firing, the reversible half of "make this stop".
A schedule's page shows the full definition (including its idempotency key) above the jobs it has produced, and offers disable and remove. Removing returns to the list, since the definition is gone.
Mount on an existing app
Install the dashboard package alongside @portqueue/core:
npm install @portqueue/dashboard
The dashboard reads through a @portqueue/core driver, so its client (pg, better-sqlite3,
@electric-sql/pglite, …) must be installed too — otherwise PortQueue throws MissingPeerDependencyError.
Create the dashboard once from a driver — not from your running PortQueue — then mount it with
the adapter for your framework. Express, Koa, Fastify, NestJS and raw Node all ship in the box.
createDashboard({ driver }) constructs and owns a small headless PortQueue behind the scenes.
That is what lets the dashboard run wherever you like: in almost every real deployment it is a
different process from the workers, so riding on their client only ever worked in the one case where
the two happened to share a process.
Headless means it observes without joining: it declares no queues, registers no instance row, runs no
workers or scheduler, and never migrates your schema. Nothing connects at mount either — the first
request opens the connection, and dash.close() releases it.
One consequence worth knowing: the queues table has no "in use" column. That flag means "declared in
this process", which a separate dashboard cannot know. pq.getQueues() still reports it for your
own application code.
- Express
- Koa
- Fastify
- NestJS
- Raw Node
import { createDashboard } from '@portqueue/dashboard';
const dash = createDashboard({ driver, basePath: '/portqueue' });
// `basePath` is the mount point, so the middleware goes on at the root.
app.use(dash.express());
import { createDashboard } from '@portqueue/dashboard';
const dash = createDashboard({ driver, basePath: '/portqueue' });
app.use(dash.koa());
import { createDashboard } from '@portqueue/dashboard';
const dash = createDashboard({ driver, basePath: '/portqueue' });
await app.register(dash.fastify());
import { createDashboard } from '@portqueue/dashboard';
const dash = createDashboard({ driver, basePath: '/portqueue' });
// Nest's default platform is Express, so mount it as functional middleware.
// No @nestjs/* dependency is required — but keep forRoutes in sync with basePath.
export class DashboardModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(dash.nest()).forRoutes('/portqueue');
}
}
import { createServer } from 'node:http';
import { createDashboard } from '@portqueue/dashboard';
const dash = createDashboard({ driver, basePath: '/portqueue' });
const handler = dash.node();
createServer((req, res) => {
if (handler(req, res)) return; // the dashboard served this request
// …fall through to your own routing
}).listen(3000);
// On the way out, release the dashboard's own connection.
process.on('SIGTERM', () => void dash.close());
driver is whichever driver instance points at your PortQueue database. If the dashboard shares a
process with your workers, reuse the same instance you passed to new PortQueue({ driver }); if it
runs on its own, construct one from the same connection details.
driver is the only required option. basePath defaults to /portqueue, and logger (default
console) is where the dashboard reports the one problem it can only notice at startup: a missing
client bundle, which otherwise serves a placeholder with a 200 and looks like a working deploy.
basePath is the mount pointIt tells the dashboard where its own assets and API sit, so it must match where you mount the
adapter. Only NestJS repeats it — keep forRoutes(...) in sync.
Anyone who can reach the mount can pause queues, remove jobs and delete schedules. Protecting it is your application's job — see Protecting the dashboard below.
Protecting the dashboard
The dashboard has no authentication of its own, and no read-only mode: anyone who can reach the mount can pause queues, retry and remove jobs, and delete schedules. Guarding a mount is something your application already does, so bring your own middleware and run it in front of the adapter — the same session, SSO or API-key check that protects the rest of your admin surface. Anything that runs first and can end the response works; the dashboard only ever sees what got past it. Shipping a second, weaker mechanism alongside yours would only invite people to rely on it.
One thing the mount does bring itself: a mutation is refused unless it carries the
x-requested-with: portqueue-dashboard header the SPA sends. A browser cannot set a custom header
cross-origin without a preflight this mount never answers, so another page an operator visits cannot
pause a queue with their session — which matters precisely because your middleware is what a browser
replays cookies and Basic credentials to. It is not authentication, and there is nothing to configure;
if you script against the JSON API yourself, send that header on anything that writes. Reads need
nothing.
No auth layer to reuse? HTTP Basic in front of the mount is enough for an internal tool —
basic-auth parses the header and its README has the
middleware; compare the credentials with timingSafeEqual from node:crypto, terminate TLS in front
of it, and rate-limit the mount if it is reachable from the internet. Or skip your app entirely and
let the CLI host the dashboard — it brings its own Basic auth, below.
Serve it from the CLI (no app changes)
If you'd rather not touch your app, the CLI hosts the dashboard as a standalone process. It
opens its own short-lived connection from --driver + --connection-string (or DATABASE_URL)
and serves the same UI.
The CLI is the one case with no host application to put a guard in front of, since it opens the
port itself — so it brings its own HTTP Basic auth, and credentials are required: pass
--root-user / --root-password or set PORTQUEUE_ROOT_USER / PORTQUEUE_ROOT_PASSWORD. The
bind defaults to --host 127.0.0.1 (loopback only), with --port 4321 and
--base-path /portqueue:
portqueue dashboard --driver postgresql \
--connection-string "$DATABASE_URL" --port 9000 \
--root-user admin --root-password "$PORTQUEUE_ROOT_PASSWORD"
So you don't retype the connection on every command, put it in a one-line shell script and call that instead — see Reuse the connection with a script on the CLI page.
Docker
You don't need to containerise your app to run the dashboard — the CLI image can host it
as its own service. Pass the connection via DATABASE_URL and expose the dashboard --port.
FROM node:22-alpine
WORKDIR /app
# Install the CLI plus the client for your driver — it's an optional peer dependency,
# so pick the one you use: pg (PostgreSQL), mysql2, oracledb, …
RUN npm install -g @portqueue/cli pg
EXPOSE 9000
# Serve the dashboard. DATABASE_URL and the dashboard credentials
# (PORTQUEUE_ROOT_USER / PORTQUEUE_ROOT_PASSWORD) are supplied at run time (see below).
ENTRYPOINT ["portqueue", "dashboard", "--driver", "postgresql", "--port", "9000"]
# Build the image once.
docker build -t portqueue-dashboard .
# Run it: pass the connection string and dashboard credentials, publish the port.
docker run --rm -p 9000:9000 \
-e DATABASE_URL="postgres://user:pass@db:5432/app" \
-e PORTQUEUE_ROOT_USER="admin" \
-e PORTQUEUE_ROOT_PASSWORD="<your-password>" \
portqueue-dashboard