Skip to content

Board — Admin Dashboard

@taskora/board is a full-featured, batteries-included admin dashboard for taskora. It ships as a pre-built React SPA served by a Hono backend, so there is no build step on your side — import it, mount it, done.

It's a separate package — install it alongside taskora only if you want the dashboard. No Hono dependency, no static assets, no board code lands in your bundle if you don't.

Unlike bull-board (which focuses on queues) or Flower (Celery-only), the taskora board is task-centric and exposes everything taskora actually does: workflow DAGs, schedules, migrations, flow control, retention, DLQ.

taskora board per-task view — stat cards, hourly throughput chart, state tabs, and a jobs table with a real retry error surfaced inline

Why a dashboard?

taskora.inspect() gives you programmatic access to every job, but during development and incident response you want eyes on the system. The board covers:

  • Observe — real-time task counts, throughput charts, job timelines
  • Debug — drill into individual jobs (data, result, error, logs, progress, retry history)
  • Visualize — workflow DAG rendering with per-node state colors
  • Manage — retry failed jobs, cancel active jobs, pause/resume schedules, clean queues, retry entire DLQ
  • Evolve — version distribution per task, canBumpSince indicator for safe migration pruning

Installation

@taskora/board is a separate package with taskora and hono as peer dependencies. Install both together:

sh
npm install @taskora/board hono
sh
yarn add @taskora/board hono
sh
pnpm add @taskora/board hono
sh
bun add @taskora/board hono

The pre-built React SPA and its dependencies (Recharts, @xyflow/react, Tailwind) are bundled inside the @taskora/board package — you do not install them yourself. If you never install @taskora/board, none of that code reaches your bundle.

Quick start

ts
import { createTaskora } from "taskora"
import { redisAdapter } from "taskora/redis"
import { createBoard } from "@taskora/board"

const taskora = createTaskora({
  adapter: redisAdapter("redis://localhost:6379"),
})

// ... define tasks, workers ...

const board = createBoard(taskora)
board.listen(3000)
// → taskora board listening on http://localhost:3000/board

Open http://localhost:3000/board in your browser. That's it.

The Board interface

createBoard(app, options?) returns a Board object with four ways to serve the UI:

ts
interface Board {
  app: Hono                                    // the raw Hono instance
  fetch: (req: Request) => Response | Promise<Response>  // Web standard fetch handler
  handler: (req, res) => void                  // Node.js-style handler (requires @hono/node-server)
  listen: (port: number) => void               // standalone server (Bun / Deno only)
}

Pick whichever fits your host:

ts
const board = createBoard(taskora)
board.listen(3000)
ts
Bun.serve({
  port: 3000,
  fetch: board.fetch,
})
ts
Deno.serve({ port: 3000 }, board.fetch)
ts
import { Hono } from "hono"

const app = new Hono()
app.route("/admin/taskora", board.app)
ts
import { serve } from "@hono/node-server"

// Standalone Node.js server
serve({ fetch: board.fetch, port: 3000 })

// Or as Express middleware (via @hono/node-server helpers)
import { createAdaptorServer } from "@hono/node-server"
const server = createAdaptorServer({ fetch: board.fetch })
expressApp.use("/admin/taskora", (req, res) => server.emit("request", req, res))

Framework parity

Anything that speaks the Web Request/Response standard (Bun, Deno, Cloudflare Workers, Hono, Vercel Edge, …) can mount board.fetch directly. For Node.js-native frameworks (Express, Fastify, Koa, Next.js API routes), wrap it with @hono/node-server.

Options

ts
createBoard(taskora, {
  basePath: "/admin/taskora",         // default: "/board"
  readOnly: false,                    // hide mutation buttons + reject POST/PUT/DELETE
  auth: async (req) => { /* ... */ }, // per-request auth middleware
  title: "My Queue",                  // browser tab title
  logo: "/custom-logo.svg",           // header logo URL
  favicon: "/favicon.ico",
  theme: "auto",                      // "light" | "dark" | "auto"
  refreshInterval: 2000,              // stats polling fallback (ms) — SSE is primary
  redact: ["password", "apiKey", "ssn"],  // deep field redaction for job data/result
  cors: { origin: "*" },
  formatters: {
    data:   (data, task) => { /* custom render pre-processing */ return data },
    result: (result, task) => result,
  },
})

Authentication

The board accepts two auth shapes. Pick one; they cannot be combined.

Drop in a single config object and the board mounts a login page, signs a session cookie, and guards the entire dashboard — SPA HTML, API, and SSE. Inspired by AdminJS: one provider, username + password, nothing else to set up.

ts
createBoard(taskora, {
  auth: {
    // HMAC signing secret, min 32 chars — generate with:
    //   openssl rand -base64 48
    cookiePassword: process.env.BOARD_COOKIE_SECRET!,

    // Called on each login attempt. Return a truthy user object to accept,
    // or null to reject. Your own code owns credential storage (env, DB, LDAP, …).
    authenticate: async ({ username, password }) => {
      if (username === "admin" && password === process.env.BOARD_PASSWORD) {
        return { id: "admin" }
      }
      return null
    },

    // Optional
    cookieName: "taskora_board_session",  // default
    // sessionTtl defaults to `false` — sessions do not expire server-side
    // and the cookie is a browser-session cookie (cleared on browser close).
    // Pass a Duration ("30s" | "5m" | "2h" | "1d" | ms number) to opt into rolling expiry.
    // sessionTtl: "7d",
  },
})

What you get:

  • GET /board/login — server-rendered login form (no SPA rebuild required).
  • POST /board/auth/login — verifies creds via authenticate, sets a signed, HttpOnly, SameSite=Lax cookie, then redirects.
  • POST /board/auth/logout — clears the cookie and redirects back to the login page. The sidebar shows a [ logout ] button automatically when session auth is enabled.
  • Unauthenticated requests to any SPA path (/board/*) are redirected to the login page with a ?redirect= parameter so users land back where they started.
  • Unauthenticated requests to the JSON API (/board/api/*) receive 401 {"error":"Unauthorized"}.

Security notes:

  • The session cookie is stateless — the user object (and optionally an expiry) is HMAC-signed into the cookie itself. No Redis state, works with every adapter. The trade-off: logout only clears the client cookie; a stolen cookie stays valid until its exp (or forever, if sessionTtl is disabled). Set sessionTtl to a tight Duration if that matters to you.
  • SameSite=Lax + HttpOnly defeats cross-site POST and prevents JS access. No separate CSRF token is required.
  • The Secure flag is set automatically when the request reaches the board over HTTPS.
  • Password hashing, rate limiting, and lockout live inside your authenticate function — the board has no opinion about how credentials are stored.

Custom auth hook

If you already ship JWT, OAuth, or your framework's own session middleware, pass a function instead. It runs on every /board/api/* request. Return undefined to allow, or a Response to short-circuit:

ts
createBoard(taskora, {
  auth: async (req) => {
    const token = req.headers.get("authorization")?.replace("Bearer ", "")
    if (!token || !(await verifyJwt(token))) {
      return new Response("Unauthorized", { status: 401 })
    }
    // return nothing → request proceeds
  },
})

The function form guards only the API — the SPA HTML and static assets stay public, so your own front door (reverse proxy, gateway, framework middleware) is expected to enforce access to the dashboard shell. This matches the pre-session-auth behavior and is preserved for backward compatibility.

For public demos or local dev, pair readOnly: true with no auth.

Field redaction

Job payloads often contain secrets. The redact option walks every field in data, result, error, and logs.meta, masking any key whose name matches (case-insensitive):

ts
createBoard(taskora, {
  redact: ["password", "secret", "token", "apiKey", "ssn", "creditCard"],
})

Nested objects and arrays are walked recursively — user.auth.password and payments[0].creditCard are both redacted. Redaction happens on the server before the response leaves the process, so secrets never touch the browser.

Read-only mode

ts
createBoard(taskora, { readOnly: true })

Mutation buttons are hidden in the UI and all POST/PUT/DELETE endpoints reject with 403. Safe to expose behind a read-only employee SSO without risking accidental retries.

What you get

Overview dashboard

Global stat cards (waiting / active / delayed / failed / completed / cancelled / expired), a 24-hour throughput chart powered by Recharts, a task table with per-task counts and health indicators, and Redis server info (version, memory, uptime, connected state).

Throughput is backed by per-minute INCR counters stamped in ack.lua / fail.lua with a 24h TTL, so the chart is accurate without any external time-series database.

Task detail

Per-task view with state tabs (waiting / active / delayed / retrying / failed / completed / cancelled / expired), a paginated job table, and bulk actions — retry-all and clean-by-age.

Task detail view for send-email — stat cards, throughput chart, state tabs, and a job table showing completed jobs and retry errors

Job detail

Everything about a single job on one screen:

  • Timeline — reconstructed from tsprocessedOnfinishedOn (state transitions with absolute + relative timestamps)
  • Data / Result / Error / Logs tabs
  • Progress bar — renders numeric progress or arbitrary progress objects
  • Attempt history — current attempt number vs maxAttempts
  • Actions — retry, cancel (handle.cancel() equivalent)
  • Workflow link — jumps to the parent workflow DAG if the job was part of one

Workflow DAG visualization

Built on @xyflow/react with auto-layout (BFS layering). Nodes are colored by state, edges animate when the downstream node is active, and clicking a node opens its job detail view. Chains, groups, and chords all render correctly — including nested compositions. Cancel an entire workflow from the header and watch the cascade propagate.

Schedule management

List all registered schedules with their cron/interval, next-run countdown (relative time display), last run status, and job ID. Actions: pause, resume, trigger-now, delete. The trigger-now button dispatches the task immediately without disturbing the scheduled cadence.

DLQ view

Failed jobs grouped by error-message frequency so you can spot the top failure modes at a glance. Per-job retry and a global "retry all" button backed by the atomic retryAllDLQ.lua script (batched 100 at a time).

Migrations view

Version distribution bar chart per task, showing how many jobs are queued or delayed at each _v version. The canBumpSince indicator tells you whether it is safe to raise since and drop old migrations — taskora checks queue / delayed / retrying sets for any job still stamped with a version below the proposed floor.

See Versioning & Migrations for the underlying mechanics.

Real-time updates (SSE)

The /api/events endpoint streams Server-Sent Events from adapter.subscribe() — every active, completed, failed, retrying, cancelled, stalled, and progress event flows to the browser in real time. A periodic stats:update frame refreshes stat cards every few seconds even when no jobs are moving.

SSE is the primary transport — refreshInterval polling only kicks in as a fallback if the EventSource disconnects.

UX niceties

  • Dark / light / auto theme via CSS custom properties (follows prefers-color-scheme)
  • Keyboard shortcuts15 for top-level navigation, / for the global job-ID search bar
  • Global job search — paste a job ID from a log line, land on the detail view

REST API

Under the hood, the SPA talks to a plain REST API mounted at ${basePath}/api. You can call these endpoints directly from your own tooling — they are not considered internal.

MethodPathDescription
GET/api/overviewGlobal stat cards, tasks, Redis info
GET/api/tasks/:task/jobs?state=&limit=&offset=Paginated jobs for a task
GET/api/tasks/:task/statsQueue counts for a task
GET/api/tasks/:task/migrationsVersion distribution + canBumpSince
POST/api/tasks/:task/retry-allRetry every failed job for a task
POST/api/tasks/:task/cleanTrim completed/failed sets
GET/api/jobs/:jobIdFull JobDetailResponse with timeline, logs, workflow link
POST/api/jobs/:jobId/retryRetry a single job (must be in failed/cancelled state)
POST/api/jobs/:jobId/cancelCancel an active or waiting job
GET/api/schedulesList all schedules
POST/api/schedules/:name/pausePause a schedule
POST/api/schedules/:name/resumeResume a schedule
POST/api/schedules/:name/triggerDispatch immediately without advancing next-run
PUT/api/schedules/:nameUpdate schedule config
DELETE/api/schedules/:nameRemove schedule
GET/api/workflowsList active/recent workflows
GET/api/workflows/:workflowIdWorkflow DAG graph + per-node state
POST/api/workflows/:workflowId/cancelCascade cancel workflow
GET/api/dlqDead-letter queue jobs with grouping
POST/api/dlq/:jobId/retryRetry single DLQ entry
POST/api/dlq/retry-allRetry entire DLQ (batched)
GET/api/throughput24h per-minute throughput buckets
GET/api/eventsServer-Sent Events stream
GET/api/configStatic config (title, logo, theme, readOnly flag)

All mutation endpoints respect readOnly and auth.

Recipes

Mount behind nginx / reverse proxy

Set basePath to match your upstream route so generated asset URLs are correct:

ts
const board = createBoard(taskora, { basePath: "/internal/taskora" })
Bun.serve({ fetch: board.fetch, port: 3000 })
nginx
location /internal/taskora/ {
  proxy_pass http://taskora-host:3000;
  proxy_set_header Host $host;
  proxy_buffering off;  # important for SSE
}

Share a port with your API

If you already have a Hono app serving your public API, mount the board on a sub-route instead of running a second port:

ts
import { Hono } from "hono"

const api = new Hono()
api.get("/health", (c) => c.json({ ok: true }))
// ... more routes ...

api.route("/admin/taskora", createBoard(taskora, {
  basePath: "/admin/taskora",
  auth: requireAdmin,
}).app)

Bun.serve({ fetch: api.fetch, port: 8080 })

Redact secrets for a specific task only

redact is global, but formatters gives you per-task control:

ts
createBoard(taskora, {
  formatters: {
    data: (data, task) => {
      if (task === "send-email") {
        const { body, ...rest } = data as any
        return { ...rest, body: "[REDACTED]" }
      }
      return data
    },
  },
})

Expose only to internal network

ts
createBoard(taskora, {
  readOnly: true,
  auth: async (req) => {
    const ip = req.headers.get("x-forwarded-for") ?? ""
    if (!ip.startsWith("10.") && !ip.startsWith("192.168.")) {
      return new Response("Forbidden", { status: 403 })
    }
  },
})

Production checklist

  • [ ] Set auth — never expose the board publicly without it
  • [ ] Configure redact for any sensitive fields in job payloads
  • [ ] Consider readOnly: true for broad internal visibility with narrow write access
  • [ ] Mount behind HTTPS (board has no TLS — that's your proxy's job)
  • [ ] If using nginx, disable proxy_buffering on the board location so SSE works
  • [ ] Pin a stable basePath — changing it invalidates cached asset URLs in browsers

See also

  • Inspector — the programmatic API the board is built on
  • Retention & DLQ — what the DLQ view actually operates on
  • Monitoring — metrics pipelines for long-term observability (the board is not a replacement for Grafana)
  • Workflows — the DAG model the workflow view visualizes
  • Versioning & Migrations — what the migrations view reports on

Released under the MIT License.