---
url: /worker-manager/guide/introduction.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Introduction
Worker Manager is a dashboard for [BullMQ](https://docs.bullmq.io/) and [Bull](https://github.com/OptimalBits/bull). It shows you what is in your queues and lets you act on it. You still use Bull or BullMQ to enqueue and process jobs, Worker Manager only visualises them.
Want to see it before installing? Open the live demo.
::: tip Using a coding agent? Install the agent skill
Claude Code: `/plugin marketplace add naldomadeira/worker-manager`, then `/plugin install worker-manager@worker-manager`.
Any agent with a skills folder:
```sh
curl -fsSL https://naldomadeira.github.io/worker-manager/worker-manager-skill.zip -o /tmp/wm-skill.zip && unzip -o /tmp/wm-skill.zip -d ~/.claude/skills/
```
Then ask for the dashboard in plain words. See [AI agent skill & setup](/worker-manager/guide/ai-agent-setup.md#install-the-agent-skill).
:::
## Two ways to run it
Mount it into your existing HTTP server with one of the adapters. That is what you want for a dashboard the team keeps around: it sits behind whatever auth the app already has, and you configure it in code.
Or run it standalone against a Redis URL, with no app involved at all:
```sh
npx @worker-manager/cli -r redis://localhost:6379
```
Quicker when you only want to look at a queue, and the only option when the workers live in a repo you are not editing, or in a language that is not Node. See the [CLI guide](/worker-manager/guide/cli.md) and [Run with Docker](/worker-manager/guide/docker.md).
## What you get
- A React dashboard for your queues: counts, jobs, logs, live updates.
- Adapters for Express, Fastify, Koa, Hapi, NestJS, Hono, H3, Elysia, Bun.
- Every repeatable job in a [schedulers view](/worker-manager/guide/exploring-the-dashboard.md), and opt-in [throughput and latency history](/worker-manager/recipes/historical-metrics.md) that outlives BullMQ's own ring buffer.
- Per-queue read-only mode, formatters, external job URLs, a visibility guard for multi-tenant setups, and [hooks](/worker-manager/recipes/access-control-hooks.md) for finer-grained access control.
- Self-hosted, no telemetry. Runs on your machines, talks to your own datastore.
- BullMQ v5 and v6, including [v6 queues stored in PostgreSQL](/worker-manager/recipes/postgres-backend.md).
## Next steps
- [Install Worker Manager](/worker-manager/guide/getting-started.md) and wire it into your framework.
- [Build your first dashboard](/worker-manager/guide/your-first-dashboard.md) for an end-to-end walkthrough.
- [Explore the dashboard](/worker-manager/guide/exploring-the-dashboard.md) for a tour of what the UI actually does.
---
url: /worker-manager/api/index.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
[Worker Manager](/worker-manager/)
[Guide](/worker-manager/guide/introduction)[Queue Adapters](/worker-manager/queue-adapters/)[Server Adapters](/worker-manager/server-adapters/)[Recipes](/worker-manager/recipes/)[Reference](/worker-manager/configuration/ui-config)API Reference
[Plain text](/worker-manager/reference/http-api)[Demo](/worker-manager/demo/)[](https://github.com/naldomadeira/worker-manager)
---
url: /worker-manager/configuration/ui-config.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# UIConfig
> Applies to: all adapters.
`UIConfig` controls the visual shell of the dashboard: title, logo, favicon, locale, polling, misc links. Pass it via `setUIConfig()` on the server adapter, or forward it through `createWorkerManagerBoard({ options: { uiConfig } })`.
## Usage
```ts
import { createWorkerManagerBoard } from '@worker-manager/api';
import { BullMQAdapter } from '@worker-manager/api/bullMQAdapter';
import { ExpressAdapter } from '@worker-manager/express';
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');
createWorkerManagerBoard({
queues: [new BullMQAdapter(emailQueue)],
serverAdapter,
options: {
uiConfig: {
boardTitle: 'My Queues',
boardLogo: {
path: 'https://cdn.example.com/logo.png',
width: '120px',
height: 32,
},
miscLinks: [{ text: 'Logout', url: '/logout', icon: '/static/logout.svg' }],
hideRedisDetails: true,
showMetrics: true,
hideDocsLink: false,
},
},
});
```
`serverAdapter.setUIConfig({ ... })` directly works the same way, `createWorkerManagerBoard` just forwards `options.uiConfig` to it.
## Fields
All fields are optional. Defaults are applied by `createWorkerManagerBoard` where noted.
| Field | Type | Default | Description |
| ------------------------------- | ---------------------------------------------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `boardTitle` | `string` | `'Bull Dashboard'` | Text in the header and `
` tag. |
| `boardLogo.path` | `string` | — | URL or static path to the logo image (required when `boardLogo` is set). |
| `boardLogo.width` | `number \| string` | — | Logo width (px number or CSS length). |
| `boardLogo.height` | `number \| string` | — | Logo height (px number or CSS length). |
| `miscLinks` | `Array<{ text: string; url: string; icon?: string }>` | `[]` | Extra links in the header menu (logout, etc.). `icon` is an optional URL or static path to an image shown before the link text; it is rendered as-is, so pick one that reads on both the light and dark dropdown background. |
| `hideDocsLink` | `boolean` | `false` | Hide the header Docs icon that links to the Worker Manager documentation site. |
| `queueSortOptions` | `Array<{ key: string; label: string }>` | — | Custom sort keys for the queue list. |
| `favIcon.default` | `string` | `'static/images/logo.svg'` | Favicon when the tab is inactive. |
| `favIcon.alternative` | `string` | `'static/favicon-32x32.png'` | Favicon when jobs are active. |
| `locale.lng` | `string` | — | Initial i18next language code (`'en'`, `'fr'`, `'zh_TW'`). |
| `dateFormats.short` | `Intl.DateTimeFormatOptions` | — | Options for timestamps that fall on today. |
| `dateFormats.common` | `Intl.DateTimeFormatOptions` | — | Options for timestamps in the current year. |
| `dateFormats.full` | `Intl.DateTimeFormatOptions` | — | Options for older timestamps. |
| `pollingInterval.showSetting` | `boolean` | — | Whether the polling interval selector shows in Settings. |
| `pollingInterval.forceInterval` | `number` | — | Forces a polling interval in seconds, overriding the user's choice. |
| `menu.width` | `string` | — | CSS width of the left sidebar (`'280px'`). |
| `overview.groupByDelimiter` | `boolean` | `false` | Sets the initial overview view. When `true`, it starts grouped: collapsible category sections derived from each queue's `delimiter`, mirroring the sidebar tree. It's only a default. Once a user picks flat or grouped in Settings, their choice is remembered and wins. See [Exploring the dashboard](/worker-manager/guide/exploring-the-dashboard.md). |
| `jobDetails.defaultTab` | `'Data' \| 'Progress' \| 'Options' \| 'Logs' \| 'Error' \| 'Timeline'` | status-dependent | Tab a job's details open on. By default a failed job opens on `Error` and everything else on `Data`. It's only a default. Once a user picks a tab in Settings, their choice is remembered and wins, and a tab that doesn't apply to a given job (such as `Timeline`, which only exists on mobile) falls back to the default behaviour. |
| `sortQueues` | `boolean` | `false` | When `true`, sidebar and overview sort queues alphabetically, groups before standalone queues. Users can toggle this in Settings. |
| `hideRedisDetails` | `boolean` | `false` | Hides the Redis Details button in the header. |
| `showMetrics` | `boolean` | `false` | Shows a per-queue throughput chart (completed/failed per minute). Relies on [BullMQ/Bull metrics collection](https://docs.bullmq.io/guide/metrics), so enable `metrics` on your workers (e.g. `metrics: { maxDataPoints: MetricsTime.ONE_WEEK }`). |
| `showWorkers` | `boolean` | `true` | Reports the workers connected to each queue, and warns when a queue that isn't paused has none. Set to `false` to drop the per-queue `CLIENT LIST` the board otherwise runs on every poll. See [Exploring the dashboard](/worker-manager/guide/exploring-the-dashboard.md). |
| `environment.label` | `string` | — | Environment badge text in the header (`'production'`). |
| `environment.color` | `string` | — | Background colour of the environment badge. |
| `environment.textColor` | `string` | — | Text colour of the environment badge. |
| `environment.fontSize` | `string \| number` | — | Font size of the environment badge. |
| `theme.light` | `Partial>` | — | Design token overrides applied to the light theme. See [Theming](#theming). |
| `theme.dark` | `Partial>` | — | Design token overrides applied to the dark theme. |
## Date formats
The three `dateFormats` entries are [`Intl.DateTimeFormatOptions`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat) objects, not format strings. The dashboard picks one by how far away the timestamp is, and passes it to `Intl.DateTimeFormat` along with the active language, so the output follows the viewer's locale.
```ts
uiConfig: {
dateFormats: {
short: { hour: '2-digit', minute: '2-digit' },
common: { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' },
full: { dateStyle: 'medium', timeStyle: 'short' },
},
}
```
Leave one out and that case keeps its shipped default.
With `showMetrics` on, each queue view gains a throughput chart of completed and failed jobs per minute over the last hour.


The demo site uses this exact configuration, `{ label: 'demo', color: '#f59f00', textColor: '#000' }`. See it live.
## Theming
For a worked walkthrough with screenshots, see [Whitelabel the dashboard](/worker-manager/recipes/whitelabel-theming.md).
What follows is the reference.
The dashboard is styled entirely from CSS custom properties, and `theme` lets you override
them per colour scheme without forking the UI. Token names follow the
[shadcn theme contract](https://ui.shadcn.com/themes), so palettes generated by
shadcn-compatible theme editors drop straight in.
```ts
uiConfig: {
theme: {
light: {
primary: '#6d28d9',
radius: '0.75rem',
'font-sans': "'Inter', system-ui, sans-serif",
},
dark: {
primary: '#a78bfa',
},
},
}
```
That is a whole rebrand. `primary` is the one token the rest of the interactive palette hangs
off: the focus ring, the sidebar's active entry and its ring all resolve to it, and hover and
selection are mixed from it at 8%, 16% and 24%. Setting it moves every one of them together.
The same holds elsewhere: `foreground` carries the text colour on cards and popovers, and the
`chart-1` to `chart-5` ramp resolves to the job status colours the board plots, so recolouring
`status-completed` recolours the completed series with it.
Each derived name is still individually overridable, and an override wins over the
derivation. Set `ring` when you want a focus ring that is not your brand colour; leave it out
when you don't.
Every key is optional, and anything you leave out keeps the shipped value. The available
tokens are the core surface and interaction set (`background`, `foreground`, `card`,
`card-foreground`, `popover`, `popover-foreground`, `primary`, `primary-foreground`,
`secondary`, `secondary-foreground`, `muted`, `muted-foreground`, `accent`,
`accent-foreground`, `destructive`, `destructive-foreground`, `border`, `input`, `ring`,
`radius`), the typography tokens (`font-sans`, `font-mono`), the sidebar set
(`sidebar`, `sidebar-foreground`, `sidebar-primary`, `sidebar-primary-foreground`,
`sidebar-accent`, `sidebar-accent-foreground`, `sidebar-border`, `sidebar-ring`),
the chart ramp (`chart-1` through `chart-5`), and the job status colours
(`status-failed`, `status-completed`, `status-waiting`, `status-waiting-children`,
`status-prioritized`, `status-active`, `status-delayed`, `status-paused`, and the two only a
[pg-boss board](/worker-manager/queue-adapters/pg-boss.md) draws: `status-retry` for a failed job waiting for its next
attempt, and `status-cancelled`).
The interaction states are tokens too: `state-hover`, `state-selected`, `state-selected-hover`
and `state-selected-foreground`, plus their `sidebar-state-*` counterparts. They are mixed from
`primary` and `sidebar-primary`, so set them only if you want a hovered or selected control to
sit somewhere other than 8%, 16% and 24% of your brand colour.
Elevation is four more: `shadow-popover` is the one recipe every floating surface uses, from
menus and toasts to both kinds of tooltip; `shadow-control` is the hairline the checkbox and the
switch thumb carry in place; `shadow-ring` is the halo a focused input or checkbox draws, derived
from `ring` so retinting the board moves it too; and `overlay` is what a modal puts between itself
and the board. They take whatever `box-shadow` and `background-color` accept, so
`shadow-popover: 'none'` gives you a flat board with borders only.
Values are plain CSS values. Unknown token names and values containing `;`, `{`, `}`, `<`
or `>` are dropped, so a theme can never inject arbitrary CSS or markup into the page.
## Source of truth
The authoritative type is in [`packages/api/typings/app.d.ts`](https://github.com/naldomadeira/worker-manager/blob/main/packages/api/typings/app.d.ts) (`UIConfig`). Defaults live in [`packages/api/src/index.ts`](https://github.com/naldomadeira/worker-manager/blob/main/packages/api/src/index.ts).
---
url: /worker-manager/configuration/production-checklist.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Production checklist
Quick gate before exposing the dashboard to real traffic.
## Auth
The dashboard has no built-in auth. Add one. See [Add basic auth](/worker-manager/recipes/basic-auth.md). Serving a token-based SPA? See [auto-login from a token-based frontend](/worker-manager/recipes/basic-auth.md#auto-login-from-a-token-based-frontend).
## CSRF
Destructive actions (retry, clean, pause, drain, obliterate) are state-changing requests. Behind a browser session, they're CSRF-reachable. See [CSRF protection](/worker-manager/recipes/csrf-protection.md).
## Read-only where possible
If the audience is "everyone with a login", most of them don't need `obliterate`. Mark queues [read-only](/worker-manager/recipes/read-only-mode.md) and enable retries only on the queues that need them.
## Multi-tenancy
Running a shared dashboard across tenants? Add a [visibility guard](/worker-manager/recipes/visibility-guard.md). Don't rely on "nobody will guess the queue name", since the name is in the URL.
## Polling interval
Default is 5 seconds. If the dashboard has many open tabs, every tab polls every queue. Cap it with `pollingInterval.forceInterval` or leave the picker on so users can dial it down. See [Polling interval](/worker-manager/recipes/change-polling-interval.md).
## Redis access
The dashboard connects directly to Redis via your queue instances. If Redis is reachable from the dashboard process, the dashboard can do anything Redis permits. Treat dashboard access as Redis access.
## Environment badge
Production and staging sharing a browser? Set `environment.label = 'production'` with a red color on the production instance. Small thing, saves a real outage.
```ts
serverAdapter.setUIConfig({
environment: { label: 'production', color: '#cc0000' },
});
```
## Version pinning
The dashboard talks to your Bull / BullMQ instances. Mismatched versions across workers and the dashboard have bitten people (see issues #1074, #1088, #1097). Pin the same BullMQ across both.
BullMQ v5 and v6 are both supported, and the adapter detects which one it has. Upgrading workers and the dashboard separately is still the thing to avoid, since the two majors store a paused queue's jobs differently. See [supported versions](/worker-manager/queue-adapters/bullmq.md#supported-versions).
## Response checking
`options.validateResponses` checks every response body against the schema its route declares and
answers 500 when one does not match. Leave it off in production, which is the default: the check
walks the whole body on every poll, and the same mismatch already fails the build. Turn it on
while developing a custom server adapter, a `historyProvider`, or a `handlerHooks.after` hook that
reshapes bodies, where a mismatch would otherwise reach the dashboard as a rendering bug.
```ts
createWorkerManagerBoard({
queues,
serverAdapter,
options: { validateResponses: process.env.NODE_ENV !== 'production' },
});
```
## Logs
Workers that call `job.log()` will have their lines visible in the dashboard under each job's Logs tab. If you're wondering "what did this job do before it failed", that's the answer. See [Job logs and flows](/worker-manager/recipes/job-logs-and-flows.md).
## Alerting
The dashboard won't page you. It only shows state while a tab is open. Wire failure alerts to BullMQ's own events, not to Worker Manager. See [Alerting on failed jobs](/worker-manager/recipes/alerting.md).
---
url: /worker-manager/guide/ai-agent-setup.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Set up with an AI agent
If you only want to look at a queue rather than integrate the dashboard into your app, skip all of this: the [standalone CLI](/worker-manager/guide/cli.md) does that in one command.
If you work with a coding agent (Claude Code, Cursor, Copilot, Codex, Windsurf, whatever), you don't have to hand-wire Worker Manager. Give it the **Worker Manager skill**, or paste the prompt below, and let it do the mechanical part: pick the engine and the server adapter, install the packages, mount the board, match the base path and put auth in front. Then read the diff.
## Install the agent skill
The skill is a folder, `worker-manager/`, with a `SKILL.md` and a few reference files: which engine to use (BullMQ on Redis, BullMQ v6 on PostgreSQL, pg-boss), which server adapter, which auth strategy (Basic, Keycloak, token, custom), the rules people get wrong by hand, and copy-paste setups for NestJS, Express and Fastify. It follows the [Agent Skills](https://agentskills.io) format and lives in the repository under [`skills/worker-manager`](https://github.com/naldomadeira/worker-manager/tree/main/skills/worker-manager). Pick one of three ways to install it.
### Claude Code plugin marketplace
The repository is a Claude Code plugin marketplace. In a Claude Code session:
```text
/plugin marketplace add naldomadeira/worker-manager
/plugin install worker-manager@worker-manager
```
Or from a shell: `claude plugin marketplace add naldomadeira/worker-manager && claude plugin install worker-manager@worker-manager`. `/plugin marketplace update worker-manager` pulls a newer version later.
### Download the zip
[`worker-manager-skill.zip`](https://naldomadeira.github.io/worker-manager/worker-manager-skill.zip) is rebuilt with every docs deploy. It holds the `worker-manager/` folder at its root: unzip it into your agent's skills directory, `~/.claude/skills/` for Claude Code, or upload it where your tool accepts skill archives.
### One-line install
For every project on this machine (personal skills):
```sh
curl -fsSL https://naldomadeira.github.io/worker-manager/worker-manager-skill.zip -o /tmp/wm-skill.zip && unzip -o /tmp/wm-skill.zip -d ~/.claude/skills/
```
For one project only, committed with it so the whole team gets it (run from the project root):
```sh
curl -fsSL https://naldomadeira.github.io/worker-manager/worker-manager-skill.zip -o /tmp/wm-skill.zip && unzip -o /tmp/wm-skill.zip -d .claude/skills/
```
Other agents that read skill folders (the Agent Skills format) can use the same folder: unzip it into that agent's skills directory instead of `~/.claude/skills/`. Agents without skill support can be pointed at `SKILL.md` directly, or given the prompt below.
Once installed, ask for what you want in plain words, for example "add a queue dashboard to this NestJS app behind Keycloak" or "mount a pg-boss board next to our BullMQ one". The agent loads the skill on its own.
## Copy this prompt
No skill support, or you'd rather be explicit? Paste this:
```text
Add Worker Manager to my app so I can inspect my job queues in a browser.
Use the current docs at https://naldomadeira.github.io/worker-manager/llms-full.txt as the
source of truth. Don't rely on memory: the packages are @worker-manager/* (a fork of bull-board)
and the API has changed across versions.
Requirements:
- Detect my HTTP framework. On NestJS, use WorkerManagerModule from @worker-manager/nestjs
(forRoot/forRootAsync, forFeature per queue). Otherwise install @worker-manager/api plus the
matching @worker-manager/ adapter (Express, Fastify, Koa, Hapi, Hono, H3, Elysia, Bun).
- Detect the queue library and the datastore:
- BullMQ or Bull on Redis: wrap each existing queue in BullMQAdapter or BullAdapter.
- BullMQ v6 on PostgreSQL (createPostgresBackend): the same BullMQAdapter; on BullMQ >= 6.3 the
connection needs { connectionString, migrate: true } or a migration step.
- pg-boss: use @worker-manager/pg-boss (Node >= 22.12, pg-boss >= 12.24), with
createPgBossBoard or engine: 'pg-boss' in NestJS. Reuse my started PgBoss instance; the board
must never start or migrate pg-boss. BullMQ and pg-boss need two boards on sibling paths.
- Reuse my existing queue instances and connections, don't create new ones.
- The base path (setBasePath or the NestJS route) and the mount path must match exactly, or the
assets 404.
- Do not expose it unauthenticated. Use the built-in auth (@worker-manager/auth, or the NestJS
`auth` option): basic, keycloak (OIDC with PKCE), token (Bearer/header plus a login form), or
custom authenticate(req) around my existing auth. Read secrets from env vars; if you can't tell
which strategy I want, use basic with env vars and tell me which ones to set.
- Show me the diff and the URL to open. Don't add options that aren't in the docs.
```
Swap the details for your case (a path, a strategy, read-only). The agent should handle the rest, including the one thing people get wrong by hand: keeping the base path and the mount path identical.
## Point your agent at the current docs
This documentation site publishes machine-readable versions of itself, generated on every build:
- [`llms.txt`](https://naldomadeira.github.io/worker-manager/llms.txt): a concise index of every page.
- [`llms-full.txt`](https://naldomadeira.github.io/worker-manager/llms-full.txt): the full text of the docs in one file.
Feed either to an agent (or an "ask the docs" tool) so it works from the current API surface instead of whatever it remembers from training. The `llms-full.txt` version is the one to use when you want it to get option names and defaults exactly right. The skill points its agent at the same files for anything it doesn't cover.
## Let an agent query your queues
Mounting the dashboard is one job; reading a running board is another. Everything the dashboard's UI does, it does over a plain JSON API. Browse it in the [interactive API reference](/worker-manager/api/index.md), read it as [plain text](/worker-manager/reference/http-api.md), which is also what lands in `llms-full.txt`, or feed a tool the machine-readable [`openapi.json`](https://naldomadeira.github.io/worker-manager/openapi.json). Point an agent at any of them and it can list queues, read a failed job's stacktrace and logs, and retry jobs.
Two things to settle before you do. Give the agent its own credential: the [token strategy](/worker-manager/recipes/token-auth.md) fits best, since scripts send `Authorization: Bearer ` (or a header you choose) while people keep a browser login, and a [custom](/worker-manager/recipes/custom-auth.md) `authenticate(req)` can recognise whatever your app already issues. And an agent that can reach the API can reach `obliterate` as easily as a `GET`, so gate it: [read-only mode](/worker-manager/recipes/read-only-mode.md) for a board it should only observe, or an [access control hook](/worker-manager/recipes/access-control-hooks.md) that recognises the agent's identity and allows `GET` alone.
## After the agent is done
The agent gets you mounted. Check these yourself:
- Auth is on before anything is reachable from outside localhost: [Basic](/worker-manager/recipes/basic-auth.md), [Keycloak](/worker-manager/recipes/keycloak-auth.md), [token](/worker-manager/recipes/token-auth.md) or [custom](/worker-manager/recipes/custom-auth.md), with secrets in env vars rather than the diff.
- [Read-only mode](/worker-manager/recipes/read-only-mode.md) if the people opening it shouldn't be retrying or obliterating queues.
- [Alerting](/worker-manager/recipes/alerting.md): the dashboard shows failures, it doesn't tell you about them. Wire that separately.
- The [production checklist](/worker-manager/configuration/production-checklist.md).
If the page loads but looks broken, it's almost always a base-path mismatch. See [Troubleshooting](/worker-manager/recipes/troubleshooting.md).
---
url: /worker-manager/guide/cli.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Standalone CLI
Sometimes you don't want to wire Worker Manager into an app at all, you just want to look at a Redis instance (or a PostgreSQL database holding BullMQ v6 queues). `@worker-manager/cli` does that: point it at a Redis URL and it finds the Bull and BullMQ queues stored there, then serves the same dashboard UI you'd get from any server adapter. See [PostgreSQL queues](#postgresql-queues) for the database side.
```sh
npx @worker-manager/cli -r redis://localhost:6379
```
It needs Node.js 20 or newer. That starts the dashboard on `http://127.0.0.1:3000` and opens it in a browser. This is a tool for local development, evaluating Worker Manager before wiring it into your app, or looking at a queue on infrastructure you've tunnelled to. It is not a replacement for mounting the adapter in your own server: there's no framework-level auth to inherit (though it has [Basic](#basic-auth) and [Keycloak](#keycloak-auth) auth built in), and every option has to be passed on the command line, an env var, or a config file instead of code.
## Discovery
On startup the CLI scans Redis for keys matching `::meta` (BullMQ) and `::id` (Bull) under each configured prefix, and builds a real `Queue` instance for every queue it finds. The default prefix is `bull`, which is what Bull and BullMQ both use unless you've changed it yourself. If your queues use a different prefix, pass `--prefix`.
By default it rescans every 10 seconds, so a queue created after the dashboard started still shows up without a restart. Pass `--scan-interval 0` to scan once at startup and stop.
If you'd rather skip discovery entirely, `--queues` takes an explicit, comma separated list of queue names to serve. The CLI still has to work out whether each one is Bull or BullMQ, but it no longer scans Redis for anything else under the prefix.
## When Redis isn't reachable
The dashboard still opens even if Redis is down or the URL is wrong. Instead of a dead terminal, `npx @worker-manager/cli` serves a diagnostic page at the same URL, explaining what it tried to connect to, the underlying error, and the likely cause: Redis isn't running, the port is wrong (6379 is the default), it's in a container whose port isn't published, it needs credentials, or it needs TLS and therefore a `rediss://` URL. A `--user`/`--password` you've set still guards this page: the URL it names is never served to a request without the right credentials.
The process stays alive and keeps retrying every 3 seconds. The page polls its own status and reloads on its own the moment Redis answers, switching to the real dashboard with no restart and no second command.
That healing only applies before the first successful connection. Once the dashboard is live, it stays live for the rest of the process, even if Redis goes away later: the diagnostic page does not come back, and the dashboard's own API requests simply stop returning until Redis is reachable again. Ctrl-C still works during that window; the CLI's shutdown is bounded so it never hangs waiting on a dead connection.
A second, rarer page shows up if the CLI reaches Redis but something after that fails for a reason that has nothing to do with connectivity, such as an ACL-restricted user that can authenticate but not run `SCAN`. That page names the real error too, but does not promise a retry, since reconnecting again would not fix it; restart the CLI once the underlying problem is addressed.
For scripts and CI, retrying forever is the wrong default: they want a non-zero exit code, not a process that waits indefinitely. Pass `--no-retry` and the CLI prints the error and exits 1 as soon as the first connection attempt fails, without ever opening a port.
## Options
```
Usage:
worker-manager [options]
npx @worker-manager/cli [options]
Options:
-r, --redis Redis connection URL [redis://localhost:6379]
--sentinel Comma separated sentinel host:port list, port [26379]
--sentinel-name Redis master group name, required with --sentinel
--sentinel-password
Password for the sentinel nodes themselves
--cluster Comma separated cluster host:port list, port [6379]
--redis-username
Username for the Redis nodes behind the sentinels
or in the cluster
--redis-password
Password for the Redis nodes behind the sentinels
--redis-db Database to select behind the sentinels
-p, --port Port to listen on [3000]
--host Interface to bind [127.0.0.1]
--prefix Comma separated key prefixes [bull]
--queues Use these queue names instead of discovering
--scan-interval Seconds between rescans, 0 to scan once [10]
--base-path Serve the dashboard under a path prefix
--read-only Disable every destructive action
--user Basic auth user (requires --password)
--password Basic auth password (requires --user)
--keycloak-url
Log in through Keycloak (OIDC) instead, e.g.
https://sso.example.com
--keycloak-realm
Keycloak realm, required with --keycloak-url
--keycloak-client-id
Client id, required with --keycloak-url
--keycloak-client-secret
Client secret, for confidential clients
--keycloak-roles
Comma separated realm/client roles, any grants access
--keycloak-bearer-only
Accept only Authorization: Bearer tokens, no login page
--token Comma separated access tokens instead: sent as
Authorization: Bearer, or typed once into a login form
--token-header
Also accept the token in this header, e.g. X-Board-Token
--public-url External URL of the board, base path included, used
for the OIDC redirect URI and the login form's origin
check [derived from the request]
--session-secret
Key the session cookie is encrypted with
[--keycloak-client-secret, or random per process]
--postgres Also serve BullMQ v6 queues stored in PostgreSQL
(postgres://user:pass@host:5432/db)
--postgres-schema
Schema the BullMQ tables live in [bullmq]
--pg-boss Serve a pg-boss board (Node >= 22.12)
(postgres://user:pass@host:5432/db)
--pg-boss-schema
Schema pg-boss was installed in [pgboss]
--pg-boss-queues
Comma separated pg-boss queues to show [all]
--pg-boss-path
Where the pg-boss board is served next to a
BullMQ board [/pg-boss]
--board-title Dashboard title
--history Record and serve long-retention metrics history
--history-retention-days
Days of history to keep [90]
--config Path to a config file
--browser Command to open the browser with [$BROWSER]
--no-open Do not open a browser
--no-retry Exit if Redis is unreachable instead of retrying
-h, --help Show this help
-v, --version Show the version
```
## Environment variables
Every flag has an environment variable equivalent, so you can configure the CLI in a container or a systemd unit without a command line to edit:
| Flag | Environment variable |
| -------------------------- | ----------------------------------------------------------------------------------- |
| `--redis` | `WORKER_MANAGER_REDIS_URL` |
| `--sentinel` | `WORKER_MANAGER_SENTINELS` |
| `--sentinel-name` | `WORKER_MANAGER_SENTINEL_NAME` |
| `--sentinel-password` | `WORKER_MANAGER_SENTINEL_PASSWORD` |
| `--cluster` | `WORKER_MANAGER_CLUSTER_NODES` |
| `--redis-username` | `WORKER_MANAGER_REDIS_USERNAME` |
| `--redis-password` | `WORKER_MANAGER_REDIS_PASSWORD` |
| `--redis-db` | `WORKER_MANAGER_REDIS_DB` |
| `--port` | `WORKER_MANAGER_PORT` |
| `--host` | `WORKER_MANAGER_HOST` |
| `--prefix` | `WORKER_MANAGER_PREFIX` |
| `--queues` | `WORKER_MANAGER_QUEUES` |
| `--scan-interval` | `WORKER_MANAGER_SCAN_INTERVAL` |
| `--base-path` | `WORKER_MANAGER_BASE_PATH` |
| `--read-only` | `WORKER_MANAGER_READ_ONLY` |
| `--user` | `WORKER_MANAGER_USER` |
| `--password` | `WORKER_MANAGER_PASSWORD` |
| `--keycloak-url` | `WORKER_MANAGER_KEYCLOAK_URL` |
| `--keycloak-realm` | `WORKER_MANAGER_KEYCLOAK_REALM` |
| `--keycloak-client-id` | `WORKER_MANAGER_KEYCLOAK_CLIENT_ID` |
| `--keycloak-client-secret` | `WORKER_MANAGER_KEYCLOAK_CLIENT_SECRET` |
| `--keycloak-roles` | `WORKER_MANAGER_KEYCLOAK_ROLES` |
| `--keycloak-bearer-only` | `WORKER_MANAGER_KEYCLOAK_BEARER_ONLY` |
| `--token` | `WORKER_MANAGER_TOKENS` |
| `--token-header` | `WORKER_MANAGER_TOKEN_HEADER` |
| `--public-url` | `WORKER_MANAGER_PUBLIC_URL` |
| `--session-secret` | `WORKER_MANAGER_SESSION_SECRET` |
| `--postgres` | `WORKER_MANAGER_POSTGRES_URL` |
| `--postgres-schema` | `WORKER_MANAGER_POSTGRES_SCHEMA` |
| `--pg-boss` | `WORKER_MANAGER_PGBOSS_URL` |
| `--pg-boss-schema` | `WORKER_MANAGER_PGBOSS_SCHEMA` |
| `--pg-boss-queues` | `WORKER_MANAGER_PGBOSS_QUEUES` |
| `--pg-boss-path` | `WORKER_MANAGER_PGBOSS_PATH` |
| `--board-title` | `WORKER_MANAGER_BOARD_TITLE` |
| `--history` | `WORKER_MANAGER_HISTORY` |
| `--history-retention-days` | `WORKER_MANAGER_HISTORY_RETENTION_DAYS` |
| `--no-open` | `WORKER_MANAGER_OPEN` (set to `false` to skip the browser; `--no-open` always wins) |
| `--no-retry` | `WORKER_MANAGER_NO_RETRY` |
| `--browser` | `WORKER_MANAGER_BROWSER`, then `BROWSER` |
| `--config` | `WORKER_MANAGER_CONFIG` |
Settings resolve in this order: a command line flag wins, then the matching environment variable, then the config file, then the built-in default. That applies field by field, so you can set a Redis URL in the environment and still override just the port with a flag on one particular run.
`--browser` picks the command used to open the dashboard. Three things can name it, and they win in this order: `--browser` on the command line, then `WORKER_MANAGER_BROWSER`, then a plain exported `$BROWSER`. `WORKER_MANAGER_BROWSER` exists so you can set one for the CLI without touching `$BROWSER` globally. With none of them set, the CLI falls back to the platform opener: `open` on macOS, `start` on Windows, `xdg-open` elsewhere.
A `browser` key in the config file sits below all three, because the config file is the last step in the resolution order above. That is worth knowing: an exported `$BROWSER` left over from another tool silently overrides a `browser` you set in the config file.
A command with arguments works too, for example `--browser 'open -a Safari'`. The value is split on whitespace and the URL is appended as the last argument, and it never goes through a shell, on any platform, Windows included.
Because the split is on whitespace, a single path that contains spaces does not survive it. The common macOS form `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`, and CRA's `BROWSER="google chrome"`, both break: every word after the first is treated as an argument to a command that does not exist at that path. It does not quietly fall back to the platform opener either. It fails to spawn, exactly as naming any other uninstalled command would, and the CLI prints "Could not open a browser automatically" and leaves you to open the URL yourself.
`--no-open` skips opening a browser at all, whatever `--browser` or `$BROWSER` say.
## Config file
For anything more than a couple of flags, use a config file. Without `--config`, the CLI looks for `worker-manager.config.mjs`, `.js`, `.cjs`, or `.json` in the current directory, in that order. `.cjs` and `.json` are always read as CommonJS/JSON; a plain `.js` file is read as CommonJS first and retried as ESM if that fails, so either `module.exports` or `export default` works there.
```js
// worker-manager.config.js
module.exports = {
redis: 'redis://localhost:6379',
prefix: ['bull', 'tenant-a'],
scanInterval: 15,
uiConfig: {
boardTitle: 'Ops Dashboard',
hideDocsLink: true,
},
queues: {
'payment-webhooks': { readOnlyMode: true },
},
};
```
`redis` takes a connection URL as above, or a full [ioredis options object](https://github.com/redis/ioredis#connect-to-redis) when a URL can't express the connection, which is what [Redis Sentinel](#redis-sentinel) needs.
`uiConfig` is the same object you'd pass to `createWorkerManagerBoard({ options: { uiConfig } })` in code, see the [UIConfig reference](/worker-manager/configuration/ui-config.md) for the full set of fields. Board-wide title, logo, locale, and so on all live there, not at the top level of the config file.
`queues` is dual purpose: an array (`queues: ['emails', 'webhooks']`) is equivalent to `--queues`, a comma-free explicit list that skips discovery. An object, as above, instead sets per-queue [`QueueAdapterOptions`](/worker-manager/queue-adapters/bullmq.md) overrides keyed by queue name, the same options you'd pass to `new BullMQAdapter(queue, options)` directly. A queue's own `readOnlyMode: true` always wins even when the board as a whole isn't read-only, but it can't turn read-only mode back off for a single queue once `--read-only` is set globally. A field can't do both jobs in the same file: pick the array form to restrict which queues are served, or the object form to configure the ones discovery finds.
## Redis Sentinel
A Sentinel deployment has no fixed master address, so there is no single URL to point `--redis` at. `--sentinel` takes the sentinel nodes instead and lets ioredis work out which Redis is currently the master:
```sh
npx @worker-manager/cli --sentinel s1.internal:26379,s2.internal:26379 --sentinel-name mymaster
```
Each entry is a `host` or `host:port`, with the port defaulting to 26379. An IPv6 literal is all colons, so it needs brackets to carry a port: `[2001:db8::1]:26379`. Without them the whole entry is read as a host and gets the default port. `--sentinel-name` is the master group name from your sentinel configuration, the same string you would pass as `name` to ioredis, and it is required: sentinels can monitor more than one group, so there is nothing sensible to guess.
`--sentinel` and `--redis` are mutually exclusive. Setting both is an error rather than a quiet precedence rule, so a leftover `WORKER_MANAGER_REDIS_URL` in a container's environment cannot silently send the dashboard to the wrong Redis.
The CLI holds one ioredis connection and hands it to everything else it builds, so failover handling is not a separate feature: the queue instances, Bull's second subscriber connection, and the `--history` recorder all follow the master through a failover because they share that connection. During the failover window the dashboard's own API requests fail the way they do for any dropped connection, and recover once ioredis has re-resolved the master.
### Credentials
A Redis URL carries its own credentials. Sentinel mode has no URL, so they get their own flags:
| Flag | Applies to |
| --------------------- | ------------------------------------ |
| `--redis-username` | The Redis nodes behind the sentinels |
| `--redis-password` | The Redis nodes behind the sentinels |
| `--redis-db` | The Redis nodes behind the sentinels |
| `--sentinel-password` | The sentinel nodes themselves |
`--sentinel-password` is separate because sentinel nodes usually have a password of their own, distinct from the one guarding the data nodes.
These four are rejected alongside a Redis URL rather than merged into it. ioredis treats an explicitly passed option as a default and lets the URL win, so a `--redis-password` next to a URL that already carries one would be silently discarded. Refusing the combination outright is clearer than a flag that sometimes takes effect. With a URL, put the credentials in the URL.
### Everything else
The flags cover the common deployment. For anything past it, the config file's `redis` key also accepts a full [ioredis options object](https://github.com/redis/ioredis#connect-to-redis), passed through to the client untouched:
```js
// worker-manager.config.js
module.exports = {
redis: {
sentinels: [
{ host: 's1.internal', port: 26379 },
{ host: 's2.internal', port: 26379 },
],
name: 'mymaster',
sentinelPassword: process.env.SENTINEL_PASSWORD,
enableTLSForSentinelMode: true,
tls: {},
},
};
```
That is the way to reach TLS to the sentinel nodes, `natMap` for a NAT-ed cluster, `preferredSlaves`, or a custom `sentinelRetryStrategy`. The object form is not limited to Sentinel: a plain `{ host, port, tls }` works too, wherever a URL is awkward. Credential flags still apply on top of an object, so a password can stay in the environment instead of the file.
## Redis Cluster
`--cluster` takes a comma-separated list of startup nodes and connects through them, the way `--redis` and `--sentinel` do for their topologies. ioredis discovers the rest of the cluster from any node that answers, so listing two or three is enough:
```sh
npx @worker-manager/cli --cluster n1:7000,n2:7000,n3:7000 --prefix '{bull}'
```
`--redis-username` and `--redis-password` apply here as they do in sentinel mode. `--redis-db` does not: a cluster only has database 0, and passing it is an error rather than a silent no-op.
Your queues need the hash-tagged prefix BullMQ already asks for in cluster mode, so one queue's keys stay in one slot:
```ts
new Queue('mailer', { connection, prefix: '{bull}' });
```
Point `--prefix` at the same string, braces included. Discovery scans every master rather than one, since `SCAN` carries no key for the client to route by.
Only BullMQ queues are served. Bull 3 builds its keys without a hash tag and its Lua touches several at once, so on a cluster every command it issues is a `CROSSSLOT` away from failing; such a queue is skipped with a warning naming it, rather than shown on the board with every action broken. BullMQ queues on the same cluster are unaffected.
The Redis stats panel reports the cluster as a whole, summing memory and client counts across the masters. [`--history`](#historical-metrics) works, storing everything under a single hash slot so its rollup script stays legal. The [historical metrics recipe](/worker-manager/recipes/historical-metrics.md#redis-cluster) covers what that means for the key layout.
## Basic auth
`--user` and `--password` add HTTP basic auth in front of the dashboard. Both are required together:
```sh
npx @worker-manager/cli -r redis://localhost:6379 --user admin --password secret --host 0.0.0.0
```
This is enough for a queue you've tunnelled to or a small internal box. It is not the layered, session-aware auth described in [Add basic auth](/worker-manager/recipes/basic-auth.md), which covers login flows and framework-integrated auth for an app you're embedding the dashboard into.
## Keycloak auth
Instead of Basic auth, the CLI can put a Keycloak (OpenID Connect) login in front of the dashboard, through [`@worker-manager/auth`](/worker-manager/recipes/keycloak-auth.md):
```sh
npx @worker-manager/cli -r redis://localhost:6379 --host 0.0.0.0 \
--keycloak-url https://sso.example.com --keycloak-realm ops \
--keycloak-client-id worker-manager --keycloak-client-secret "$KEYCLOAK_SECRET" \
--keycloak-roles wm-admin \
--public-url https://queues.example.com --session-secret "$SESSION_SECRET"
```
Browsers are redirected to the Keycloak login page (authorization code flow with PKCE) and come back with an encrypted, `HttpOnly` session cookie that is refreshed silently while the refresh token lasts. Scripts can skip the browser and send `Authorization: Bearer ` instead; `--keycloak-bearer-only` turns the login page off entirely. A user without one of `--keycloak-roles` (realm roles or client roles) gets a 403.
Register `/auth/callback` as a valid redirect URI on the Keycloak client and `/` as a valid post logout redirect URI. Without `--public-url` the URL is derived from the request's `Host` and `X-Forwarded-*` headers. Set `--session-secret` whenever more than one CLI process serves the same board, or sessions will not survive a restart. `--user`/`--password` and `--keycloak-url` are mutually exclusive.
In a config file, the same settings live under `keycloak`, with the option names of [`@worker-manager/auth`](/worker-manager/recipes/keycloak-auth.md):
```js
module.exports = {
keycloak: {
url: 'https://sso.example.com',
realm: 'ops',
clientId: 'worker-manager',
clientSecret: process.env.KEYCLOAK_SECRET,
requiredRoles: ['wm-admin'],
cookie: { secret: process.env.SESSION_SECRET },
},
};
```
## Token auth
For a board without an identity provider, `--token` protects it with one or more static tokens, through [`@worker-manager/auth`](/worker-manager/recipes/token-auth.md):
```sh
npx @worker-manager/cli -r redis://localhost:6379 --host 0.0.0.0 \
--token "$BOARD_TOKEN" --token-header X-Board-Token --session-secret "$SESSION_SECRET"
```
Scripts send `Authorization: Bearer ` (or the `--token-header` header); API calls without it get a `401`. A browser is sent to a small login form at `/auth/login`, which trades the token for an encrypted, `HttpOnly`, `SameSite=Strict` session cookie. Without `--session-secret` sessions are encrypted with a random per-process key and end when the process restarts. `--token` cannot be combined with `--user`/`--password` or Keycloak.
In a config file, the settings live under `token`:
```js
module.exports = {
token: {
tokens: [process.env.BOARD_TOKEN],
header: 'X-Board-Token',
cookie: { secret: process.env.SESSION_SECRET },
},
};
```
## PostgreSQL queues
BullMQ v6 can store queues in PostgreSQL. `--postgres` serves them:
```sh
npx @worker-manager/cli --postgres postgres://bullmq:bullmq@localhost:5432/bullmq
```
Queue names are discovered from the tables of BullMQ's PostgreSQL schema (`bullmq` by default, `--postgres-schema` to change it), on the same `--scan-interval` as Redis discovery, or taken from `--queues`. The CLI bundles its own BullMQ v6 and `pg` for this, whatever BullMQ version your workers run.
With no Redis source configured (no `--redis`, `--sentinel`, `--cluster`, their environment variables, or a `redis` entry in the config file), the board serves PostgreSQL only and never connects to Redis. With one, it serves both on the same board; a PostgreSQL outage then keeps the last known PostgreSQL queues on the board instead of taking the Redis ones down. `--history` records into Redis when there is one; on a PostgreSQL-only board it records into PostgreSQL instead, in `worker_manager_metrics_*` tables in the `--postgres-schema` schema, which it creates on start unless the board is `--read-only`. A read-only board serves what another process recorded.
In a config file, `postgres` takes the URL, or a [node-postgres pool config](https://node-postgres.com/apis/pool) with an optional `schema`:
```js
module.exports = {
postgres: { host: 'db', user: 'bullmq', password: process.env.PGPASSWORD, database: 'jobs', schema: 'bullmq' },
};
```
## pg-boss
`--pg-boss` serves a board over a [pg-boss](https://github.com/timgit/pg-boss) schema: its queues, jobs in every state, and schedules.
See [the pg-boss engine](/worker-manager/queue-adapters/pg-boss.md) for what the board shows, the recommended indexes and a read-only database role.
```sh
npx @worker-manager/cli --pg-boss postgres://app:secret@localhost:5432/app
```
It needs Node.js 22.12 or newer, because pg-boss does. On an older Node.js, `--pg-boss` stops at startup with a message saying so, while every other mode of the CLI keeps running on Node.js 20. The [Docker image](/worker-manager/guide/docker.md) is on Node.js 22 already. The CLI bundles its own pg-boss 12 and reads and writes through it; your app does not need to share a version with it.
| Flag | Default | What it does |
| ------------------------- | ----------- | ----------------------------------------------------------------------------- |
| `--pg-boss ` | | The PostgreSQL database pg-boss lives in |
| `--pg-boss-schema ` | `pgboss` | The schema pg-boss was installed in |
| `--pg-boss-queues ` | every queue | Show only these queues. Any other queue answers 404, in the UI and in the API |
| `--pg-boss-path ` | `/pg-boss` | Where the pg-boss board is served when a BullMQ board has the root |
Nothing is migrated, supervised or created in that database. The board reads the pg-boss tables with plain SQL, inside a supported range of pg-boss schema versions, and writes (retry, cancel, resume, delete, send, schedules) through the pg-boss API. Writes need the database to be on exactly the schema version the bundled pg-boss writes. If it is on another one, because your app is on an older or newer pg-boss, the board turns writes off, reads keep working, and the reason is logged at startup and shown in the UI:
```text
Worker Manager listening on http://127.0.0.1:3000
pg-boss: postgres://app:***@localhost:5432/app (schema pgboss, version 41)
pg-boss writes are disabled: the database is on pg-boss schema version 41, but the pg-boss bundled with this CLI writes version 42. Reading keeps working.
```
A schema pg-boss was never installed in, or one outside the supported range, is logged the same way, and the board says so instead of showing queues.
### Next to a BullMQ board
With only `--pg-boss`, the pg-boss board is the whole dashboard and sits at the root (or at `--base-path`). Add a BullMQ source (`--redis`, `--sentinel`, `--cluster`, `--postgres`, or their environment and config file equivalents) and you get two boards from one process: BullMQ at the root, pg-boss under `--pg-boss-path`. Each board's header links to the other.
```sh
npx @worker-manager/cli \
--redis redis://localhost:6379 \
--pg-boss postgres://app:secret@localhost:5432/app \
--user admin --password secret
```
| URL | Board |
| -------------------------------- | ------------------ |
| `http://127.0.0.1:3000/` | BullMQ, from Redis |
| `http://127.0.0.1:3000/pg-boss/` | pg-boss |
The two boards share everything that is not about the queues:
- **Auth.** Basic or Keycloak auth is mounted once, at the root, so it covers both boards. There is one login, and with Keycloak one redirect URI (`/auth/callback`).
- **`--read-only`** applies to both. On the pg-boss board the write routes are not mounted at all.
- **`--base-path`** prefixes both: `--base-path /ops` puts BullMQ at `/ops/` and pg-boss at `/ops/pg-boss/`.
- **Outages stay separate.** The pg-boss board is served ahead of the Redis gate, so it keeps working while Redis is unreachable, and an unreachable pg-boss database is a warning, not a failed start, when BullMQ is there too. On its own, `--pg-boss` fails startup when the database does not answer, like a PostgreSQL-only board.
`--pg-boss-path` cannot be `/` or a path the BullMQ board answers itself (`/api`, `/static`, `/auth`, `/queue`, `/job-schedulers`). The links between the boards are added after any `miscLinks` in your `uiConfig`, which both boards share, title included.
`--history` gives the pg-boss board its own history, kept in the same database as pg-boss but in a separate schema, `worker_manager` (tables `worker_manager_metrics_*`), and never inside the pg-boss schema. Its queues are recorded as `pgboss::`, so they cannot collide with BullMQ queues of the same name. The BullMQ board keeps its history where it always has. Completed and failed counts need an index on the pg-boss `job` table; the CLI warns at startup when there isn't one, and the [historical metrics recipe](/worker-manager/recipes/historical-metrics.md) has the DDL. `--read-only` stops the recording and serves what is already there.
In a config file, `pgBoss` takes the URL, or a [node-postgres pool config](https://node-postgres.com/apis/pool) plus `schema`, `queues` and `path`:
```js
module.exports = {
redis: 'redis://localhost:6379',
pgBoss: {
host: 'db',
user: 'app',
password: process.env.PGPASSWORD,
database: 'app',
schema: 'pgboss',
queues: ['emails', 'invoices'],
path: '/pg-boss',
},
};
```
A URL from `--pg-boss` or `WORKER_MANAGER_PGBOSS_URL` replaces the config file's connection but keeps its `schema`, `queues` and `path` unless those are overridden too.
## Historical metrics
BullMQ's own metrics are a per-minute ring buffer capped at `maxDataPoints`, so the throughput chart can't look back further than that buffer reaches. `--history` turns on the long-retention path from the [historical metrics recipe](/worker-manager/recipes/historical-metrics.md) without wiring `@worker-manager/metrics` into an app of your own:
```sh
npx @worker-manager/cli -r redis://localhost:6379 --history
```
That registers `RedisMetricsHistoryProvider` on the Redis connection the dashboard already holds, so every queue chart gains a 60m / 7d / 30d / 90d range selector and a cross-queue "Metrics history" page shows up in the sidebar. It flips `showMetrics` on too, because the range selector lives inside the per-queue chart and that chart doesn't render without it.
It also writes. A `MetricsRecorder` runs in the CLI process and once a minute copies each queue's completed and failed counters into long-retention buckets, then samples wait time, run time and the age of the oldest waiting job. The recorder follows discovery rather than a fixed list: a queue that shows up between rescans starts recording on the next tick, and one that disappears stops. No restart either way.
`--history-retention-days` sets how long history is kept, 90 days by default. It moves the hourly and daily windows only and leaves minute-level detail at 7 days, since that tier holds essentially all the bytes. Per-tier retention, the key namespace, the snapshot interval and turning latency sampling off go in the config file under a `history` key:
```js
// worker-manager.config.js
module.exports = {
redis: 'redis://localhost:6379',
history: {
enabled: true,
prefix: 'worker-manager:metrics',
retention: { minutes: 7, hours: 90, days: 90 },
latency: false,
snapshotIntervalMs: 60000,
},
};
```
### What it writes
Recording writes to the same Redis your queues live in, under the `worker-manager:metrics:` namespace, and never touches a key Bull or BullMQ owns. Set `history.prefix` in the config file to move that namespace, which is what keeps two boards on one Redis from sharing a history. Redis TTLs enforce retention, so there's nothing to prune by hand. [Storage footprint](/worker-manager/recipes/historical-metrics.md#storage-footprint) has the measured numbers; the short version is roughly 1.1 MB per queue for the counters at the default retention plus about 250 KB for latency, and an idle queue costs nothing.
`--read-only` stops the writing and keeps the reading, so the board serves whatever another process has recorded. That's what you want when your workers already run a `MetricsRecorder` of their own and the CLI is only there to look at the result. The config file can ask for the opposite with `history: { record: true }` alongside `--read-only`, for a board that mustn't touch your queues but does own its history.
Running several instances with `--history` at once is safe. The minute upsert applies a delta against the value already stored rather than adding to it, so a minute recorded twice still counts once, and latency sampling takes a short per-queue lease, so only one process scans a given queue on a given tick.
### When the charts stay empty
Completed and failed history is copied out of BullMQ's own metrics buffer, which stays empty unless your workers were built with metrics enabled:
```ts
new Worker(name, processor, {
connection,
metrics: { maxDataPoints: MetricsTime.ONE_WEEK },
});
```
The CLI warns about this at startup when no discovered queue has any metrics data, because otherwise a queue whose workers never enabled metrics looks exactly like an idle one. Latency and queue age need nothing from your workers, since they're read from sorted sets BullMQ maintains anyway. One exception there: a queue using `removeOnComplete: true` has its jobs deleted before the next tick can read them, so it never accumulates latency data.
This is BullMQ only. Bull v3 has no native metrics to snapshot, and BullMQ v6 queues backed by PostgreSQL aren't discoverable from the CLI in the first place.
## Docker
The CLI also ships as an image, `ghcr.io/naldomadeira/worker-manager`, so a container next to your Redis needs no Node on the host and doesn't re-resolve the package from npm every time it starts:
```sh
docker run --rm -p 127.0.0.1:3000:3000 \
-e WORKER_MANAGER_USER=admin -e WORKER_MANAGER_PASSWORD=secret \
ghcr.io/naldomadeira/worker-manager --redis redis://host.docker.internal:6379
```
The entrypoint is the CLI, so every flag and variable on this page works there too. [Run with Docker](/worker-manager/guide/docker.md) covers the tags, a Compose file, mounting a config file, and putting it behind a reverse proxy.
## Queues written by something other than Node
BullMQ has an official [Python package](https://python-bullmq.readthedocs.io/), and gets written to from Go, Ruby, and other languages over the raw Redis protocol, since the job format is just a set of Redis keys, not a Node API. Those teams have never had a way to use Worker Manager, because every server adapter assumes a Node HTTP app to mount into. The CLI doesn't have that assumption: it scans Redis for the same keys regardless of what wrote them, and builds a `Queue` instance the same way whether the producer was `bullmq` or `python-bullmq`.
The caveat is the same one that applies everywhere else in worker-manager: the dashboard can only show what Bull and BullMQ store in Redis. A producer that doesn't write jobs in the format either library expects may show up incompletely, or not render some fields at all.
## Driving it from a script or an agent
The CLI serves the same JSON API the UI itself calls, so a shell script or an agent debugging a stuck job can query it instead of reading Redis keys by hand or writing a throwaway script:
```sh
npx @worker-manager/cli -r redis://localhost:6379 --port 3000 --no-open &
curl -s http://127.0.0.1:3000/api/queues | jq '.queues[] | {name, counts, isPaused}'
```
```json
{
"name": "Emails.Transactional.PasswordReset",
"counts": {
"active": 0,
"completed": 500,
"delayed": 6,
"failed": 171,
"paused": 0,
"prioritized": 0,
"waiting": 0,
"waiting-children": 0
},
"isPaused": false
}
```
`--no-open` skips the browser launch, which matters in a script or a headless agent session where there's nothing to open a browser on. `--port` pins the port so the caller knows where to send the request instead of parsing it out of stdout.
## What it doesn't do yet
Bull 3 queues aren't servable on a Redis Cluster, since Bull builds its keys without a hash tag. They're skipped with a warning; BullMQ queues on the same cluster work normally.
`--prefix` also doesn't take wildcards. A queue's Redis key and its name can both contain colons, so there's no reliable way to guess where a wildcard prefix ends and the queue name begins. List the prefixes you need explicitly instead, for example `--prefix bull,tenant-a,tenant-b`.
---
url: /worker-manager/guide/docker.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Run with Docker
`ghcr.io/naldomadeira/worker-manager` is the official image: the [standalone CLI](/worker-manager/guide/cli.md) with a Node runtime wrapped around it. The dashboard runs as its own container next to your Redis, so there's nothing to install on the host and no app to mount an adapter into. That's usually what you want when the workers live in a repo you aren't editing, or when nothing in the stack is Node in the first place.
```sh
docker run --rm -p 127.0.0.1:3000:3000 \
-e WORKER_MANAGER_USER=admin -e WORKER_MANAGER_PASSWORD=secret \
ghcr.io/naldomadeira/worker-manager --redis redis://host.docker.internal:6379
```
That serves the dashboard on `http://127.0.0.1:3000` with every Bull and BullMQ queue it finds under the `bull` key prefix. `host.docker.internal` is how a container reaches a Redis running on the host: Docker Desktop provides it, and on plain Docker Engine you add `--add-host host.docker.internal:host-gateway`.
## What's in the image
`node:22-alpine` and the published `@worker-manager/cli`, nothing else. About 63 MB, built for `linux/amd64` and `linux/arm64`, running as the unprivileged `node` user.
The entrypoint is the CLI itself, so anything after the image name is a flag exactly as the [CLI guide](/worker-manager/guide/cli.md#options) documents it, and every `WORKER_MANAGER_*` variable behaves the same way. There's no image-specific configuration to learn. Two CLI defaults come preset, because they're the two that make no sense in a container:
| Variable | Image default | Why |
| --------------------- | ------------- | ------------------------------------------------------------------------------ |
| `WORKER_MANAGER_HOST` | `0.0.0.0` | The CLI default `127.0.0.1` only accepts connections from inside the container |
| `WORKER_MANAGER_OPEN` | `false` | There's no browser in there to open |
Both are ordinary environment variables, so `--host` or your own `-e WORKER_MANAGER_HOST` still wins.
There's also a `HEALTHCHECK` polling the dashboard on its own port, so `depends_on: { worker-manager: { condition: service_healthy } }` works for anything that should start behind it. Basic auth doesn't get in its way, since a 401 still proves the server is answering.
## Tags
| Tag | Points at |
| -------- | -------------------------------- |
| `latest` | The newest release |
| `9` | The newest release in that major |
| `9.5.0` | That exact release, forever |
Pin the exact version if you'd rather nothing moved under you, or the major for patches without surprises. The [package page](https://github.com/naldomadeira/worker-manager/pkgs/container/worker-manager) lists every tag that exists.
## Docker Compose
Next to a Redis of your own:
```yaml
services:
redis:
image: redis:latest
ports:
- '6379:6379'
worker-manager:
image: ghcr.io/naldomadeira/worker-manager:9
command: --redis redis://redis:6379
environment:
WORKER_MANAGER_USER: ${WORKER_MANAGER_USER}
WORKER_MANAGER_PASSWORD: ${WORKER_MANAGER_PASSWORD}
ports:
- '127.0.0.1:3000:3000'
depends_on:
- redis
```
None of that is Compose specific. It's an ordinary container listening on 3000, so any orchestrator runs it the same way.
## Configuring it
Flags after the image name, `WORKER_MANAGER_*` variables and a config file all work, and they resolve in that order. The CLI guide has the [full table](/worker-manager/guide/cli.md#environment-variables); these are the ones that come up in a container:
```sh
docker run --rm -p 127.0.0.1:3000:3000 \
-e WORKER_MANAGER_REDIS_URL=redis://redis:6379 \
-e WORKER_MANAGER_PREFIX=bull,tenant-a \
-e WORKER_MANAGER_READ_ONLY=true \
-e WORKER_MANAGER_BOARD_TITLE='Ops Dashboard' \
ghcr.io/naldomadeira/worker-manager
```
For anything longer, mount a [config file](/worker-manager/guide/cli.md#config-file). The working directory is `/app`, which is where the CLI looks, so a file mounted there is picked up without a `--config` flag as long as uid 1000 can read it:
```yaml
volumes:
- ./worker-manager.config.js:/app/worker-manager.config.js:ro
```
[Historical metrics](/worker-manager/recipes/historical-metrics.md) work here too, since the image carries `@worker-manager/metrics` as part of the CLI. `--history`, or `WORKER_MANAGER_HISTORY=true`, registers the history provider and starts recording throughput and latency into your Redis once a minute:
```yaml
worker-manager:
image: ghcr.io/naldomadeira/worker-manager:9
command: --redis redis://redis:6379 --history --history-retention-days 90
```
The container is a normal recorder, so it keeps writing for as long as it runs and stops when you stop it. Two of them against one Redis is safe, and `--read-only` keeps the charts while writing nothing. The [CLI guide](/worker-manager/guide/cli.md#historical-metrics) covers the config file keys and what leaves the charts empty.
[Redis Sentinel](/worker-manager/guide/cli.md#redis-sentinel) needs no URL, which is the point: the master address is not fixed, so the container is given the sentinel nodes and the master group name instead.
```yaml
worker-manager:
image: ghcr.io/naldomadeira/worker-manager:9
environment:
WORKER_MANAGER_SENTINELS: sentinel-1:26379,sentinel-2:26379,sentinel-3:26379
WORKER_MANAGER_SENTINEL_NAME: mymaster
WORKER_MANAGER_SENTINEL_PASSWORD: ${SENTINEL_PASSWORD}
WORKER_MANAGER_REDIS_PASSWORD: ${REDIS_PASSWORD}
```
Leave `WORKER_MANAGER_REDIS_URL` unset there. Setting both is an error, so an old URL left behind in a compose file or an env file stops the container rather than quietly winning.
A [pg-boss](/worker-manager/guide/cli.md#pg-boss) board takes a PostgreSQL URL. The image runs Node.js 22, which is what pg-boss needs, and bundles its own pg-boss 12. With nothing else set it is the only board:
```sh
docker run --rm -p 127.0.0.1:3000:3000 \
-e WORKER_MANAGER_USER=admin -e WORKER_MANAGER_PASSWORD=secret \
-e WORKER_MANAGER_PGBOSS_URL=postgres://app:secret@db:5432/app \
ghcr.io/naldomadeira/worker-manager
```
Next to a Redis, BullMQ keeps the root and pg-boss moves to `/pg-boss/` (or `WORKER_MANAGER_PGBOSS_PATH`), behind the same login and linked from each board's header:
```yaml
worker-manager:
image: ghcr.io/naldomadeira/worker-manager:9
environment:
WORKER_MANAGER_REDIS_URL: redis://redis:6379
WORKER_MANAGER_PGBOSS_URL: postgres://app:${PGPASSWORD}@db:5432/app
WORKER_MANAGER_PGBOSS_SCHEMA: pgboss
WORKER_MANAGER_USER: ${WORKER_MANAGER_USER}
WORKER_MANAGER_PASSWORD: ${WORKER_MANAGER_PASSWORD}
```
The container never migrates or creates anything in the pg-boss schema. If your app is on a pg-boss whose schema version differs from the one in the image, the board stays readable, turns writes off and logs why at startup.
Serving the dashboard under a path prefix, which is what a reverse proxy routing on the path needs, is `--base-path`:
```sh
docker run --rm -p 127.0.0.1:3000:3000 \
ghcr.io/naldomadeira/worker-manager --redis redis://redis:6379 --base-path /queues
```
## Keeping it private
The container listens on every interface inside itself, so `WORKER_MANAGER_USER` and `WORKER_MANAGER_PASSWORD` (or `--user` and `--password`) aren't optional here, and the port mapping above publishes to `127.0.0.1` on the host rather than everywhere. The CLI warns at startup when it's bound to a non-loopback host with no auth set, since that's an unauthenticated dashboard with delete-job and obliterate-queue on it, reachable from anywhere that can route to the host. If all you need is visibility, `--read-only` turns off every destructive action.
Basic auth over plain HTTP still sends the credentials in the clear. Publishing the port beyond the host, whether that's a routable address, a cloud security group or a proxy without TLS, wants an SSH tunnel or TLS termination in front of it either way.
## Building it yourself
The `Dockerfile` at the root of the repo is the one that produces the published image, and the CLI version is a build argument:
```sh
docker build --build-arg WORKER_MANAGER_VERSION=9.5.0 -t worker-manager .
```
Worth doing if you need a different base image or an internal registry.
## Without an image
The CLI also runs from npm inside a stock Node container. That re-resolves the package on every start, so it pins nothing and needs egress to the registry:
```yaml
worker-manager:
image: node:22-alpine
command: npx -y @worker-manager/cli --redis redis://redis:6379 --host 0.0.0.0 --no-open
environment:
WORKER_MANAGER_USER: ${WORKER_MANAGER_USER}
WORKER_MANAGER_PASSWORD: ${WORKER_MANAGER_PASSWORD}
ports:
- '127.0.0.1:3000:3000'
depends_on:
- redis
```
`--host 0.0.0.0` and `--no-open` are spelled out here, since only the Worker Manager image presets them.
---
url: /worker-manager/guide/exploring-the-dashboard.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Exploring the dashboard
A tour of how the dashboard is laid out and the controls you'll use day to day: grouped queues, the collapsible sidebar, search, and the per-queue info panel.
## Grouping queues by category
When a queue name contains a delimiter, Worker Manager splits it into a path. A queue named `Emails.Transactional.Welcome` registered with `{ delimiter: '.' }` becomes `Emails › Transactional › Welcome`. The sidebar has always shown this as a tree; the main overview can now show the same structure.

Each category header rolls up the job counts of every queue beneath it, so you can read the health of a whole domain, say all of `Payments`, without expanding it. Queues with no delimiter stay as plain cards.
Switch between the flat card grid and the grouped view from **Settings → Queues → Group queues by category**. Expand or collapse every section at once with the chevrons in the overview toolbar, and pause or resume a whole category from its header.
To make grouped the starting view for everyone, set it in `UIConfig`. This is only the default: once a user switches it in Settings, their choice is remembered and overrides the config on the next load.
```ts
createWorkerManagerBoard({
queues: [
new BullMQAdapter(welcomeEmails, { delimiter: '.' }),
new BullMQAdapter(receiptEmails, { delimiter: '.' }),
],
serverAdapter,
options: {
uiConfig: {
overview: { groupByDelimiter: true },
},
},
});
```
Each view remembers which sections you collapsed, independently of the sidebar.
## Collapsing the sidebar
The toggle in the top-left of the header hides the sidebar and gives the content the full width, which helps on smaller screens or when you're working inside a single queue. The state is saved to your browser, so the dashboard reopens the way you left it.

## Searching
The filter box at the top of the sidebar matches queues by name and drives both the sidebar tree and the overview at once. Press `⌘K` (or `Ctrl K`) anywhere to jump straight to it. If the sidebar is collapsed, it opens first.
## Filtering the overview by status
The row above the cards counts each status across the whole board. Click one and the overview drops to the queues holding jobs in that state, which is usually how you work out which queue the failures are actually in.
**All** is the first tab and the one you start on. It is also the way back out: the status sits in the URL as `?status=`, so a filtered board is a link you can paste to someone, and All clears it.
The row scrolls rather than wraps once the statuses outrun the width. That happens on a narrow window, and in the grouped view, where the expand and collapse controls take the end of the row.
## Schedulers
Queues that register job schedulers get a **Schedulers** entry in the sidebar. It lists every scheduler the board can see, across all queues, with its cron pattern or interval, when it fires next, when it last ran, and how many times it has run.

### Timeline view
The **Table | Timeline** toggle in the header switches the same list to a timeline, and the board remembers which one you picked. Each scheduler is a row, grouped by queue, and the markers along it are its runs inside the window: the filled dot is the next run BullMQ has queued, the smaller dots are the ones after it, and the hollow ring is the last run where it is known. **Day** shows the next 24 hours in hour columns, **Week** and **Month** show days, with weekends shaded and a line marking now.

A schedule that fires too often to draw one dot per run, every minute or every 15 minutes at month zoom, becomes a single striped band that states its cadence on hover. The **Overlaps** lane at the top marks minutes in which two or more schedulers start at once, and the runs involved get an amber ring, which is the quickest way to spot the 02:00 pile-up worth spreading out. Per-minute schedules are left out of it, since they overlap with everything.
The server only reports when a scheduler fires next, so the rest of each row is worked out in the browser: interval schedules repeat from that next run, and cron patterns are expanded in the scheduler's own time zone, honouring its run limit and end date. The expansion covers the usual syntax (five or six fields, lists, ranges, steps, month and weekday names, `@daily` and friends). A pattern using `L`, `W` or `#` shows its next run alone, marked with an asterisk, rather than a guess. Clicking a row opens the same edit form as the table, or the next run's job on a queue where editing is not available.
Last run is not something BullMQ stores. It is read from the pending run of each schedule, which the worker creates as the previous run starts, so a scheduler that has never fired leaves the column empty.
Both times link to the job behind them when there is one to open. The next run always links, since that job is sitting in the delayed set waiting to be picked up, which is also how you can inspect its payload or promote it. The last run links only when the job it produced still exists and the dashboard can name it, which means interval schedules whose previous run has not been trimmed away by `removeOnComplete`. Naming the previous run of a cron schedule would mean parsing the pattern backwards, so those show the time alone.
Each row can be removed, which stops the schedule and its pending run together, or edited to change the cron pattern, interval, time zone, run limit or end date. Editing only rewrites the schedule: the job the scheduler produces keeps the name, data and options your application registered. Both actions respect `readOnlyMode`, and editing is unavailable on legacy Bull queues, which have no way to update a repeatable job in place.
A row can also be run on demand. That adds one job built from the scheduler's template, with the same name, data and options, straight into the queue as an ordinary waiting job. The schedule itself is not touched: its pending run stays where it was and still fires at its own time, and the run counter does not move, because as far as BullMQ is concerned this is a job you added by hand rather than an iteration of the schedule. Use it to verify a fix without waiting for the next cron window. Running asks for confirmation first, respects `readOnlyMode`, and is unavailable on legacy Bull queues, which store no template to build the job from.
### These are operational changes, not configuration
Most applications register their schedulers on start, and `upsertJobScheduler` overrides whatever is stored. So a schedule you edit here lasts until the next deploy or restart, at which point your application's own definition wins again, and a scheduler you remove comes back the same way. That makes the view a good place to stop a misbehaving cron or move it a few hours while you fix the job, and a poor place to make a change you expect to keep. Change the code for that.
The exception is a scheduler your application created dynamically and never re-registers, for a single tenant say. Nothing brings that one back, so removing it is permanent. Worker Manager cannot tell the two apart, which is why the confirmation says the runs stop until the application registers the scheduler again rather than promising either outcome.
Removing a scheduler takes its pending run with it. Completed and failed runs from the past stay where they are, and a run already being processed finishes normally.
Opening a queue that has schedulers shows a link into the same view, filtered to that queue.
## Metrics history
With a [history provider](/worker-manager/recipes/historical-metrics.md) configured, **Metrics history** in the sidebar charts completed and failed jobs across every queue over 7, 30 or 90 days, with a per-queue breakdown underneath.
Between the two sits **Daily activity**, the same daily totals as a calendar: one cell per day, darker the busier it was. Switch it between completed jobs, failed jobs and the failure rate. 7 and 30 days read as a single strip; 90 days folds into weeks, GitHub style. Beside it are the peak day, the daily average and an average per weekday, which is where a weekly rhythm or a bad Monday shows up. Today's cell has a dashed outline because its bucket is still filling. Every cell names its day and counts on hover and to a screen reader, and the arrow keys move between days.

## Queue info
Open any queue and click the info icon next to its name.

It opens a panel showing how the queue is configured: type, paused state, global concurrency, the configured rate limit, how many workers are connected, and the default job options (attempts, backoff, retention), so you don't have to dig through code.
Global concurrency and the rate limit are the two values the board can write, so those two rows have a pencil on them. The editor opens over the panel and puts you back on it when you close it, which saves reading a number on one screen and changing it on another. Neither pencil shows on a read-only queue, or on a Bull queue, which has no runtime setter for either. Both editors are in the queue's actions dropdown as well.

The default job options come from the queue itself, so what you see is what a job added now would inherit: attempts, backoff, and the retention that decides how long completed and failed jobs stick around.

## Why a job is not moving
A job sitting in a queue doing nothing looks the same whether it is simply waiting its turn, whether a worker keeps picking it up and losing it, or whether BullMQ has already decided it is going to fail. The board carries five facts off each job that tell those apart, shown as pills beside the job name and only when there is something to say.
`stalled N` counts the times a worker took the job and never finished, which happens when a worker is killed mid-job or blocks long enough for its lock to expire. Beside it, `N started` is how many times the job was picked up, so a job on its first attempt that has already started twice is one that stalled and came back.

`will fail` marks a job BullMQ has condemned: it has stalled past `maxStalledCount` and is waiting in the queue only until a worker takes it and fails it immediately. Its reason fills the Error tab, which otherwise says a job about to be nothing but an error has no errors.

`dedup ` names the deduplication key a job was added under, which is what explains a job you expected to see and cannot find: it was added, matched a live key, and dropped. The full id is in the tooltip when it is too long for the pill.

`priority N` shows what the prioritized tab is actually ordering by, which was previously only readable from the raw options JSON.
None of these appear on Bull queues, which report none of them, and none appear on a healthy job. The only thing the board spends space on is the case worth acting on, the same way the no-workers badge does.
## Rescheduling and reprioritising a job
A delayed job's card shows when it will run, and until now the only two things you could do about that were promote it, which runs it immediately, or delete it. Neither is what you want when a nightly export needs to move two hours later because the upstream feed is late.
Delayed jobs now carry a **Reschedule** action beside Promote. It opens with the job's current run time filled in, and picking a new one calls `Job#changeDelay` with the difference. A time in the past is not an error: it resolves to a delay of zero, so the job runs as soon as a worker is free, which is the same outcome as promoting it.
Prioritized jobs get the matching **Change priority** action, which calls `Job#changePriority`. Lower numbers run first, 0 removes the priority, and the ceiling is 2097151, which is BullMQ's own limit and the point past which its ordering stops being reliable.

Both respect `readOnlyMode`, and both are BullMQ-only. Bull can neither change a delay nor a priority after a job is added, so the actions never appear on a Bull queue and the endpoints answer 400 if something calls them anyway.
Like the schedulers view, these are operational changes rather than configuration. Rescheduling a job moves that one run; it does not change what your application will add next time.
## Settings
The gear in the header opens per-browser preferences, split into General, Queues and Jobs. Polling interval, language, theme and the environment badge live in the first; grouping and sort order in the second; which job tab opens by default, how deep JSON starts collapsed and how many jobs a page shows in the third. Everything is stored in your own browser, so nothing here changes what anyone else sees.

Some of these can be fixed or hidden for everyone from [UIConfig](/worker-manager/configuration/ui-config.md), which is how you stop people picking a two second polling interval against a large Redis.
## On a phone
Below 768px the sidebar becomes a dropdown in the header and the cards go to one column. The status filters stay scrollable rather than wrapping, so the counts you act on are still one tap away.

## Connected workers
A queue with a growing waiting count looks the same whether it is simply busy or whether every worker consuming it has died. The dashboard tells the two apart.
Nothing appears while a queue is healthy. A badge shows up only once a queue has no workers connected and is not paused, on its overview card and beside the status tabs on its own page, so the only thing the dashboard ever spends room on is the case worth acting on. A paused queue is meant to have nothing consuming it, so it never warns.

On the queue page it sits with the status tabs, next to the backlog it explains.

Who is actually connected lives in the queue info panel, under **Connected workers**, with the count beside global concurrency in the overview. Open it from the info icon next to the queue name, or by clicking the badge, which drops you straight onto that section.

Each worker leads with whatever identifies it, which is the name you gave it (`new Worker(queueName, processor, { name: 'mailer-1' })`) or its address when you gave it none, and carries the address and how long it has been connected underneath. The badge only exists while something is wrong, so this panel is how you check on a queue that looks fine.
The list comes from Redis `CLIENT LIST`, which is how both Bull and BullMQ implement `getWorkers()`. Some hosted Redis providers block that command, Google Memorystore among them. There the dashboard says nothing about workers at all, in the badge or the info panel, rather than reporting a queue as having none.
Whether a queue has workers rides along with the queue listing the dashboard already polls, so the warning costs no request of its own. Who they are is asked for once, when you open the info panel. What it does cost is one `CLIENT LIST` per queue per poll, which is cheap on a handful of queues and less so on a board with dozens, so it can be switched off wholesale with `showWorkers: false` in [UIConfig](/worker-manager/configuration/ui-config.md). That stops the command being run rather than just hiding the badge.
---
url: /worker-manager/guide/getting-started.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Installation
Install the core `@worker-manager/api` plus one adapter for your framework.
::: tip Using a coding agent? Install the agent skill
Claude Code: `/plugin marketplace add naldomadeira/worker-manager`, then `/plugin install worker-manager@worker-manager`.
Any agent with a skills folder:
```sh
curl -fsSL https://naldomadeira.github.io/worker-manager/worker-manager-skill.zip -o /tmp/wm-skill.zip && unzip -o /tmp/wm-skill.zip -d ~/.claude/skills/
```
Then ask for the dashboard in plain words. See [AI agent skill & setup](/worker-manager/guide/ai-agent-setup.md#install-the-agent-skill).
:::
## Prerequisites
- Node.js 20+ or Bun 1.x. CI covers Node 20, 22 and 24.
- A running Redis instance
- [Bull](https://github.com/OptimalBits/bull) or [BullMQ](https://docs.bullmq.io/) already set up in your app
::: tip Sharing an ioredis connection?
Worker Manager reads your existing Bull/BullMQ queues, so it inherits their Redis connection. If you construct BullMQ with a shared `ioredis` instance rather than a plain `{ host, port }`, BullMQ requires that connection to be created with `maxRetriesPerRequest: null`. This is a BullMQ requirement, not a Worker Manager one, but it's the most common setup snag. See the [BullMQ connection docs](https://docs.bullmq.io/guide/connections).
:::
## Install
Pick the adapter that matches your framework:
| Framework | Install command |
| --------- | --------------------------------------------------------- |
| Express | `npm install @worker-manager/api @worker-manager/express` |
| Fastify | `npm install @worker-manager/api @worker-manager/fastify` |
| NestJS | `npm install @worker-manager/api @worker-manager/nestjs` |
| Koa | `npm install @worker-manager/api @worker-manager/koa` |
| Hapi | `npm install @worker-manager/api @worker-manager/hapi` |
| Hono | `npm install @worker-manager/api @worker-manager/hono` |
| H3 | `npm install @worker-manager/api @worker-manager/h3` |
| Elysia | `npm install @worker-manager/api @worker-manager/elysia` |
| Bun | `npm install @worker-manager/api @worker-manager/bun` |
## Next steps
- [Build your first dashboard](/worker-manager/guide/your-first-dashboard.md) for a framework-agnostic walkthrough.
- Or jump to your adapter: [Express](/worker-manager/server-adapters/express.md), [Fastify](/worker-manager/server-adapters/fastify.md), [Koa](/worker-manager/server-adapters/koa.md), [Hapi](/worker-manager/server-adapters/hapi.md), [NestJS](/worker-manager/server-adapters/nestjs.md), [Hono](/worker-manager/server-adapters/hono.md), [H3](/worker-manager/server-adapters/h3.md), [Elysia](/worker-manager/server-adapters/elysia.md), [Bun](/worker-manager/server-adapters/bun.md).
---
url: /worker-manager/guide/playground.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Playground
The repository ships a playground, a small NestJS app in `playground/` that exercises the whole
stack before you wire the board into your own service. It exists to validate changes to the
library itself, and to show a complete, working setup you can copy from.
It brings up:
- **Redis** for six classic BullMQ queues (`notifications.*`, `payments.*`, `reports.generate`,
`orders.pipeline`), with flows and two job schedulers.
- **PostgreSQL** for two BullMQ v6 queues stored in Postgres (`pg.invoices`, `pg.data-exports`).
- **A second board over pg-boss** at `/pg-boss`, on a `pgboss` schema in the same
PostgreSQL. See [the pg-boss board](#the-pg-boss-board).
- **Keycloak 26** with a pre-imported realm, a confidential client, and two users.
- **Synthetic traffic**: workers with random latency, progress, logs and failures, so every view
of the board has something in it.
## Run it
```sh
yarn install && yarn build # the playground links the local packages
yarn playground:infra # docker compose: redis :6390, postgres :5440, keycloak :8090
cp playground/.env.example playground/.env
yarn playground # http://localhost:3100/queues and /pg-boss
```
Pick the auth mode with `WM_AUTH` in `playground/.env`:
| `WM_AUTH` | How you get in |
| ---------- | --------------------------------------------------------------------------------------------------------------------------- |
| `none` | Open access. |
| `basic` | Browser prompt, `admin` / `admin` (`WM_BASIC_USER`, `WM_BASIC_PASSWORD`). |
| `keycloak` | Redirect to Keycloak. `admin` / `admin` has the `wm-admin` role and gets in; `viewer` / `viewer` is signed in but gets 403. |
Both boards share the auth mode and the credentials above. Under Keycloak each board has a session
of its own: the named pg-boss board sets `wm_session_pgboss`, so signing in to one does not sign you
in to, or out of, the other.
Other switches in `playground/.env`:
| Variable | Default | Effect |
| ------------- | --------------------------------- | --------------------------------------------------------------------------------------------- |
| `WM_PGBOSS` | `true` when `POSTGRES_URL` is set | Mounts the pg-boss board and its traffic. `false`, or an empty `POSTGRES_URL`, leaves it out. |
| `WM_READONLY` | `false` | Both boards read-only. The pg-boss board then does not register its mutation routes. |
| `WM_TRAFFIC` | `true` | Workers and producers on both engines. `false` freezes the boards for inspection. |

## Validate it
With the app running, `yarn workspace @worker-manager/playground smoke` checks the board against
the mode it reports on `/health`:
- `basic`: 401 plus a `WWW-Authenticate` challenge without credentials, 401 with a wrong
password, 200 with the right one, and `/auth/me` names the user.
- `keycloak`: navigations redirect to Keycloak with PKCE S256, the API answers
`401 { error: { key: 'ERRORS.UNAUTHORIZED' } }` without a session, a `wm-admin` bearer token
gets through, a user without the role gets `403 ERRORS.FORBIDDEN`, and `/auth/me` reports the
Keycloak profile.
- Every mode: both Redis and PostgreSQL queues are listed.
- Every mode, when `/health` reports `pgBoss: true`: `/pg-boss` answers per the mode (HTML, 401
or a 302 to Keycloak), `/pg-boss/api/pg-boss/queues` lists the four queues with their policies
and the dead letter queue, `/pg-boss/api/pg-boss/info` reports `writable: true`, both schedules
are listed, and a job goes through send, cancel, resume and delete. With `WM_READONLY=true`,
`info` reports `readOnly: true` and the mutations answer 404.
It exits non-zero on the first failed check, so it can run in CI after `docker compose up --wait`.
## How it is wired
The whole integration is one module import. The adapter is detected from the running Nest app,
queues are registered at the root, and auth is a plain option:
```ts
WorkerManagerModule.forRoot({
route: '/queues',
auth: {
strategy: 'keycloak',
url: 'http://localhost:8090',
realm: 'worker-manager',
clientId: 'worker-manager-board',
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
requiredRoles: ['wm-admin'],
publicUrl: 'http://localhost:3100',
cookie: { secret: process.env.WM_COOKIE_SECRET, secure: false },
},
title: 'Worker Manager Playground',
uiConfig: { environment: { label: 'playground', color: '#6366f1' } },
queues: allQueues.map((queue) => ({ queue, adapter: BullMQAdapter })),
});
```
## The pg-boss board
`playground/src/pgboss/` creates the app's own `PgBoss` with `migrate: true` on the playground's
PostgreSQL, schema `pgboss`, and starts it before the app listens. It creates five queues and two
schedules:
| Queue | Policy | Behaviour |
| ---------------------- | ----------- | ----------------------------------------------------------------------- |
| `mail.send` | `standard` | 3 retries with backoff; also gets deferred reminders (`startAfter: 60`) |
| `reports.nightly` | `singleton` | One active at a time, slow jobs; cron schedule `*/2 * * * *` |
| `billing.sync` | `stately` | Keyed per customer, so most sends are dropped by pg-boss on purpose |
| `payments.capture` | `standard` | 1 retry, then dead-letters into `payments.dead-letter` |
| `payments.dead-letter` | `standard` | No worker, so what lands there stays visible |
`mail.send` also has an RRULE schedule, `FREQ=MINUTELY;INTERVAL=3`. Workers fail at random, so
the board shows `retry`, `failed` and dead-lettered jobs next to `completed` ones.
The board is a second, named `WorkerManagerModule.forRoot()`. It takes the started instance, plus
a `connection` so reads get a server-side `statement_timeout`:
```ts
WorkerManagerModule.forRoot({
name: 'pgboss',
route: '/pg-boss',
engine: 'pg-boss',
auth: boardAuth(),
uiConfig: { miscLinks: [{ text: 'BullMQ board', url: '/queues' }] },
pgBoss: { instance: pgBoss, connection: process.env.POSTGRES_URL, schema: 'pgboss', delimiter: '.' },
});
```
Each board has a `miscLinks` entry pointing at the other. See
[the NestJS pg-boss board](/worker-manager/server-adapters/nestjs.md#pg-boss-board) for every
option.
See `playground/src/app.module.ts` for the switch between the three modes and
`playground/src/queues/` for the Redis and PostgreSQL queue setup.
---
url: /worker-manager/guide/your-first-dashboard.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Your first dashboard
Express and BullMQ. Every other adapter follows the same three-step shape, see the [adapter pages](/worker-manager/server-adapters/index.md) for specifics.
## 1. Create the queue
```ts [queues.ts]
import { Queue } from 'bullmq';
export const emailQueue = new Queue('emails', {
connection: { host: 'localhost', port: 6379 },
});
```
## 2. Mount the dashboard
```ts
import express from 'express';
import { createWorkerManagerBoard } from '@worker-manager/api';
import { BullMQAdapter } from '@worker-manager/api/bullMQAdapter';
import { ExpressAdapter } from '@worker-manager/express';
import { emailQueue } from './queues';
const app = express();
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');
createWorkerManagerBoard({
queues: [new BullMQAdapter(emailQueue)],
serverAdapter,
});
app.use('/admin/queues', serverAdapter.getRouter());
app.listen(3000, () => {
console.log('Dashboard: http://localhost:3000/admin/queues');
});
```
::: tip
`setBasePath` and the `app.use` mount path must match. Change one, change the other, otherwise asset and API URLs will 404.
:::
## 3. Open the dashboard
Start the server and visit `http://localhost:3000/admin/queues`. You'll see the `emails` queue with counts, an empty job list, and a live-updating header.
Add a job:
```ts
await emailQueue.add('welcome', { to: 'you@example.com' });
```
## Where to next
- Add more queues: pass them to `createWorkerManagerBoard({ queues: [...] })`, or [add and remove them at runtime](/worker-manager/recipes/manage-queues-at-runtime.md).
- Lock the dashboard with [read-only mode](/worker-manager/recipes/read-only-mode.md).
- Scope queues per tenant with a [visibility guard](/worker-manager/recipes/visibility-guard.md).
- Change title, logo, polling via [UIConfig](/worker-manager/configuration/ui-config.md).
- Rewrite job fields for the UI with [formatters](/worker-manager/recipes/formatters.md).
---
url: /worker-manager/index.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Worker Manager
Dashboard for BullMQ, Bull and pg-boss
> On Redis or PostgreSQL, with Basic, Keycloak, token or custom auth built in. Mount it in NestJS, Express, Fastify or Next.js, or run it from the CLI. Install the agent skill and let your coding agent wire it up.
[Get Started](/guide/introduction) | [Try the demo](/demo/) | [Agent skill](/guide/ai-agent-setup#install-the-agent-skill) | [View on GitHub](https://github.com/naldomadeira/worker-manager)
## Features
- [🤖 **Agent skill, one command**](/guide/ai-agent-setup#install-the-agent-skill): Claude Code: /plugin marketplace add naldomadeira/worker-manager, then /plugin install worker-manager@worker-manager. Any other agent: unzip worker-manager-skill.zip into its skills folder.
- 🔐 **Auth built in**: Basic, Keycloak / OpenID Connect with PKCE, static tokens with a login form, or your own authenticate(req), on every adapter.
- 🐘 **Redis or PostgreSQL**: BullMQ on Redis (standalone, Sentinel, Cluster) or on PostgreSQL, plus a pg-boss engine. History storage in Redis or PostgreSQL too.
- ⚡ **Nothing to wire up**: npx @worker-manager/cli -r redis://localhost:6379, or the official Docker image. No install, no code.
- 🧩 **Or mount it in your app**: Adapters for Express, Fastify, Koa, Hapi, NestJS, Hono, H3, Elysia, and Bun.
- ⏰ **Schedulers and history**: Every repeatable job in one view. Opt-in throughput and latency history that outlives BullMQ's ring buffer.
- 🔒 **Safe to share**: Read-only mode, per-request visibility guards for multi-tenant boards, and hooks for finer access control.
- 🎨 **Theming and formatters**: Theme tokens named after the shadcn contract, so a generated palette drops in. Formatters rewrite job data without touching your producers.
- 🐂 **BullMQ v5 & v6, Pro, and Bull**: All three queue adapters ship in the core package, including v6 queues stored in PostgreSQL.
---
url: /worker-manager/queue-adapters/bull.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# BullAdapter
For the [Bull](https://github.com/OptimalBits/bull) queue library.
## Import
```ts
import { BullAdapter } from '@worker-manager/api/bullAdapter';
// or
const { BullAdapter } = require('@worker-manager/api/bullAdapter');
```
## Usage
```ts
import { createWorkerManagerBoard } from '@worker-manager/api';
import { BullAdapter } from '@worker-manager/api/bullAdapter';
import { ExpressAdapter } from '@worker-manager/express';
import Queue from 'bull';
const myQueue = new Queue('my-queue', {
redis: { host: 'localhost', port: 6379 },
});
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');
createWorkerManagerBoard({
queues: [new BullAdapter(myQueue)],
serverAdapter,
});
```
## Options
All options are optional.
| Option | Type | Default | Description |
| ---------------- | --------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `readOnlyMode` | `boolean` | `false` | Hides all queue and job actions. |
| `allowRetries` | `boolean` | `true` | Shows or hides the retry buttons on **failed** jobs. Forced to `false` when `readOnlyMode` is `true`. |
| `description` | `string` | `''` | Queue description text displayed in the UI. |
| `displayName` | `string` | `''` | Overrides the queue name shown in the UI. |
| `prefix` | `string` | `''` | Prepended to job names in the UI. |
| `delimiter` | `string` | `''` | Delimiter between the prefix and the job name. |
| `externalJobUrl` | `(job) => { href, displayText? }` | none | Links each job card to a page in your own app. See [External job URLs](/worker-manager/recipes/external-job-url.md). |
| `jobDataSchema` | `object` (JSON Schema) | none | Describes the shape of a job's `data`, driving the **Add job** form's prefill, autocomplete and inline validation. See [Job data schema](/worker-manager/queue-adapters/index.md#job-data-schema). |
::: tip
`allowCompletedRetries` (available on [`BullMQAdapter`](/worker-manager/queue-adapters/bullmq.md)) has no effect here. Bull can't retry completed jobs, so it's always off.
:::
## Instance methods
```ts
adapter.setFormatter('name', (job) => `#${job.name}`);
adapter.setFormatter('data', (data) => redact(data));
adapter.setFormatter('returnValue', (value) => redact(value));
adapter.setFormatter('progress', (progress) => `${Math.round(progress)}%`);
adapter.setVisibilityGuard((request) => {
// return true to show this queue, false to hide it
return request.headers['x-tenant-id'] === 'acme';
});
```
---
url: /worker-manager/queue-adapters/bullmq-pro.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# BullMQ Pro
For the [BullMQ Pro](https://docs.bullmq.io/bullmq-pro/introduction) queue library. Import `BullMQProAdapter` instead of `BullMQAdapter` to get awareness of Pro groups: the jobs held by `waiting`/`limited`/`maxed`/`paused` groups are folded into the `waiting`/`delayed`/`paused` job counts, those jobs are listed alongside regular jobs in the same tabs, and the group id is shown next to the job name in the UI.
## Install
```sh
npm install @worker-manager/api @worker-manager/express
```
## Usage
```js
const { QueuePro } = require('@taskforcesh/bullmq-pro');
const { createWorkerManagerBoard } = require('@worker-manager/api');
const { BullMQProAdapter } = require('@worker-manager/api/bullMQProAdapter');
const { ExpressAdapter } = require('@worker-manager/express');
const queuePro = new QueuePro('queueProName');
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');
createWorkerManagerBoard({
queues: [new BullMQProAdapter(queuePro)],
serverAdapter,
});
```
All `BullMQAdapter` options (`readOnlyMode`, `allowRetries`, `description`, `prefix`, `setFormatter`, `setVisibilityGuard`) work the same way on `BullMQProAdapter`.
## Group job counts
The jobs inside groups are counted from the per-group count that `getGroupsByStatus()` returns,
which `@taskforcesh/bullmq-pro` added in **7.46.3**. Groups that come back without one, which is every
group on an older version, are counted with `getGroupJobsCount()` instead, one call per such
group. Only a version that has neither falls back to counting a group as a single job.
Three things follow from how bullmq-pro reports groups:
- Counting grouped jobs means listing every group on every refresh: no call totals them. On a
queue with a very large number of groups, that listing is the expensive part of a refresh.
- The board takes one reading of the queue, job counts and group listings together, and serves
both the numbers and the page of jobs from it for up to five seconds. A page therefore always
matches the counts its pagination was worked out from, at the cost of numbers that can be that
stale. Anything done from the board (adding, pausing, cleaning, ...) drops the reading at once.
- Jobs added to a group with a `priority` live in a separate sorted set that the count returned
by `getGroupsByStatus()` does not include, so they are missing from the counts (and from the
tail of the listing) even on 7.46.3.
---
url: /worker-manager/queue-adapters/bullmq.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# BullMQAdapter
For the [BullMQ](https://docs.bullmq.io/) queue library.
## Supported versions
`@worker-manager/api` declares its BullMQ peer as `^5.56.0 || ^6.0.0`, and the adapter figures out which one it is holding. Nothing to configure.
Two v6 changes are visible in the dashboard:
- **No Paused tab.** v6 removed the paused job state. A paused queue's jobs are stored as `waiting`, so that is where the dashboard shows them. The queue still displays its paused banner and the pause and resume buttons still work.
- **PostgreSQL queues are supported.** v6 can run on Postgres instead of Redis. See [PostgreSQL backend](/worker-manager/recipes/postgres-backend.md).
### Support policy
Three BullMQ versions run the full `@worker-manager/api` suite on every commit:
| Tested version | Why |
| -------------- | -------------------------------------------------------------- |
| `5.56.0` | The exact lower bound of the peer range, pinned with no caret. |
| latest `5.x` | The version most installs resolve to. |
| latest `6.x` | The current major. |
The lower bound is a tested claim rather than a guess: `packages/api/jest.config.bullmq-floor.js` refuses to run if the pinned alias and the declared peer range disagree, so the range cannot be widened without the suite following it down.
Below `5.56.0` the dashboard still starts and still lists, inspects and retries jobs, but three things break, which is why the range stops where it does. Job schedulers report `every` as a string instead of a number, so the interval column is wrong and the previous run cannot be named. Rewriting a schedule from an interval to a cron pattern leaves the old interval behind in Redis, so the scheduler ends up storing both. Versions before 5.41 have no `Queue#removeGlobalConcurrency`, so clearing a global concurrency limit silently does nothing.
Anything at or above `5.56.0` gets every feature. If you are pinned lower and something on that list matters to you, open an issue rather than assuming the floor is fixed: it is set by what CI can prove, and it moves down whenever a fix makes a lower version pass.
Raising the floor is a breaking change and only happens in a major release of `@worker-manager/api`.
## Import
```ts
import { BullMQAdapter } from '@worker-manager/api/bullMQAdapter';
// or
const { BullMQAdapter } = require('@worker-manager/api/bullMQAdapter');
```
## Usage
```ts
import { createWorkerManagerBoard } from '@worker-manager/api';
import { BullMQAdapter } from '@worker-manager/api/bullMQAdapter';
import { ExpressAdapter } from '@worker-manager/express';
import { Queue } from 'bullmq';
const myQueue = new Queue('my-queue', {
connection: { host: 'localhost', port: 6379 },
});
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');
createWorkerManagerBoard({
queues: [new BullMQAdapter(myQueue)],
serverAdapter,
});
```
## Options
All options are optional.
| Option | Type | Default | Description |
| ----------------------- | --------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `readOnlyMode` | `boolean` | `false` | Hides all queue and job actions. |
| `allowRetries` | `boolean` | `true` | Shows or hides the retry buttons on **failed** jobs. Forced to `false` when `readOnlyMode` is `true`. |
| `allowCompletedRetries` | `boolean` | `true` | Shows or hides the retry button on **completed** jobs. Only takes effect when `allowRetries` is `true`. |
| `description` | `string` | `''` | Queue description text displayed in the UI. |
| `displayName` | `string` | `''` | Overrides the queue name shown in the UI. |
| `prefix` | `string` | `''` | Prepended to job names in the UI. |
| `delimiter` | `string` | `''` | Delimiter between the prefix and the job name. |
| `externalJobUrl` | `(job) => { href, displayText? }` | none | Links each job card to a page in your own app. See [External job URLs](/worker-manager/recipes/external-job-url.md). |
| `jobDataSchema` | `object` (JSON Schema) | none | Describes the shape of a job's `data`, driving the **Add job** form's prefill, autocomplete and inline validation. See [Job data schema](/worker-manager/queue-adapters/index.md#job-data-schema). |
## Instance methods
```ts
adapter.setFormatter('name', (job) => `#${job.name}`);
adapter.setFormatter('data', (data) => redact(data));
adapter.setFormatter('returnValue', (value) => redact(value));
adapter.setFormatter('progress', (progress) => `${Math.round(progress)}%`);
adapter.setVisibilityGuard((request) => {
// return true to show this queue, false to hide it
return request.headers['x-tenant-id'] === 'acme';
});
```
## Flow graph
The flow graph on the job detail page works with `BullMQAdapter` queues automatically. There's nothing to configure. When you open a job that belongs to a [BullMQ flow](https://docs.bullmq.io/guide/flows), Worker Manager reads the parent/child graph and renders it.
It walks the job's parent chain across queues to find the flow root, then reads the tree through a `FlowProducer` that shares the root queue's connection (on BullMQ v6 it reuses the queue's backend, so queues on the [PostgreSQL backend](/worker-manager/recipes/postgres-backend.md) work too). So it works as long as every queue in the flow is registered on the board.
The read is bounded. `FlowProducer#getFlow` is called with `depth` and `maxChildren`, both defaulting to what BullMQ itself uses, and the response is then capped at 200 descendants so the per-job state and dependency lookups cannot grow without limit on a large flow. Nodes left holding back children are marked, and the UI loads them on demand. See [job flows](/worker-manager/recipes/job-logs-and-flows.md) for what that looks like.
::: tip
The flow graph only spans queues Worker Manager knows about. If a parent job lives in a queue you didn't pass to `createWorkerManagerBoard`, the graph stops at the boundary. Register every queue that participates in the flow.
:::
Bull (the legacy library) has no flows, so the panel is BullMQ-only.
---
url: /worker-manager/queue-adapters/index.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Queue engines
A board runs one **engine**. The BullMQ engine is the default and has been there all along: it drives Bull, BullMQ and BullMQ Pro queues through queue adapters, on Redis or on PostgreSQL. The pg-boss engine, stable since 2.4.0, mounts a board over a pg-boss schema, with pages of its own. The shell around them (sidebar, command palette, themes, auth, server adapters, NestJS module, CLI) is the same.
| Queue system | Engine | Entry point | Docs |
| ----------------------------------- | ------- | ------------------- | ------------------------------------------------------------ |
| Bull | BullMQ | `BullAdapter` | [Bull →](/worker-manager/queue-adapters/bull.md) |
| BullMQ (Redis, or PostgreSQL on v6) | BullMQ | `BullMQAdapter` | [BullMQ →](/worker-manager/queue-adapters/bullmq.md) |
| BullMQ Pro | BullMQ | `BullMQProAdapter` | [BullMQ Pro →](/worker-manager/queue-adapters/bullmq-pro.md) |
| pg-boss | pg-boss | `createPgBossBoard` | [pg-boss →](/worker-manager/queue-adapters/pg-boss.md) |
The rest of this page is about the BullMQ engine's queue adapters, which `@worker-manager/api` ships with; third-party queue systems can add their own. The pg-boss engine takes no queue adapters: it lists the queues of its schema itself. See [its page](/worker-manager/queue-adapters/pg-boss.md).
`BullMQProAdapter` extends `BullMQAdapter` to handle [Pro groups](https://docs.bullmq.io/bullmq-pro/introduction). All `BullMQAdapter` options work the same way on it.
`BullMQAdapter` covers BullMQ v5 and v6, including [v6 queues stored in PostgreSQL](/worker-manager/recipes/postgres-backend.md). See [supported versions](/worker-manager/queue-adapters/bullmq.md#supported-versions) for the two differences you can see in the UI.
## Capabilities
What the board offers depends on what the library behind a queue can do. Each BullMQ-engine queue reports it in `capabilities` on `GET /api/queues` (from the adapter's `getCapabilities()`), and the UI shows a control only when its capability is on, rather than switching on the library name. A pg-boss board reports its own set in `capabilities` on `GET /api/pg-boss/info`.
| | Bull | BullMQ on Redis | BullMQ on PostgreSQL | BullMQ Pro | pg-boss |
| ------------------------------------------------------------------- | ---------------------------- | ----------------------------------------------------- | --------------------------------------- | --------------------------------------- | --------------------------------------------- |
| Pause and resume a queue | Yes | Yes | Yes | Yes | No |
| Paused tab | Yes | v5 only | No | Like the BullMQ it runs on | No |
| Job logs | Yes | Yes | Yes | Yes | No |
| Job progress | Yes | Yes | Yes | Yes | No |
| Flows | No | Graph | Graph | Graph | Dependency lists |
| Promote a delayed job | Yes | Yes | Yes | Yes | No |
| Edit a job's data | Yes | Yes | Yes | Yes | No |
| Change a job's priority | No | Yes | Yes | Yes | No |
| Remove a parent's unprocessed children | No | Yes | Yes | Yes | No |
| Retry a failed job | Yes | Yes | Yes | Yes | Yes |
| Retry a completed job | No | Yes | Yes | Yes | No |
| Cancel and resume a job | No | No | No | No | Yes |
| Global concurrency | No | Yes | Yes | Yes | No |
| Configured rate limit | No | When the queue has `setGlobalRateLimit` | When the queue has `setGlobalRateLimit` | When the queue has `setGlobalRateLimit` | No |
| Workers panel | Yes | Yes | Yes, from `pg_stat_activity` | Yes | No |
| Throughput chart (the library's own metrics) | Yes | Yes | Yes | Yes | No |
| [Historical metrics](/worker-manager/recipes/historical-metrics.md) | No | Yes | Yes | Yes | Yes |
| Schedules | Repeatable jobs, remove only | Job schedulers (`every`, cron): edit, run now, remove | Job schedulers: edit, run now, remove | Job schedulers: edit, run now, remove | Cron and RRULE: create, edit, run now, remove |
| Datastore panel | Redis `INFO` | Redis `INFO` | PostgreSQL | Redis `INFO` | PostgreSQL and the pg-boss schema |
| Groups | No | No | No | Yes | No |
A few notes on the table:
- **Paused tab.** BullMQ v6 dropped the paused job state, on Redis and on PostgreSQL alike: a paused queue's jobs are stored as `waiting`. The queue still shows its paused banner and the buttons still work.
- **Rescheduling a delayed job** is offered on every BullMQ-engine queue. Bull cannot do it and answers the request with `ERRORS.JOB_EDIT_NOT_SUPPORTED`.
- **Workers panel.** Turned off everywhere by `showWorkers: false`.
- **pg-boss** has states BullMQ does not (`retry`, `cancelled`) and none of pause, logs, progress, workers or rate limits. See [what does not exist there](/worker-manager/queue-adapters/pg-boss.md#what-does-not-exist-here).
## Shared options
All BullMQ-engine adapters accept the same optional options:
| Option | Type | Default | Description |
| ----------------------- | --------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `readOnlyMode` | `boolean` | `false` | Hides all queue and job actions. |
| `allowRetries` | `boolean` | `true` | Shows or hides the retry buttons on **failed** jobs. Forced to `false` when `readOnlyMode` is `true`. |
| `allowCompletedRetries` | `boolean` | `true` | Shows or hides the retry button on **completed** jobs. Only takes effect when `allowRetries` is `true`. Always `false` on `BullAdapter` (Bull can't retry completed jobs). |
| `description` | `string` | `''` | Queue description text displayed in the UI. |
| `displayName` | `string` | `''` | Overrides the queue name shown in the UI. |
| `prefix` | `string` | `''` | Prepended to job names in the UI. |
| `delimiter` | `string` | `''` | Delimiter between the prefix and the job name. |
| `externalJobUrl` | `(job) => { href, displayText? }` | none | Links each job card to a page in your own app. See [External job URLs](/worker-manager/recipes/external-job-url.md). |
| `jobDataSchema` | `object` (JSON Schema) | none | Describes the shape of a job's `data`. Drives the **Add job** form: prefills the editor with a starting value and turns on schema-aware autocomplete and validation. See [Job data schema](#job-data-schema). |
## Job data schema
Pass a [JSON Schema](https://json-schema.org/) as `jobDataSchema` to teach the dashboard what a queue's job `data` looks like. The **Add job** form then does three things with it:
- **Prefills** the job data editor with a starting value: the schema's `default`, otherwise its first `examples` entry, otherwise a skeleton built from `properties` (each key seeded with its own `default` or a typed placeholder).
- **Autocompletes** the expected keys as you type, with any `description` shown on hover.
- **Validates** the JSON against the schema inline, flagging missing required fields, wrong types, and unknown keys before you submit.
```ts
new BullMQAdapter(resetPassword, {
jobDataSchema: {
type: 'object',
additionalProperties: false,
required: ['userId', 'email'],
properties: {
userId: { type: 'string', description: 'Internal id of the user requesting the reset.' },
email: { type: 'string', format: 'email', description: 'Address the reset link is sent to.' },
locale: { type: 'string', description: 'BCP-47 locale for the email template.', default: 'en' },
},
},
});
```

The schema is documentation for the dashboard only. It is not enforced by Bull or BullMQ, so keep it in step with what your worker actually expects.
## Instance methods
All adapters expose `setFormatter` and `setVisibilityGuard`:
```ts
adapter.setFormatter('name', (job) => `#${job.name}`);
adapter.setFormatter('data', (data) => redact(data));
adapter.setFormatter('returnValue', (value) => redact(value));
adapter.setFormatter('progress', (progress) => `${Math.round(progress)}%`);
adapter.setVisibilityGuard((request) => {
// return true to show this queue, false to hide it
return request.headers['x-tenant-id'] === 'acme';
});
```
## Mixing adapters
You can mix Bull and BullMQ queues in the same board. pg-boss queues cannot join them: a pg-boss board is a board of its own, which can sit [next to this one](/worker-manager/recipes/multiple-dashboards.md#bullmq-and-pg-boss-side-by-side).
```ts
createWorkerManagerBoard({
queues: [
new BullAdapter(bullQueue),
new BullMQAdapter(bullmqQueue),
new BullMQProAdapter(bullmqProQueue),
],
serverAdapter,
});
```
---
url: /worker-manager/queue-adapters/pg-boss.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# pg-boss
[pg-boss](https://github.com/timgit/pg-boss) is a job queue that lives entirely in PostgreSQL. `@worker-manager/pg-boss` mounts a whole board over one pg-boss schema: the same shell, sidebar, command palette, themes and auth as a BullMQ board, with pages built around what pg-boss actually stores. It lists queues with their policy and cached counters, shows jobs in all six pg-boss states, and lets you retry, cancel, resume, delete and send jobs and edit schedules.
A board runs one engine. BullMQ and pg-boss queues never share a board. If your app has both, mount [two boards side by side](/worker-manager/recipes/multiple-dashboards.md#bullmq-and-pg-boss-side-by-side).

Open the pg-boss demo to click around it. The header's links menu switches between the BullMQ and pg-boss boards.
## When to use it
- Your jobs are in pg-boss and you want a board for them next to, or instead of, the official `@pg-boss/dashboard`, with the auth, server adapters, NestJS module and CLI Worker Manager already has.
- You run BullMQ and pg-boss in the same system and want both behind the same login, with the same look.
If your queues are BullMQ v6 queues stored in PostgreSQL, you don't need this: that is still the BullMQ engine. See [PostgreSQL backend](/worker-manager/recipes/postgres-backend.md).
## Requirements
| | Supported |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| pg-boss | `^12.24.0` |
| pg-boss schema version | 35 to 42 tested (35 is pg-boss 12.24.0, 42 is 12.33.0 and 12.34.0). Newer schemas are read, see [newer pg-boss schemas](#newer-pg-boss-schemas). |
| Node.js | 22.12 or later, pg-boss's own floor. The rest of Worker Manager stays on Node.js 20. |
| PostgreSQL | Whatever your pg-boss supports. CockroachDB, YugabyteDB and PGlite are not tested. |
### Stability
The pg-boss engine is stable since Worker Manager 2.4.0 and follows semver like the BullMQ engine: a breaking change to its screens' behaviour or to the `/api/pg-boss` HTTP contract only ships in a major release. That promise covers the supported range above, `pg-boss ^12.24.0` on schemas 35 to 42. A newer schema is not a breaking change either: the board probes it, keeps reading, and switches off and names only what it lacks (see [newer pg-boss schemas](#newer-pg-boss-schemas)).
pg-boss 11 and older use a different schema and are not supported. Schedule previews (the next runs column, and the preview in the schedule editor) and RRULE schedules need pg-boss 12.31 or later. On 12.24 to 12.30 the schedules page still lists and edits cron schedules, with no next runs.
## Install
```sh
npm install @worker-manager/pg-boss @worker-manager/express
```
`@worker-manager/pg-boss` depends on `pg` and takes `@worker-manager/api` and `pg-boss` as peers. `pg-boss` is an optional peer: it is only loaded when the board has no instance of yours to use, to write and to preview schedules (see [connection modes](#connection-modes)).
## Mount it
The board mounts on any Worker Manager server adapter. With Express:
```ts
import express from 'express';
import { PgBoss } from 'pg-boss';
import { ExpressAdapter } from '@worker-manager/express';
import { createPgBossBoard } from '@worker-manager/pg-boss';
const boss = new PgBoss(process.env.DATABASE_URL!);
await boss.start(); // your app's instance, started by your app as usual
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/pg-boss');
const board = createPgBossBoard({
serverAdapter,
pgBoss: {
instance: boss, // writes go through your instance
connection: process.env.DATABASE_URL, // reads go through a small pool of the board's own
schema: 'pgboss',
delimiter: '.', // groups `emails.welcome` under `emails` in the sidebar
},
options: { readOnly: false },
});
const app = express();
app.use('/pg-boss', serverAdapter.getRouter());
app.listen(3000);
// On shutdown: closes the board's own pool. Your instance and your pools stay yours.
await board.close();
```
The same thing, elsewhere:
- **NestJS**: `WorkerManagerModule.forRoot({ name: 'pgboss', engine: 'pg-boss', pgBoss: { ... } })`. See [the NestJS pg-boss board](/worker-manager/server-adapters/nestjs.md#pg-boss-board).
- **CLI**: `npx @worker-manager/cli --pg-boss postgres://app:secret@localhost:5432/app`, on its own or next to a BullMQ board. See [the CLI's pg-boss section](/worker-manager/guide/cli.md#pg-boss).
- **Docker**: `WORKER_MANAGER_PGBOSS_URL`. See [Run with Docker](/worker-manager/guide/docker.md).
- **Any other framework**: swap `ExpressAdapter` for the [server adapter](/worker-manager/server-adapters/index.md) you use. Nothing else changes.
`createPgBossBoard` returns `{ engine, close() }`. `engine` is what [`pgBossMetricsSources`](/worker-manager/recipes/historical-metrics.md#pg-boss-queues) records history from.
### Options
`pgBoss`:
| Option | Default | Description |
| ----------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `instance` | | Your app's `PgBoss`, already started. Writes go through it. |
| `connection` | | A connection string, a `pg` pool config, or a `pg.Pool` of yours (borrowed, never closed). Reads go through it. |
| `schema` | `'pgboss'` | The schema pg-boss was installed in. One board reads one schema; use a board per schema. |
| `queues` | every queue | An allowlist of names, or a predicate. Any other queue answers 404, in the UI and in the API. |
| `includeInternalQueues` | `false` | Show pg-boss's own `__pgboss__*` queues. |
| `delimiter` | none | Groups queue names in the sidebar, for example `'.'`. |
| `queryTimeoutMs` | `5000` | `statement_timeout` of every read. See [query timeout](#query-timeout). |
| `countCap` | `10000` | The live per-state counts stop here and show `10k+`. |
| `visibilityGuard` | | `(request, queueName) => boolean \| Promise`, asked per request. A hidden queue answers 404, like a missing one. |
| `allowUntestedSchema` | `false` | Write to a pg-boss schema newer than the newest this release is tested with. Such a schema is always read; without this option it is never written. See [newer pg-boss schemas](#newer-pg-boss-schemas). |
`options` takes the usual [board options](/worker-manager/configuration/ui-config.md) (`uiConfig`, `historyProvider`, `handlerHooks`, `validateResponses`, `uiBasePath`) plus `readOnly`.
## Connection modes
Pass `instance`, `connection`, or both:
| You pass | Reads | Writes |
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `instance` and `connection` (recommended) | A pool of the board's own (3 connections, `application_name` `worker-manager`), with a real server-side `statement_timeout`. A `pg.Pool` of yours is borrowed instead, with the timeout set per read-only transaction. | Your instance. |
| `instance` only | Your instance's database. The timeout can only be enforced on the client side. | Your instance. |
| `connection` only | As in the first row. | A pg-boss instance the board builds per command and **never starts**, and only while the database is on exactly the schema version the installed pg-boss writes. Otherwise the board stays readable and says why writes are off. |
In the last mode the board needs `pg-boss` installed next to it, which is also where schedule previews come from when there is no instance. Without it, or with a pg-boss whose schema version differs from the database's (your app is on an older or newer pg-boss), the board is read-only and shows the reason in a banner: "The database is on pg-boss schema 41, but the pg-boss available here writes schema 42."
## We never migrate or alter your database
pg-boss's `start()` migrates the schema by default, and with `migrate: false` it still demands an exact version match and starts maintenance timers. The board never calls it. What it does and does not do:
- It never calls `start()`, `stop()`, `supervise()` or a migration, and never creates a schema, table or index. The one pg-boss instance it can build itself is never started, so no timer runs and nothing is monitored or scheduled from the board's process.
- Reads are plain `SELECT`s against the pg-boss tables. It does not call `getQueueStats()`, which can `UPDATE` the queue table when its cache is stale.
- Writes go through pg-boss's public API (`send`, `retry`, `cancel`, `resume`, `deleteJob`, `deleteQueuedJobs`, `deleteStoredJobs`, `schedule`, `unschedule`), so pg-boss's own rules for singletons, dead letters and flows hold. There is no SQL `UPDATE` of its tables.
- A version guard reads the schema version, and a probe of `information_schema` reads which tables and columns the schema has. Below 35, or with no pg-boss installed in that schema, the board reads nothing and says so on every page. Above 42 it keeps reading; see [newer pg-boss schemas](#newer-pg-boss-schemas). The indexes below are recommendations for you to create; the board never creates them.
## Newer pg-boss schemas
Upgrading pg-boss in your app past the newest schema this release was tested with does not take the board down. The board treats the schema the way pg-boss's own dashboard does:
- **It probes instead of assuming.** When the board starts, and again only if the schema version changes, one `information_schema.columns` query lists the columns of `queue`, `job`, `schedule`, `job_dependency`, `queue_stats` and `warning`. Every read is built from that list: a column that is gone reads as empty rather than failing the query, and a table that is gone turns its feature off.
- **It keeps reading everything that is still there.** Queues, jobs, counts, job pages and schedules work as before.
- **It turns off only what is missing**, and a banner on every page names it: "pg-boss schema v43 is newer than tested (max v42); some features are disabled: warnings." The same list is in `GET /api/pg-boss/info` as `untested`, `features` and `disabledFeatures`, and a route whose feature is off answers 409 `ERRORS.PGBOSS_FEATURE_UNAVAILABLE`.
- **Writes stay off** on an untested schema, with `ERRORS.PGBOSS_SCHEMA_UNTESTED` as the reason, unless the board sets `allowUntestedSchema: true`. Writes go through pg-boss's own API, so what they touch is pg-boss's business, but a schema no release of the board was tested against is opt-in. With `connection` only, the board's own pg-boss must also be on that exact schema version, as always.
```ts
createPgBossBoard({
serverAdapter,
pgBoss: { instance: boss, connection: process.env.DATABASE_URL, allowUntestedSchema: true },
});
```
The same probe covers a schema inside the tested range that lacks something, for example a `warning` table dropped by hand: that feature is off and the banner says so. A schema missing a column no read can do without (`job.state`, `queue.name` and a few others) is not read at all, with `ERRORS.PGBOSS_SCHEMA_INCOMPATIBLE` naming the columns. Schemas older than 35 are refused as before, since pg-boss 12.24 is the floor the queries are written for.

## What the board shows
The queue page has one tab per pg-boss state, in pg-boss's own order: `created`, `retry`, `active`, `completed`, `cancelled`, `failed`. Two conditions that are not states show as badges on a job: **deferred** (a `created` job whose `startAfter` is still in the future) and **blocked** (a flow dependent still waiting on the jobs it depends on).

The job list pages by keyset, newest first, with Previous and Next rather than numbered pages. Search by exact job id, or filter by singleton key.
Above the tabs, the **queue depth** chart shows the ready, deferred, active and failed jobs of the queue over the last hour, 6 hours, 24 hours or 7 days, from the snapshots pg-boss's monitor writes to `queue_stats`. Each point is the highest value seen in its interval, so a short spike is never averaged away. The snapshots only exist for queues that run with `persistQueueStats` while some instance supervises them; until then the chart says how to turn them on. Only the one-hour window follows the polling interval.

Tick the jobs on a page, or all of them with the box in the toolbar, to act on them at once. A bar at the bottom of the page counts the selection and offers the commands the state tab accepts: retry on `failed`, resume on `cancelled`, cancel on `created`, `retry` and `active`, delete everywhere but `active`. On the tab of every state it offers only what every selected job accepts. Each bulk command asks for confirmation with the count, and the toast says how many jobs actually changed, since a job that moved on in the meantime is skipped. The selection clears when the tab, the page or the filter changes. A read-only board, or one whose writes are off, shows no checkboxes.


To open a job whose queue you do not know, paste its id into the command palette (Ctrl/⌘ K), which offers "Open job …", or into the search box on the overview. `GET /api/pg-boss/jobs/:jobId` looks it up with the queues named, so both columns of pg-boss's `(name, id)` primary key are in the index condition and nothing scans the job table. Only the queues the caller may see are searched: a job on a queue hidden by `queues`, `includeInternalQueues` or `visibilityGuard` answers 404, exactly like an id that does not exist.
A job page has its data, its output (the result on a completed job, the error on a failed one), its options, a timeline, its dependencies (what it waits on and what waits on it, as two lists of links) and, for a job pg-boss moved to a dead letter queue, the job it came from.

Actions follow the job's state:
| State | Actions |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `created` | Cancel, delete, duplicate |
| `retry` | Cancel, delete |
| `active` | Cancel. The confirmation warns that this does not stop a handler already running in another process: the job is marked cancelled, and the worker's later `complete()` changes nothing. Delete is refused with 409. |
| `completed` | Delete, duplicate |
| `cancelled` | Resume, delete |
| `failed` | Retry, delete, duplicate |
Per queue, the actions menu has **Send job** (data, priority, start after, singleton key, retry limit, delay and backoff, expiry), **Retry all failed**, **Delete queued jobs** (everything that has not started) and **Delete finished jobs** (completed, cancelled and failed). The HTTP API also takes up to 100 job ids at once for retry, cancel, resume and delete. A job that changed state in the meantime answers 409 instead of being touched.
The schedules page lists every cron and RRULE schedule with its time zone, its next runs (worked out on the server by pg-boss's `previewSchedule()`), the last job it sent, and actions to create, edit, remove or run one now. Running one now sends a job with the schedule's data and options, which is exactly what pg-boss's timekeeper does when it fires.

The header's datastore panel shows the PostgreSQL server, the pg-boss schema and its version, the supported range, and whether the board can write.
The **warnings** page lists what pg-boss reported about its own health, newest first: slow queries, queue backlogs, a pinned transaction horizon, disabled autovacuum, bloated indexes, clock skew and invalid schedules, with a filter by type and each warning's details. It reads pg-boss's `warning` table, which is only written when an instance runs with `persistWarnings: true`; with none, the page says so. It pages by date on pg-boss's own `warning_i1 (created_on DESC)` index. A warning that names a queue the viewer cannot see, as its queue, quoted in its message or among a slow query's parameters, is never listed. The overview has a card with the five most recent. The page is read-only: pg-boss prunes the table itself after `warningRetentionDays`, and the board never deletes anything.

## What does not exist here
These are BullMQ ideas pg-boss does not have, so the board does not pretend to offer them:
- **Pause and resume a queue.** pg-boss has no paused queue.
- **Job logs and progress.** pg-boss stores neither.
- **Workers.** pg-boss does not register its workers anywhere the board could read.
- **Rate limits and global concurrency.** Not pg-boss features. Its policies (`singleton`, `stately`, `exclusive`, `key_strict_fifo`, `short`) are shown as they are.
- **Promote, and editing a job's data, delay or priority.** pg-boss has no promote; its `update()` is not wired into the board yet.
- **Flows as a graph.** pg-boss flows are a DAG, shown as lists of dependencies and dependents.
- **Creating, updating or deleting queues**, and redriving a dead letter queue. A queue's life cycle belongs to your app's code.
- **Searching job data.** Search is by id and by singleton key, and by id across every visible queue from the command palette.
The board-wide capabilities are listed per library in the [overview table](/worker-manager/queue-adapters/index.md#capabilities).
## Freshness of the counters
The overview, the sidebar and the queue cards read the counters pg-boss caches in its `queue` table (`queued`, `deferred`, `ready`, `active`, `failed`, `total`). Those are only written by the `supervise` loop of some pg-boss instance, every `monitorIntervalSeconds` (60 seconds by default). So:
- The overview shows when the counters were written ("Counters from 25 seconds ago").
- Older than five minutes, a banner says no instance running `supervise` has refreshed them since. With no pg-boss instance supervising at all, they stay at zero, and the banner says the queues were never monitored.
- The tabs on a queue page do not use the cache. They count each state live, capped at `countCap` (`10k+` above it), and only on the queue you have open.
If the cards look frozen while jobs are clearly moving, check that at least one instance of your app runs pg-boss with `supervise` on (the default).
## Query timeout
Every read runs under a `statement_timeout` of `queryTimeoutMs` (5 seconds by default). A read that runs out answers `ERRORS.PGBOSS_QUERY_TIMEOUT` and the page suggests filtering by id or singleton key. A count that runs out shows `?` on its tab while the list keeps working.
The timeout is only enforced by the server when the board reads through a `connection`. PostgreSQL arms `statement_timeout` when a statement starts, so it cannot be set from inside the single statement a pg-boss instance's `executeSql` runs. With `instance` alone, the board stops waiting after `queryTimeoutMs` and answers the timeout, but the query keeps running on the server until it finishes. Pass a `connection` for reads whenever you can.
## Recommended indexes
pg-boss's own indexes are built for fetching work, not for browsing it. Two optional indexes make the board and its history cheap on large queues. The board never creates them, and pg-boss's schema drift check lists them under `extraIndexes` without failing (its reindex tooling treats them like its own).
### Job lists
Listing `completed`, `failed` or `cancelled` jobs of a queue has no index to follow: PostgreSQL reads every row of the queue in that state and sorts it. With 5 million jobs in the shared `job_common` table, the first page of `completed` took 3.4 seconds in our benchmark; with this index it takes under a millisecond, and the six live counts drop from 2.5 to 1.0 seconds:
```sql
CREATE INDEX CONCURRENTLY wm_job_list ON pgboss.job_common (name, state, created_on DESC, id DESC);
```
A queue created with `partition: true` has a table of its own, which needs the same index:
```sql
SELECT name, table_name FROM pgboss.queue WHERE partition;
CREATE INDEX CONCURRENTLY wm_job_list_ ON pgboss. (name, state, created_on DESC, id DESC);
```
Without it everything still works; only those lists are slower, and a very large one runs into the [query timeout](#query-timeout) cleanly.
### History
[Historical metrics](/worker-manager/recipes/historical-metrics.md#pg-boss-queues) count finished jobs by the minute of `completed_on`, which needs an index on `(name, completed_on)`. This is the statement `pgBossMetricsIndexDdl()` returns, and the one the recorder prints when it finds no such index:
```sql
CREATE INDEX wm_job_completed_on ON pgboss.job (name, completed_on);
```
On a busy database, build it without locking writes: the recipe has the `CONCURRENTLY` variant per partition. Until it exists, that queue's throughput and latency history stays off and the recorder says so once.
## A read-only PostgreSQL role
A read-only board only ever runs `SELECT`, so it can connect as a role that cannot change anything:
```sql
CREATE ROLE wm_reader LOGIN PASSWORD 'change-me';
GRANT USAGE ON SCHEMA pgboss TO wm_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA pgboss TO wm_reader;
-- pg-boss creates a table per partitioned queue later; grant those too, as the role that owns pgboss:
ALTER DEFAULT PRIVILEGES FOR ROLE app IN SCHEMA pgboss GRANT SELECT ON TABLES TO wm_reader;
```
```ts
createPgBossBoard({
serverAdapter,
pgBoss: { connection: 'postgres://wm_reader:change-me@db:5432/app', schema: 'pgboss' },
options: { readOnly: true },
});
```
Pass no `instance` here: with `readOnly: true` nothing writes, and the board's mutation routes are not even registered, so a forged request gets a 404. The test suite runs the whole read surface as a role with nothing but `USAGE` and `SELECT`. See [Read-only mode](/worker-manager/recipes/read-only-mode.md#pg-boss-boards) for how `readOnly` behaves on a pg-boss board.
## Historical metrics
`@worker-manager/metrics` records throughput and latency history for pg-boss queues as well, from pg-boss's finished jobs. See [the pg-boss section of the historical metrics recipe](/worker-manager/recipes/historical-metrics.md#pg-boss-queues).
## HTTP API
A pg-boss board serves its own routes under `/api/pg-boss/*` (plus `/api/metrics/*` with a history provider), and none of the BullMQ `/api/queues` routes. They are listed under the `pg-boss` tag in the [HTTP API reference](/worker-manager/api/index.md). Errors are translation keys, like everywhere else in the API. The contract follows semver: a breaking change only ships in a major release (see [stability](#stability)).
---
url: /worker-manager/recipes/access-control-hooks.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Access control hooks
> Applies to: all adapters.
A [visibility guard](/worker-manager/recipes/visibility-guard.md) decides which queues a request may see, and [read-only mode](/worker-manager/recipes/read-only-mode.md) decides whether a queue accepts writes at all. Neither can express "support may retry a job but not obliterate a queue". `handlerHooks` can: it runs a function of your own before every API call, and lets you decide from the method and route whether that particular call goes through.
```ts
createWorkerManagerBoard({
queues: [new BullMQAdapter(emailQueue)],
serverAdapter,
options: {
handlerHooks: {
before: ({ method, route, request }) => {
const role = decodeUserFromHeaders(request.headers)?.role;
if (role === 'admin') return;
if (method === 'get') return;
return { allow: false };
},
},
},
});
```
That gives everyone read access and reserves every write for admins.
## The before hook
`before` receives `{ method, route, request }`. `method` is the lowercase HTTP method, `route` is the route pattern rather than the concrete URL (`/api/queues/:queueName/:jobId/retry`, not `/api/queues/emails/42/retry`), and `request` is the same `WorkerManagerRequest` a visibility guard gets, carrying `headers`, `params`, `query` and `body`.
Return nothing, or `{ allow: true }`, and the request proceeds. Return `{ allow: false }` and it stops there with **403** and the `ERRORS.FORBIDDEN` key. Both parts are overridable:
```ts
before: ({ route }) => {
if (route.includes('obliterate')) {
return {
allow: false,
status: 405,
errorKey: 'ERRORS.QUEUE_READ_ONLY',
message: 'Obliterate is disabled on this board.',
};
}
},
```
`errorKey` has to be one of the keys in the `ErrorTranslationKey` union, because [the API never puts English in an `error` field](/worker-manager/configuration/ui-config.md). `message` is the optional free-text detail, and it is the one place a hook can phrase something itself.
The hook may be async. If it throws, the request fails with **500** and `ERRORS.INTERNAL_SERVER_ERROR` rather than falling through, so a bug in your own authorisation code denies the call instead of allowing it.
## The after hook
`after` receives the same context plus the handler's `{ status?, body }`, and returns the response to actually send. Use it to redact a field, or to log what the board did:
```ts
handlerHooks: {
after: (context, result) => {
auditLog.write({ method: context.method, route: context.route });
return result;
},
},
```
It does not run when `before` denied the request, so an `after` that writes an audit line records what happened, not what was attempted. Log denials in `before` if you need those too.
## What hooks do not cover
The hooks wrap the JSON API only. The dashboard's own HTML and its static assets never pass through them, so a `before` that denies everything still serves the page: you get a board that loads and then fails to fetch anything. If you want unauthorised people not to reach the dashboard at all, that is [basic auth](/worker-manager/recipes/basic-auth.md) or your framework's own middleware in front of the mount path.
They also run alongside the queue-level checks rather than replacing them. A call a hook allows still has to satisfy the queue's visibility guard and its `readOnlyMode`. A hook can take permissions away, then, but it cannot hand any back.
## Source of truth
The wrapper is [`packages/api/src/hooks.ts`](https://github.com/naldomadeira/worker-manager/blob/main/packages/api/src/hooks.ts). `BoardHooks`, `HookContext` and `BeforeHookResult` are in [`packages/api/typings/app.d.ts`](https://github.com/naldomadeira/worker-manager/blob/main/packages/api/typings/app.d.ts).
---
url: /worker-manager/recipes/alerting.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Alerting on failed jobs
> Applies to: all adapters.
Worker Manager is a viewer, not a monitor. It shows the state of your queues while you have the tab open. It doesn't watch them for you and it sends nothing anywhere, so "how do I get alerted when a job fails?" isn't a question the dashboard answers. That part is on you, and it belongs in your worker code, not the board.
BullMQ already emits the events you need. Wire your alerting to those, and use Worker Manager to investigate once an alert fires.
## Alert from the worker (same process)
If the alert lives in the same process as the worker, listen to its `failed` event. In most cases you only want to page once a job has spent all its retries, not on every intermediate attempt:
```ts
import { Worker } from 'bullmq';
const worker = new Worker('emails', processor, { connection });
worker.on('failed', (job, err) => {
// `failed` fires on every attempt. Only alert once retries are exhausted.
const exhausted = !job || job.attemptsMade >= (job.opts.attempts ?? 1);
if (exhausted) {
notifyOnCall(`Job ${job?.id} on "emails" failed for good: ${err.message}`);
}
});
```
Attach an `error` listener too. Without one, an error inside the worker can bubble up as an unhandled exception and take the process down:
```ts
worker.on('error', (err) => {
logger.error({ err }, 'bullmq worker error');
});
```
## Alert from anywhere (cross-process)
Worker events only fire in the process running the worker. If your alerting service runs in a separate process (an API pod, a small dedicated watcher), use `QueueEvents`, which reads the events straight from Redis:
```ts
import { QueueEvents } from 'bullmq';
const events = new QueueEvents('emails', { connection });
events.on('failed', ({ jobId, failedReason }) => {
notifyOnCall(`Job ${jobId} on "emails" failed: ${failedReason}`);
});
events.on('stalled', ({ jobId }) => {
// A worker picked the job up and then went silent (crash, OOM, event-loop block).
notifyOnCall(`Job ${jobId} on "emails" stalled`);
});
```
`QueueEvents` gives you `jobId` and `failedReason`, but not the `Job` instance, so the "retries exhausted?" check from the worker version isn't available here. If you only want the final failure, either keep that logic in the worker, or fetch the job (`queue.getJob(jobId)`) and inspect `attemptsMade` yourself.
## Route permanently-failed jobs to a dead-letter queue
A common pattern: once a job is truly dead, push it onto a separate "dead-letter" queue, then register that queue on the board. Now permanently-failed work has its own inbox you can inspect and replay from the dashboard, instead of hunting through the failed tab of a busy queue.
```ts
const deadLetters = new Queue('emails-dead-letter', { connection });
worker.on('failed', async (job, err) => {
if (job && job.attemptsMade >= (job.opts.attempts ?? 1)) {
await deadLetters.add('dead', { original: job.data, reason: err.message });
}
});
// Register both on the board so the DLQ is one click away.
createWorkerManagerBoard({
queues: [new BullMQAdapter(emailsQueue), new BullMQAdapter(deadLetters)],
serverAdapter,
});
```
Mark the dead-letter queue [read-only](/worker-manager/recipes/read-only-mode.md) if it's only there to be read.
## What the dashboard does give you
Not alerts, but three things worth knowing:
- **No workers warning.** A queue that has nothing consuming it and isn't paused is flagged on the overview, which is the case a rising waiting count on its own can't tell you about. It's on by default and can be turned off with `showWorkers: false` in [UIConfig](/worker-manager/configuration/ui-config.md). See [Exploring the dashboard](/worker-manager/guide/exploring-the-dashboard.md). Still only visible while someone has the tab open, so it catches a dead worker during triage rather than waking anyone up.
- **Throughput chart.** Set `showMetrics: true` in [UIConfig](/worker-manager/configuration/ui-config.md) for a completed/failed-per-minute chart per queue. It needs [BullMQ metrics](https://docs.bullmq.io/guide/metrics) enabled on your workers. Good for spotting a spike after the fact, not for waking anyone up.
- **Job logs.** Lines your worker writes with `job.log()` show up under each job. When an alert points you at a failed job, that's where the "what happened" usually is. See [Job logs and flows](/worker-manager/recipes/job-logs-and-flows.md).
## Going deeper
Alerting well (deduping, escalation, telling a stalled worker apart from a genuinely failing job) is a BullMQ and ops concern, not a Worker Manager one. The BullMQ docs are the source for the event semantics:
- [Events](https://docs.bullmq.io/guide/events): the full `QueueEvents` list.
- [Workers](https://docs.bullmq.io/guide/workers): worker-level listeners and the `error` event.
- [Retrying failing jobs](https://docs.bullmq.io/guide/retrying-failing-jobs): attempts, backoff, and what "failed for good" means.
---
url: /worker-manager/recipes/basic-auth.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Add basic auth
Don't expose the dashboard on the open internet without auth. The quickest route is the built-in
middleware from `@worker-manager/auth`, which works on every Node framework. The framework-native
approaches further down remain valid alternatives when you already have a login in your app.
## Built-in middleware
```sh
npm install @worker-manager/auth
```
```ts
import { createAuthMiddleware } from '@worker-manager/auth';
const auth = createAuthMiddleware(
{
strategy: 'basic',
users: [{ username: 'admin', password: process.env.BOARD_PASSWORD!, roles: ['admin'] }],
// Optional: checked when no static user matches, e.g. against your user table.
validate: async (username, password) => (await checkUser(username, password)) ?? false,
},
{ basePath: '/ui' }
);
// Express
app.use('/ui', auth, serverAdapter.getRouter());
```
On Fastify, wrap the board plugin so the hook only covers the board:
```ts
import { createAuthMiddleware, createFastifyAuthPlugin } from '@worker-manager/auth';
app.register(createFastifyAuthPlugin(serverAdapter.registerPlugin(), auth), { prefix: '/ui' });
```
On NestJS, pass the same object as the module's `auth` option:
```ts
WorkerManagerModule.forRoot({
auth: { strategy: 'basic', users: [{ username: 'admin', password: process.env.BOARD_PASSWORD! }] },
});
```
Credentials are compared in constant time (both sides hashed, then `crypto.timingSafeEqual`).
A failure answers `401` with a `WWW-Authenticate: Basic` challenge and
`{ "error": { "key": "ERRORS.UNAUTHORIZED" } }`, so the page, the API and the assets are all
covered. `GET /ui/auth/me` returns the signed-in user. For single sign-on, the same package does
[Keycloak](/worker-manager/recipes/keycloak-auth.md).
The standalone [CLI](/worker-manager/guide/cli.md#basic-auth) uses the same middleware behind `--user`/`--password`.
## Alternatives per framework
Here's the minimum per framework if you would rather use its own auth tooling.
### Express + Passport
From [`examples/express/custom-login`](https://github.com/naldomadeira/worker-manager/tree/main/examples/express/custom-login).
```js
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const { ensureLoggedIn } = require('connect-ensure-login');
const session = require('express-session');
passport.use(new LocalStrategy((username, password, cb) => {
if (username === 'bull' && password === 'board') {
return cb(null, { user: 'worker-manager' });
}
return cb(null, false);
}));
passport.serializeUser((user, cb) => cb(null, user));
passport.deserializeUser((user, cb) => cb(null, user));
app.use(session({ secret: 'keyboard cat', resave: true, saveUninitialized: true }));
app.use(passport.initialize());
app.use(passport.session());
app.post('/ui/login', passport.authenticate('local', { failureRedirect: '/ui/login?invalid=true' }),
(req, res) => res.redirect('/ui'));
app.use('/ui', ensureLoggedIn({ redirectTo: '/ui/login' }), serverAdapter.getRouter());
```
A logged-in session reaches `/ui` without a second login, which is all "auto-login" really means for a cookie-based app. If your API uses bearer tokens instead, see [Auto-login from a token-based frontend](#auto-login-from-a-token-based-frontend).
Run it:
```sh
git clone https://github.com/naldomadeira/worker-manager
cd worker-manager/examples/express/custom-login
npm install && npm start
# http://localhost:3000/ui (login: bull / board)
```
### Fastify + @fastify/basic-auth
From [`examples/fastify/auth`](https://github.com/naldomadeira/worker-manager/tree/main/examples/fastify/auth).
```js
await app.register(require('@fastify/basic-auth'), {
validate: (username, password, req, reply, done) => {
if (username === 'bull' && password === 'board') return done();
done(new Error('Unauthorized'));
},
authenticate: { realm: 'Worker Manager' },
});
app.after(() => {
const serverAdapter = new FastifyAdapter();
createWorkerManagerBoard({ queues: [new BullMQAdapter(queue)], serverAdapter });
serverAdapter.setBasePath('/ui');
app.register(serverAdapter.registerPlugin(), { prefix: '/ui' });
app.addHook('onRequest', (req, reply, next) => {
app.basicAuth(req, reply, (err) => err ? reply.code(401).send({ error: err.name }) : next());
});
});
```
The `onRequest` hook covers every route registered after it. Scope the auth plugin inside a child context if you want it to cover only the dashboard.
### Hapi + strategy
From [`examples/hapi/auth`](https://github.com/naldomadeira/worker-manager/tree/main/examples/hapi/auth).
```js
await app.register(require('@hapi/basic'));
app.auth.strategy('simple', 'basic', {
validate: async (_req, username, password) => ({
isValid: username === 'bull' && password === 'board',
credentials: { username },
}),
});
const serverAdapter = new HapiAdapter();
createWorkerManagerBoard({ queues: [new BullMQAdapter(queue)], serverAdapter });
serverAdapter.setBasePath('/ui');
await app.register(
{ plugin: serverAdapter.registerPlugin(), options: { auth: 'simple' } },
{ routes: { prefix: '/ui' } }
);
```
The plugin options pass straight to Hapi's route config, so the auth strategy applies to every Worker Manager route.
### NestJS + guards
From [`examples/nestjs/fastify-custom-auth`](https://github.com/naldomadeira/worker-manager/tree/main/examples/nestjs/fastify-custom-auth).
NestJS on the Fastify platform with a standard `@UseGuards()` guard. The example uses passport-local plus `@fastify/secure-session` for session cookies.
```ts
@Controller()
export class AppController {
@Post('login')
@UseFilters(AuthExceptionFilter)
@UseGuards(AuthGuard('local'))
login(@Request() req: FastifyRequest, @Response() reply: FastifyReply) {
req.session.set('sev-data', req.user);
return reply.status(302).redirect('/queues');
}
}
```
The dashboard is mounted by `@worker-manager/nestjs`, and the module's own guard checks the session before the route resolves.
## Auto-login from a token-based frontend
If your app uses a cookie session, you already have auto-login. The browser sends the session cookie on every request, including when someone opens `/ui`, so a logged-in admin lands on the dashboard through your existing middleware without logging in again. There's nothing else to do.
Bearer tokens are where it gets awkward. When a separate SPA authenticates by sending an `Authorization: Bearer` header, opening `/ui` in a new tab won't carry that header. It's just a normal browser navigation, so your token middleware turns it away. What the dashboard needs is a cookie the browser will send on its own.
So give it one. Add an admin-only endpoint that logs the user into a session and hands back the URL:
```js
// Guarded by your normal bearer-token middleware, admin only.
app.get('/api/queue-monitor', requireAdmin, (req, res) => {
req.login(req.user, (err) => { // sets the session cookie
if (err) return res.status(500).end();
res.json({ url: `${req.protocol}://${req.get('host')}/ui` });
});
});
```
The SPA calls it with its token and opens the URL it gets back:
```js
const { url } = await api.get('/api/queue-monitor');
window.open(url, '_blank', 'noopener,noreferrer');
```
The new tab now has a session cookie, so the `ensureLoggedIn` gate from the Express example lets it through. It's the same session a normal login would create; you're just creating it on demand.
> Don't put the credentials in the URL. A link like `https://user:pass@host/ui` is an easy one-click shortcut, but those credentials end up in the address bar, the browser history, and referrer headers. Use a cookie.
## Combine with read-only mode
Auth keeps strangers out. [Read-only mode](/worker-manager/recipes/read-only-mode.md) keeps authenticated users from running destructive actions. Use both for public-facing status boards.
---
url: /worker-manager/recipes/change-polling-interval.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Polling interval
The dashboard polls `GET /api/queues` to refresh. Default is 5 seconds. Two knobs in UIConfig.
## Let users pick
```ts
serverAdapter.setUIConfig({
pollingInterval: { showSetting: true },
});
```
Adds a picker in the settings modal. The browser persists the user's choice in localStorage.
## Force a specific interval and hide the picker
`forceInterval` is in **seconds**, the same unit as the default of 5:
```ts
serverAdapter.setUIConfig({
pollingInterval: { forceInterval: 10 },
});
```
Good for busy dashboards where you want to guarantee a floor, or slow ones where you want to cap load.
Don't go below 1 second. You'll hammer Redis without gaining anything visible.
---
url: /worker-manager/recipes/csrf-protection.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# CSRF protection
The dashboard's destructive actions (retry, clean, pause, obliterate) are state-changing PUT/POST calls. If the dashboard lives on the same origin as an untrusted user session, protect those calls with CSRF tokens.
From [`examples/express/csrf`](https://github.com/naldomadeira/worker-manager/tree/main/examples/express/csrf). The example uses [`csrf-csrf`](https://github.com/Psifi-Solutions/csrf-csrf) (double-submit cookie pattern). The older `csurf` package is deprecated, don't reach for it.
```js
const { doubleCsrf } = require('csrf-csrf');
const cookieParser = require('cookie-parser');
const { doubleCsrfProtection, generateToken } = doubleCsrf({
getSecret: () => 'Secret',
ignoredMethods: ['GET', 'HEAD', 'OPTIONS'],
getTokenFromRequest: (req) => req.headers['x-xsrf-token'] || '',
cookieName: 'x-csrf-token',
cookieOptions: { secure: process.env.NODE_ENV === 'production' },
});
app.use(cookieParser());
app.get('/ui/*', (req, res, next) => {
if (['api', 'static'].every((part) => !req.path.includes(`/${part}/`))) {
const token = generateToken(req, res, true);
res.cookie('XSRF-TOKEN', token, {
sameSite: 'lax',
path: '/ui/',
secure: process.env.NODE_ENV === 'production',
});
}
next();
});
app.use('/ui', doubleCsrfProtection, serverAdapter.getRouter());
```
Two pieces are at play. `doubleCsrfProtection` rejects any non-GET request to `/ui` that lacks a valid token. The `XSRF-TOKEN` cookie is readable by the dashboard's bundled Axios, which mirrors it into the `x-xsrf-token` header on every request. That matches what `getTokenFromRequest` pulls off.
The extra `res.cookie` dance exists because the base cookie set by `csrf-csrf` is `httpOnly` (tracked in a pending upstream PR). Dashboard JS needs a readable copy, hence the second cookie.
If you're behind a login with `SameSite=Lax` session cookies, modern browsers already block most cross-site POSTs. Belt-and-suspenders CSRF on top doesn't hurt for high-value dashboards.
## See also
- [Basic auth](/worker-manager/recipes/basic-auth.md). A login is usually a prerequisite before CSRF matters.
---
url: /worker-manager/recipes/custom-auth.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Custom auth
The `custom` strategy of `@worker-manager/auth` hands the decision to your code: validate a
Cloudflare Access JWT, reuse your app's API-key check, trust a header set by an authenticating
proxy. The rest of the middleware behaves as for the built-in strategies: `req.user` is set, the
`onAuthenticated` hook runs, `GET ${basePath}/auth/me` answers, and the dashboard header shows who
is signed in.
```sh
npm install @worker-manager/auth
```
## Options
| Option | Description |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `authenticate(req)` | Required. Resolve an `AuthUser` (`{ username, name?, email?, roles? }`) to let the request in, or `null`/`undefined` to reject it. A thrown error fails the request with that error. |
| `onUnauthenticated(req, res)` | Optional. Answers a rejected request instead of the default `401` JSON: a redirect, a challenge header, a page. If it leaves the response unsent, the default `401` is sent after it. |
| `logoutUrl` | Optional. Where the dashboard's "Sign out" item points. Hidden when unset. |
| `onAuthenticated(user, req)` | Optional. Return `false` to answer `403`. |
`req` is Node's `IncomingMessage`, so the same function works on Express, Fastify, Koa and NestJS.
The default rejection is `401` with `{ "error": { "key": "ERRORS.UNAUTHORIZED" }, "code": "UNAUTHORIZED" }`.
## Cloudflare Access
Cloudflare Access puts a signed JWT in the `Cf-Access-Jwt-Assertion` header of every request it
lets through. Verify it against your team's keys with [`jose`](https://github.com/panva/jose):
```ts
import { createRemoteJWKSet, jwtVerify } from 'jose';
import type { CustomAuthOptions } from '@worker-manager/auth';
const TEAM = 'https://your-team.cloudflareaccess.com';
const jwks = createRemoteJWKSet(new URL(`${TEAM}/cdn-cgi/access/certs`));
export const cloudflareAccess: CustomAuthOptions = {
strategy: 'custom',
async authenticate(req) {
const token = req.headers['cf-access-jwt-assertion'];
if (typeof token !== 'string') return null;
try {
const { payload } = await jwtVerify(token, jwks, {
issuer: TEAM,
audience: process.env.CF_ACCESS_AUD, // the application's AUD tag
});
const email = String(payload.email);
return { username: email, email, roles: [] };
} catch {
return null;
}
},
logoutUrl: '/cdn-cgi/access/logout',
};
```
Always verify the JWT. The plain `Cf-Access-Authenticated-User-Email` header can be forged by
anything that reaches your origin without going through Cloudflare.
## Reusing an API-key check (NestJS)
```ts
WorkerManagerModule.forRootAsync({
imports: [ApiKeysModule],
inject: [ApiKeysService],
useFactory: (apiKeys: ApiKeysService) => ({
route: '/admin/queues',
auth: {
strategy: 'custom',
authenticate: async (req) => {
const key = req.headers['x-api-key'];
const owner = typeof key === 'string' ? await apiKeys.verify(key) : null;
return owner?.scopes.includes('queues:admin')
? { username: owner.name, roles: owner.scopes }
: null;
},
},
}),
});
```
## Customising the rejection
```ts
{
strategy: 'custom',
authenticate: readSessionFromMyApp,
onUnauthenticated: (req, res) => {
res.statusCode = 302;
res.setHeader('Location', `/login?next=${encodeURIComponent(req.url ?? '/')}`);
res.end();
},
}
```
Redirect only page loads: the dashboard's own API calls expect a JSON `401`. Checking
`req.headers.accept?.includes('text/html')` is enough to tell them apart.
---
url: /worker-manager/recipes/external-job-url.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Link jobs to your own admin
If your jobs exist in your own admin app too (an order, an email, a rendered report), you can link each dashboard job card to the corresponding page in your app.
Pass `externalJobUrl` to the queue adapter:
```ts
import { BullMQAdapter } from '@worker-manager/api/bullMQAdapter';
const adapter = new BullMQAdapter(ordersQueue, {
externalJobUrl: (job) => ({
displayText: `Order ${job.data.orderId}`,
href: `https://admin.example.com/orders/${job.data.orderId}`,
}),
});
```
The function receives the job's JSON representation and returns `{ href, displayText? }`. The dashboard adds a link next to the job name pointing at your URL.
Useful when debugging from the dashboard and you want to jump straight to the real-world record.
---
url: /worker-manager/recipes/formatters.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Formatters
> Applies to: all adapters.
Formatters rewrite how a job's fields render in the dashboard without touching producer or worker code. Useful when `data`, `returnValue`, `name`, or `progress` need a presentation layer: masking secrets, summarising big payloads, humanising enums, prefixing names in multi-tenant setups.
## Register a formatter
```ts
import { createWorkerManagerBoard } from '@worker-manager/api';
import { BullMQAdapter } from '@worker-manager/api/bullMQAdapter';
const adapter = new BullMQAdapter(emailQueue);
adapter.setFormatter('data', (data) => ({
...data,
apiKey: data.apiKey ? '***' : undefined,
}));
createWorkerManagerBoard({ queues: [adapter], serverAdapter });
```
`setFormatter` is per queue adapter. Each queue has its own set, only the fields you register are transformed.
## Available fields
| Field | Formatter receives | Must return | Notes |
| --------------- | --------------------------------------------------------- | --------------------------- | --------------------------------------------------------------------------------------- |
| `'data'` | `job.data` | any JSON-serialisable value | Runs for every job. Good for redaction and summarisation. |
| `'returnValue'` | `job.returnvalue` | any JSON-serialisable value | Only meaningful on completed jobs. |
| `'name'` | the full `QueueJobJson` (`id`, `name`, `data`, `opts`, …) | `string` | Compose a display name from multiple fields. The raw job name stays at `jobProps.name`. |
| `'progress'` | `job.progress` (raw value) | any JSON-serialisable value | Typed as `string \| boolean \| number \| object`; return whatever the UI should render. |
## When formatters run
- On every job-list request and every job-detail request.
- Server-side, inside the API handler, before the response is sent.
- Not persisted. Your Redis data is untouched, only the response payload is rewritten.
## Performance
Formatters run once per job per request, the dashboard polls at a fixed interval, so the real cost is `cost per call × jobs per page × polls per minute`. Keep them cheap:
- No I/O, network, or DB lookups.
- Avoid large intermediate allocations, prefer in-place masking over deep clones.
- If the transform is expensive, cache the derived value on `data` at enqueue time instead.
## Source of truth
`setFormatter` and the `format` dispatch live on `BaseAdapter` in [`packages/api/src/queueAdapters/base.ts`](https://github.com/naldomadeira/worker-manager/blob/main/packages/api/src/queueAdapters/base.ts). The `FormatterField` union is in [`packages/api/typings/app.d.ts`](https://github.com/naldomadeira/worker-manager/blob/main/packages/api/typings/app.d.ts). Formatters are applied in [`packages/api/src/handlers/queues.ts`](https://github.com/naldomadeira/worker-manager/blob/main/packages/api/src/handlers/queues.ts) (`formatJob`).
---
url: /worker-manager/recipes/global-concurrency.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Global concurrency
BullMQ supports a global cap on concurrent jobs across all workers for a queue. The dashboard can read and change it.
## Set from the UI
Open the queue's actions dropdown, pick **Set concurrency**, enter a number. The current value is shown in the queue info panel, under **Global concurrency**.
Worker Manager calls `Queue.setGlobalConcurrency(n)` on your behalf. Workers respect the new cap on their next job pickup. Setting it to 0 removes the limit.
## Set in code
```ts
await queue.setGlobalConcurrency(5);
```
The value is stored in Redis so any worker across any process sees it.
Bull-only queues: global concurrency isn't supported. The UI hides the control.
## Read-only mode
Read-only mode disables the control. If you want stakeholders to see the number but not change it, `readOnlyMode: true` is the switch.
---
url: /worker-manager/recipes/historical-metrics.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Historical metrics
> Applies to: BullMQ and [pg-boss](#pg-boss-queues) boards.
Worker Manager is a viewer, not a monitor, and its built-in throughput chart reflects that: it reads BullMQ's native `queue.getMetrics()`, a per-minute ring buffer capped at `maxDataPoints`, scoped to a single queue, and only as deep as that buffer's window. Restart the buffer's window, or just wait long enough, and the older points are gone. There's no long history and no cross-queue total, because BullMQ was never asked to keep one.
`@worker-manager/metrics` is an opt-in companion package that fills that gap. It doesn't replace the live chart, it adds a second, longer-retention path behind it: a recorder that snapshots the native metrics into Redis (or [PostgreSQL](#postgresql-storage)) before they roll off, and a history provider you register with `createWorkerManagerBoard` that lets the UI read them back.
## How it fits together
Two pieces, living in two different places.
`MetricsRecorder` runs in your own always-on process, typically wherever your workers already live. On an interval, it reads each queue's native completed/failed per-minute metrics and writes them into long-retention Redis buckets: a daily rollup per queue, plus a cross-queue global rollup. Writes are idempotent by minute, so it's safe to run the recorder in several processes, or restart it, without double-counting. There's no singleton to coordinate and no leader election.
`RedisMetricsHistoryProvider` runs wherever you build the board. You pass it to `createWorkerManagerBoard({ options: { historyProvider } })`. The core itself only defines the `MetricsHistoryProvider` interface and stays stateless: registering a provider just turns on one additional read endpoint that delegates to it. `@worker-manager/metrics` is the batteries-included implementation, with Redis and PostgreSQL storage, but if you already have a metrics store of your own, you can implement the interface directly instead of adopting this package. With no provider configured, nothing about the board changes.
## Without an app: the CLI and the Docker image
If you aren't embedding Worker Manager in an app at all, the [standalone CLI](/worker-manager/guide/cli.md) and the [Docker image](/worker-manager/guide/docker.md) do both halves for you. `@worker-manager/metrics` ships as part of `@worker-manager/cli`, and `--history` registers the provider and starts a recorder in the same process:
```sh
npx @worker-manager/cli --redis redis://localhost:6379 --history
docker run --rm -p 127.0.0.1:3000:3000 ghcr.io/naldomadeira/worker-manager \
--redis redis://redis:6379 --history
```
On a PostgreSQL-only board (`--postgres` with no Redis) the history goes into [PostgreSQL](#postgresql-storage), in `worker_manager_metrics_*` tables in the BullMQ schema, created on start unless the board is `--read-only`.
The flag turns on `showMetrics` as well, so the range selector appears on queue pages and not only on the Metrics history page. `--history-retention-days` sets the window; per-tier retention, the snapshot interval and `latency: false` go in the CLI's config file under a `history` key. `--read-only` keeps the provider and drops the recorder, which is what you want when your workers already record and the CLI is only there to read.
Everything below still applies, from the precondition on your workers to the storage footprint and the Redis keys involved. The one difference is that the recorder follows the CLI's discovery instead of a fixed list, so a queue that shows up between rescans starts recording on the next tick.
## Precondition: native metrics must be on
The recorder can only snapshot what BullMQ is already collecting, so your Workers need native metrics enabled with a window wide enough to survive any recorder downtime:
```ts
import { Worker, MetricsTime } from 'bullmq';
const worker = new Worker(name, processor, {
connection,
metrics: { maxDataPoints: MetricsTime.ONE_WEEK },
});
```
If metrics aren't enabled on the workers, `queue.getMetrics()` returns nothing, and the recorder has nothing to snapshot. Registering the `historyProvider` still turns the history UI on, but with no snapshots behind it the charts simply render empty, the same way the live metrics view does when metrics are off. A week-long window gives the recorder plenty of slack to catch up after a deploy or an outage before any minute falls out of the ring buffer unrecorded.
## PostgreSQL-backed queues
BullMQ 6 can back a queue with PostgreSQL instead of Redis, and the recorder records those queues like any other. Counter metrics come from `queue.getMetrics()`, whose per-minute buffer the adapter dates from BullMQ's own `metrics` table, since BullMQ's PostgreSQL backend reports `prevTS` as 0. Latency and queue age are read from BullMQ's `job` table through the queue's own pool: finished jobs by finish time, which BullMQ indexes, and the oldest waiting job from a few probes on the ready index rather than a scan of the backlog. A paused or prioritized job is an ordinary `waiting` row there, so both count towards the queue age, as they do on Redis.
Where a queue lives and where its history is stored are independent. On a board that also has Redis, PostgreSQL queues record into Redis next to the Redis ones. On a board with no Redis at all, the history goes into PostgreSQL too.
## PostgreSQL storage
For a deployment that runs BullMQ 6 entirely on PostgreSQL, `PostgresMetricsStore` keeps the history in the same database. It has the same tiers, retention, cross-queue rollup, latency histograms and queue-age gauge as Redis, behind the same provider contract, so the charts and the storage panel behave identically. `pg` is an optional peer dependency of the package; install it only for this.
```ts
import {
MetricsRecorder,
PostgresMetricsHistoryProvider,
PostgresMetricsStore,
} from '@worker-manager/metrics';
const store = new PostgresMetricsStore({
connection: process.env.DATABASE_URL, // or a pg.Pool, or a pool config
schema: 'bullmq', // default: a pool config's `schema`, then `public`
migrate: true, // create or upgrade the tables on first use
});
// In the process where your workers run:
const recorder = new MetricsRecorder({ queues, store, retentionDays: 90 });
recorder.start();
// Where you build the board:
createWorkerManagerBoard({
queues,
serverAdapter,
options: {
uiConfig: { showMetrics: true },
historyProvider: new PostgresMetricsHistoryProvider({ store, retentionDays: 90 }),
},
});
// On shutdown:
recorder.stop();
await store.close(); // ends the pool only if the store created it
```
`connection` takes what BullMQ's PostgreSQL backend takes, so the object you already give BullMQ can be reused, `schema` key included. The recorder and the provider never close a store they were handed; the provider can also be given `{ connection, schema, tablePrefix, migrate }` directly, in which case it owns the store and `await provider.disconnect()` closes it. `new MetricsHistoryAdmin({ store })` gives the same `stats()` and `purge()` as on Redis. Pass `onSnapshotError` to the recorder to hear about a tick that failed because the database was unreachable; the next tick simply retries.
### Tables and migrations
Four tables in `schema`, each named with `tablePrefix` (default `worker_manager_metrics_`), which is also how two boards share one database:
::: tip Upgrading from 1.x
The default was `bull_board_metrics_` before v2.0, and the tables are not renamed for you. Pass `tablePrefix: 'bull_board_metrics_'` to keep the history a 1.x board recorded.
:::
| Table | Holds |
| -------------------------------------- | -------------------------------------------------------------------------------------- |
| `worker_manager_metrics_counters` | completed/failed sums per minute, hour and day, and the queue-age max per hour and day |
| `worker_manager_metrics_histograms` | runtime and waittime bucket counts per hour and day |
| `worker_manager_metrics_sampler_state` | each queue's sampling lease and watermark |
| `worker_manager_metrics_meta` | the schema version |
Rows are keyed by `(queue, metric, tier, bucket)`, where `bucket` is a UTC minute, hour or day index and `__global__` is the cross-queue rollup.
`migrate: true` creates whatever is missing on first use, in one transaction behind an advisory lock, so several processes starting together is fine. It creates the schema only if it does not exist, so an existing schema needs no database-level privilege. Without `migrate`, the first query checks the version and fails with instructions. Where the application role has no DDL rights, run the migration as a deploy step:
```ts
import { migratePostgresMetrics } from '@worker-manager/metrics';
await migratePostgresMetrics({ connection: process.env.DATABASE_URL, schema: 'bullmq' });
```
### Same guarantees, in SQL
Every snapshot of a queue's metric is one transaction that diffs the incoming minutes against the stored minute rows and adds only the difference to the hour and day rows and to `__global__`, under an advisory lock on that queue and metric. Re-snapshotting after a restart, or from a second recorder, adds nothing, exactly as the Redis script does. The sampling lease is an upsert that only replaces an expired lease, on the database clock, so two recorders never sample the same queue on the same tick. Instead of TTLs, each writer deletes rows past each tier's window once a day. Autovacuum reclaims the space.
### Sizing
A PostgreSQL row costs more than a Redis hash field: about 180 bytes per minute or hour counter and about 300 bytes per histogram row, indexes included. A queue busy every minute takes about 250 KB per metric per day of minute detail, or roughly 6.5 MB per busy queue at the default retention across counters, histograms and the gauge, plus the same once for the cross-queue rollup. That is several times the Redis figures in [Storage footprint](#storage-footprint), and the minute window is still the one to tune.
In the storage panel, `keys` counts rows and `bytes` is the tables' actual on-disk size (`pg_total_relation_size`, indexes and TOAST included) split between queues and tiers in proportion to their rows' size, so the per-queue figures add up to what the tables occupy.
## Job latency
Alongside the completed/failed counters, the recorder also tracks two histograms per queue: wait time and run time. Wait time is `processedOn - timestamp`, how long a job sat before a worker picked it up, and it's the signal that says you need more workers. Run time is `finishedOn - processedOn`, how long the handler itself took, and it's the signal that says the handler regressed. They're kept as two separate numbers rather than one combined figure because they point at different fixes: a wait spike means scale out, a run spike means look at the handler.
Collecting them needed no changes to your workers. BullMQ's `moveToFinished` does `ZADD targetSet, timestamp, jobId` when a job completes or fails, so the completed and failed sets are sorted sets scored by finish time. On the same tick that snapshots the counter metrics, the recorder scans each finished set past a watermark it keeps per queue, and that scan returns exactly the jobs that finished since the last tick, no more and no less, so there are no gaps and nothing is double-counted. Because it reads job hashes and sorted sets BullMQ already writes, latency sampling doesn't depend on `queue.getMetrics()` or the `metrics` worker option at all; the precondition above is only for the counter metrics.
The most important thing to understand about the wait histogram is what it can't see. It's built entirely from jobs that finished, so when a queue is genuinely backed up and jobs stop finishing, the histogram goes quiet exactly when the number matters most: a stalled queue looks identical to an idle one. That's why the recorder also records a queue-age gauge, the age of the oldest job still waiting, and the UI overlays it on the same axis as the wait chart. It keeps reporting when the histogram can't, because it doesn't depend on anything finishing. It's the same reason Sidekiq's `Queue#latency` measures how long the oldest job has been waiting rather than the latency of jobs it has already run.
Retries are excluded from the wait histogram, but not the run histogram. A job's `timestamp` field is set once, at creation, but `processedOn` is overwritten on every attempt, so a retried job's naive wait time would absorb every prior attempt and all the backoff between them. Run time has no such problem: every attempt gets its own sample, retried or not.
Percentiles read off these histograms are estimates, not exact values, bounded by the width of the bucket a sample landed in. The bucket layout is fixed and not something you can configure per queue, because two ranges holding different bucket layouts can't be merged into one percentile: a multi-day query has to combine buckets from different days, and that only works if every day used the same layout.
If a queue is configured with `removeOnComplete: true`, jobs are deleted the instant they finish, so there is nothing left in the completed set for the next tick to scan, and no latency data will ever appear for it. There's no error and no warning, just an empty chart. If latency data isn't showing up for a queue, this is the first thing to check. Removing jobs by hand does the same thing on a smaller scale: deleting a job, cleaning a finished set, or retrying a failed job takes it out of the set before the next tick reads it, so up to a tick's worth of samples can go missing. The counter charts are unaffected by any of that, because BullMQ counts a job as it finishes and never decrements when it is removed, which is why cleaning out old failures does not lower yesterday's failure line.
Latency shares the chart area with throughput behind a tab, on a queue's own page and on the Metrics history page alike, so you get one chart at a time rather than a stack of them. The totals above the chart follow the tab: completed and failed counts on Throughput, p95 run and p95 wait for the selected range on Latency. Wait time carries the queue-age gauge as a dashed line on the same axis, since both are durations:

Run p95, wait p95 and queue age are drawn by default; p50 and p99 for each are a click away in the legend, and what you enable is remembered. The axis is logarithmic, and labelled as such. Latency spans orders of magnitude, so on a linear axis a p99 measured in minutes flattens p50 and p95 into a single line along the bottom and the chart stops saying anything about the typical case.
The newest point on any chart is drawn dashed while its bucket is still filling, because today's day, or the current hour, only covers the time elapsed so far and would otherwise read as a sudden drop.
Latency sampling is on by default whenever the recorder runs. Set `latency: false` to turn it off:
```ts
const recorder = new MetricsRecorder({
queues: [new BullMQAdapter(myQueue)],
connection: redisOptions,
latency: false, // optional, default true
});
```
A latency tick that fails is swallowed rather than propagated, so a broken scan can't take the counter snapshot down with it, which also means a collector that has been failing since startup looks exactly like a board with no traffic. Pass `onLatencyError: (error, queueName) => log(error)` to tell the two apart; it stays silent if you don't.
## Install
```bash
yarn add @worker-manager/metrics
```
`ioredis` is a peer dependency; you already have it if you're using BullMQ. For [PostgreSQL storage](#postgresql-storage), add `pg` as well.
## Set up the recorder
In the process where your workers run:
```ts
import { MetricsRecorder } from '@worker-manager/metrics';
import { BullMQAdapter } from '@worker-manager/api/bullMQAdapter';
const recorder = new MetricsRecorder({
queues: [new BullMQAdapter(myQueue)],
connection: redisOptions, // same ioredis connection options your app uses
retentionDays: 90, // optional, default 90
snapshotIntervalMs: 60_000, // optional, default 60s
});
recorder.start();
```
`queues` also takes a function, resolved on every tick rather than once. Reach for it when the queue set changes at runtime, whether you're discovering queues the way the CLI does or adding them through `addQueue`:
```ts
const recorder = new MetricsRecorder({
queues: () => currentAdapters,
connection: redisOptions,
});
```
Retention is enforced by Redis itself, so old buckets expire on their own and there's nothing to prune by hand. Buckets are UTC-aligned, and every key the recorder writes is namespaced under `worker-manager:metrics:`, so it can't collide with BullMQ's own keys. Pass `prefix` to move that namespace, which is what separates two boards sharing one Redis:
```ts
const recorder = new MetricsRecorder({ queues, connection, prefix: 'staging:metrics' });
```
Give the provider and any `MetricsHistoryAdmin` the same prefix. A provider pointed at a namespace nothing writes to reports empty history rather than an error, so a mismatch looks like a board that never recorded anything.
::: tip Upgrading from 1.x
The default namespace was `bull-board:metrics` (`{bull-board:metrics}` on a cluster) before v2.0, and nothing is migrated. Pass `prefix: 'bull-board:metrics'` to the recorder, the provider and any `MetricsHistoryAdmin` to keep the history a 1.x board recorded.
:::
On shutdown, call `recorder.stop()`. It clears the snapshot interval and, if the recorder created its own Redis connection internally, closes it too. If you passed in your own `Redis` instance, `stop()` leaves that connection alone, so it's a safe no-op to call either way.
## Storage footprint
Worth understanding before you turn this on, since it writes to the same Redis your queues run on.
### Three resolutions, three retentions
Every snapshot is written at three resolutions at once: per-minute buckets, an hourly rollup, and a daily total. They cost wildly different amounts, so each has its own retention:
| Tier | What it holds | Default retention | Size per busy day, per queue and metric |
| ------ | --------------------------------------------- | ----------------- | --------------------------------------- |
| Minute | One entry per minute that had activity | 7 days | \~72 KB |
| Hour | 24 entries per day | 90 days | \~0.3 KB |
| Day | One entry per day, in a single hash per queue | 90 days | \~15 bytes |
Those figures are measured, not estimated: `packages/metrics/tests/footprint.spec.ts` writes real data through the real code path and asserts them. Absolute numbers shift a little with your Redis version and `hash-max-listpack-entries`, so the test pins the ratios rather than exact bytes and prints what it measured.
The minute tier is roughly 240 times more expensive than the hourly rollup covering the same day. That single ratio is what the whole design turns on.
### What that adds up to
At the defaults, tracking both completed and failed:
| Scenario | Per queue |
| ------------------------------------------ | --------- |
| Busy every minute, around the clock | \~1.1 MB |
| Bursty, active roughly a tenth of the time | \~120 KB |
| Idle | \~0 |
Plus the cross-queue rollup, which costs about as much as one queue running at the combined rate, once, no matter how many queues you register.
Idle time is free. A minute with a zero count is never written, so the footprint follows how busy a queue actually is, not how long it has been recording.
### Why the rollups exist
Keeping 90 days of minute-level detail costs about 15 MB per queue. Keeping 90 days of _hourly_ detail costs about 55 KB, and the daily charts the UI actually draws don't read either one, they read the daily totals.
So rather than storing one expensive resolution and throwing detail away later, the recorder writes all three as it goes. Every tier is derived from the same delta in the same atomic script, so they can't drift apart, and there is no compaction job to schedule, resume, or make idempotent after a crash.
That's what lets the minute window default to 7 days instead of 90, which is where the \~93% saving comes from. Nothing you can see in the UI changes.
### Tuning it
```ts
const recorder = new MetricsRecorder({
queues: [new BullMQAdapter(myQueue)],
connection: redisOptions,
retention: {
minutes: 7, // days of minute-level detail
hours: 90, // days of hourly rollup
days: 90, // days of daily totals
},
});
```
Every tier is optional and falls back to the default. Pass the same `retention` to `RedisMetricsHistoryProvider` so reads use the same window.
The minute window is the one to think about, for two reasons. It is where essentially all the storage goes, and it doubles as the recorder's catch-up window: after downtime, the recorder will not backfill minutes older than it. Set it to at least as long as you'd want to recover from an outage, and no longer than your workers' `maxDataPoints` buffer can supply anyway. The default of 7 days lines up with the recommended `MetricsTime.ONE_WEEK`.
Cutting `minutes` to 1 takes a busy queue from \~1.1 MB to \~200 KB, at the cost of only being able to catch up on a day of downtime. Hourly and daily history are unaffected either way.
`retentionDays: N` still works as a shorthand. It sets the hourly and daily windows to `N` and leaves the minute window at its default, so an existing config can't accidentally ask for a year of minute-level detail.
### Nothing grows without a ceiling
Day-scoped keys carry a TTL that is only refreshed while that day is being written, so each one dies its tier's retention after the day it covers. The daily totals hashes are written every day, so their TTL keeps rolling forward; they're trimmed instead, dropping entries that fall outside the window on the first write of each new day. Both paths are covered by tests.
Upgrading from an earlier version is safe: days recorded before the hourly tier existed are still served, by folding their minute buckets on read.
### What latency costs
The figures above are for the completed/failed counters. Latency histograms and the queue-age gauge use a different, packed storage format, so they're measured separately against a real Redis rather than derived from the same arithmetic: a queue where most jobs land in a couple of buckets each hour costs a lot less than one where all 18 buckets fill up every hour, and only a real measurement across both shapes tells you which end of that range to expect.
| Scenario | Measured |
| ---------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
| One queue, 90 day retention, both histograms plus the queue-age gauge, realistic concentrated distribution | 254.5 KB |
| Same, pathological: all 18 buckets populated heavily every hour | 574.6 KB |
| Shared `__global__` cross-queue rollup | \~224 KB once, for the whole board |
| `sample()` wall time at 1000 finished jobs in one tick | 9.11 ms |
| Redis round trips per queue per tick | \~12, flat in job count |
| Subsampling cap holds tick time bounded | 5000 jobs: 30.39 ms, 10000 jobs: 29.94 ms |
The line that decides whether this is affordable on a large board is the global rollup: it's a single shared cost for the whole board, not multiplied per queue, because there is exactly one `__global__` key no matter how many queues are registered. 200 queues at typical traffic is roughly 200 × 254.5 KB, about 50 MB, plus the one shared 224 KB rollup, not 200 copies of it.
The round-trip count staying flat, and tick time barely moving between 5000 and 10000 jobs, come from the same design decision: above `maxSamplesPerTick`, the sampler takes a uniform subset of the finished jobs and scales the counts back up, rather than fetching every job, so a tick against a queue processing thousands of jobs a minute costs about the same as one processing hundreds.
## Inspecting and clearing history from the board
When the configured provider supports it (the shipped `RedisMetricsHistoryProvider` does), a **Storage** entry appears in the actions menu on the Metrics history page, next to the range selector. It's tucked into the menu because it's an occasional maintenance task rather than something you'd read day to day. Usage is only fetched when you open it, since measuring real memory use means reading every history key.

It shows the total footprint broken down by tier, so an unexpectedly large minute tier is visible at a glance, along with a per-queue table and the range of days on record.

Two actions sit underneath it:
- **Keep only the last 7d / 30d / 90d** deletes everything recorded before the range you're currently charting.
- **Clear all history** deletes everything, for every queue.
Both open a confirmation that spells out exactly what goes: the cutoff date or the total size, that it can't be undone, and that the deleted days will stop appearing in the charts. Recording carries on either way, so new data starts accumulating from the next snapshot.

The actions are hidden when every registered queue is in `readOnlyMode`, matching how the rest of the board treats destructive operations. If your provider implements `getUsage` but not `purge`, the modal renders read-only.
One caveat on per-queue purges: the cross-queue rollup is corrected by subtracting the queue's own recorded values, so it needs those keys to still exist. A queue whose keys were already removed out of band, or whose minute hashes have aged out, can leave a residue in the rollup that a per-queue purge can't reach. Clearing all history resets it.
## Inspecting and clearing history from code
`MetricsHistoryAdmin` is the same maintenance surface as a plain library object, for a debug endpoint, a one-off script, or a cleanup job.
```ts
import { MetricsHistoryAdmin } from '@worker-manager/metrics';
const admin = new MetricsHistoryAdmin({ connection: redisOptions });
const stats = await admin.stats();
// {
// keys: 182, bytes: 1543210, minutes: 12480,
// oldestDay: '2026-04-23', newestDay: '2026-07-22',
// tiers: { minute: { keys: 14, bytes: 1400000 }, hour: {...}, day: {...} },
// queues: [{ queue: 'mailer', keys: 91, bytes: 900123, minutes: 7200, tiers: {...} }, ...]
// }
```
`stats()` reports the real Redis footprint (`MEMORY USAGE` per key), split by tier and by queue, largest first, with the cross-queue rollup listed as `__global__`. Measurements go out in pipelined batches, so a namespace of a few thousand keys costs a couple of dozen round trips rather than a few thousand. It still reads every history key though, so treat it as an ops call rather than something to poll.
`purge()` deletes stored history. It's scoped to the recorder's namespace and driven by `SCAN`, so it never blocks Redis and never touches your queues' own keys. Against a cluster both calls scan every master, because `SCAN` carries no key for the client to route by and would otherwise answer from one arbitrary node:
```ts
await admin.purge(); // everything
await admin.purge({ queue: 'mailer' }); // one queue
await admin.purge({ before: '2026-06-01' }); // anything older than a day
await admin.purge({ queue: 'mailer', before: new Date('2026-06-01') });
```
Purging a single queue also subtracts that queue's numbers from the cross-queue rollup, so the Metrics history page reflects the queues that are left instead of keeping a deleted queue's throughput folded into the total. Purging is idempotent and safe to repeat, and the recorder simply starts refilling from the next snapshot.
Call `admin.disconnect()` when you're done. Like the recorder and the provider, it only closes the Redis connection if it opened one itself.
## Register the provider
Where you build the board. The per-queue chart on each queue page needs `showMetrics: true` in `uiConfig` as well (see [UIConfig](/worker-manager/configuration/ui-config.md)); the dedicated "Metrics history" page below doesn't need it, but you'll usually want both:
```ts
import { createWorkerManagerBoard } from '@worker-manager/api';
import { RedisMetricsHistoryProvider } from '@worker-manager/metrics';
createWorkerManagerBoard({
queues,
serverAdapter,
options: {
uiConfig: { showMetrics: true },
historyProvider: new RedisMetricsHistoryProvider({ connection: redisOptions }),
},
});
```
Call `provider.disconnect()` alongside `recorder.stop()` on shutdown. Same rule applies: it only closes the Redis connection if the provider opened it itself.
## What changes in the UI
Once a `historyProvider` is configured, `hasHistoryProvider` flips on and things show up in two places, gated by two separate settings.
With `showMetrics: true`, each queue's metrics chart gains a range selector: 60m, 7d, 30d, 90d. 60m stays exactly what it was, the live native view straight off the ring buffer. The longer ranges switch to reading from the history provider instead. Without `showMetrics: true`, the per-queue chart doesn't render at all, range selector included, regardless of whether a `historyProvider` is set. The chart can also be collapsed with the chevron in its header; the collapsed state is remembered.

A "Metrics history" page also appears in the sidebar, independent of `showMetrics`. It's a cross-queue view, the closest thing Worker Manager has to a wallboard: total completed/failed throughput across every registered queue, over the same range selector, plus a per-queue breakdown table underneath, sorted by total runs.
Each row in that table carries a bar scaled against the busiest queue and split into a completed and a failed segment. Bar length compares volume between queues, the split compares outcomes inside one queue, and hovering a bar gives the exact counts. A queue that only fails a fraction of a percent of its runs would otherwise draw a segment too thin to see, so a non-zero segment never shrinks below 1% of the track. The failure rate is spelled out next to the failed count.

Leave `historyProvider` unset and none of this appears; the board behaves exactly as it did before.
## pg-boss queues
pg-boss keeps no per-minute metrics buffer, so there is nothing for the recorder to snapshot. What it does keep is every finished job, with the time it finished, for as long as the queue's `deleteAfterSeconds` allows. `@worker-manager/pg-boss` turns that into the same history a BullMQ board gets: `pgBossMetricsSources` hands the recorder one source per queue, and each source counts the queue's jobs by the minute of `completed_on`.
```ts
import { createPgBossBoard, pgBossMetricsSources } from '@worker-manager/pg-boss';
import {
MetricsRecorder,
namespacedHistoryProvider,
PostgresMetricsHistoryProvider,
PostgresMetricsStore,
} from '@worker-manager/metrics';
const store = new PostgresMetricsStore({ connection: pool, migrate: true });
const provider = new PostgresMetricsHistoryProvider({ store });
const board = createPgBossBoard({
serverAdapter,
pgBoss: { connection: pool, schema: 'pgboss' },
options: {
uiConfig: { showMetrics: true },
historyProvider: namespacedHistoryProvider(provider, 'pgboss:pgboss:'),
},
});
const sources = pgBossMetricsSources(board.engine);
const recorder = new MetricsRecorder({ store, sources });
recorder.start();
```
`sources` is resolved on every tick, so a queue created after start is recorded from its first tick, and the engine's `queues` allowlist applies. It can also be built without a board, from the same options `createPgBossBoard` takes under `pgBoss`: `pgBossMetricsSources({ connection, schema })`. That opens a reader of its own, which `sources.close()` ends. Nothing is written to the pg-boss schema: the sources only read.
What gets recorded:
| Series | Where it comes from |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Completed, failed | Jobs in state `completed` or `failed`, by the minute of `completed_on`. Cancelled jobs are left out: pg-boss stamps `completed_on` on a cancel too. |
| Run time | `completed_on - started_on` of each finished job. |
| Wait time | `started_on - start_after`, not `- created_on`, so a job deferred on purpose does not count as waiting. Retried jobs (`retry_count > 0`) are left out, as on BullMQ. |
| Queue age | Age of the oldest job that is queued, not blocked by a dependency, and due (`start_after <= now()`). |
A minute is only handed over once it has closed on the database clock, with a five-second margin (`safetyMarginMs`) for a completing transaction that commits after its minute ended. After a restart the recorder resumes from the newest minute it already stored rather than re-reading the whole minute window, because pg-boss may have deleted some of the jobs it counted, and a recount would write a smaller number back over the recorded one.
### The index it needs
The counts and the latency scan read a range of `completed_on` inside one queue. pg-boss has no index for that, so without one every tick reads every retained row of the queue. The sources therefore look for a usable index when a queue first appears (through `pg_indexes`, every five minutes after that), and without one they keep that queue's counters and latency scan off and say so once through `onWarning` (`console.warn` by default). The queue-age gauge stays on either way; pg-boss's own fetch index covers it.
The index, which this package never creates for you:
```sql
CREATE INDEX wm_job_completed_on ON pgboss.job (name, completed_on);
```
`job` is partitioned (a `job_common` default partition plus one table per queue created with `partition: true`), and on a partitioned table that statement takes a lock that blocks writes on every partition while it builds. On a busy schema, build it per partition instead, without blocking:
```sql
CREATE INDEX wm_job_completed_on ON ONLY pgboss.job (name, completed_on);
CREATE INDEX CONCURRENTLY wm_job_common_completed_on ON pgboss.job_common (name, completed_on);
ALTER INDEX pgboss.wm_job_completed_on ATTACH PARTITION pgboss.wm_job_common_completed_on;
-- then the same two statements for every table this lists:
SELECT table_name FROM pgboss.queue WHERE partition;
```
The parent index turns valid once every partition has one attached, and a queue partitioned later gets its own automatically. Any valid btree index whose leading columns are `(name, completed_on)` counts, on `job` or on the queue's own table, with no predicate or one that keeps both `completed` and `failed`. pg-boss's schema drift check lists an index like this under `extraIndexes` without failing, and its reindex tooling handles it like its own.
### Two boards, one store
pg-boss queues record as `pgboss::`, and their cross-queue totals go into `pgboss::__global__` rather than `__global__`. `namespacedHistoryProvider(provider, 'pgboss::')` is the pg-boss board's view of that: queue names without the prefix, and its own global series. `pgBossMetricsNamespace(schema)` and `sources.namespace` both give the prefix. That is what lets a BullMQ board and a pg-boss board in one app share one `PostgresMetricsStore` (or one Redis namespace) with neither global chart counting the other's jobs. One recorder can record both at once: `new MetricsRecorder({ store, queues, sources })`.
The pg-boss board's storage panel lists only its own queues, and its "clear all" stays inside the namespace. The BullMQ board sharing the store is not namespaced, so its storage panel lists the pg-boss queues too, and its "clear all" clears them as well. Clearing a single queue is scoped correctly on both.
### Retention
The series can only reach back as far as pg-boss keeps finished jobs: `deleteAfterSeconds`, seven days by default, which is the same order as the recorder's minute window. A recorder that is down for longer than that loses the minutes in between for good, exactly like a BullMQ recorder that outlives its workers' `maxDataPoints`. Once recorded, the history follows the recorder's retention (90 days by default), however soon pg-boss deletes the jobs.
### Queue depth
With `persistQueueStats: true` on a queue, and some instance running `supervise`, pg-boss keeps its own queue-size snapshots in `queue_stats`. `readPgBossQueueDepth(engine, queue, { from, to, bucketSeconds, aggregate })` folds them into buckets, the same way pg-boss's `getQueueStatsHistoryBucketed` does. The board serves the same series at `GET /api/pg-boss/queues/:queueName/depth?range=1h|6h|24h|7d` and draws it as the queue depth chart on each queue page; see [the pg-boss page](/worker-manager/queue-adapters/pg-boss.md#what-the-board-shows).
## Redis Cluster
Hand `connection` a `Cluster` and the recorder, the provider and the admin all work. One detail of the key layout is worth knowing before you turn it on.
Each snapshot writes a queue's three tiers and the three `__global__` rollup tiers in one `EVAL`. That single script is what makes the write idempotent at every resolution at once: it computes the delta against the minute already stored and applies it everywhere, so a restart or a second recorder re-snapshotting the same window adds nothing. Redis Cluster rejects a multi-key command whose keys fall in different slots, so those six keys have to share one.
The namespace therefore carries a [hash tag](https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/#hash-tags) whenever the connection is a cluster: `worker-manager:metrics` is written as `{worker-manager:metrics}`, and a `prefix` of your own is wrapped the same way unless it already contains a `{...}` tag, in which case yours is used as given and picks the slot.
```ts
new MetricsRecorder({ queues, connection: cluster }); // {worker-manager:metrics}
new MetricsRecorder({ queues, connection: cluster, prefix: 'staging' }); // {staging}
new MetricsRecorder({ queues, connection: cluster, prefix: '{eu}:metrics' }); // {eu}:metrics
```
One slot means a single master holds the history for the whole board. That is the cost of keeping the cross-queue rollup correct on write instead of recomputing it on every read, and it is small: the numbers in [Storage footprint](#storage-footprint) are the whole of it, roughly 50 MB at 200 busy queues, plus one `EVAL` per queue per metric per minute.
Standalone keys are untagged and unchanged, so an existing deployment keeps the history it has. Nothing carries across from a standalone Redis to a cluster, since the key names differ.
Latency sampling reads BullMQ's own keys over the same connection, so your queues need the hash-tagged prefix BullMQ already requires in cluster mode (`new Queue(name, { prefix: '{bull}' })`). Without one a queue's keys scatter across slots and the sampler's pipelines are rejected; it swallows that error to protect the counter snapshot, so pass `onLatencyError` if you want to see it.
The board's own Redis stats panel is cluster-aware too: memory and client counts are summed across the masters and the uptime is the youngest node's, rather than reporting whichever node `INFO` happened to reach.
The [CLI](/worker-manager/guide/cli.md#redis-cluster) and the Docker image reach a cluster with `--cluster`, and `--history` works there the same way.
## Stability
`@worker-manager/metrics` is stable since 2.5.0. Its main entry follows semver, and so do both storage layouts: a minor or patch upgrade reads and writes the history an earlier 2.x release recorded, in Redis and in PostgreSQL alike. The `@worker-manager/metrics/internal` entry is the exception. It holds the building blocks Worker Manager's own packages use, such as `LatencyStore` and the `CounterSource` interface, and may change in any release.
Both layouts are versioned, and a build never writes storage that a newer build laid out:
- The PostgreSQL tables record `schema_version` in their `meta` table, checked or migrated before the first query (see [Tables and migrations](#tables-and-migrations)).
- A Redis namespace records its layout in the hash `:__meta__`, field `layout`, currently `1` (`REDIS_METRICS_LAYOUT_VERSION`). On a cluster the key sits inside the namespace's hash tag, next to the data. The recorder writes the marker on its first snapshot. History recorded before 2.5.0 has no marker and is already layout 1, so it is adopted as it is.
When the marker or the schema version is newer than the installed package, the recorder refuses every snapshot before writing anything, and the error names both versions. Pass `onSnapshotError` to see it; `await recorder.snapshot()` throws it. A purge from `MetricsHistoryAdmin` or the storage panel is refused the same way, while the charts keep reading.
## Scope
This is BullMQ and pg-boss. Bull v3 has no native metrics to snapshot. Completed and failed throughput, wait time, run time, and queue age are tracked; there's no history for other job states or for job data itself.
The shipped UI reads daily rollups. The provider also supports hourly granularity through the `/api/metrics/history` endpoint (`granularity: 'hour'`) for custom consumers, though the built-in charts and the Metrics history page don't use it.
---
url: /worker-manager/recipes/index.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Recipes
Short, code-first walkthroughs for common setups. Each recipe is a page; each page ties back to a runnable example in the repo.
Most recipes link into the live demo so you can see the shape before porting it.
## Recipes
| Task | Recipe | Adapters shown |
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------- | ------------------------------ |
| Protect the dashboard with basic auth | [Add basic auth](/worker-manager/recipes/basic-auth.md) | Express, Fastify, Hapi, NestJS |
| Sign in through Keycloak (OIDC), or accept bearer tokens | [Keycloak auth](/worker-manager/recipes/keycloak-auth.md) | Express, Fastify, NestJS, CLI |
| Protect the board with a static token and a login form | [Token auth](/worker-manager/recipes/token-auth.md) | Express, Fastify, NestJS, CLI |
| Plug in your own check (Cloudflare Access, API keys) | [Custom auth](/worker-manager/recipes/custom-auth.md) | All |
| Defend against CSRF on destructive actions | [CSRF protection](/worker-manager/recipes/csrf-protection.md) | Express |
| Run several dashboards in one app | [Multiple dashboards](/worker-manager/recipes/multiple-dashboards.md) | Express |
| Add or remove queues after startup | [Manage queues at runtime](/worker-manager/recipes/manage-queues-at-runtime.md) | All |
| Show only a tenant's queues per request | [Per-tenant visibility](/worker-manager/recipes/per-tenant-visibility.md) | Fastify |
| Allow some API actions and deny others, per requester | [Access control hooks](/worker-manager/recipes/access-control-hooks.md) | All |
| Surface worker logs and job flows in the UI | [Job logs and flows](/worker-manager/recipes/job-logs-and-flows.md) | All |
| Get notified when jobs fail | [Alerting on failed jobs](/worker-manager/recipes/alerting.md) | All |
| Change or force the polling interval | [Polling interval](/worker-manager/recipes/change-polling-interval.md) | All |
| Link jobs to your own admin pages | [External job URLs](/worker-manager/recipes/external-job-url.md) | All |
| Set global concurrency from the UI | [Global concurrency](/worker-manager/recipes/global-concurrency.md) | All |
| Cap a queue's throughput, or clear a limit a worker tripped | [Rate limits](/worker-manager/recipes/rate-limits.md) | BullMQ |
| Keep long-retention throughput history beyond BullMQ's ring buffer | [Historical metrics](/worker-manager/recipes/historical-metrics.md) | BullMQ |
| Recolour and rebrand the board for your own deployment | [Whitelabel theming](/worker-manager/recipes/whitelabel-theming.md) | Both |
| Run the board against BullMQ v6 queues stored in PostgreSQL | [PostgreSQL backend](/worker-manager/recipes/postgres-backend.md) | BullMQ |
| Deploy the dashboard on Next.js / Vercel | [Next.js & Vercel](/worker-manager/recipes/nextjs.md) | Hono, Express |
| Diagnose a dashboard that won't load | [Troubleshooting](/worker-manager/recipes/troubleshooting.md) | All |
Missing something? Open an issue, good recipes become features.
---
url: /worker-manager/recipes/job-logs-and-flows.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Job logs and flows
Two features people often miss.
## Job logs
In a BullMQ worker, call `job.log()` to push lines that appear in the dashboard's job detail view:
```ts
import { Worker } from 'bullmq';
new Worker('emails', async (job) => {
await job.log(`Sending to ${job.data.to}`);
await sendEmail(job.data);
await job.log('Sent.');
}, { connection });
```
Open the job in the dashboard, switch to the Logs tab.
`job.log()` is BullMQ-only. Bull has no equivalent.

Live example: open the demo and drill into a worker-processed job in `emails:welcome`.
## Job flows
BullMQ supports flows. Parent jobs that wait on child jobs across queues. Build one with `FlowProducer`:
```ts
import { FlowProducer } from 'bullmq';
const flow = new FlowProducer({ connection });
await flow.add({
name: 'build-report',
queueName: 'reports',
children: [
{ name: 'fetch-data', queueName: 'fetch' },
{ name: 'render-pdf', queueName: 'render' },
],
});
```
Worker Manager draws the whole flow as a graph on the job's detail view, whichever job in it you opened. It pans and zooms, and it opens fitted to the entire flow so you see the shape first. The control below the zoom buttons recentres on the job you came in on.

Clicking a node fills the panel beside the canvas with that job's state and data rather than navigating away, so your position in the graph survives. Use `Open this job` in the panel when you do want its own page. The button in the card header expands the canvas to fullscreen, and `Escape` leaves it.
Live example: open the demo and scroll to `reports:nightly` for a parent job with children.
### Large flows
A flow wide or deep enough to be expensive is not fetched whole. The response carries the top of the flow up to a fixed budget, and any node holding back children says so: a node reading `Load 20 more` has more children than it was given. Clicking it loads that node's children in place, without disturbing the viewport or anything you had already expanded.
Fan out is the common shape here, one parent with a child per unit of work, so a single level is loaded whole rather than in pages: one click on a parent with 600 children gets all 600. Past a thousand children on one node the graph stops being the right tool, and the node says how many it is showing and leaves you to open the child queue.
The endpoint takes `depth` and `maxChildren` if you want a different window, and both default to the values BullMQ itself uses.
### When a parent is waiting on children that are not coming
A parent sits in `waiting-children` until its children finish, and the graph alone does not tell you whether that is going to happen. Each node with children carries the counts BullMQ keeps for it: how many are done, how many are unfinished, and how many were ignored.
Unfinished is worth reading carefully. It is the parent's `dependencies` set, which holds every child the parent is still waiting on, and a child that has failed and may yet retry is still in there. So a stuck flow can show unfinished children that are not going to move on their own. Select the node to see the failure.

Ignored is the one worth knowing about. A child added with `ignoreDependencyOnFailure` that fails does not fail its parent, it is set aside and the parent carries on as if it had succeeded:
```ts
await flow.add({
name: 'build-report',
queueName: 'reports',
children: [
{ name: 'fetch-optional', queueName: 'fetch', opts: { ignoreDependencyOnFailure: true } },
],
});
```
That is the point of the option, and it also means a report can complete having quietly skipped half its inputs. Hovering the ignored count shows why each of those children failed, read from `Job#getIgnoredChildrenFailures()`.
Ignored is called out in colour; done and unfinished stay muted, since a parent working through its children normally is not news. A leaf, having no children, shows nothing.
---
url: /worker-manager/recipes/keycloak-auth.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Keycloak auth
`@worker-manager/auth` puts a Keycloak (OpenID Connect) login in front of the dashboard. Browsers
go through the authorization code flow with PKCE and keep an encrypted session cookie; scripts and
other services send a bearer token. It is one framework-agnostic middleware, so the same options
work on Express, Fastify, Koa, NestJS and the [CLI](/worker-manager/guide/cli.md#keycloak-auth).
```sh
npm install @worker-manager/auth
```
## Configure the Keycloak client
In your realm, create an OpenID Connect client (say `worker-manager`) with:
- **Client authentication** on (a confidential client), or off for a public client without a secret.
- **Standard flow** enabled. Enable **Direct access grants** only if scripts log in with a password.
- **Valid redirect URIs**: `https://ops.example.com/queues/auth/callback`
- **Valid post logout redirect URIs**: `https://ops.example.com/queues/`
- **Advanced → Proof Key for Code Exchange Code Challenge Method**: `S256`
Then create a realm role (say `wm-admin`) and assign it to the people who should see the board.
Client roles on the `worker-manager` client work too.
## Express
```ts
import express from 'express';
import { createWorkerManagerBoard } from '@worker-manager/api';
import { BullMQAdapter } from '@worker-manager/api/bullMQAdapter';
import { ExpressAdapter } from '@worker-manager/express';
import { createAuthMiddleware } from '@worker-manager/auth';
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/queues');
createWorkerManagerBoard({ queues: [new BullMQAdapter(emails)], serverAdapter });
const auth = createAuthMiddleware(
{
strategy: 'keycloak',
url: 'https://sso.example.com', // append /auth for Keycloak older than 17
realm: 'ops',
clientId: 'worker-manager',
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
publicUrl: 'https://ops.example.com/queues',
requiredRoles: ['wm-admin'],
cookie: { secret: process.env.SESSION_SECRET! },
},
{ basePath: '/queues' }
);
const app = express();
app.use('/queues', auth, serverAdapter.getRouter());
```
## Fastify
```ts
import { createAuthMiddleware, createFastifyAuthPlugin } from '@worker-manager/auth';
const serverAdapter = new FastifyAdapter();
serverAdapter.setBasePath('/queues');
createWorkerManagerBoard({ queues: [new BullMQAdapter(emails)], serverAdapter });
const auth = createAuthMiddleware(keycloakOptions, { basePath: '/queues' });
app.register(createFastifyAuthPlugin(serverAdapter.registerPlugin(), auth), { prefix: '/queues' });
```
The hook lives in the board plugin's own scope, so it guards the board's page, API and assets and
nothing else in your app.
## NestJS
Pass the same options as `auth`, typically from `ConfigService`:
```ts
WorkerManagerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
auth: {
strategy: 'keycloak',
url: config.getOrThrow('KEYCLOAK_URL'),
realm: config.getOrThrow('KEYCLOAK_REALM'),
clientId: config.getOrThrow('KEYCLOAK_CLIENT_ID'),
clientSecret: config.get('KEYCLOAK_CLIENT_SECRET'),
requiredRoles: ['wm-admin'],
cookie: { secret: config.getOrThrow('SESSION_SECRET') },
},
}),
});
```
See [NestJS](/worker-manager/server-adapters/nestjs.md#authentication) for the rest of the module options.
## What happens to a request
| Request | Answer |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `Authorization: Bearer ` | Verified against the realm JWKS: signature, issuer `${url}/realms/${realm}`, expiry, and `aud` or `azp` equal to `clientId`. |
| Valid session cookie | Let through. An expired session is refreshed silently with the refresh token. |
| Browser navigation without a session | `302` to the Keycloak login page, then back to the page it started on. |
| API call, XHR or asset without credentials | `401` with `{ "error": { "key": "ERRORS.UNAUTHORIZED" } }`. |
| Signed in, but without one of `requiredRoles` | `403` with `{ "error": { "key": "ERRORS.FORBIDDEN" } }`. |
The middleware also serves, under the board's base path:
- `GET /auth/me` returns `{ strategy, user: { username, name, email, roles }, logoutUrl }`. The UI
uses it to show who is signed in.
- `GET /auth/login?returnTo=/queues/...` starts a login explicitly.
- `GET /auth/callback` is the redirect URI.
- `GET /auth/logout` clears the session and sends the browser to Keycloak's end-session endpoint.
## The session cookie
The cookie (`wm_session` by default) is `HttpOnly`, `SameSite=Lax`, scoped to the base path,
`Secure` when the board is on https, and encrypted with AES-256-GCM using `cookie.secret`. It holds
the user's claims, the access expiry and, when they fit in the 4 KB cookie limit, the refresh
token and the ID token (used as the logout hint).
Set `cookie.secret` to a long random value and share it across instances. Without it the
middleware falls back to `clientSecret`, and failing that to a random per-process key, which logs
everyone out on every restart.
## Behind a reverse proxy
The redirect URI must match what Keycloak has registered. Set `publicUrl` to the board's external
URL, base path included. Without it, the URL is built from the request's `X-Forwarded-Proto`,
`X-Forwarded-Host` (or `Host`) and the base path, so make sure your proxy sends those.
## API clients only
`bearerOnly: true` disables the browser flow: every request without a valid bearer token gets a
`401`, and `/auth/login`, `/auth/callback` and `/auth/logout` are not served. No cookie secret is
needed.
## Extra checks
`onAuthenticated(user, req)` runs after the role check. Return `false` to answer `403`, for rules
the options cannot express:
```ts
createAuthMiddleware({
strategy: 'keycloak',
// ...
onAuthenticated: (user) => user.email?.endsWith('@example.com'),
});
```
## Combine with read-only mode
Give viewers a board they cannot break: run a second, [read-only](/worker-manager/recipes/read-only-mode.md) board
for a wider role, and keep `requiredRoles: ['wm-admin']` on the writable one.
---
url: /worker-manager/recipes/manage-queues-at-runtime.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Add and remove queues at runtime
> Applies to: all adapters.
`createWorkerManagerBoard` isn't a one-shot call. It also returns four functions that change which queues the board shows while it's running, with no rebuild or restart:
```ts
const { addQueue, removeQueue, setQueues, replaceQueues } = createWorkerManagerBoard({
queues: [new BullMQAdapter(emailQueue)],
serverAdapter,
});
```
Use them when queues aren't all known at startup: per-tenant queues created on demand, queues discovered from a registry, or a dashboard that follows queues as workers spin them up and down.
## The four functions
| Function | Signature | Effect |
| --------------- | ---------------------------------------------- | --------------------------------------------------------------------------------- |
| `addQueue` | `(queue: BaseAdapter) => void` | Adds one queue. Overwrites if a queue with the same name is already registered. |
| `removeQueue` | `(queueOrName: string \| BaseAdapter) => void` | Removes one queue, by adapter or by name. No-op if it isn't registered. |
| `setQueues` | `(queues: BaseAdapter[]) => void` | Adds or overwrites a batch, and **leaves other registered queues in place**. |
| `replaceQueues` | `(queues: BaseAdapter[]) => void` | Same as `setQueues`, but also **removes any queue not in the list**. A full sync. |
Queues are keyed by name (`queue.getName()`, which includes any `prefix` you set). Registering two adapters that resolve to the same name means the second wins.
## Add a queue on demand
```ts
const board = createWorkerManagerBoard({ queues: [], serverAdapter });
function onTenantCreated(tenantId: string) {
const queue = new Queue(`emails-${tenantId}`, { connection });
board.addQueue(new BullMQAdapter(queue));
}
function onTenantDeleted(tenantId: string) {
board.removeQueue(`emails-${tenantId}`);
}
```
The dashboard picks up the change on its next poll, with no reload or remount.
## Sync the board to a known set
`setQueues` vs `replaceQueues` differ only in what happens to queues you _don't_ pass:
```ts
// Board currently shows: A, B, C
board.setQueues([b, d]); // → A, B, C, D (adds D, updates B, keeps A and C)
board.replaceQueues([b, d]); // → B, D (drops A and C)
```
Reach for `replaceQueues` when you have the authoritative full list and want the board to mirror it exactly. Reach for `setQueues` when you're merging in a batch and don't want to disturb queues registered elsewhere.
## Notes
- Changes take effect on the next request. The functions write to the same `Map` the board reads each time, so there's no cache to bust.
- Removing a queue only detaches it from the dashboard. It doesn't close the Bull/BullMQ connection or touch Redis, so clean those up yourself if the queue is really gone.
- On [NestJS](/worker-manager/server-adapters/nestjs.md) you don't hold the return value of `createWorkerManagerBoard` directly. The module already calls `addQueue` when you register feature queues, and it exposes the same board instance for manual changes via `@InjectWorkerManager() board: WorkerManagerBoard`. See [`examples/nestjs/redis`](https://github.com/naldomadeira/worker-manager/tree/main/examples/nestjs/redis).
## Source of truth
The four functions are built in [`packages/api/src/queuesApi.ts`](https://github.com/naldomadeira/worker-manager/blob/main/packages/api/src/queuesApi.ts) and returned from `createWorkerManagerBoard` in [`packages/api/src/index.ts`](https://github.com/naldomadeira/worker-manager/blob/main/packages/api/src/index.ts).
---
url: /worker-manager/recipes/multiple-dashboards.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Multiple dashboards in one app
You might want separate dashboards for different queue groups. One per team, or one read-only and one read-write.
From [`examples/express/multiple-boards`](https://github.com/naldomadeira/worker-manager/tree/main/examples/express/multiple-boards).
```js
const serverAdapter1 = new ExpressAdapter();
const serverAdapter2 = new ExpressAdapter();
createWorkerManagerBoard({
queues: [new BullMQAdapter(queueA)],
serverAdapter: serverAdapter1,
});
createWorkerManagerBoard({
queues: [new BullMQAdapter(queueB)],
serverAdapter: serverAdapter2,
});
serverAdapter1.setBasePath('/instance1');
serverAdapter2.setBasePath('/instance2');
app.use('/instance1', serverAdapter1.getRouter());
app.use('/instance2', serverAdapter2.getRouter());
```
Each adapter is independent. Pass a different UIConfig per instance (`boardTitle`, `boardLogo`, `environment` badge) to make them visually distinct.
## BullMQ and pg-boss side by side
A board runs one engine, so an app with BullMQ and [pg-boss](/worker-manager/queue-adapters/pg-boss.md) queues mounts two boards. Give each one a header link to the other with `miscLinks`:
```ts
import { createWorkerManagerBoard } from '@worker-manager/api';
import { BullMQAdapter } from '@worker-manager/api/bullMQAdapter';
import { ExpressAdapter } from '@worker-manager/express';
import { createPgBossBoard } from '@worker-manager/pg-boss';
const miscLinks = [
{ text: 'BullMQ', url: '/queues/' },
{ text: 'pg-boss', url: '/pg-boss/' },
];
const bullmqAdapter = new ExpressAdapter().setBasePath('/queues');
createWorkerManagerBoard({
queues: [new BullMQAdapter(emailQueue)],
serverAdapter: bullmqAdapter,
options: { uiConfig: { miscLinks } },
});
const pgBossAdapter = new ExpressAdapter().setBasePath('/pg-boss');
const pgBossBoard = createPgBossBoard({
serverAdapter: pgBossAdapter,
pgBoss: { instance: boss, connection: process.env.DATABASE_URL, schema: 'pgboss' },
options: { uiConfig: { miscLinks } },
});
app.use('/queues', bullmqAdapter.getRouter());
app.use('/pg-boss', pgBossAdapter.getRouter());
```
Mount them side by side, not one inside the other (`/queues` and `/queues/pg-boss`): on Express the outer router would also answer the inner board's paths. The same goes for the other two ways to do it:
- **NestJS.** Keep the BullMQ board as it is and add a named board with `engine: 'pg-boss'`: `WorkerManagerModule.forRootAsync({ name: 'pgboss', useFactory: (boss) => ({ route: '/pg-boss', engine: 'pg-boss', pgBoss: { instance: boss } }), inject: ['PG_BOSS'] })`. Each named board has its own `route`, `auth` and session cookie. See [several boards](/worker-manager/server-adapters/nestjs.md#several-boards) and [the pg-boss board](/worker-manager/server-adapters/nestjs.md#pg-boss-board).
- **CLI and Docker.** Give the CLI a BullMQ source and `--pg-boss `: BullMQ is served at the root and pg-boss under `/pg-boss/` (`--pg-boss-path`), behind one login, and each board's header links to the other. See [next to a BullMQ board](/worker-manager/guide/cli.md#next-to-a-bullmq-board).
The two boards can share one [historical metrics](/worker-manager/recipes/historical-metrics.md#pg-boss-queues) store: pg-boss queues are recorded under a `pgboss::` prefix, so neither board's totals count the other's jobs.
## When to use this instead of a visibility guard
- Different auth strategies per dashboard. Use multiple dashboards.
- Same auth, per-tenant queue visibility. Use [per-tenant visibility](/worker-manager/recipes/per-tenant-visibility.md) instead.
- Different `readOnlyMode` policies per audience. Multiple dashboards, each with different queue-adapter options.
- Queues on different engines (BullMQ and pg-boss). Multiple dashboards, always: one board runs one engine.
---
url: /worker-manager/recipes/nextjs.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Next.js & Vercel
There is no dedicated Next.js adapter. Worker Manager runs inside a Next.js API
route using an existing adapter. Two runnable examples:
- [`examples/nextjs/app-router`](https://github.com/naldomadeira/worker-manager/tree/main/examples/nextjs/app-router): App Router, `@worker-manager/hono` adapter.
- [`examples/nextjs/pages-router`](https://github.com/naldomadeira/worker-manager/tree/main/examples/nextjs/pages-router): Pages Router, `@worker-manager/express` adapter.
Both deploy to Vercel. The mounting differs by router; the Vercel-specific
config is identical and is the part most people miss.
## App Router (Hono)
A single optional catch-all Route Handler at
`app/api/queues/[[...path]]/route.ts`:
```ts
import { createWorkerManagerBoard } from '@worker-manager/api';
import { BullMQAdapter } from '@worker-manager/api/bullMQAdapter';
import { HonoAdapter } from '@worker-manager/hono';
import { serveStatic } from '@hono/node-server/serve-static';
import { Hono } from 'hono';
import { handle } from 'hono/vercel';
import { queue } from '@/lib/queue';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
const basePath = '/api/queues';
const serverAdapter = new HonoAdapter(serveStatic);
serverAdapter.setBasePath(basePath);
createWorkerManagerBoard({ queues: [new BullMQAdapter(queue)], serverAdapter });
const app = new Hono();
app.route(basePath, serverAdapter.registerPlugin());
export const GET = handle(app);
export const POST = handle(app);
export const PUT = handle(app);
export const PATCH = handle(app);
export const DELETE = handle(app);
```
## Pages Router (Express)
A single optional catch-all API route at `pages/api/queues/[[...path]].ts` that
delegates to an Express router. Disable `bodyParser` and enable
`externalResolver` so Express owns the response:
```ts
import { createWorkerManagerBoard } from '@worker-manager/api';
import { BullMQAdapter } from '@worker-manager/api/bullMQAdapter';
import { ExpressAdapter } from '@worker-manager/express';
import express from 'express';
import { queue } from '../../../lib/queue';
const basePath = '/api/queues';
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath(basePath);
createWorkerManagerBoard({ queues: [new BullMQAdapter(queue)], serverAdapter });
const app = express();
app.use(basePath, serverAdapter.getRouter());
export const config = { api: { bodyParser: false, externalResolver: true } };
export default function handler(req, res) {
return app(req, res);
}
```
## The Vercel fix (both routers)
`@worker-manager/api` finds the UI's compiled assets with
`eval(require.resolve('@worker-manager/ui/package.json'))`. The `eval` deliberately
hides the require from bundlers, and that includes Next.js's static file tracer
([`@vercel/nft`](https://github.com/vercel/nft)). On Vercel the UI files are
never copied into the function, so you get:
```
Error: Cannot find module '@worker-manager/ui/package.json'
```
Fix it in `next.config.js`:
```js
/** @type {import('next').NextConfig} */
module.exports = {
// Resolve @worker-manager/* and bullmq from node_modules at runtime, not from the bundle.
serverExternalPackages: ['@worker-manager/api', '@worker-manager/ui', 'bullmq'],
// Force the compiled UI into the serverless function (the tracer can't see the eval).
outputFileTracingIncludes: {
'/api/queues/*': ['./node_modules/@worker-manager/ui/dist/**/*'],
},
};
```
::: warning Monorepo
If `node_modules` is hoisted to a workspace root (e.g. `apps/web` in a Turborepo),
add `outputFileTracingRoot: path.join(__dirname, '../../')` so the included paths
resolve from the right place.
:::
`serverExternalPackages` and `outputFileTracingIncludes` are stable top-level
options in **Next.js 15+** (in 13/14 they lived under `experimental`).
### Alternative: `uiBasePath`
Instead of the trace config you can tell Worker Manager where the UI lives directly,
skipping the `eval(require.resolve(...))` entirely:
```ts
createWorkerManagerBoard({
queues,
serverAdapter,
options: { uiBasePath: 'node_modules/@worker-manager/ui' },
});
```
You still need `outputFileTracingIncludes` so the files are actually deployed,
this only changes how the path is resolved, not whether the files are present.
## Workers
BullMQ workers are long-running and **cannot** run inside serverless functions.
Next.js (the dashboard and any job-producing routes) deploys to Vercel; the
worker runs as a separate always-on process: a container, a VM, or a dedicated
worker service. Both examples ship a standalone `worker.ts` for local
processing.
---
url: /worker-manager/recipes/per-tenant-visibility.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Per-tenant visibility
Show each user only the queues they're allowed to see. One shared dashboard, per-request filtering.
See also: [Visibility guard](/worker-manager/recipes/visibility-guard.md) for the full reference.
From [`examples/fastify/visibility-guard`](https://github.com/naldomadeira/worker-manager/tree/main/examples/fastify/visibility-guard) (Fastify + cookie auth + JWT).
## How the Fastify example wires it
The full example issues a signed JWT on login with an `allowedQueues` array, then the guard decodes the cookie and matches it against the queue name:
```js
function visibilityGuard(req) {
const cookies = fastify.parseCookie(req.headers.cookie || '');
if (!cookies.token) return false;
try {
const decoded = fastify.jwt.verify(cookies.token);
return decoded.allowedQueues?.includes(this.queue.name) ?? false;
} catch {
return false;
}
}
createWorkerManagerBoard({
queues: queues.map((queue) => {
const adapter = new BullMQAdapter(queue);
adapter.setVisibilityGuard(visibilityGuard);
return adapter;
}),
serverAdapter,
});
```
`this.queue.name` inside the guard gives you the queue the guard is attached to, so one function handles every queue.
## Minimal Express sketch
Same idea, simpler auth:
```ts
import { BullMQAdapter } from '@worker-manager/api/bullMQAdapter';
const tenantAQueue = new BullMQAdapter(queueA);
tenantAQueue.setVisibilityGuard((req) => req.headers['x-tenant-id'] === 'tenant-a');
const tenantBQueue = new BullMQAdapter(queueB);
tenantBQueue.setVisibilityGuard((req) => req.headers['x-tenant-id'] === 'tenant-b');
const sharedQueue = new BullMQAdapter(common);
sharedQueue.setVisibilityGuard(() => true);
createWorkerManagerBoard({
queues: [tenantAQueue, tenantBQueue, sharedQueue],
serverAdapter,
});
```
Every API call passes through the guard. A hidden queue is invisible in the sidebar, absent from counts, and returns 404 on direct access.
## Hot-path warning
The guard runs once per visible queue per request, and the UI polls. Don't do synchronous HTTP or DB calls inside it. Decode a JWT, pull a tenant ID from a cookie, check a map. That's the budget.
---
url: /worker-manager/recipes/postgres-backend.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# PostgreSQL backend
BullMQ v6 can store queues in PostgreSQL instead of Redis. Worker Manager reads those queues the same way it reads Redis ones, so there is nothing extra to configure on the board.
## Setup
Install `pg` alongside BullMQ v6, then pass `createPostgresBackend` as the third argument to `Queue`:
```js
const express = require('express');
const { Queue, createPostgresBackend } = require('bullmq');
const { createWorkerManagerBoard } = require('@worker-manager/api');
const { BullMQAdapter } = require('@worker-manager/api/bullMQAdapter');
const { ExpressAdapter } = require('@worker-manager/express');
const connection = 'postgres://user:password@localhost:5432/bullmq';
const emails = new Queue('emails', { connection }, createPostgresBackend);
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');
createWorkerManagerBoard({
queues: [new BullMQAdapter(emails)],
serverAdapter,
});
const app = express();
app.use('/admin/queues', serverAdapter.getRouter());
app.listen(3000);
```
::: warning A fresh database needs its schema
Since BullMQ 6.3, it refuses to start against a database it has not migrated, with
`SchemaMigrationRequiredError: PostgreSQL schema "bullmq" is not initialized` (6.0–6.2 migrated
on `waitUntilReady()` by themselves and have no `migrate` option). Either run its
migrations once as a deploy step, or let the connection apply them on first connect. That
takes the object form of the connection, which is also where a custom `schema` goes:
```js
const connection = {
connectionString: 'postgres://user:password@localhost:5432/bullmq',
schema: 'bullmq', // optional, the default
migrate: true,
};
```
Workers need the same connection and the same `createPostgresBackend` factory as their queue.
:::
::: tip The throughput chart needs worker metrics
`uiConfig.showMetrics` charts BullMQ's own per-minute metrics, which workers only collect when
asked: `new Worker(name, processor, { connection, metrics: { maxDataPoints: MetricsTime.ONE_WEEK } }, createPostgresBackend)`.
:::
That is the whole difference: the third argument on `Queue`. `BullMQAdapter` takes the queue as it always has.
::: tip
`ioredis` is an optional peer dependency of BullMQ v6, so a Postgres-only app does not need it installed.
:::
## What the dashboard shows
Job listing, counts, adding, retrying, cleaning, pausing, promoting, flows and the schedulers view all behave exactly as they do on Redis.
One panel is Redis-specific and adapts:
**Datastore details** reports what Postgres can answer, and retitles itself:
| | |
| ----------------- | ------ |
| Version | 17.10 |
| Up time | 3 days |
| Connected clients | 6 |
| Blocked clients | 0 |
| Port | 5432 |
Memory usage, peak memory, fragmentation ratio and replication mode are left out rather than filled with a number that means something else. `pg_database_size` measures disk, not memory.
## Mixing backends
A single board can hold Redis-backed and Postgres-backed queues at once. Each queue answers for itself:
```js
createWorkerManagerBoard({
queues: [
new BullMQAdapter(new Queue('emails', { connection: pgConnection }, createPostgresBackend)),
new BullMQAdapter(new Queue('reports', { connection: { host: 'localhost', port: 6379 } })),
],
serverAdapter,
});
```
The datastore details panel describes the first registered queue, so put the one you care about first if you mix them.
## Historical metrics
[`@worker-manager/metrics`](/worker-manager/recipes/historical-metrics.md) records PostgreSQL-backed queues like Redis ones: counters from the queue's metrics, latency and queue age from BullMQ's `job` table. Its history can live in PostgreSQL too, so a board with no Redis at all still gets the 7, 30 and 90 day charts and the storage panel:
```js
const {
MetricsRecorder,
PostgresMetricsHistoryProvider,
PostgresMetricsStore,
} = require('@worker-manager/metrics');
const store = new PostgresMetricsStore({ connection, schema: 'bullmq', migrate: true });
const recorder = new MetricsRecorder({ queues: [new BullMQAdapter(emails)], store });
recorder.start();
createWorkerManagerBoard({
queues: [new BullMQAdapter(emails)],
serverAdapter,
options: {
uiConfig: { showMetrics: true },
historyProvider: new PostgresMetricsHistoryProvider({ store }),
},
});
```
The tables are prefixed `worker_manager_metrics_` and sit next to BullMQ's in the same schema here; see [PostgreSQL storage](/worker-manager/recipes/historical-metrics.md#postgresql-storage) for the schema, migrations and sizing. With the CLI, `--postgres ... --history` does the same with no code.
---
url: /worker-manager/recipes/rate-limits.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Rate limits
BullMQ has two things called a rate limit and the dashboard exposes both, because they answer different questions.
The **configured limit** is a setting: how many jobs this queue may process across all its workers in a window of time. It lives beside global concurrency and you edit it the same way.
The **active limit** is a state: what a worker wrote when it called `queue.rateLimit(ms)`, usually because a third-party API pushed back. While it is set, nothing on the queue runs. It expires on its own, and until then a queue with healthy workers and a full waiting list does nothing at all.
## Set the configured limit from the UI
Open the queue's actions dropdown, pick "Set rate limit", enter a maximum and a window in milliseconds.
Worker Manager calls `Queue.setGlobalRateLimit(max, duration)` on your behalf. Leaving both fields empty removes the limit through `removeGlobalRateLimit()`.
## Set it in code
```ts
await queue.setGlobalRateLimit(500, 60_000);
```
Five hundred jobs a minute, across every worker on the queue. The value is stored in Redis, so a worker in any process sees it. Read it back with `getGlobalRateLimit()`, which returns `null` when none is set.
The current value is also shown in the queue info panel, under Rate limit.
## When a worker trips a limit
A queue that is currently rate limited grows a badge beside the status tabs, counting down the milliseconds left on the limiter.

Clicking the badge clears the limit through `Queue.removeRateLimitKey()`, and so does Release now inside the rate limit dialog. This is the part with no equivalent anywhere else: a limit a worker set is otherwise only cleared by waiting it out.

Clearing it does not stop the worker setting it again. If a worker rate limits the queue every time an upstream API returns 429, releasing the limit sends the next job straight back into that 429. It is the right tool when the limit was set by something that has since recovered, and the wrong one when the thing that caused it is still broken.
Only BullMQ supports either half. Bull's `limiter` is fixed when the queue is constructed and has no runtime setter, so the menu entry never appears on a Bull queue and the API answers 400.
## Read-only mode
Read-only mode disables both, the same way it disables global concurrency. The badge still shows, since knowing why a queue is stalled is not a write.
---
url: /worker-manager/recipes/read-only-mode.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Read-only mode
> Applies to: all adapters, and [pg-boss boards](#pg-boss-boards).
Read-only mode disables every destructive action on a queue. No retries, no removals, no queue operations (pause, resume, empty, clean, obliterate), no adding jobs. Use it to share the dashboard with stakeholders, support, or shared dev environments without risking anything.
## Enable per queue
```ts
import { createWorkerManagerBoard } from '@worker-manager/api';
import { BullMQAdapter } from '@worker-manager/api/bullMQAdapter';
createWorkerManagerBoard({
queues: [
new BullMQAdapter(emailQueue, { readOnlyMode: true }),
],
serverAdapter,
});
```
The flag is per queue adapter, so one board can mix read-only and writable queues.
## What gets disabled
With `readOnlyMode: true`, the API endpoint for any write action on that queue returns **HTTP 405 Method Not Allowed**, and the matching buttons are hidden in the UI.
| Action | Disabled in read-only mode |
| -------------------------------------------------------------- | ----------------------------------------------------- |
| Add job | Yes |
| Retry job (single) | Yes |
| Retry all | Yes |
| Promote job (single) | Yes |
| Promote all | Yes |
| Remove job (single) | Yes |
| Clean bulk (completed / failed / waiting) | Yes |
| Empty queue | Yes |
| Obliterate queue | Yes |
| Pause / resume queue | Yes |
| Set global concurrency | Yes |
| Set rate limit, and release one a worker tripped | Yes |
| Reschedule a delayed job / change a prioritized job's priority | Yes |
| Edit or remove a job scheduler | Yes |
| Remove a parent's unprocessed children | Yes |
| Update job data | Yes |
| View queue, job, logs, flow | No, still accessible |
| Pause-all / resume-all | Read-only queues are silently skipped, others proceed |
## Disabling retries only
`allowRetries` is independent. On a **writable** queue, `allowRetries: false` hides the retry buttons in the UI while leaving every other action in place:
```ts
new BullMQAdapter(emailQueue, { allowRetries: false });
```
Defaults to `true` on writable queues. When `readOnlyMode: true`, `allowRetries` is forced to `false`, the option is ignored since retries are themselves a destructive action.
To keep failed-job retries but hide the retry button on **completed** jobs, use `allowCompletedRetries: false` (BullMQ only):
```ts
new BullMQAdapter(emailQueue, { allowCompletedRetries: false });
```
It only takes effect while `allowRetries` is `true`. On `BullAdapter` it's always off, because Bull can't retry completed jobs.
::: warning
`allowRetries: false` only hides the retry buttons, it doesn't block the retry API endpoint. Anyone who knows the URL can still trigger a retry. Use `readOnlyMode: true` for real enforcement.
:::
## pg-boss boards
A [pg-boss board](/worker-manager/queue-adapters/pg-boss.md) has no queue adapters, so read-only mode is set on the whole board:
```ts
createPgBossBoard({
serverAdapter,
pgBoss: { connection: process.env.PGBOSS_READER_URL, schema: 'pgboss' },
options: { readOnly: true },
});
```
(`readOnly: true` on a NestJS board with `engine: 'pg-boss'`, and `--read-only` on the CLI, do the same.)
It is stricter than a BullMQ queue's `readOnlyMode`: the mutation routes (send, retry, cancel, resume, delete, the bulk and per-queue commands, and schedule edits) are never registered, so a forged request gets **404**, not 405, and cannot tell that the route exists. The UI hides every control that changes something. Reading, including the schedule preview, keeps working.
Separately from `readOnly`, a pg-boss board turns writes off by itself when it cannot write safely, for instance when it only has a `connection` and the database is on a different pg-boss schema version from the pg-boss installed next to it, or when the schema is newer than the board is tested with and `allowUntestedSchema` is not set. The routes are there then, but answer **409** `ERRORS.PGBOSS_WRITES_DISABLED` with the reason, and the UI shows it in a banner.
### A read-only PostgreSQL role
A read-only pg-boss board runs nothing but `SELECT`, so it can connect as a role that cannot write at all, which makes the guarantee hold at the database too:
```sql
CREATE ROLE wm_reader LOGIN PASSWORD 'change-me';
GRANT USAGE ON SCHEMA pgboss TO wm_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA pgboss TO wm_reader;
-- Tables pg-boss creates later (one per partitioned queue); run as the role that owns pgboss:
ALTER DEFAULT PRIVILEGES FOR ROLE app IN SCHEMA pgboss GRANT SELECT ON TABLES TO wm_reader;
```
Connect with that role through `connection` and pass no `instance`. See [the pg-boss page](/worker-manager/queue-adapters/pg-boss.md#a-read-only-postgresql-role).
## Source of truth
See `QueueAdapterOptions` in [`packages/api/typings/app.d.ts`](https://github.com/naldomadeira/worker-manager/blob/main/packages/api/typings/app.d.ts), the flag resolution in [`packages/api/src/queueAdapters/base.ts`](https://github.com/naldomadeira/worker-manager/blob/main/packages/api/src/queueAdapters/base.ts), and the 405 enforcement in [`packages/api/src/providers/queue.ts`](https://github.com/naldomadeira/worker-manager/blob/main/packages/api/src/providers/queue.ts).
---
url: /worker-manager/recipes/token-auth.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Token auth
The `token` strategy of `@worker-manager/auth` protects the dashboard with one or more static
tokens, with no usernames and no identity provider. Scripts send the token in a header; a browser
types it once into a small login form and gets an encrypted session cookie. It suits internal
boards where a Keycloak realm is overkill and a shared Basic password is not wanted in the
browser's credential cache.
```sh
npm install @worker-manager/auth
```
## Options
| Option | Default | Description |
| ---------------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tokens` | | Accepted tokens. Compared in constant time against every entry on every request. |
| `validate(token)` | | Tried when no static token matched. Return an `AuthUser`, `true` (accept as `user`) or `false`. Runs on every request, so revoking a token ends its browser sessions at once. |
| `header` | | A header carrying the bare token, e.g. `X-Board-Token`. `Authorization: Bearer ` is always accepted too. |
| `user` | `{ username: 'token', roles: [] }` | The identity attached to `req.user` and shown in the header. |
| `cookie` | | `{ secret, name?, secure?, maxAgeSeconds? }`. Turns on the browser login form. Without it only requests carrying the token in a header get in. |
| `publicUrl` | request origin | External URL of the board. Set it when a proxy rewrites `Host`, so the login form's origin check compares against the right host. |
| `onAuthenticated(user, req)` | | Return `false` to answer `403`, as for the other strategies. |
Use long random tokens (32 characters or more): there is no lockout, so the token's entropy is the
only defence against guessing. `openssl rand -base64 32` is plenty.
## NestJS
```ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { WorkerManagerModule } from '@worker-manager/nestjs';
import { BullMQAdapter } from '@worker-manager/api/bullMQAdapter';
@Module({
imports: [
WorkerManagerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
route: '/admin/queues',
auth: {
strategy: 'token',
tokens: [config.getOrThrow('BOARD_TOKEN')],
header: 'X-Board-Token',
cookie: { secret: config.getOrThrow('BOARD_SESSION_SECRET') },
},
}),
}),
WorkerManagerModule.forFeature({ name: 'emails', adapter: BullMQAdapter }),
],
})
export class AppModule {}
```
## Express
```ts
import { createAuthMiddleware } from '@worker-manager/auth';
const auth = createAuthMiddleware(
{
strategy: 'token',
tokens: [process.env.BOARD_TOKEN!],
cookie: { secret: process.env.BOARD_SESSION_SECRET! },
},
{ basePath: '/queues' }
);
app.use('/queues', auth, serverAdapter.getRouter());
```
## Fastify
```ts
import { createAuthMiddleware, createFastifyAuthPlugin } from '@worker-manager/auth';
const auth = createAuthMiddleware(tokenOptions, { basePath: '/queues' });
app.register(createFastifyAuthPlugin(serverAdapter.registerPlugin(), auth), { prefix: '/queues' });
```
The plugin registers the `POST /auth/login` route the form needs inside the board's own scope.
## CLI and Docker
```sh
worker-manager --token "$BOARD_TOKEN" --token-header X-Board-Token \
--session-secret "$BOARD_SESSION_SECRET" --host 0.0.0.0
```
`--token` takes a comma separated list. The environment variables are `WORKER_MANAGER_TOKENS`,
`WORKER_MANAGER_TOKEN_HEADER` and `WORKER_MANAGER_SESSION_SECRET`, and a config file takes
`token: { tokens, header, user, cookie }`. Without a session secret the CLI encrypts sessions with
a random per-process key, so they end on restart.
## What happens to a request
| Request | Answer |
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Authorization: Bearer ` or the `header` | Checked against `tokens`, then `validate`. A wrong token gets `401` with `WWW-Authenticate: Bearer realm="worker-manager", error="invalid_token"`. |
| Valid session cookie | Let through; the token inside is checked again, so a removed token no longer gets in. |
| Page load without credentials (`cookie` set) | `302` to `${basePath}/auth/login?returnTo=`. |
| API call, XHR or asset without credentials | `401` JSON with `{ "error": { "key": "ERRORS.UNAUTHORIZED" } }`, never a redirect. |
| `onAuthenticated` returned `false` | `403` with `{ "error": { "key": "ERRORS.FORBIDDEN" } }`. |
The middleware also serves, under the board's base path, when `cookie` is set:
- `GET /auth/login` renders the form: one password field, no scripts, no external assets, a strict
`Content-Security-Policy` and `X-Frame-Options: DENY`.
- `POST /auth/login` checks the token and, on success, sets the session cookie and answers `303`
to `returnTo` (same-origin paths only). A wrong token re-renders the form with `401`; the token is
never echoed back.
- `GET /auth/logout` clears the cookie and returns to the form.
- `GET /auth/me` returns `{ strategy: 'token', user, logoutUrl }`, as for the other strategies.
## Security notes
- **The session cookie** (`wm_session` by default, `wm_session_` for a named NestJS board) is
`HttpOnly`, `SameSite=Strict`, scoped to the base path, `Secure` on https, and sealed with
AES-256-GCM under `cookie.secret`. It carries the token and an expiry; nothing in it is readable
client side, and a tampered or foreign cookie is cleared.
- **CSRF on the login form**: the `POST` is only accepted when `Origin` (or, without it, `Referer`)
is the board's own origin, and a `Sec-Fetch-Site` other than `same-origin` is refused, so another
site cannot log a browser into a session of its choosing. Together with `SameSite=Strict`, no
cross-site request carries the session either.
- **Links from other sites**: a `SameSite=Strict` cookie is withheld from a navigation that starts
on another site, such as a link in chat. Such a page load gets a tiny page that reloads itself
from the board's origin, which does carry the cookie, so a signed-in user is not asked again.
- **No secrets in logs**: the middleware logs nothing, and neither tokens nor the session secret
appear in any response.
Rate-limit `POST ${basePath}/auth/login` at your proxy if the board is reachable from the internet.
---
url: /worker-manager/recipes/troubleshooting.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Troubleshooting
> Applies to: all adapters.
The failure modes below account for most "it won't load" reports. Nearly all of them come down to one thing: the path Worker Manager thinks it's mounted at doesn't match the path the browser actually requests.
## The page loads but assets and API calls 404
You see the HTML, but the styles are missing and the network tab is full of 404s for `/static/...` and `/api/...`.
`setBasePath` and the path you mount the router at have to be the same string. Worker Manager bakes the base path into the HTML it serves, and the browser builds every asset and API URL from it. If they disagree, every follow-up request misses.
```ts
serverAdapter.setBasePath('/admin/queues'); // ← these two
app.use('/admin/queues', serverAdapter.getRouter()); // ← must match
```
Change one, change the other.
## Same 404s, but only behind a reverse proxy
Works on `localhost`, breaks once it's behind nginx / a load balancer / an ingress at a subpath.
The base path has to be the path the **browser** sees, not the internal one. If the proxy exposes the dashboard at `https://example.com/tools/queues` but forwards to your app at `/`, then `setBasePath('/tools/queues')`, the public path, and let the proxy route it. The rule is the same as above; the "mount path" is just whatever the outside world requests.
If the proxy strips the prefix before forwarding, either stop stripping it or set the base path to the stripped value, whichever keeps the browser's URL and the base path in agreement.
## Counts are right but every list is empty, behind a reverse proxy
The tabs show the right numbers, every queue and every status says it has no jobs, and Redis holds the jobs when you look.
The dashboard reads the open queue's jobs from `/api/queues?activeQueue=&status=...`. Counts are built for every queue on every request, but the `jobs` array is only filled for the queue named in `activeQueue`. A proxy that drops the query string therefore leaves the counts intact and empties every list.
nginx does this when `proxy_pass` contains a variable. With a regex `location` that captures the path into `$1`, nginx sends exactly the URI you built and does not append the original query string:
```nginx
location ~ /queues/(.*) {
proxy_pass http://127.0.0.1:3000/queues/$1;
}
```
Use a prefix location with a literal URI instead, which forwards the query string untouched:
```nginx
location ^~ /queues/ {
proxy_pass http://127.0.0.1:3000/queues/;
}
```
If the regex form has to stay, append `$is_args$args` to the `proxy_pass` URI.
To confirm, request `/api/queues?activeQueue=` once against the Node process directly and once through the proxy. If `jobs` is filled in the first response and empty in the second, the proxy is at fault.
## `Cannot find module '@worker-manager/ui/package.json'`
Thrown at startup, almost always under a bundler (Next.js/Vercel, esbuild, `ncc`, a Docker build that prunes `node_modules`).
Worker Manager locates the compiled UI with `eval(require.resolve('@worker-manager/ui/package.json'))`. The `eval` is deliberate: it hides the require from bundlers so they don't try to inline the whole UI, but it also means bundlers don't know to ship those files. Two fixes: point worker-manager at the UI directly with `options.uiBasePath`, and/or tell the bundler to include the files. The [Next.js & Vercel recipe](/worker-manager/recipes/nextjs.md) walks through both.
## Blank page, or a 500 on the dashboard root
If the response is a 500 rather than a 404, it's usually the missing-UI case above (the view can't render). A genuinely blank page with no failed requests is more often a strict **Content-Security-Policy** on the parent app blocking the dashboard's scripts or styles. Check the browser console for CSP violations and allow Worker Manager's `/static` origin if so.
## Buttons do nothing, or actions error after an upgrade
If retry/clean/pause started failing after you bumped a dependency, suspect a Bull / BullMQ version mismatch between your workers and the version Worker Manager resolves. Pin the same version across both. See [version pinning](/worker-manager/configuration/production-checklist.md#version-pinning).
## The Paused tab disappeared after upgrading to BullMQ v6
Working as intended. BullMQ v6 removed the paused job state, so a paused queue keeps its jobs in `waiting` and reports no paused count at all. The dashboard stops offering a tab that could only ever be empty, and shows those jobs under **Waiting** instead. The queue still shows its paused banner, and pause and resume still work. See [supported versions](/worker-manager/queue-adapters/bullmq.md#supported-versions).
## A destructive action returns 405
That's [read-only mode](/worker-manager/recipes/read-only-mode.md) doing its job. The queue was registered with `readOnlyMode: true` (or the action is gated by `allowRetries`). Intended, not a bug.
## "Retry all" says it skipped some ids, or a count is higher than the list
If every queue and every status shows the same gap behind a reverse proxy, check the [query string](#counts-are-right-but-every-list-is-empty-behind-a-reverse-proxy) first.
The status set holds ids whose job data is gone, so the dashboard has an id and nothing to show for it. Counts come from the set (a `ZCARD`), which is why the badge can read higher than the rows beneath it, and why retrying leaves it stuck above zero.
Redis is almost always the cause. BullMQ and Bull both require `maxmemory-policy noeviction`; under `allkeys-lru` or any other policy Redis evicts individual job hashes once memory fills, while the sets that point at them survive. Check with `redis-cli config get maxmemory-policy`, raise the instance's memory before switching the policy, or the writes that used to evict will start failing outright.
The leftover ids are not removed for you, since deleting datastore entries is more than a dashboard should do behind your back. They age out of `completed` as new jobs push them past `removeOnComplete`, and **Clean** removes them from any set, along with the real jobs in it.
## Jest: `Must use import to load ES Module` from `content-disposition` \{#jest-esm-content-disposition}
A Jest suite running as CommonJS (ts-jest without ESM mode, the NestJS default) fails as soon as
it loads `@worker-manager/fastify`:
```text
Must use import to load ES Module: .../content-disposition@3.0.0/.../dist/index.js
at .../@fastify/static/index.js
```
`@fastify/static` 10.1.4 and later depend on `content-disposition@3`, which is published as an ES
module only. Node 22 can `require()` it, Jest's CommonJS runtime cannot. The dashboard itself is
fine; only the test runner is affected. Pick one of these:
**Let the package manager reuse `@fastify/static` 9.** Since 2.2.0, `@worker-manager/fastify`
accepts `@fastify/static` `^9.0.0 || ^10.0.0`. If your app already depends on
`@fastify/static@9` (whose `content-disposition@1` is CommonJS), pnpm and npm install that one
copy for both; with Yarn, run `yarn dedupe @fastify/static`. Check with
`pnpm why @fastify/static` or `npm ls @fastify/static`. This works in every Jest mode, including
`--experimental-vm-modules`. Note that 9.x predates the fix for
[GHSA-r799-r9gc-m956](https://github.com/fastify/fastify-static/security/advisories/GHSA-r799-r9gc-m956),
a route-guard bypass on case-insensitive filesystems; the dashboard serves only its own public
assets behind a hook that covers the whole prefix, so it is not exposed, but your own
`@fastify/static` routes might be.
**Or compile that one package to CommonJS for Jest.** With ts-jest, in `jest.config.js`:
```js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
transform: {
'^.+\\.tsx?$': 'ts-jest',
// content-disposition@3 ships ESM only; compile it to CommonJS for Jest.
'^.+/node_modules/content-disposition/.+\\.js$': [
'ts-jest',
{ tsconfig: { allowJs: true, module: 'commonjs' } },
],
},
// Everything else in node_modules stays untransformed, as by default.
transformIgnorePatterns: ['^(?!.*/node_modules/content-disposition/).*/node_modules/'],
};
```
It works with npm, Yarn and pnpm layouts and keeps the patched `@fastify/static` 10. It does not
help when Jest runs with `--experimental-vm-modules`: in that mode Jest refuses to `require()` any
file of a `"type": "module"` package, however it is transformed, so use the first option there.
## Still stuck
Open an issue on [naldomadeira/worker-manager](https://github.com/naldomadeira/worker-manager/issues) with your adapter, versions, and the mount/base-path setup. Most reports resolve to one of the above once the exact paths are on the table.
---
url: /worker-manager/recipes/visibility-guard.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Visibility guard
> Applies to: all adapters.
A visibility guard is a per-request predicate on a queue adapter that decides whether the requester can see that queue. Use it for multi-tenant setups, or for role-based access on a shared dashboard.
## Shape
`setVisibilityGuard` lives on `BaseAdapter`, every queue adapter (Bull, BullMQ) inherits it:
```ts
queueAdapter.setVisibilityGuard(
(request: WorkerManagerRequest) => boolean | Promise,
);
```
`WorkerManagerRequest` carries the fields Worker Manager pulled from the underlying server's request: `queues`, `uiConfig`, `query`, `params`, `body`, `headers`. Authenticate off `request.headers` (cookies, bearer tokens) and route on `request.params.queueName` or a reference captured in the closure.
## Register the guard
```ts
import { createWorkerManagerBoard } from '@worker-manager/api';
import { BullMQAdapter } from '@worker-manager/api/bullMQAdapter';
import { ExpressAdapter } from '@worker-manager/express';
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');
const billingAdapter = new BullMQAdapter(billingQueue);
billingAdapter.setVisibilityGuard((request) => {
const user = decodeUserFromHeaders(request.headers);
return user?.roles.includes('billing') ?? false;
});
const notificationsAdapter = new BullMQAdapter(notificationsQueue);
notificationsAdapter.setVisibilityGuard((request) => {
const user = decodeUserFromHeaders(request.headers);
return !!user; // visible to any authenticated user
});
createWorkerManagerBoard({
queues: [billingAdapter, notificationsAdapter],
serverAdapter,
});
```
Each queue has its own guard. Queues without a guard are visible to everyone.
## How it runs
- Invoked on every queue list request and every per-queue API call.
- Runs on the hot path. The dashboard polls on an interval, so every queue's guard runs every poll cycle.
- Async is allowed (`Promise`), but I/O inside the guard will serialise requests. Read a pre-validated session off headers, or use a small in-memory cache, rather than hitting the DB on every poll.
- Guards run after your framework's auth layer. Reject unauthenticated requests before Worker Manager's router, the guard should assume "is the requester authenticated?" is already decided.
## Hidden means hidden
A queue that fails its guard is fully invisible to that request:
- It does not appear in the sidebar or the overview.
- It is not counted in aggregate metrics.
- Every per-queue API endpoint (jobs, logs, flow, retry, pause, …) returns **HTTP 404 Queue not found**.
- Queue-wide actions like pause-all and resume-all silently skip it.
No "locked" state. The UI behaves as if the queue doesn't exist.
## Full runnable example
- [`examples/fastify/visibility-guard`](https://github.com/naldomadeira/worker-manager/tree/main/examples/fastify/visibility-guard): cookie-based auth with two users, each limited to a different queue.
## Source of truth
`setVisibilityGuard` and `isVisible` are on `BaseAdapter` in [`packages/api/src/queueAdapters/base.ts`](https://github.com/naldomadeira/worker-manager/blob/main/packages/api/src/queueAdapters/base.ts). Enforcement is in [`packages/api/src/providers/queue.ts`](https://github.com/naldomadeira/worker-manager/blob/main/packages/api/src/providers/queue.ts) and in the list handlers under [`packages/api/src/handlers/`](https://github.com/naldomadeira/worker-manager/tree/main/packages/api/src/handlers).
---
url: /worker-manager/recipes/whitelabel-theming.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Whitelabel the dashboard
The board is styled entirely from CSS custom properties, and `uiConfig.theme` overrides them per
colour scheme. You do not fork the UI or ship a stylesheet over the top of it. The tokens you set
are written into the page at boot.
Token names follow the [shadcn theme contract](https://ui.shadcn.com/themes), so a palette from any
shadcn-compatible theme editor drops straight in.
## Start with one token
`primary` is the token the rest of the interactive palette hangs off. The focus ring, the sidebar's
active entry, the selected status tab, the selected page in the pagination, and the hover and
selection washes all resolve to it.
```ts
createWorkerManagerBoard({
queues,
serverAdapter,
options: {
uiConfig: {
theme: {
light: { primary: '#6d28d9', radius: '0.75rem' },
dark: { primary: '#a78bfa' },
},
},
},
});
```
That is the whole change behind both of these:


The job status colours stay where they were. Red still means failed, whatever your brand colour is.
`radius` moved every corner on the board at once, which is usually what you want and occasionally a
surprise.
## Set light and dark separately
`theme.light` and `theme.dark` are independent maps, and you almost always want both. A brand colour
picked against white tends to fail contrast against the dark surface, which is why the example above
lightens the violet for dark mode instead of reusing it.
Anything you leave out keeps the shipped value, so a theme can be two lines or fifty.
## The tokens
| Group | Names |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Surfaces | `background`, `foreground`, `card`, `card-foreground`, `popover`, `popover-foreground`, `muted`, `muted-foreground` |
| Interaction | `primary`, `primary-foreground`, `secondary`, `secondary-foreground`, `accent`, `accent-foreground`, `destructive`, `destructive-foreground`, `ring` |
| Interaction states | `state-hover`, `state-selected`, `state-selected-hover`, `state-selected-foreground` |
| Strokes and shape | `border`, `input`, `radius` |
| Elevation | `shadow-popover`, `shadow-control`, `overlay` |
| Type | `font-sans`, `font-mono` |
| Sidebar | `sidebar`, `sidebar-foreground`, `sidebar-primary`, `sidebar-primary-foreground`, `sidebar-accent`, `sidebar-accent-foreground`, `sidebar-border`, `sidebar-ring`, and the matching `sidebar-state-*` set |
| Charts | `chart-1` through `chart-5` |
| Job statuses | `status-failed`, `status-completed`, `status-waiting`, `status-waiting-children`, `status-prioritized`, `status-active`, `status-delayed`, `status-paused`, and on a [pg-boss board](/worker-manager/queue-adapters/pg-boss.md) `status-retry` and `status-cancelled` |
A pg-boss board reuses the BullMQ colours where the states mean the same thing (`created` is drawn
with `status-waiting`, and `active`, `completed` and `failed` with their namesakes) and adds two of
its own, `status-retry` and `status-cancelled`, for the states BullMQ does not have. They are
ordinary tokens, overridable per theme like the rest.
A few of those are worth a sentence. The interaction states are mixed from `primary` at 8%, 16% and
24%, so set them only if you want a hovered or selected control somewhere other than three strengths
of your brand colour. `shadow-popover` is the one recipe every floating surface uses, and setting it
to `none` gives you a flat board with borders only. The sidebar has its own namespace because a dark
rail against a light board is a thing people want. And the board puts tabular figures on everything
numeric, so a monospace with proportional digits will look wrong in the counts.
## Deriving, and overriding a derivation
Most of that list is derived rather than set independently. `ring`, `sidebar-primary` and
`sidebar-ring` are `var(--primary)`. `card-foreground` and `popover-foreground` are
`var(--foreground)`. The chart ramp is the status colours. The state washes are `color-mix()` of
`primary`.
Every derived name is still individually overridable, and an override wins, because `uiConfig.theme`
writes into `:root` and `html.dark` and both outrank the derivation. Set `ring` when you want a
focus ring that is not your brand colour, and leave it out when you don't.
## The rest of the chrome
Theming is the palette. The other whitelabel knobs are separate `uiConfig` keys:
```ts
uiConfig: {
boardTitle: 'Acme Jobs',
boardLogo: { path: '/static/acme.svg', width: 32, height: 32 },
favIcon: { default: '/static/favicon.ico', alternative: '/static/favicon-32x32.png' },
environment: { label: 'production', color: '#b91c1c', textColor: '#fff' },
hideDocsLink: true,
}
```

See [UIConfig](/worker-manager/configuration/ui-config.md) for the full reference.
## What a theme cannot do
Values are plain CSS values, and they are sanitised on the way in. Unknown token names are dropped,
and any value containing `;`, `{`, `}`, `<` or `>` is rejected, so a theme cannot inject rules or
markup into the page. If a token you set has no effect, check the spelling against the table above.
A name that is not in the contract is discarded rather than passed through, silently.
---
url: /worker-manager/reference/http-api.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# HTTP API reference
> This page is generated from the route table in `@worker-manager/api`. Do not edit it by hand: run
> `yarn workspace @worker-manager/api openapi` instead. The same content is browsable as an
> [interactive reference](/worker-manager/api/index.md), and machine-readable at
> [`openapi.json`](https://naldomadeira.github.io/worker-manager/openapi.json).
The dashboard's own UI is a client of this API and nothing else, so anything the UI can do is
available here. Every route is served relative to the base path you passed to `setBasePath()`. A
board mounted at `/admin/queues` serves `GET /admin/queues/api/queues`.
## Authentication
There is none. Worker Manager does not authenticate requests and never has: the board inherits
whatever protects the route it is mounted on, which is your application's own middleware. See
[basic auth](/worker-manager/recipes/basic-auth.md) for the standalone case, and
[access control hooks](/worker-manager/recipes/access-control-hooks.md) for per-route rules.
This matters when pointing a script or an agent at a running board. You send whatever credential
your own middleware expects, as an ordinary header, and Worker Manager neither issues nor validates
it.
## What can reject a call
A route existing in this document does not mean a given board will answer it.
- Queues registered with `readOnlyMode` reject every write with **405** and
`ERRORS.QUEUE_READ_ONLY`.
- A [visibility guard](/worker-manager/recipes/visibility-guard.md) makes a queue answer **404** as though it were
not registered.
- A [`handlerHooks.before`](/worker-manager/recipes/access-control-hooks.md) hook can reject any call, by default
with **403** and `ERRORS.FORBIDDEN`.
- The four `/api/metrics/*` routes are registered only when a `historyProvider` is configured,
and individually only when the provider implements the matching capability. Without one they
are not mounted at all and answer **404**. See [historical metrics](/worker-manager/recipes/historical-metrics.md).
## Request validation
Every query string and request body documented here is checked against its schema before the
route runs, and a request that does not match is refused with **400** before anything is read or
written. The check runs after `handlerHooks.before`, so a hook that hides a route still answers
first and a malformed request cannot be used to discover that a hidden route exists.
Query values arrive as strings and are coerced by the schema, which is why parameters such as
`page` document a string alongside a number: the wire carries `page=2` and the handler receives
`2`. An empty value reads as an omitted one, so `?page=` is the same request as no `page` at all.
## Error bodies
Every failure returns `ErrorResponseBody`. Its `error` field is a translation key rather than a
sentence, because the API never puts user-facing English in a response and the client owns the
wording. `code` is the stable identifier to branch on when you handle a specific failure rather
than display it.
```json
{
"error": { "key": "ERRORS.QUEUE_NOT_FOUND" },
"message": { "key": "ERRORS.JOB_IS_ACTIVE_DETAILS", "options": { "jobId": "42" } },
"code": "JOB_BELONGS_TO_JOB_SCHEDULER"
}
```
## Response shapes
Every response documented here is derived from the same schema the handler is type-checked
against, so a handler that stops returning what it advertises does not compile. A board can also
check its responses at runtime with `options.validateResponses`, which is meant for developing a
custom adapter or hook rather than for production.
## Versioning
The `info.version` in the spec describes the shape of this HTTP API and is deliberately
independent of the `@worker-manager/api` package version, so a routine release does not churn the
generated artifacts.
## Queues
Board-level and per-queue operations. `GET /api/queues` is the one the dashboard polls: it returns counts for every queue the request may see, and the jobs of only the queue named in `activeQueue`, paged by `page` and `jobsPerPage`. Everything else here acts on a single queue named in the path, and is refused with **405** when that queue was registered read-only.
### `GET /api/queues`
List every visible queue with its job counts, and the jobs of the active queue.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ------------- | ----- | -------- | ------ |
| `activeQueue` | query | no | string |
| `status` | query | no | Status |
| `page` | query | no | string |
| `jobsPerPage` | query | no | string |
Responds `200` with [`GetQueuesResponse`](#getqueuesresponse).
### `GET /api/queues/{queueName}/metrics`
Read the BullMQ completed and failed counter metrics of one queue.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
Responds `200` with [`GetQueueMetricsResponse`](#getqueuemetricsresponse).
### `GET /api/queues/{queueName}/default-job-options`
Read the default job options configured on one queue.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
Responds `200` with [`GetQueueDefaultJobOptionsResponse`](#getqueuedefaultjoboptionsresponse).
### `GET /api/queues/{queueName}/workers`
List the workers currently consuming one queue.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
Responds `200` with [`GetQueueWorkersResponse`](#getqueueworkersresponse).
### `GET /api/queues/{queueName}/rate-limit`
Read the configured rate limit of one queue.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
Responds `200` with [`GetQueueRateLimitResponse`](#getqueueratelimitresponse).
### `PUT /api/queues/{queueName}/rate-limit`
Set the rate limit of one queue.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
Request body: [`SetRateLimitBody`](#setratelimitbody)
Responds `200` with [`EmptyResponse`](#emptyresponse).
### `GET /api/queues/{queueName}/job-data-schema`
Read the JSON Schema describing the job data of one queue.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
Responds `200` with [`GetQueueJobDataSchemaResponse`](#getqueuejobdataschemaresponse).
### `PUT /api/queues/pause`
Pause every writable queue on the board.
> Available only when: The board runs engine 'bullmq', the default.
Responds `200` with [`EmptyResponse`](#emptyresponse).
### `PUT /api/queues/resume`
Resume every writable queue on the board.
> Available only when: The board runs engine 'bullmq', the default.
Responds `200` with [`EmptyResponse`](#emptyresponse).
### `POST /api/queues/{queueName}/add`
Add a job to one queue.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
Request body: [`AddJobBody`](#addjobbody)
Responds `200` with [`AddJobResponse`](#addjobresponse).
### `PUT /api/queues/{queueName}/retry/{queueStatus}`
Retry every job of one queue in the given status.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ------------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
| `queueStatus` | path | yes | string |
Responds `200` with [`RetryAllResponse`](#retryallresponse).
### `PUT /api/queues/{queueName}/promote`
Promote every delayed job of one queue.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
Responds `200` with [`EmptyResponse`](#emptyresponse).
### `PUT /api/queues/{queueName}/clean/{queueStatus}`
Remove every job of one queue in the given status.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ------------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
| `queueStatus` | path | yes | string |
Responds `200` with [`EmptyResponse`](#emptyresponse).
### `PUT /api/queues/{queueName}/pause`
Pause one queue.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
Responds `200` with [`EmptyResponse`](#emptyresponse).
### `PUT /api/queues/{queueName}/resume`
Resume one queue.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
Responds `200` with [`EmptyResponse`](#emptyresponse).
### `PUT /api/queues/{queueName}/concurrency`
Set the global concurrency limit of one queue.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
Request body: [`SetGlobalConcurrencyBody`](#setglobalconcurrencybody)
Responds `200` with [`EmptyResponse`](#emptyresponse).
### `PUT /api/queues/{queueName}/rate-limit/release`
Release an active rate limit on one queue.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
Responds `200` with [`EmptyResponse`](#emptyresponse).
### `PUT /api/queues/{queueName}/empty`
Remove every job from one queue.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
Responds `200` with [`EmptyResponse`](#emptyresponse).
### `PUT /api/queues/{queueName}/obliterate`
Obliterate one queue, removing the queue itself along with all of its jobs.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
Request body: [`ObliterateQueueBody`](#obliteratequeuebody)
Responds `200` with [`EmptyResponse`](#emptyresponse).
## Jobs
Reads and mutations for one job, addressed by its queue and id. Removing a job that is the pending run of a job scheduler is refused with **400** and the `JOB_BELONGS_TO_JOB_SCHEDULER` code, because deleting it alone would leave the schedule registered but unable to fire again.
### `GET /api/queues/{queueName}/{jobId}/logs`
Read the logs of one job.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
| `jobId` | path | yes | string |
Responds `200` with [`GetJobLogsResponse`](#getjoblogsresponse).
### `GET /api/queues/{queueName}/{jobId}/flow`
Read the flow tree one job belongs to.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ------------- | ----- | -------- | ------ |
| `queueName` | path | yes | string |
| `jobId` | path | yes | string |
| `root` | query | no | any |
| `depth` | query | no | object |
| `maxChildren` | query | no | object |
Responds `200` with [`GetJobFlowResponse`](#getjobflowresponse).
### `GET /api/queues/{queueName}/{jobId}`
Read one job and its current status.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
| `jobId` | path | yes | string |
Responds `200` with [`GetJobResponse`](#getjobresponse).
### `PUT /api/queues/{queueName}/{jobId}/retry`
Retry one job.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
| `jobId` | path | yes | string |
Responds `204` with no body.
### `PUT /api/queues/{queueName}/{jobId}/clean`
Remove one job.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
| `jobId` | path | yes | string |
Responds `204` with no body.
### `PUT /api/queues/{queueName}/{jobId}/promote`
Promote one delayed job.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
| `jobId` | path | yes | string |
Responds `204` with no body.
### `PATCH /api/queues/{queueName}/{jobId}/update-data`
Replace the data of one job.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
| `jobId` | path | yes | string |
Request body: [`UpdateJobDataBody`](#updatejobdatabody)
Responds `200` with [`EmptyResponse`](#emptyresponse).
### `PATCH /api/queues/{queueName}/{jobId}/delay`
Reschedule one delayed job.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
| `jobId` | path | yes | string |
Request body: [`ChangeJobDelayBody`](#changejobdelaybody)
Responds `200` with [`EmptyResponse`](#emptyresponse).
### `PATCH /api/queues/{queueName}/{jobId}/priority`
Change the priority of one job.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
| `jobId` | path | yes | string |
Request body: [`ChangeJobPriorityBody`](#changejobprioritybody)
Responds `200` with [`EmptyResponse`](#emptyresponse).
### `PUT /api/queues/{queueName}/{jobId}/remove-unprocessed-children`
Remove the unprocessed children of one job.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
| `jobId` | path | yes | string |
Responds `200` with [`RemoveUnprocessedChildrenResponse`](#removeunprocessedchildrenresponse).
## Job schedulers
Repeatable job definitions, meaning the schedule itself rather than the runs it produces. Listing spans every visible queue unless you name one. Editing a schedule replaces it, so a body that sets neither a cron pattern nor an interval is rejected.
### `GET /api/job-schedulers`
List job schedulers across every visible queue, or one named queue.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ----------- | ----- | -------- | ------ |
| `queueName` | query | no | string |
Responds `200` with [`GetJobSchedulersResponse`](#getjobschedulersresponse).
### `PUT /api/queues/{queueName}/job-schedulers/{schedulerId}/remove`
Remove one job scheduler.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ------------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
| `schedulerId` | path | yes | string |
Responds `204` with no body.
### `PATCH /api/queues/{queueName}/job-schedulers/{schedulerId}`
Update the schedule of one job scheduler.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ------------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
| `schedulerId` | path | yes | string |
Request body: [`UpdateJobSchedulerBody`](#updatejobschedulerbody)
Responds `204` with no body.
### `PUT /api/queues/{queueName}/job-schedulers/{schedulerId}/run`
Run one job scheduler now, leaving its schedule untouched.
> Available only when: The board runs engine 'bullmq', the default.
| Parameter | In | Required | Type |
| ------------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
| `schedulerId` | path | yes | string |
Responds `200` with [`RunJobSchedulerResponse`](#runjobschedulerresponse).
## Metrics history
Long-retention counter and latency history. These routes exist only on a board configured with a `historyProvider`, and each one individually only when the provider implements the matching capability, so on a board without one they are not mounted and answer **404**.
### `GET /api/metrics/history`
Read recorded job counter history over a time range.
> Available only when: A `historyProvider` is configured on the board.
| Parameter | In | Required | Type |
| ------------- | ----- | -------- | ------------------------- |
| `from` | query | yes | string |
| `to` | query | yes | string |
| `granularity` | query | no | MetricsHistoryGranularity |
| `queue` | query | no | string |
| `metric` | query | no | MetricsHistoryMetric |
Responds `200` with [`GetMetricsHistoryResponse`](#getmetricshistoryresponse).
### `GET /api/metrics/history/usage`
Report how much storage the recorded history occupies.
> Available only when: A `historyProvider` is configured on the board. The provider implements `getUsage`.
Responds `200` with [`GetMetricsHistoryUsageResponse`](#getmetricshistoryusageresponse).
### `POST /api/metrics/history/purge`
Delete recorded history.
> Available only when: A `historyProvider` is configured on the board. The provider implements `purge` and the board is not read-only.
Request body: [`PurgeMetricsHistoryBody`](#purgemetricshistorybody)
Responds `200` with [`PurgeMetricsHistoryResponse`](#purgemetricshistoryresponse).
### `GET /api/metrics/latency`
Read recorded runtime or wait-time latency percentiles over a time range.
> Available only when: A `historyProvider` is configured on the board. The provider implements `getLatency`.
| Parameter | In | Required | Type |
| ------------- | ----- | -------- | -------------------------- |
| `metric` | query | yes | MetricsLatencyMetric |
| `from` | query | no | string |
| `to` | query | no | string |
| `granularity` | query | no | `hour` \| `day` \| `range` |
| `queue` | query | no | string |
| `percentiles` | query | no | string |
Responds `200` with [`GetMetricsLatencyResponse`](#getmetricslatencyresponse).
## pg-boss
Every route of a board created with engine 'pg-boss' (`createPgBossBoard` from `@worker-manager/pg-boss`). Such a board registers these, the metrics history routes and the entry page, and none of the BullMQ routes; a BullMQ board registers none of these. Reads are SQL against the pg-boss schema, writes go through the pg-boss API. Mutations are not registered on a read-only board, and answer **409** `ERRORS.PGBOSS_WRITES_DISABLED` while the schema guard keeps writes off. Stable since 2.4.0: this part of the contract follows semver, so a breaking change only ships in a major.
### `GET /api/pg-boss/info`
Report the pg-boss installation, the schema guard and what the board can do.
> Available only when: The board was created with engine 'pg-boss'.
Responds `200` with [`GetPgBossInfoResponse`](#getpgbossinforesponse).
### `GET /api/pg-boss/queues`
List every visible pg-boss queue with its cached counters.
> Available only when: The board was created with engine 'pg-boss'.
Responds `200` with [`GetPgBossQueuesResponse`](#getpgbossqueuesresponse).
### `GET /api/pg-boss/queues/{queueName}`
Read one pg-boss queue.
> Available only when: The board was created with engine 'pg-boss'.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
Responds `200` with [`GetPgBossQueueResponse`](#getpgbossqueueresponse).
### `GET /api/pg-boss/queues/{queueName}/counts`
Count the jobs of one queue in each state, live and capped.
> Available only when: The board was created with engine 'pg-boss'.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
Responds `200` with [`GetPgBossStateCountsResponse`](#getpgbossstatecountsresponse).
### `GET /api/pg-boss/queues/{queueName}/depth`
Chart one queue's depth over time from pg-boss's own `queue_stats` snapshots, bucketed.
> Available only when: The board was created with engine 'pg-boss'. Answers 409 `ERRORS.PGBOSS_FEATURE_UNAVAILABLE` on a schema without `queue_stats`.
| Parameter | In | Required | Type |
| ----------- | ----- | -------- | ----------------------------- |
| `queueName` | path | yes | string |
| `range` | query | no | `1h` \| `6h` \| `24h` \| `7d` |
| `aggregate` | query | no | `max` \| `avg` |
Responds `200` with [`GetPgBossQueueDepthResponse`](#getpgbossqueuedepthresponse).
### `GET /api/pg-boss/queues/{queueName}/jobs`
List the jobs of one queue, newest first, one keyset page at a time.
> Available only when: The board was created with engine 'pg-boss'.
| Parameter | In | Required | Type |
| -------------- | ----- | -------- | --------------- |
| `queueName` | path | yes | string |
| `state` | query | no | PgBossJobState |
| `cursor` | query | no | string |
| `limit` | query | no | string |
| `order` | query | no | `desc` \| `asc` |
| `id` | query | no | string |
| `singletonKey` | query | no | string |
Responds `200` with [`GetPgBossJobsResponse`](#getpgbossjobsresponse).
### `POST /api/pg-boss/queues/{queueName}/jobs`
Send a job to one queue.
> Available only when: The board was created with engine 'pg-boss'. The board is not read-only. Answers 409 `ERRORS.PGBOSS_WRITES_DISABLED` while the schema guard has writes off.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
Request body: [`SendPgBossJobBody`](#sendpgbossjobbody)
Responds `200` with [`SendPgBossJobResponse`](#sendpgbossjobresponse).
### `GET /api/pg-boss/queues/{queueName}/jobs/{jobId}`
Read one job with its data and output.
> Available only when: The board was created with engine 'pg-boss'.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
| `jobId` | path | yes | string |
Responds `200` with [`GetPgBossJobResponse`](#getpgbossjobresponse).
### `GET /api/pg-boss/queues/{queueName}/jobs/{jobId}/dependencies`
List the jobs one job waits on and the jobs waiting on it.
> Available only when: The board was created with engine 'pg-boss'.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
| `jobId` | path | yes | string |
Responds `200` with [`GetPgBossDependenciesResponse`](#getpgbossdependenciesresponse).
### `GET /api/pg-boss/jobs/{jobId}`
Find a job by id in whichever visible queue holds it, probing each queue on its primary key.
> Available only when: The board was created with engine 'pg-boss'.
| Parameter | In | Required | Type |
| --------- | ---- | -------- | ------ |
| `jobId` | path | yes | string |
Responds `200` with [`FindPgBossJobResponse`](#findpgbossjobresponse).
### `GET /api/pg-boss/warnings`
List pg-boss's persisted warnings, newest first, one keyset page at a time. Warnings naming a hidden queue are left out.
> Available only when: The board was created with engine 'pg-boss'. Answers 409 `ERRORS.PGBOSS_FEATURE_UNAVAILABLE` on a schema without the `warning` table.
| Parameter | In | Required | Type |
| --------- | ----- | -------- | ------ |
| `type` | query | no | string |
| `cursor` | query | no | string |
| `limit` | query | no | string |
Responds `200` with [`GetPgBossWarningsResponse`](#getpgbosswarningsresponse).
### `GET /api/pg-boss/schedules`
List the schedules of every visible queue, or one named queue.
> Available only when: The board was created with engine 'pg-boss'.
| Parameter | In | Required | Type |
| ----------- | ----- | -------- | ------ |
| `queueName` | query | no | string |
Responds `200` with [`GetPgBossSchedulesResponse`](#getpgbossschedulesresponse).
### `POST /api/pg-boss/schedules/preview`
Work out the next occurrences of a cron or RRULE expression.
> Available only when: The board was created with engine 'pg-boss'. Needs pg-boss 12.31 or later, else 409 `ERRORS.PGBOSS_PREVIEW_UNAVAILABLE`.
Request body: [`PreviewPgBossScheduleBody`](#previewpgbossschedulebody)
Responds `200` with [`PreviewPgBossScheduleResponse`](#previewpgbossscheduleresponse).
### `PUT /api/pg-boss/queues/{queueName}/jobs/retry`
Retry failed jobs, up to 100 ids at once.
> Available only when: The board was created with engine 'pg-boss'. The board is not read-only. Answers 409 `ERRORS.PGBOSS_WRITES_DISABLED` while the schema guard has writes off.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
Request body: [`PgBossJobIdsBody`](#pgbossjobidsbody)
Responds `200` with [`PgBossCommandResponse`](#pgbosscommandresponse).
### `PUT /api/pg-boss/queues/{queueName}/jobs/{jobId}/retry`
Retry one failed job.
> Available only when: The board was created with engine 'pg-boss'. The board is not read-only. Answers 409 `ERRORS.PGBOSS_WRITES_DISABLED` while the schema guard has writes off.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
| `jobId` | path | yes | string |
Responds `200` with [`PgBossCommandResponse`](#pgbosscommandresponse).
### `PUT /api/pg-boss/queues/{queueName}/jobs/cancel`
Cancel jobs that have not finished, up to 100 ids at once.
> Available only when: The board was created with engine 'pg-boss'. The board is not read-only. Answers 409 `ERRORS.PGBOSS_WRITES_DISABLED` while the schema guard has writes off.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
Request body: [`PgBossJobIdsBody`](#pgbossjobidsbody)
Responds `200` with [`PgBossCommandResponse`](#pgbosscommandresponse).
### `PUT /api/pg-boss/queues/{queueName}/jobs/{jobId}/cancel`
Cancel one job that has not finished. A running handler is not interrupted.
> Available only when: The board was created with engine 'pg-boss'. The board is not read-only. Answers 409 `ERRORS.PGBOSS_WRITES_DISABLED` while the schema guard has writes off.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
| `jobId` | path | yes | string |
Responds `200` with [`PgBossCommandResponse`](#pgbosscommandresponse).
### `PUT /api/pg-boss/queues/{queueName}/jobs/resume`
Resume cancelled jobs, up to 100 ids at once.
> Available only when: The board was created with engine 'pg-boss'. The board is not read-only. Answers 409 `ERRORS.PGBOSS_WRITES_DISABLED` while the schema guard has writes off.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
Request body: [`PgBossJobIdsBody`](#pgbossjobidsbody)
Responds `200` with [`PgBossCommandResponse`](#pgbosscommandresponse).
### `PUT /api/pg-boss/queues/{queueName}/jobs/{jobId}/resume`
Resume one cancelled job.
> Available only when: The board was created with engine 'pg-boss'. The board is not read-only. Answers 409 `ERRORS.PGBOSS_WRITES_DISABLED` while the schema guard has writes off.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
| `jobId` | path | yes | string |
Responds `200` with [`PgBossCommandResponse`](#pgbosscommandresponse).
### `PUT /api/pg-boss/queues/{queueName}/jobs/remove`
Delete jobs, up to 100 ids at once.
> Available only when: The board was created with engine 'pg-boss'. The board is not read-only. Answers 409 `ERRORS.PGBOSS_WRITES_DISABLED` while the schema guard has writes off.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
Request body: [`PgBossJobIdsBody`](#pgbossjobidsbody)
Responds `200` with [`PgBossCommandResponse`](#pgbosscommandresponse).
### `PUT /api/pg-boss/queues/{queueName}/jobs/{jobId}/remove`
Delete one job that is not active.
> Available only when: The board was created with engine 'pg-boss'. The board is not read-only. Answers 409 `ERRORS.PGBOSS_WRITES_DISABLED` while the schema guard has writes off.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
| `jobId` | path | yes | string |
Responds `200` with [`PgBossCommandResponse`](#pgbosscommandresponse).
### `PUT /api/pg-boss/queues/{queueName}/retry-failed`
Retry every failed job of one queue.
> Available only when: The board was created with engine 'pg-boss'. The board is not read-only. Answers 409 `ERRORS.PGBOSS_WRITES_DISABLED` while the schema guard has writes off.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
Responds `200` with [`PgBossCommandResponse`](#pgbosscommandresponse).
### `PUT /api/pg-boss/queues/{queueName}/delete-queued`
Delete every job of one queue that has not started.
> Available only when: The board was created with engine 'pg-boss'. The board is not read-only. Answers 409 `ERRORS.PGBOSS_WRITES_DISABLED` while the schema guard has writes off.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
Responds `200` with [`PgBossCommandResponse`](#pgbosscommandresponse).
### `PUT /api/pg-boss/queues/{queueName}/delete-stored`
Delete every completed, cancelled and failed job of one queue.
> Available only when: The board was created with engine 'pg-boss'. The board is not read-only. Answers 409 `ERRORS.PGBOSS_WRITES_DISABLED` while the schema guard has writes off.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
Responds `200` with [`PgBossCommandResponse`](#pgbosscommandresponse).
### `PUT /api/pg-boss/queues/{queueName}/schedules`
Create or replace the schedule with this key on one queue.
> Available only when: The board was created with engine 'pg-boss'. The board is not read-only. Answers 409 `ERRORS.PGBOSS_WRITES_DISABLED` while the schema guard has writes off.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
Request body: [`UpsertPgBossScheduleBody`](#upsertpgbossschedulebody)
Responds `200` with [`PgBossScheduleResponse`](#pgbossscheduleresponse).
### `PUT /api/pg-boss/queues/{queueName}/schedules/remove`
Remove one schedule.
> Available only when: The board was created with engine 'pg-boss'. The board is not read-only. Answers 409 `ERRORS.PGBOSS_WRITES_DISABLED` while the schema guard has writes off.
| Parameter | In | Required | Type |
| ----------- | ---- | -------- | ------ |
| `queueName` | path | yes | string |
Request body: [`RemovePgBossScheduleBody`](#removepgbossschedulebody)
Responds `200` with [`PgBossCommandResponse`](#pgbosscommandresponse).
## Datastore
Statistics for the datastore behind the board's first registered queue. Answers **404** when that queue is backed by something other than Redis that cannot report them, and **403** when the board sets `hideRedisDetails`.
### `GET /api/redis/stats`
Read the datastore statistics of the board's first visible queue.
> Available only when: The board runs engine 'bullmq', the default.
Responds `200` with [`GetRedisStatsResponse`](#getredisstatsresponse).
## Schemas
### AppJob
| Field | Type | Required |
| ----------------- | ------------------------------------- | -------- |
| `id` | string \| number \| null | no |
| `name` | string | yes |
| `timestamp` | number | yes |
| `processedOn` | number \| null | no |
| `processedBy` | string \| null | no |
| `finishedOn` | number \| null | no |
| `progress` | string \| boolean \| number \| object | yes |
| `attempts` | number | yes |
| `failedReason` | string | no |
| `stacktrace` | string\[] | yes |
| `delay` | number | no |
| `opts` | any | yes |
| `data` | any | yes |
| `returnValue` | any | yes |
| `isFailed` | boolean | yes |
| `externalUrl` | ExternalJobUrl | no |
| `groupId` | string \| number | no |
| `priority` | number | no |
| `attemptsStarted` | number | no |
| `stalledCounter` | number | no |
| `deduplicationId` | string | no |
| `deferredFailure` | string | no |
### AppJobScheduler
| Field | Type | Required |
| ---------------- | ------ | -------- |
| `id` | string | yes |
| `queueName` | string | yes |
| `name` | string | yes |
| `pattern` | string | no |
| `every` | number | no |
| `tz` | string | no |
| `limit` | number | no |
| `startDate` | number | no |
| `endDate` | number | no |
| `next` | number | no |
| `nextRunJobId` | string | no |
| `lastRun` | number | no |
| `lastRunJobId` | string | no |
| `iterationCount` | number | no |
| `template` | object | no |
### AppQueue
| Field | Type | Required |
| ------------------------- | ------------------ | -------- |
| `delimiter` | string | yes |
| `name` | string | yes |
| `displayName` | string | no |
| `description` | string | no |
| `counts` | JobCounts | yes |
| `jobs` | AppJob\[] | yes |
| `statuses` | Status\[] | yes |
| `pagination` | Pagination | yes |
| `readOnlyMode` | boolean | yes |
| `allowRetries` | boolean | yes |
| `allowCompletedRetries` | boolean | yes |
| `isPaused` | boolean | yes |
| `type` | `bull` \| `bullmq` | yes |
| `library` | QueueLibrary | yes |
| `datastore` | Datastore | yes |
| `capabilities` | QueueCapabilities | yes |
| `globalConcurrency` | number \| null | yes |
| `activeRateLimitTtl` | number | yes |
| `supportsGlobalRateLimit` | boolean | yes |
| `jobSchedulerCount` | number | yes |
| `hasWorkers` | boolean \| null | yes |
### ErrorResponseBody
| Field | Type | Required |
| --------- | ----------------------------- | -------- |
| `error` | object | yes |
| `message` | string \| TranslatableMessage | no |
| `code` | string | no |
| `details` | string | no |
### ExternalJobUrl
| Field | Type | Required |
| ------------- | ------ | -------- |
| `displayText` | string | no |
| `href` | string | yes |
### FlowDependencies
| Field | Type | Required |
| ------------- | ------ | -------- |
| `processed` | number | yes |
| `unprocessed` | number | yes |
| `ignored` | number | yes |
| `failed` | number | yes |
### FlowNode
| Field | Type | Required |
| ---------------------------- | ------------------------------------- | -------- |
| `id` | string | yes |
| `name` | string | yes |
| `state` | string | yes |
| `progress` | string \| boolean \| number \| object | yes |
| `queueName` | string | yes |
| `children` | FlowNode\[] | yes |
| `truncated` | boolean | no |
| `dependencies` | FlowDependencies | no |
| `ignoredChildFailureReasons` | object | no |
### JobCounts
`object`
### JobFlow
| Field | Type | Required |
| ------------ | ---------------- | -------- |
| `nodeId` | string | yes |
| `isFlowNode` | boolean | yes |
| `flowRoot` | FlowNode \| null | yes |
### JobState
``latest` \| `active` \| `waiting` \| `waiting-children` \| `prioritized` \| `completed` \| `failed` \| `delayed` \| `paused` \| `stuck` \| `unknown``
### JobStatus
``active` \| `waiting` \| `waiting-children` \| `prioritized` \| `completed` \| `failed` \| `delayed` \| `paused``
### MetricsHistoryGranularity
``hour` \| `day``
### MetricsHistoryMetric
``completed` \| `failed` \| `queueage``
### MetricsLatencyGranularity
``hour` \| `day` \| `range``
### MetricsLatencyMetric
``runtime` \| `waittime``
### MetricsHistoryPoint
| Field | Type | Required |
| ------- | ------ | -------- |
| `ts` | number | yes |
| `value` | number | yes |
### MetricsHistoryPurgeResult
| Field | Type | Required |
| --------------- | ------ | -------- |
| `keysDeleted` | number | yes |
| `fieldsDeleted` | number | yes |
### MetricsHistoryQueueUsage
| Field | Type | Required |
| --------- | --------- | -------- |
| `queue` | string | yes |
| `keys` | number | yes |
| `bytes` | number | yes |
| `minutes` | number | yes |
| `days` | string\[] | yes |
| `tiers` | object | yes |
### MetricsHistoryTierUsage
| Field | Type | Required |
| ------- | ------ | -------- |
| `keys` | number | yes |
| `bytes` | number | yes |
### MetricsHistoryUsage
| Field | Type | Required |
| ----------- | --------------------------- | -------- |
| `keys` | number | yes |
| `bytes` | number | yes |
| `minutes` | number | yes |
| `oldestDay` | string \| null | yes |
| `newestDay` | string \| null | yes |
| `tiers` | object | yes |
| `queues` | MetricsHistoryQueueUsage\[] | yes |
### MetricsLatencyPoint
| Field | Type | Required |
| -------- | ------ | -------- |
| `ts` | number | yes |
| `count` | number | yes |
| `values` | object | yes |
### Pagination
| Field | Type | Required |
| ----------- | ------ | -------- |
| `pageCount` | number | yes |
| `range` | object | yes |
### QueueType
``bull` \| `bullmq``
### QueueLibrary
``bull` \| `bullmq` \| `bullmq-pro``
### QueueCapabilities
| Field | Type | Required |
| --------------------------- | ------------------ | -------- |
| `pause` | boolean | yes |
| `logs` | boolean | yes |
| `progress` | boolean | yes |
| `flows` | boolean | yes |
| `promote` | boolean | yes |
| `updateData` | boolean | yes |
| `changeDelay` | boolean | yes |
| `changePriority` | boolean | yes |
| `removeUnprocessedChildren` | boolean | yes |
| `completedRetry` | boolean | yes |
| `globalConcurrency` | boolean | yes |
| `globalRateLimit` | boolean | yes |
| `nativeMetrics` | boolean | yes |
| `workers` | boolean | yes |
| `jobSchedulers` | object | yes |
| `jobOptionsSchema` | `bull` \| `bullmq` | yes |
### JobSchedulerKind
``every` \| `cron``
### Datastore
``redis` \| `postgres``
### Status
``latest` \| `active` \| `waiting` \| `waiting-children` \| `prioritized` \| `completed` \| `failed` \| `delayed` \| `paused``
### QueueDefaultJobOptions
| Field | Type | Required |
| ------------------ | --------------------------- | -------- |
| `attempts` | number | no |
| `delay` | number | no |
| `priority` | number | no |
| `lifo` | boolean | no |
| `backoff` | number \| object | no |
| `removeOnComplete` | boolean \| number \| object | no |
| `removeOnFail` | boolean \| number \| object | no |
### QueueMetrics
| Field | Type | Required |
| ------- | --------- | -------- |
| `meta` | object | yes |
| `data` | number\[] | yes |
| `count` | number | yes |
### QueueRateLimit
| Field | Type | Required |
| ---------- | ------ | -------- |
| `max` | number | yes |
| `duration` | number | yes |
### QueueWorker
| Field | Type | Required |
| ------ | -------------- | -------- |
| `id` | string | yes |
| `name` | string \| null | yes |
| `addr` | string | yes |
| `age` | number | yes |
### RedisStats
| Field | Type | Required |
| --------- | --------------------------------------- | -------- |
| `backend` | `redis` \| `postgres` | no |
| `version` | string | yes |
| `mode` | `standalone` \| `sentinel` \| `cluster` | no |
| `port` | number | yes |
| `os` | string | no |
| `uptime` | number | yes |
| `memory` | object | no |
| `clients` | object | yes |
### TranslatableMessage
| Field | Type | Required |
| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| `key` | `ERRORS.COMPLETED_RETRIES_DISABLED` \| `ERRORS.FORBIDDEN` \| `ERRORS.INTERNAL_SERVER_ERROR` \| `ERRORS.INVALID_BEFORE_DATE` \| `ERRORS.INVALID_CONCURRENCY` \| `ERRORS.INVALID_DATE_RANGE` \| `ERRORS.INVALID_GRANULARITY` \| `ERRORS.INVALID_METRIC` \| `ERRORS.INVALID_PRIORITY` \| `ERRORS.INVALID_QUEUE` \| `ERRORS.INVALID_QUERY_PARAM` \| `ERRORS.INVALID_RATE_LIMIT` \| `ERRORS.INVALID_REQUEST_BODY` \| `ERRORS.INVALID_RUN_AT` \| `ERRORS.INVALID_SCHEDULER_END_DATE` \| `ERRORS.INVALID_SCHEDULER_INTERVAL` \| `ERRORS.INVALID_SCHEDULER_LIMIT` \| `ERRORS.INVALID_SCHEDULER_PATTERN` \| `ERRORS.INVALID_SCHEDULER_SCHEDULE` \| `ERRORS.JOB_BELONGS_TO_JOB_SCHEDULER` \| `ERRORS.JOB_BELONGS_TO_JOB_SCHEDULER_DETAILS` \| `ERRORS.JOB_EDIT_NOT_SUPPORTED` \| `ERRORS.JOB_HAS_NO_UNPROCESSED_CHILDREN` \| `ERRORS.JOB_IS_ACTIVE` \| `ERRORS.JOB_IS_ACTIVE_DETAILS` \| `ERRORS.JOB_NOT_DELAYED` \| `ERRORS.JOB_NOT_FOUND` \| `ERRORS.JOB_NOT_RETRIABLE` \| `ERRORS.JOB_SCHEDULER_EDIT_NOT_SUPPORTED` \| `ERRORS.JOB_SCHEDULER_NOT_FOUND` \| `ERRORS.JOB_SCHEDULER_RUN_NOT_SUPPORTED` \| `ERRORS.JOB_UNPROCESSED_CHILDREN_NOT_SUPPORTED` \| `ERRORS.PGBOSS_BULK_LIMIT` \| `ERRORS.PGBOSS_FEATURE_UNAVAILABLE` \| `ERRORS.PGBOSS_INVALID_CURSOR` \| `ERRORS.PGBOSS_INVALID_SCHEDULE` \| `ERRORS.PGBOSS_JOB_NOT_FOUND` \| `ERRORS.PGBOSS_JOB_STATE_CONFLICT` \| `ERRORS.PGBOSS_NOT_INSTALLED` \| `ERRORS.PGBOSS_PREVIEW_UNAVAILABLE` \| `ERRORS.PGBOSS_QUERY_TIMEOUT` \| `ERRORS.PGBOSS_SCHEMA_INCOMPATIBLE` \| `ERRORS.PGBOSS_SCHEMA_MISMATCH` \| `ERRORS.PGBOSS_SCHEMA_UNSUPPORTED` \| `ERRORS.PGBOSS_SCHEMA_UNTESTED` \| `ERRORS.PGBOSS_WRITER_UNAVAILABLE` \| `ERRORS.PGBOSS_WRITES_DISABLED` \| `ERRORS.QUEUE_HAS_ACTIVE_JOBS` \| `ERRORS.QUEUE_HAS_ACTIVE_JOBS_DETAILS` \| `ERRORS.QUEUE_NOT_FOUND` \| `ERRORS.QUEUE_NOT_PAUSED` \| `ERRORS.QUEUE_READ_ONLY` \| `ERRORS.RATE_LIMIT_NOT_SUPPORTED` \| `ERRORS.REDIS_STATS_UNAVAILABLE` \| `ERRORS.REDIS_UNAVAILABLE` \| `ERRORS.RETRIES_DISABLED` \| `ERRORS.STATUS_NOT_RETRIABLE` \| `ERRORS.UNAUTHORIZED` \| `ERRORS.WORKERS_DISABLED` | yes |
| `options` | object | no |
### PgBossJobState
``created` \| `retry` \| `active` \| `completed` \| `cancelled` \| `failed``
### PgBossQueueCounts
| Field | Type | Required |
| ---------- | ------ | -------- |
| `queued` | number | yes |
| `deferred` | number | yes |
| `ready` | number | yes |
| `active` | number | yes |
| `failed` | number | yes |
| `total` | number | yes |
### PgBossQueueSummary
| Field | Type | Required |
| -------------------- | ----------------- | -------- |
| `name` | string | yes |
| `policy` | string | yes |
| `partition` | boolean | yes |
| `counts` | PgBossQueueCounts | yes |
| `statsCapturedOn` | string \| null | yes |
| `readyHistory` | number\[] | yes |
| `deadLetter` | string \| null | yes |
| `retryLimit` | number | yes |
| `retryDelay` | number | yes |
| `retryBackoff` | boolean | yes |
| `retryDelayMax` | number \| null | yes |
| `expireInSeconds` | number | yes |
| `retentionSeconds` | number | yes |
| `deleteAfterSeconds` | number | yes |
| `warningQueueSize` | number | yes |
| `backlogged` | boolean | yes |
| `heartbeatSeconds` | number \| null | yes |
| `notify` | boolean | yes |
| `singletonsActive` | string\[] \| null | yes |
| `scheduleCount` | number | yes |
| `createdOn` | string | yes |
| `updatedOn` | string | yes |
### PgBossStateCount
| Field | Type | Required |
| -------- | -------------- | -------- |
| `count` | number \| null | yes |
| `capped` | boolean | yes |
### PgBossStateCounts
| Field | Type | Required |
| ----------- | ---------------- | -------- |
| `created` | PgBossStateCount | yes |
| `retry` | PgBossStateCount | yes |
| `active` | PgBossStateCount | yes |
| `completed` | PgBossStateCount | yes |
| `cancelled` | PgBossStateCount | yes |
| `failed` | PgBossStateCount | yes |
### PgBossDeadLetterSource
| Field | Type | Required |
| ------------ | -------------- | -------- |
| `queueName` | string | yes |
| `id` | string | yes |
| `createdOn` | string \| null | yes |
| `retryCount` | number \| null | yes |
### PgBossJobSummary
| Field | Type | Required |
| ------------------ | ------------------------------ | -------- |
| `id` | string | yes |
| `queueName` | string | yes |
| `state` | PgBossJobState | yes |
| `priority` | number | yes |
| `retryCount` | number | yes |
| `retryLimit` | number | yes |
| `createdOn` | string | yes |
| `startAfter` | string | yes |
| `startedOn` | string \| null | yes |
| `completedOn` | string \| null | yes |
| `singletonKey` | string \| null | yes |
| `groupId` | string \| null | yes |
| `deferred` | boolean | yes |
| `blocked` | boolean | yes |
| `deadLetterSource` | PgBossDeadLetterSource \| null | yes |
### PgBossJob
| Field | Type | Required |
| --------------------- | ------------------------------ | -------- |
| `id` | string | yes |
| `queueName` | string | yes |
| `state` | PgBossJobState | yes |
| `priority` | number | yes |
| `retryCount` | number | yes |
| `retryLimit` | number | yes |
| `createdOn` | string | yes |
| `startAfter` | string | yes |
| `startedOn` | string \| null | yes |
| `completedOn` | string \| null | yes |
| `singletonKey` | string \| null | yes |
| `groupId` | string \| null | yes |
| `deferred` | boolean | yes |
| `blocked` | boolean | yes |
| `deadLetterSource` | PgBossDeadLetterSource \| null | yes |
| `data` | any | yes |
| `output` | any | yes |
| `policy` | string \| null | yes |
| `retryDelay` | number | yes |
| `retryBackoff` | boolean | yes |
| `retryDelayMax` | number \| null | yes |
| `expireInSeconds` | number | yes |
| `deleteAfterSeconds` | number | yes |
| `keepUntil` | string | yes |
| `singletonOn` | string \| null | yes |
| `groupTier` | string \| null | yes |
| `heartbeatSeconds` | number \| null | yes |
| `heartbeatOn` | string \| null | yes |
| `deadLetter` | string \| null | yes |
| `blocking` | boolean | yes |
| `pendingDependencies` | number | yes |
### PgBossDependencyRef
| Field | Type | Required |
| ----------- | ------ | -------- |
| `queueName` | string | yes |
| `id` | string | yes |
### PgBossScheduleKind
``cron` \| `rrule``
### PgBossSchedule
| Field | Type | Required |
| ------------ | ------------------ | -------- |
| `queueName` | string | yes |
| `key` | string | yes |
| `kind` | PgBossScheduleKind | yes |
| `expression` | string | yes |
| `timezone` | string | yes |
| `data` | any | yes |
| `options` | object | yes |
| `createdOn` | string | yes |
| `updatedOn` | string | yes |
| `lastJobId` | string \| null | yes |
| `nextRuns` | string\[] | yes |
### PgBossCapabilities
| Field | Type | Required |
| ----------------- | ------- | -------- |
| `send` | boolean | yes |
| `retry` | boolean | yes |
| `cancel` | boolean | yes |
| `resume` | boolean | yes |
| `delete` | boolean | yes |
| `scheduleWrite` | boolean | yes |
| `schedulePreview` | boolean | yes |
| `bulk` | boolean | yes |
### PgBossFeature
``queueCounters` \| `readyHistory` \| `schedules` \| `scheduleKind` \| `dependencies` \| `deadLetterSource` \| `queueDepth` \| `warnings``
### PgBossFeatures
| Field | Type | Required |
| ------------------ | ------- | -------- |
| `queueCounters` | boolean | yes |
| `readyHistory` | boolean | yes |
| `schedules` | boolean | yes |
| `scheduleKind` | boolean | yes |
| `dependencies` | boolean | yes |
| `deadLetterSource` | boolean | yes |
| `queueDepth` | boolean | yes |
| `warnings` | boolean | yes |
### PgBossInfo
| Field | Type | Required |
| ---------------------- | --------------------------- | -------- |
| `schema` | string | yes |
| `delimiter` | string | yes |
| `installed` | boolean | yes |
| `schemaVersion` | number \| null | yes |
| `supportedRange` | object | yes |
| `readable` | boolean | yes |
| `writable` | boolean | yes |
| `readOnly` | boolean | yes |
| `unavailableReason` | TranslatableMessage \| null | yes |
| `writesDisabledReason` | TranslatableMessage \| null | yes |
| `untested` | boolean | yes |
| `features` | PgBossFeatures | yes |
| `disabledFeatures` | PgBossFeature\[] | yes |
| `persistQueueStats` | boolean | yes |
| `persistWarnings` | boolean | yes |
| `datastore` | RedisStats \| null | yes |
| `capabilities` | PgBossCapabilities | yes |
### PgBossQueueDepthPoint
| Field | Type | Required |
| ---------- | ------ | -------- |
| `ts` | number | yes |
| `deferred` | number | yes |
| `queued` | number | yes |
| `ready` | number | yes |
| `active` | number | yes |
| `failed` | number | yes |
| `total` | number | yes |
### PgBossWarning
| Field | Type | Required |
| ----------- | -------------- | -------- |
| `id` | string | yes |
| `type` | string | yes |
| `message` | string | yes |
| `data` | any | yes |
| `queueName` | string \| null | yes |
| `createdOn` | string | yes |
### GetQueuesResponse
| Field | Type | Required |
| -------- | ----------- | -------- |
| `queues` | AppQueue\[] | yes |
### GetJobResponse
| Field | Type | Required |
| -------- | -------- | -------- |
| `job` | AppJob | yes |
| `status` | JobState | yes |
### AddJobResponse
| Field | Type | Required |
| -------- | -------- | -------- |
| `job` | AppJob | yes |
| `status` | JobState | yes |
### GetQueueMetricsResponse
| Field | Type | Required |
| ----------- | -------------------- | -------- |
| `completed` | QueueMetrics \| null | yes |
| `failed` | QueueMetrics \| null | yes |
### GetQueueDefaultJobOptionsResponse
| Field | Type | Required |
| ------------------ | --------------------------- | -------- |
| `attempts` | number | no |
| `delay` | number | no |
| `priority` | number | no |
| `lifo` | boolean | no |
| `backoff` | number \| object | no |
| `removeOnComplete` | boolean \| number \| object | no |
| `removeOnFail` | boolean \| number \| object | no |
### GetQueueJobDataSchemaResponse
`object`
### GetQueueRateLimitResponse
| Field | Type | Required |
| ----------- | ---------------------- | -------- |
| `supported` | boolean | yes |
| `rateLimit` | QueueRateLimit \| null | yes |
### GetQueueWorkersResponse
| Field | Type | Required |
| --------- | ---------------------- | -------- |
| `workers` | QueueWorker\[] \| null | yes |
### GetJobSchedulersResponse
| Field | Type | Required |
| ------------ | ------------------ | -------- |
| `schedulers` | AppJobScheduler\[] | yes |
### RunJobSchedulerResponse
| Field | Type | Required |
| ----- | ------ | -------- |
| `job` | AppJob | yes |
### GetJobLogsResponse
`string[]`
### GetJobFlowResponse
| Field | Type | Required |
| ------------ | ---------------- | -------- |
| `nodeId` | string | yes |
| `isFlowNode` | boolean | yes |
| `flowRoot` | FlowNode \| null | yes |
### GetRedisStatsResponse
`RedisStats \| object`
### GetMetricsHistoryResponse
| Field | Type | Required |
| ----------- | ---------------------- | -------- |
| `completed` | MetricsHistoryPoint\[] | no |
| `failed` | MetricsHistoryPoint\[] | no |
| `queueage` | MetricsHistoryPoint\[] | no |
### GetMetricsHistoryUsageResponse
| Field | Type | Required |
| ----------- | --------------------------- | -------- |
| `keys` | number | yes |
| `bytes` | number | yes |
| `minutes` | number | yes |
| `oldestDay` | string \| null | yes |
| `newestDay` | string \| null | yes |
| `tiers` | object | yes |
| `queues` | MetricsHistoryQueueUsage\[] | yes |
### GetMetricsLatencyResponse
`MetricsLatencyPoint[]`
### PurgeMetricsHistoryResponse
| Field | Type | Required |
| --------------- | ------ | -------- |
| `keysDeleted` | number | yes |
| `fieldsDeleted` | number | yes |
### RetryAllResponse
| Field | Type | Required |
| --------- | ------ | -------- |
| `retried` | number | yes |
| `skipped` | number | yes |
### RemoveUnprocessedChildrenResponse
| Field | Type | Required |
| --------- | ------ | -------- |
| `removed` | number | yes |
### JobBelongsToJobSchedulerResponse
| Field | Type | Required |
| ---------------- | ------------------- | -------- |
| `error` | TranslatableMessage | yes |
| `message` | TranslatableMessage | yes |
| `code` | object | yes |
| `jobSchedulerId` | string | yes |
### EmptyResponse
| Field | Type | Required |
| ----- | ---- | -------- |
### GetPgBossInfoResponse
| Field | Type | Required |
| ---------------------- | --------------------------- | -------- |
| `schema` | string | yes |
| `delimiter` | string | yes |
| `installed` | boolean | yes |
| `schemaVersion` | number \| null | yes |
| `supportedRange` | object | yes |
| `readable` | boolean | yes |
| `writable` | boolean | yes |
| `readOnly` | boolean | yes |
| `unavailableReason` | TranslatableMessage \| null | yes |
| `writesDisabledReason` | TranslatableMessage \| null | yes |
| `untested` | boolean | yes |
| `features` | PgBossFeatures | yes |
| `disabledFeatures` | PgBossFeature\[] | yes |
| `persistQueueStats` | boolean | yes |
| `persistWarnings` | boolean | yes |
| `datastore` | RedisStats \| null | yes |
| `capabilities` | PgBossCapabilities | yes |
### GetPgBossQueuesResponse
| Field | Type | Required |
| -------- | --------------------- | -------- |
| `queues` | PgBossQueueSummary\[] | yes |
### GetPgBossQueueResponse
| Field | Type | Required |
| ------- | ------------------ | -------- |
| `queue` | PgBossQueueSummary | yes |
### GetPgBossStateCountsResponse
| Field | Type | Required |
| -------- | ----------------- | -------- |
| `counts` | PgBossStateCounts | yes |
| `cap` | number | yes |
### GetPgBossJobsResponse
| Field | Type | Required |
| ------------ | ------------------- | -------- |
| `jobs` | PgBossJobSummary\[] | yes |
| `nextCursor` | string \| null | yes |
| `prevCursor` | string \| null | yes |
### GetPgBossJobResponse
| Field | Type | Required |
| ----- | --------- | -------- |
| `job` | PgBossJob | yes |
### FindPgBossJobResponse
| Field | Type | Required |
| ----- | ---------------- | -------- |
| `job` | PgBossJobSummary | yes |
### GetPgBossQueueDepthResponse
| Field | Type | Required |
| --------------- | ------------------------ | -------- |
| `points` | PgBossQueueDepthPoint\[] | yes |
| `from` | number | yes |
| `to` | number | yes |
| `bucketSeconds` | number | yes |
### GetPgBossWarningsResponse
| Field | Type | Required |
| ------------ | ---------------- | -------- |
| `warnings` | PgBossWarning\[] | yes |
| `nextCursor` | string \| null | yes |
| `prevCursor` | string \| null | yes |
### GetPgBossDependenciesResponse
| Field | Type | Required |
| -------------- | ---------------------- | -------- |
| `dependencies` | PgBossDependencyRef\[] | yes |
| `dependents` | PgBossDependencyRef\[] | yes |
### GetPgBossSchedulesResponse
| Field | Type | Required |
| ----------- | ----------------- | -------- |
| `schedules` | PgBossSchedule\[] | yes |
### PreviewPgBossScheduleResponse
| Field | Type | Required |
| ------ | --------- | -------- |
| `runs` | string\[] | yes |
### SendPgBossJobResponse
| Field | Type | Required |
| ----- | -------------- | -------- |
| `id` | string \| null | yes |
### PgBossCommandResponse
| Field | Type | Required |
| ----------- | ------ | -------- |
| `requested` | number | yes |
| `affected` | number | yes |
### PgBossScheduleResponse
| Field | Type | Required |
| ---------- | -------------- | -------- |
| `schedule` | PgBossSchedule | yes |
### GetQueuesQuery
| Field | Type | Required |
| ------------- | ------ | -------- |
| `activeQueue` | string | no |
| `status` | Status | no |
| `page` | string | no |
| `jobsPerPage` | string | no |
### GetJobSchedulersQuery
| Field | Type | Required |
| ----------- | ------ | -------- |
| `queueName` | string | no |
### GetJobFlowQuery
| Field | Type | Required |
| ------------- | ------ | -------- |
| `root` | any | no |
| `depth` | object | no |
| `maxChildren` | object | no |
### GetMetricsHistoryQuery
| Field | Type | Required |
| ------------- | ------------------------- | -------- |
| `from` | string | yes |
| `to` | string | yes |
| `granularity` | MetricsHistoryGranularity | no |
| `queue` | string | no |
| `metric` | MetricsHistoryMetric | no |
### GetMetricsLatencyQuery
| Field | Type | Required |
| ------------- | -------------------------- | -------- |
| `metric` | MetricsLatencyMetric | yes |
| `from` | string | no |
| `to` | string | no |
| `granularity` | `hour` \| `day` \| `range` | no |
| `queue` | string | no |
| `percentiles` | string | no |
### AddJobBody
| Field | Type | Required |
| --------- | ------ | -------- |
| `name` | string | no |
| `data` | any | no |
| `options` | object | no |
### UpdateJobDataBody
| Field | Type | Required |
| --------- | ---- | -------- |
| `jobData` | any | yes |
### ChangeJobDelayBody
| Field | Type | Required |
| ------- | ------ | -------- |
| `runAt` | number | yes |
### ChangeJobPriorityBody
| Field | Type | Required |
| ---------- | ------- | -------- |
| `priority` | integer | yes |
### SetGlobalConcurrencyBody
| Field | Type | Required |
| ------------- | ------- | -------- |
| `concurrency` | integer | yes |
### SetRateLimitBody
`object \| object`
### ObliterateQueueBody
| Field | Type | Required |
| ------- | ------- | -------- |
| `force` | boolean | no |
### UpdateJobSchedulerBody
| Field | Type | Required |
| --------- | ------------------------ | -------- |
| `pattern` | string | no |
| `every` | string \| number \| null | no |
| `tz` | string | no |
| `limit` | integer \| null | no |
| `endDate` | string \| number \| null | no |
### PurgeMetricsHistoryBody
| Field | Type | Required |
| -------- | ------ | -------- |
| `queue` | string | no |
| `before` | string | no |
### GetPgBossJobsQuery
| Field | Type | Required |
| -------------- | --------------- | -------- |
| `state` | PgBossJobState | no |
| `cursor` | string | no |
| `limit` | string | no |
| `order` | `desc` \| `asc` | no |
| `id` | string | no |
| `singletonKey` | string | no |
### GetPgBossSchedulesQuery
| Field | Type | Required |
| ----------- | ------ | -------- |
| `queueName` | string | no |
### GetPgBossQueueDepthQuery
| Field | Type | Required |
| ----------- | ----------------------------- | -------- |
| `range` | `1h` \| `6h` \| `24h` \| `7d` | no |
| `aggregate` | `max` \| `avg` | no |
### GetPgBossWarningsQuery
| Field | Type | Required |
| -------- | ------ | -------- |
| `type` | string | no |
| `cursor` | string | no |
| `limit` | string | no |
### PreviewPgBossScheduleBody
| Field | Type | Required |
| ------------ | ------- | -------- |
| `expression` | string | yes |
| `tz` | string | no |
| `count` | integer | no |
### SendPgBossJobBody
| Field | Type | Required |
| --------- | ------ | -------- |
| `data` | any | no |
| `options` | object | no |
### PgBossJobIdsBody
| Field | Type | Required |
| ----- | --------- | -------- |
| `ids` | string\[] | yes |
### UpsertPgBossScheduleBody
| Field | Type | Required |
| --------- | ---------------- | -------- |
| `key` | string | no |
| `cron` | string | yes |
| `tz` | string | no |
| `data` | any | no |
| `options` | object | no |
| `missed` | `skip` \| `once` | no |
### RemovePgBossScheduleBody
| Field | Type | Required |
| ----- | ------ | -------- |
| `key` | string | no |
---
url: /worker-manager/server-adapters/bun.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Bun
[Bun](https://bun.sh/). `@worker-manager/bun` targets Bun's native HTTP server.
## Install
```sh
bun add @worker-manager/api @worker-manager/bun
```
```ts
import { createWorkerManagerBoard } from '@worker-manager/api';
import { BullMQAdapter } from '@worker-manager/api/bullMQAdapter';
import { BunAdapter } from '@worker-manager/bun';
import { Queue } from 'bullmq';
const queue = new Queue('my-queue', {
connection: { host: 'localhost', port: 6379 },
});
const serverAdapter = new BunAdapter();
serverAdapter.setBasePath('/ui');
createWorkerManagerBoard({
queues: [new BullMQAdapter(queue)],
serverAdapter,
});
const workerManagerRoutes = serverAdapter.getRoutes();
Bun.serve({
port: 3000,
routes: {
'/health': { GET: () => Response.json({ status: 'ok' }) },
...workerManagerRoutes,
},
});
```
`getRoutes()` returns an object shaped for `Bun.serve({ routes })`. Spread it alongside your own routes, no separate router to mount.
## Full runnable example
- Simple setup: [`examples/more/bun`](https://github.com/naldomadeira/worker-manager/tree/main/examples/more/bun)
## Next steps
- [UIConfig](/worker-manager/configuration/ui-config.md): title, logo, locale, polling.
- [Read-only mode](/worker-manager/recipes/read-only-mode.md): disable destructive actions.
- [Visibility guard](/worker-manager/recipes/visibility-guard.md): scope visible queues per request.
- [Formatters](/worker-manager/recipes/formatters.md): rewrite job fields for the UI.
---
url: /worker-manager/server-adapters/elysia.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Elysia
[Elysia](https://elysiajs.com/) on Bun. `@worker-manager/elysia` is an Elysia plugin.
## Install
```sh
bun add @worker-manager/api @worker-manager/elysia
```
```ts
import { createWorkerManagerBoard } from '@worker-manager/api';
import { BullMQAdapter } from '@worker-manager/api/bullMQAdapter';
import { ElysiaAdapter } from '@worker-manager/elysia';
import { Queue } from 'bullmq';
import Elysia from 'elysia';
const queue = new Queue('my-queue', {
connection: { host: 'localhost', port: 6379 },
});
const serverAdapter = new ElysiaAdapter({
prefix: '/ui',
basePath: '/api/ui',
});
createWorkerManagerBoard({
queues: [new BullMQAdapter(queue)],
serverAdapter,
options: {
// Works around a Bun build issue caused by eval in the default UI bundle.
uiBasePath: 'node_modules/@worker-manager/ui',
},
});
const app = new Elysia({ prefix: '/api' })
.use(await serverAdapter.registerPlugin())
.listen(3000);
```
::: tip
Top-level `await` in the example. Wrap the body in `async function main() { ... }; main()` if your runtime doesn't support it.
:::
`ElysiaAdapter` takes `{ prefix, basePath }` in the constructor instead of `setBasePath()`. `prefix` is the plugin's mount path inside Elysia, `basePath` is the full path the UI calls (including any outer Elysia `prefix`).
## Full runnable example
- Simple setup: [`examples/more/elysia`](https://github.com/naldomadeira/worker-manager/tree/main/examples/more/elysia)
## Next steps
- [UIConfig](/worker-manager/configuration/ui-config.md): title, logo, locale, polling.
- [Read-only mode](/worker-manager/recipes/read-only-mode.md): disable destructive actions.
- [Visibility guard](/worker-manager/recipes/visibility-guard.md): scope visible queues per request.
- [Formatters](/worker-manager/recipes/formatters.md): rewrite job fields for the UI.
---
url: /worker-manager/server-adapters/express.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Express
[Express.js](https://expressjs.com/). `@worker-manager/express` mounts as a sub-router under any path.
## Install
```sh
npm install @worker-manager/api @worker-manager/express
```
```ts
import { createWorkerManagerBoard } from '@worker-manager/api';
import { BullMQAdapter } from '@worker-manager/api/bullMQAdapter';
import { ExpressAdapter } from '@worker-manager/express';
import { Queue } from 'bullmq';
import express from 'express';
const queue = new Queue('my-queue', {
connection: { host: 'localhost', port: 6379 },
});
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');
createWorkerManagerBoard({
queues: [new BullMQAdapter(queue)],
serverAdapter,
});
const app = express();
app.use('/admin/queues', serverAdapter.getRouter());
app.listen(3000);
```
The path in `setBasePath()` must match the mount point in `app.use()`.
## Full runnable examples
- Simple setup: [`examples/express/basic`](https://github.com/naldomadeira/worker-manager/tree/main/examples/express/basic)
- With basic auth: [`examples/express/custom-login`](https://github.com/naldomadeira/worker-manager/tree/main/examples/express/custom-login)
- With CSRF: [`examples/express/csrf`](https://github.com/naldomadeira/worker-manager/tree/main/examples/express/csrf)
- Multiple dashboard instances: [`examples/express/multiple-boards`](https://github.com/naldomadeira/worker-manager/tree/main/examples/express/multiple-boards)
## Next steps
- [UIConfig](/worker-manager/configuration/ui-config.md): title, logo, locale, polling.
- [Read-only mode](/worker-manager/recipes/read-only-mode.md): disable destructive actions.
- [Visibility guard](/worker-manager/recipes/visibility-guard.md): scope visible queues per request.
- [Formatters](/worker-manager/recipes/formatters.md): rewrite job fields for the UI.
---
url: /worker-manager/server-adapters/fastify.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Fastify
[Fastify](https://fastify.dev/). `@worker-manager/fastify` registers as a plugin.
## Install
```sh
npm install @worker-manager/api @worker-manager/fastify
```
::: warning Fastify 5 only
The adapter bundles `@fastify/static` and `@fastify/view`, both of which target `fastify@5`. Registering it on `fastify@4` throws a version mismatch from `fastify-plugin` before the dashboard ever serves a request. Upgrade Fastify, or mount the dashboard on a separate Express instance instead.
:::
```ts
import { createWorkerManagerBoard } from '@worker-manager/api';
import { BullMQAdapter } from '@worker-manager/api/bullMQAdapter';
import { FastifyAdapter } from '@worker-manager/fastify';
import { Queue } from 'bullmq';
import Fastify from 'fastify';
const queue = new Queue('my-queue', {
connection: { host: 'localhost', port: 6379 },
});
const app = Fastify();
const serverAdapter = new FastifyAdapter();
createWorkerManagerBoard({
queues: [new BullMQAdapter(queue)],
serverAdapter,
});
serverAdapter.setBasePath('/ui');
await app.register(serverAdapter.registerPlugin(), { prefix: '/ui' });
await app.listen({ host: '0.0.0.0', port: 3000 });
```
`FastifyAdapter` takes the base path from `setBasePath()` or the `prefix` option on `app.register()`. If you set both, they must match, otherwise asset URLs will 404.
::: tip
Top-level `await` in the example. Wrap the body in `async function main() { ... }; main()` if you're on plain CommonJS.
:::
## Full runnable examples
- Simple setup: [`examples/fastify/basic`](https://github.com/naldomadeira/worker-manager/tree/main/examples/fastify/basic)
- With basic auth: [`examples/fastify/auth`](https://github.com/naldomadeira/worker-manager/tree/main/examples/fastify/auth)
- With visibility guard: [`examples/fastify/visibility-guard`](https://github.com/naldomadeira/worker-manager/tree/main/examples/fastify/visibility-guard)
## Next steps
- [UIConfig](/worker-manager/configuration/ui-config.md): title, logo, locale, polling.
- [Read-only mode](/worker-manager/recipes/read-only-mode.md): disable destructive actions.
- [Visibility guard](/worker-manager/recipes/visibility-guard.md): scope visible queues per request.
- [Formatters](/worker-manager/recipes/formatters.md): rewrite job fields for the UI.
---
url: /worker-manager/server-adapters/h3.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# H3
[H3](https://h3.unjs.io/). `@worker-manager/h3` plugs in as an H3 event handler.
## Install
```sh
npm install @worker-manager/api @worker-manager/h3
```
```ts
import { createApp, createRouter } from 'h3';
import { createWorkerManagerBoard } from '@worker-manager/api';
import { BullMQAdapter } from '@worker-manager/api/bullMQAdapter';
import { H3Adapter } from '@worker-manager/h3';
import { Queue } from 'bullmq';
const queue = new Queue('my-queue', {
connection: { host: 'localhost', port: 6379 },
});
const serverAdapter = new H3Adapter();
serverAdapter.setBasePath('/ui');
createWorkerManagerBoard({
queues: [new BullMQAdapter(queue)],
serverAdapter,
});
export const app = createApp();
const router = createRouter();
app.use(router);
app.use(serverAdapter.registerHandlers());
```
`registerHandlers()` returns an H3 handler tree. Mount it alongside your router, the adapter handles its own sub-paths under the base path.
## Supported h3 versions
`@worker-manager/h3` supports h3 1.x and 2.x, and the suite runs against both on every CI build.
The 2.x side is tested against `2.0.1-rc.29`, because that is what h3 currently publishes under
its `latest` tag; 1.x lives on the `1x` tag. The declared range picks up a stable 2.0.0 as soon
as it ships.
You do not need to configure anything. The adapter registers its routes and sets the content type
of the dashboard HTML explicitly, which is what makes the same code work on both majors.
## Full runnable example
- Simple setup: [`examples/more/h3`](https://github.com/naldomadeira/worker-manager/tree/main/examples/more/h3)
## Next steps
- [UIConfig](/worker-manager/configuration/ui-config.md): title, logo, locale, polling.
- [Read-only mode](/worker-manager/recipes/read-only-mode.md): disable destructive actions.
- [Visibility guard](/worker-manager/recipes/visibility-guard.md): scope visible queues per request.
- [Formatters](/worker-manager/recipes/formatters.md): rewrite job fields for the UI.
---
url: /worker-manager/server-adapters/hapi.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Hapi
[Hapi](https://hapi.dev/). `@worker-manager/hapi` registers as a Hapi plugin.
## Install
```sh
npm install @worker-manager/api @worker-manager/hapi
```
```ts
import { createWorkerManagerBoard } from '@worker-manager/api';
import { BullMQAdapter } from '@worker-manager/api/bullMQAdapter';
import { HapiAdapter } from '@worker-manager/hapi';
import { Queue } from 'bullmq';
import Hapi from '@hapi/hapi';
const queue = new Queue('my-queue', {
connection: { host: 'localhost', port: 6379 },
});
const app = Hapi.server({ port: 3000, host: 'localhost' });
const serverAdapter = new HapiAdapter();
createWorkerManagerBoard({
queues: [new BullMQAdapter(queue)],
serverAdapter,
});
serverAdapter.setBasePath('/ui');
await app.register(serverAdapter.registerPlugin(), {
routes: { prefix: '/ui' },
});
await app.start();
```
`registerPlugin()` wires up Hapi's view and static-file plugins internally, you don't need `@hapi/inert` or `@hapi/vision`. The `routes.prefix` must match the base path.
## Full runnable examples
- Simple setup: [`examples/hapi/basic`](https://github.com/naldomadeira/worker-manager/tree/main/examples/hapi/basic)
- With basic auth: [`examples/hapi/auth`](https://github.com/naldomadeira/worker-manager/tree/main/examples/hapi/auth)
## Next steps
- [UIConfig](/worker-manager/configuration/ui-config.md): title, logo, locale, polling.
- [Read-only mode](/worker-manager/recipes/read-only-mode.md): disable destructive actions.
- [Visibility guard](/worker-manager/recipes/visibility-guard.md): scope visible queues per request.
- [Formatters](/worker-manager/recipes/formatters.md): rewrite job fields for the UI.
---
url: /worker-manager/server-adapters/hono.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Hono
[Hono](https://hono.dev/). `@worker-manager/hono` gives you a Hono sub-app.
## Install
```sh
npm install @worker-manager/api @worker-manager/hono @hono/node-server
```
`@hono/node-server` is only for Node.js. On Bun, Deno, or Workers bring your own serve function, see the [Hono docs](https://hono.dev/docs/getting-started/basic).
```ts
import { createWorkerManagerBoard } from '@worker-manager/api';
import { BullMQAdapter } from '@worker-manager/api/bullMQAdapter';
import { HonoAdapter } from '@worker-manager/hono';
import { Queue } from 'bullmq';
import { Hono } from 'hono';
import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static';
const queue = new Queue('my-queue', {
connection: { host: 'localhost', port: 6379 },
});
const app = new Hono();
const serverAdapter = new HonoAdapter(serveStatic);
createWorkerManagerBoard({
queues: [new BullMQAdapter(queue)],
serverAdapter,
});
const basePath = '/ui';
serverAdapter.setBasePath(basePath);
app.route(basePath, serverAdapter.registerPlugin());
serve({ fetch: app.fetch, port: 3000 });
```
`serve` and `serveStatic` depend on the runtime, check the example for Node, Bun, Deno variants. `HonoAdapter` takes the runtime's `serveStatic` helper in the constructor so it can serve the bundled UI assets.
## Full runnable example
- Simple setup: [`examples/more/hono`](https://github.com/naldomadeira/worker-manager/tree/main/examples/more/hono)
## Next steps
- [UIConfig](/worker-manager/configuration/ui-config.md): title, logo, locale, polling.
- [Read-only mode](/worker-manager/recipes/read-only-mode.md): disable destructive actions.
- [Visibility guard](/worker-manager/recipes/visibility-guard.md): scope visible queues per request.
- [Formatters](/worker-manager/recipes/formatters.md): rewrite job fields for the UI.
---
url: /worker-manager/server-adapters/index.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Server Adapters
One server adapter per framework. The core `@worker-manager/api` package is shared. Pick your framework below.
::: tip Try the live demo first. All 9 adapters serve the same UI.
:::
## Adapter matrix
| Framework | Package | Docs |
| --------- | ------------------------- | ------------------------------------------------------- |
| Express | `@worker-manager/express` | [Express →](/worker-manager/server-adapters/express.md) |
| Fastify | `@worker-manager/fastify` | [Fastify →](/worker-manager/server-adapters/fastify.md) |
| NestJS | `@worker-manager/nestjs` | [NestJS →](/worker-manager/server-adapters/nestjs.md) |
| Koa | `@worker-manager/koa` | [Koa →](/worker-manager/server-adapters/koa.md) |
| Hapi | `@worker-manager/hapi` | [Hapi →](/worker-manager/server-adapters/hapi.md) |
| Hono | `@worker-manager/hono` | [Hono →](/worker-manager/server-adapters/hono.md) |
| H3 | `@worker-manager/h3` | [H3 →](/worker-manager/server-adapters/h3.md) |
| Elysia | `@worker-manager/elysia` | [Elysia →](/worker-manager/server-adapters/elysia.md) |
| Bun | `@worker-manager/bun` | [Bun →](/worker-manager/server-adapters/bun.md) |
## Sails
No dedicated Sails adapter. Sails runs on Express, so use `@worker-manager/express` inside a Sails controller. Working example: [`examples/more/sails`](https://github.com/naldomadeira/worker-manager/tree/main/examples/more/sails).
## Next.js
No dedicated Next.js adapter. Mount Worker Manager inside a Next.js API route using the Hono adapter (App Router) or the Express adapter (Pages Router). The Vercel deployment needs a small bit of `next.config.js`, see [Next.js & Vercel](/worker-manager/recipes/nextjs.md) and the [`nextjs/app-router`](https://github.com/naldomadeira/worker-manager/tree/main/examples/nextjs/app-router) / [`nextjs/pages-router`](https://github.com/naldomadeira/worker-manager/tree/main/examples/nextjs/pages-router) examples.
## Shape
Most adapters follow the same three steps:
1. Create a server adapter and set its base path (`setBasePath()`, or constructor options for Elysia).
2. Call `createWorkerManagerBoard({ queues, serverAdapter })` with your queue adapters.
3. Register the adapter with your app (Express `app.use`, Fastify `app.register`, Bun spreads `getRoutes()` into `Bun.serve`, etc.).
Elysia and Bun are a bit different, the adapter pages show exactly what goes where.
See [Your first dashboard](/worker-manager/guide/your-first-dashboard.md) for a concrete Express walkthrough.
---
url: /worker-manager/server-adapters/koa.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# Koa
[Koa](https://koajs.com/). `@worker-manager/koa` gives you middleware to mount on your app.
## Install
```sh
npm install @worker-manager/api @worker-manager/koa
```
```ts
import { createWorkerManagerBoard } from '@worker-manager/api';
import { BullMQAdapter } from '@worker-manager/api/bullMQAdapter';
import { KoaAdapter } from '@worker-manager/koa';
import { Queue } from 'bullmq';
import Koa from 'koa';
const queue = new Queue('my-queue', {
connection: { host: 'localhost', port: 6379 },
});
const app = new Koa();
const serverAdapter = new KoaAdapter();
createWorkerManagerBoard({
queues: [new BullMQAdapter(queue)],
serverAdapter,
});
serverAdapter.setBasePath('/ui');
app.use(serverAdapter.registerPlugin());
app.listen(3000);
```
`registerPlugin()` returns Koa middleware. Mount it after `setBasePath()`.
## Full runnable example
- Simple setup: [`examples/more/koa`](https://github.com/naldomadeira/worker-manager/tree/main/examples/more/koa)
## Next steps
- [UIConfig](/worker-manager/configuration/ui-config.md): title, logo, locale, polling.
- [Read-only mode](/worker-manager/recipes/read-only-mode.md): disable destructive actions.
- [Visibility guard](/worker-manager/recipes/visibility-guard.md): scope visible queues per request.
- [Formatters](/worker-manager/recipes/formatters.md): rewrite job fields for the UI.
---
url: /worker-manager/server-adapters/nestjs.md
---
> For AI agents: the complete documentation index is available at /worker-manager/llms.txt, the full documentation bundle is available at /worker-manager/llms-full.txt.
# NestJS
[NestJS](https://nestjs.com/). Worker Manager ships a NestJS module plus a plain adapter you can wire manually.
## Install
```sh
npm install @worker-manager/api @worker-manager/nestjs
```
Also install the adapter for the HTTP platform your Nest app uses (Express is the default):
```sh
npm install @worker-manager/express
# or, for Fastify:
npm install @worker-manager/fastify
```
## Supported NestJS versions
`@worker-manager/nestjs` supports NestJS 9, 10, 11 and 12. The suite runs against both 11 and 12 on
every CI build.
NestJS 12 ships as ESM only, so a Nest 12 application has to be ESM itself. `@worker-manager/nestjs`
is published as CommonJS and its named exports are importable from an ESM app, so nothing about
the setup below changes on Nest 12.
## Module-based setup (recommended)
Register `WorkerManagerModule.forRoot()` in your root module, then `WorkerManagerModule.forFeature()` per queue from the feature module.
```ts
// app.module.ts
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { WorkerManagerModule } from '@worker-manager/nestjs';
import { ExpressAdapter } from '@worker-manager/express';
import { FeatureModule } from './feature/feature.module';
@Module({
imports: [
BullModule.forRoot({
connection: { host: 'localhost', port: 6379 },
}),
WorkerManagerModule.forRoot({
route: '/queues', // the default
adapter: ExpressAdapter, // optional: detected from the Nest platform when left out
}),
FeatureModule,
],
})
export class AppModule {}
```
```ts
// feature/feature.module.ts
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { WorkerManagerModule } from '@worker-manager/nestjs';
import { BullMQAdapter } from '@worker-manager/api/bullMQAdapter';
@Module({
imports: [
BullModule.registerQueue({ name: 'feature_queue' }),
WorkerManagerModule.forFeature({
name: 'feature_queue',
adapter: BullMQAdapter, // or BullAdapter for Bull v3
}),
],
})
export class FeatureModule {}
```
`forRoot()` options, all optional:
| Option | Default | |
| ------------------------ | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | none | Registers a [named board](#several-boards) with its own DI tokens, so one app can mount several. |
| `engine` | `'bullmq'` | `'pg-boss'` mounts a [pg-boss board](#pg-boss-board) instead of a BullMQ one. |
| `pgBoss` | | Where a pg-boss board reads and writes. Only read with `engine: 'pg-boss'`. |
| `route` | `'/queues'` | Base path where the dashboard is mounted, relative to the Nest global prefix. |
| `adapter` | auto-detected | Server adapter class (`ExpressAdapter` or `FastifyAdapter`). When left out, the module asks `HttpAdapterHost` which platform the app runs on and loads `@worker-manager/express` or `@worker-manager/fastify`, failing with an install hint if the package is missing. |
| `auth` | none | Built-in authentication (Basic, Keycloak, token or custom). See [Authentication](#authentication). |
| `enabled` | `true` | `false` registers nothing: no routes, no middleware, `forFeature()` becomes a no-op and `@InjectWorkerManager()` resolves `null`. Handy to switch the board off per environment. |
| `readOnly` | `false` | Read-only mode for every queue registered through `queues` or `forFeature()`, unless the queue sets `options.readOnlyMode` itself. On a pg-boss board, the whole board is read-only. |
| `queues` | `[]` | Queues to register at the root without a separate `forFeature()` import. Same shape as `forFeature()` entries. |
| `uiConfig` | | Merged into `boardOptions.uiConfig`, taking precedence. |
| `title`, `logo`, `theme` | | Shortcuts for `uiConfig.boardTitle`, `uiConfig.boardLogo` and `uiConfig.theme`. |
| `boardOptions` | | Forwarded to `createWorkerManagerBoard` (e.g. `uiConfig`, `uiBasePath`). |
| `middleware` | | Optional Nest middleware on the board route. On Express it runs after `auth`; on Fastify it is Nest middleware on the exact `route`. |
```ts
WorkerManagerModule.forRoot({
title: 'Ops queues',
readOnly: process.env.NODE_ENV === 'production',
enabled: process.env.QUEUE_BOARD !== 'off',
queues: [{ name: 'emails', adapter: BullMQAdapter }],
});
```
`forFeature()` options (pass either `name` or `queue`):
- `name`: queue name registered with `BullModule.registerQueue`. The module resolves the instance from Nest's DI container.
- `queue`: a queue instance to register directly, instead of resolving it by `name`. See [Queues with the same name](#queues-with-the-same-name) below.
- `adapter`: `BullMQAdapter` or `BullAdapter`.
- `options`: queue adapter options like `readOnlyMode` or `description`.
To register several queues at once, pass multiple option objects:
```ts
WorkerManagerModule.forFeature(
{ name: 'emails', adapter: BullMQAdapter },
{ name: 'billing', adapter: BullMQAdapter },
);
```
### Queues with the same name
`@nestjs/bullmq` builds a queue's DI token from its `name` alone, and the `prefix` is not part of it. So if you run the same queue name under two prefixes (a common multi-tenant setup), both share one DI token and a `name` lookup can only ever return one of them. Registering both by `name` makes one queue shadow the other on the board.
Pass the instances directly via `queue` instead. Hold the queues somewhere you control (a provider, a service, wherever you created them) and hand them to `forFeature`:
```ts
@Module({
imports: [
WorkerManagerModule.forFeature(
{ queue: emailsTenantA, adapter: BullMQAdapter, options: { prefix: 'tenant-a:' } },
{ queue: emailsTenantB, adapter: BullMQAdapter, options: { prefix: 'tenant-b:' } },
),
],
})
export class FeatureModule {}
```
The board keys entries by `prefix` + name, so the two show up as `tenant-a:emails` and `tenant-b:emails`. Set each adapter's `prefix` to match the queue's own prefix so the labels line up.
### Async configuration
`WorkerManagerModule.forRootAsync()` takes `imports` plus one of `useFactory` (with `inject`),
`useClass` or `useExisting`. The latter two name a provider implementing
`WorkerManagerOptionsFactory`:
```ts
import { Injectable, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import {
WorkerManagerModule,
type WorkerManagerModuleOptions,
type WorkerManagerOptionsFactory,
} from '@worker-manager/nestjs';
@Injectable()
class BoardConfig implements WorkerManagerOptionsFactory {
constructor(private readonly config: ConfigService) {}
createWorkerManagerOptions(): WorkerManagerModuleOptions {
return {
enabled: this.config.get('QUEUE_BOARD_ENABLED') !== 'false',
readOnly: this.config.get('NODE_ENV') === 'production',
};
}
}
@Module({
imports: [WorkerManagerModule.forRootAsync({ imports: [ConfigModule], useClass: BoardConfig })],
})
export class AppModule {}
```
## Authentication
`auth` puts `@worker-manager/auth` in front of every board route: the
page, the API and the static assets. It works the same on Express and Fastify, and the mount path
includes the Nest global prefix.
### Basic
```ts
WorkerManagerModule.forRoot({
auth: {
strategy: 'basic',
users: [{ username: 'admin', password: process.env.BOARD_PASSWORD!, roles: ['admin'] }],
},
});
```
Unauthenticated requests get `401` with a `WWW-Authenticate: Basic` challenge and
`{ "error": { "key": "ERRORS.UNAUTHORIZED" } }`. Credentials are compared in constant time.
### Keycloak, configured from `ConfigService`
```ts
WorkerManagerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
route: '/queues',
auth: {
strategy: 'keycloak',
url: config.getOrThrow('KEYCLOAK_URL'), // https://sso.example.com
realm: config.getOrThrow('KEYCLOAK_REALM'),
clientId: config.getOrThrow('KEYCLOAK_CLIENT_ID'),
clientSecret: config.get('KEYCLOAK_CLIENT_SECRET'),
publicUrl: config.get('BOARD_PUBLIC_URL'), // e.g. https://api.example.com/queues
requiredRoles: ['wm-admin'],
cookie: { secret: config.getOrThrow('BOARD_SESSION_SECRET') },
},
}),
});
```
Browsers go through the OIDC authorization code flow with PKCE and get an encrypted session
cookie; API clients may send `Authorization: Bearer ` instead. A user without one of
`requiredRoles` gets `403` with `ERRORS.FORBIDDEN`. The module also serves `GET /queues/auth/me`
and `GET /queues/auth/logout`. See [Keycloak auth](/worker-manager/recipes/keycloak-auth.md) for the Keycloak client
settings.
### Token, with a login form for browsers
```ts
WorkerManagerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
route: '/admin/queues',
auth: {
strategy: 'token',
tokens: [config.getOrThrow('BOARD_TOKEN')],
header: 'X-Board-Token', // optional; Authorization: Bearer always works
cookie: { secret: config.getOrThrow('BOARD_SESSION_SECRET') },
},
}),
});
```
Scripts send the token in `X-Board-Token` or as a Bearer token; API calls without it get `401`
JSON. A browser opening `/admin/queues` is sent to a login form at `/admin/queues/auth/login`,
which trades the token for an encrypted `SameSite=Strict` session cookie. See
[Token auth](/worker-manager/recipes/token-auth.md).
### Custom
```ts
auth: {
strategy: 'custom',
authenticate: async (req) => (await apiKeys.verify(req.headers['x-api-key'])) ?? null,
},
```
`authenticate(req)` resolves the user or `null` (`401`); `onUnauthenticated(req, res)` can answer
instead. See [Custom auth](/worker-manager/recipes/custom-auth.md), including a Cloudflare Access example.
## Testing with `Test.createTestingModule`
The module works in a Nest `TestingModule` without an explicit `adapter`, on Express and Fastify
alike:
```ts
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
const app = moduleRef.createNestApplication(new FastifyAdapter());
await app.init();
await app.getHttpAdapter().getInstance().ready(); // Fastify only
```
`compile()` builds providers before `createNestApplication()` tells the app which HTTP platform it
runs on, so the board mounts on a stand-in and the real adapter is picked in `app.init()`. The
board injected with `@InjectWorkerManager()` is usable during `compile()`; queues added to it then
are applied once the adapter exists. Before 2.2.0 this threw `could not pick a server adapter for
the "unknown" HTTP platform`, and passing `adapter` explicitly was the workaround.
If the suite fails loading `@worker-manager/fastify` with `Must use import to load ES Module`, see
[Troubleshooting](/worker-manager/recipes/troubleshooting.md#jest-esm-content-disposition).
## PostgreSQL-backed queues
BullMQ v6 can store queues in PostgreSQL (see [PostgreSQL backend](/worker-manager/recipes/postgres-backend.md)).
Such a queue has no Redis connection and usually no DI token, so pass the instance:
```ts
import { Queue, createPostgresBackend } from 'bullmq'; // bullmq@6, plus the `pg` package
import { BullMQAdapter } from '@worker-manager/api/bullMQAdapter';
const invoices = new Queue(
'invoices',
// BullMQ 6.3+: `migrate: true` creates its schema on first connect; see the PostgreSQL recipe.
{ connection: { connectionString: process.env.POSTGRES_URL, migrate: true } },
createPostgresBackend
);
@Module({
imports: [
WorkerManagerModule.forRoot({
queues: [{ queue: invoices, adapter: BullMQAdapter }],
}),
],
})
export class AppModule {}
```
`@nestjs/bullmq` has no way to pass a backend factory, so PostgreSQL queues and their workers
are created with `bullmq` directly, as above, rather than through `BullModule.registerQueue` and
`@Processor`. Redis queues can keep using `@nestjs/bullmq` in the same app; if your Redis queues
must stay on BullMQ v5, install v6 under an alias for the Postgres ones
(`"bullmq-v6": "npm:bullmq@^6"`, then `import { Queue } from 'bullmq-v6'`).
If the queue is created inside a provider instead, inject the board with `@InjectWorkerManager()` and
call `board.addQueue(new BullMQAdapter(queue))` from `onModuleInit`. Redis and PostgreSQL queues can share one board; the datastore panel reports
Postgres stats for the Postgres queue.
### Running next to @bull-board/nestjs
Migrating one service at a time? Worker Manager's module registers its providers under its own
DI tokens (`worker_manager_*`) since 1.0.1, so the legacy `@bull-board/nestjs` module and this one
can be imported in the same app on different routes, each with its own `forFeature` queues.
You can inject the board instance anywhere:
```ts
import { Controller } from '@nestjs/common';
import { WorkerManagerBoard, InjectWorkerManager } from '@worker-manager/nestjs';
@Controller('ops')
export class OpsController {
constructor(@InjectWorkerManager() private readonly board: WorkerManagerBoard) {}
}
```
## Several boards
Give each `forRoot()` a `name` and its own `route` to mount several boards in one application,
for example one per team or one per datastore:
```ts
@Module({
imports: [
BullModule.forRoot({ connection: { host: 'localhost', port: 6379 } }),
WorkerManagerModule.forRoot({ name: 'ops', route: '/ops', auth: opsAuth }),
WorkerManagerModule.forRootAsync({
name: 'billing',
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({ route: '/billing', title: config.get('TITLE') }),
}),
WorkerManagerModule.forFeature('ops', { name: 'emails', adapter: BullMQAdapter }),
WorkerManagerModule.forFeature('billing', { name: 'invoices', adapter: BullMQAdapter }),
],
})
export class AppModule {}
```
- A named board's providers use the tokens `worker_manager_options:`,
`worker_manager_adapter:` and `worker_manager_instance:`
(`getWorkerManagerToken(name)` returns the last one). The unnamed board keeps the plain
`worker_manager_*` tokens, so an existing `forRoot()` without `name` is unchanged and can sit
next to named ones.
- `forFeature(name, ...queues)` registers into the named board; `forFeature(...queues)` still
targets the unnamed one.
- With `forRootAsync()`, `name` goes on the async options, next to `useFactory`: it decides the
tokens, so it has to be known before the factory runs.
- Inject a named board with `@InjectWorkerManager(name)`:
```ts
@Injectable()
export class QueueRegistry {
constructor(@InjectWorkerManager('ops') private readonly ops: WorkerManagerBoard) {}
}
```
Names are letters, digits, `.`, `_` and `-`. Give every board its own `route` and do not nest one
under another (`/queues` and `/queues/ops`): on Express the outer board's middleware also matches
the inner board's paths and answers for them.
Each board applies its own `auth` on its own prefix. With Keycloak:
- Register every board's redirect URI in the Keycloak client, `https:////auth/callback`
for each `route` (and a post-logout redirect URI of `https:////`). With `publicUrl`,
set it per board.
- A named board's session cookie is `wm_session_` instead of `wm_session`, scoped to the
board's path, unless `auth.cookie.name` is set. Boards never share a session: log in once per
board. The cookie secret can be the same.
## pg-boss board
`engine: 'pg-boss'` mounts a board over a [pg-boss](https://github.com/timgit/pg-boss) schema, with
the same shell, auth and routing as a BullMQ board. It needs pg-boss 12.24 or later, Node 22.12 or
later and one more package:
```sh
npm install @worker-manager/pg-boss
```
`@worker-manager/pg-boss` is an optional peer: it is only loaded when a board asks for the pg-boss
engine. The board never migrates, supervises or creates anything in the database.
See [the pg-boss engine](/worker-manager/queue-adapters/pg-boss.md) for connection modes, indexes and what the board shows.
Hand it the app's own pg-boss instance, already started, as a provider token:
```ts
import { PgBoss } from 'pg-boss';
@Module({
providers: [
{
provide: 'PG_BOSS',
useFactory: async () => {
const boss = new PgBoss(process.env.DATABASE_URL!);
await boss.start();
return boss;
},
},
],
exports: ['PG_BOSS'],
})
export class PgBossModule {}
@Module({
imports: [
PgBossModule,
WorkerManagerModule.forRoot({ route: '/queues' }), // the BullMQ board, unchanged
WorkerManagerModule.forRootAsync({
name: 'pgboss',
imports: [PgBossModule],
inject: ['PG_BOSS'],
useFactory: (boss: PgBoss) => ({
route: '/pg-boss',
engine: 'pg-boss',
auth: { strategy: 'basic', users: [{ username: 'ops', password: process.env.BOARD_PASSWORD! }] },
pgBoss: { instance: boss, connection: process.env.DATABASE_URL, schema: 'pgboss' },
}),
}),
],
})
export class AppModule {}
```
`pgBoss` takes:
| Option | | |
| ------------------------------------------------------------------------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `instance` | | The app's pg-boss instance, already started. Preferred for writes. |
| `useExisting` | | A provider token that resolves to that instance, looked up at bootstrap, instead of `instance`. Works with plain `forRoot()`. |
| `connection` | | A connection string, `pg` pool config or `pg.Pool` to read through. Reads through `instance` alone cannot enforce the query timeout on the server, so pass both when you can. With a connection and no instance, writes go through a pg-boss that is never started. |
| `schema` | `'pgboss'` | The pg-boss schema. |
| `queues` | all | An allowlist of queue names, or a predicate. |
| `delimiter` | | Groups queue names in the sidebar, for example `'.'`. |
| `includeInternalQueues`, `queryTimeoutMs`, `countCap`, `visibilityGuard` | | Passed through to `createPgBossBoard`. |
| `engine` | | A ready-made `PgBossEngine` (for tests, `createPgBossStubEngine()` from `@worker-manager/api/engine`). `@worker-manager/pg-boss` is not loaded then, and the engine is yours to close. |
`readOnly: true` makes the whole board read-only. The board lists the queues of its schema, so
`queues` at the root and `forFeature()` into a pg-boss board are configuration errors. The engine's
own read pool is closed with the application; the instance and a pool you pass stay yours.
`@InjectWorkerManager('pgboss')` resolves `{ engine, close() }`.
## Plain adapter setup
If you'd rather wire the server adapter yourself (custom middleware, existing Nest conventions), do it in a module's `configure()`:
```ts
import {
DynamicModule,
MiddlewareConsumer,
Module,
NestModule,
} from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { createWorkerManagerBoard } from '@worker-manager/api';
import { BullMQAdapter } from '@worker-manager/api/bullMQAdapter';
import { ExpressAdapter } from '@worker-manager/express';
import { Queue } from 'bullmq';
@Module({})
export class QueuesModule implements NestModule {
static register(): DynamicModule {
return {
module: QueuesModule,
imports: [
BullModule.forRoot({
connection: { host: 'localhost', port: 6379 },
}),
BullModule.registerQueue({ name: 'test' }),
],
};
}
constructor(private readonly testQueue: Queue) {}
configure(consumer: MiddlewareConsumer) {
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/queues');
createWorkerManagerBoard({
queues: [new BullMQAdapter(this.testQueue)],
serverAdapter,
});
consumer.apply(serverAdapter.getRouter()).forRoutes('/queues');
}
}
```
## Full runnable examples
- Redis with `@nestjs/bullmq` and basic auth: [`examples/nestjs/redis`](https://github.com/naldomadeira/worker-manager/tree/main/examples/nestjs/redis)
- PostgreSQL-backed BullMQ v6 queues: [`examples/nestjs/postgres`](https://github.com/naldomadeira/worker-manager/tree/main/examples/nestjs/postgres)
- Keycloak auth configured from `ConfigService`: [`examples/nestjs/keycloak`](https://github.com/naldomadeira/worker-manager/tree/main/examples/nestjs/keycloak)
- Fastify platform with a custom auth hook: [`examples/nestjs/fastify-custom-auth`](https://github.com/naldomadeira/worker-manager/tree/main/examples/nestjs/fastify-custom-auth)
- The pg-boss board over the app's own pg-boss instance: [`examples/nestjs/pg-boss`](https://github.com/naldomadeira/worker-manager/tree/main/examples/nestjs/pg-boss)
## Next steps
- [UIConfig](/worker-manager/configuration/ui-config.md): title, logo, locale, polling.
- [Read-only mode](/worker-manager/recipes/read-only-mode.md): disable destructive actions.
- [Visibility guard](/worker-manager/recipes/visibility-guard.md): scope visible queues per request.
- [Formatters](/worker-manager/recipes/formatters.md): rewrite job fields for the UI.