├── .changeset ├── README.md └── config.json ├── .gitattributes ├── .github └── workflows │ ├── release.yml │ └── tests.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── docker-compose.yml ├── examples ├── with-elysia-and-bun │ ├── bun.lockb │ ├── main.ts │ ├── package.json │ └── tsconfig.json ├── with-express │ ├── main.ts │ ├── package.json │ ├── tsconfig.json │ └── yarn.lock ├── with-fastify │ ├── main.ts │ ├── package.json │ ├── tsconfig.json │ └── yarn.lock ├── with-hono │ ├── main.ts │ ├── package.json │ ├── pnpm-lock.yaml │ └── tsconfig.json └── with-next │ ├── next-env.d.ts │ ├── next.config.js │ ├── package.json │ ├── pages │ ├── _app.tsx │ ├── api │ │ └── queuedash │ │ │ └── [trpc].ts │ └── queuedash │ │ └── [[...slug]].tsx │ ├── tsconfig.json │ └── yarn.lock ├── package.json ├── packages ├── api │ ├── .eslintrc.js │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ │ ├── adapters │ │ │ ├── elysia.ts │ │ │ ├── express.ts │ │ │ ├── fastify.ts │ │ │ ├── hono.ts │ │ │ └── utils.ts │ │ ├── main.ts │ │ ├── routers │ │ │ ├── _app.ts │ │ │ ├── job.ts │ │ │ ├── queue.ts │ │ │ └── scheduler.ts │ │ ├── tests │ │ │ ├── jobs.test.ts │ │ │ ├── queue.test.ts │ │ │ ├── schedulers.test.ts │ │ │ └── test.utils.ts │ │ ├── trpc.ts │ │ └── utils │ │ │ └── global.utils.ts │ ├── tsconfig.json │ ├── vite.config.ts │ └── vitest.config.ts ├── client │ ├── .eslintrc.js │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ │ └── main.tsx │ ├── tsconfig.json │ ├── vite-env.d.ts │ └── vite.config.ts ├── dev │ ├── .eslintrc.js │ ├── CHANGELOG.md │ ├── next-env.d.ts │ ├── next.config.js │ ├── package.json │ ├── pages │ │ ├── _app.tsx │ │ ├── api │ │ │ ├── queuedash │ │ │ │ └── [trpc].ts │ │ │ └── reset.ts │ │ └── queuedash │ │ │ └── [[...slug]].tsx │ ├── tsconfig.json │ └── utils │ │ ├── fake-data.ts │ │ └── worker.ts └── ui │ ├── .eslintrc.js │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── postcss.config.js │ ├── src │ ├── App.tsx │ ├── components │ │ ├── ActionMenu.tsx │ │ ├── AddJobModal.tsx │ │ ├── Alert.tsx │ │ ├── Button.tsx │ │ ├── Checkbox.tsx │ │ ├── ErrorCard.tsx │ │ ├── JobActionMenu.tsx │ │ ├── JobModal.tsx │ │ ├── JobOptionTag.tsx │ │ ├── JobTable.tsx │ │ ├── JobTableSkeleton.tsx │ │ ├── Layout.tsx │ │ ├── QueueActionMenu.tsx │ │ ├── QueueStatusTabs.tsx │ │ ├── SchedulerActionMenu.tsx │ │ ├── SchedulerModal.tsx │ │ ├── SchedulerTable.tsx │ │ ├── Skeleton.tsx │ │ ├── TableRow.tsx │ │ ├── ThemeSwitcher.tsx │ │ └── Tooltip.tsx │ ├── main.ts │ ├── pages │ │ ├── HomePage.tsx │ │ └── QueuePage.tsx │ ├── styles │ │ └── global.css │ └── utils │ │ ├── config.ts │ │ └── trpc.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ ├── vite-env.d.ts │ └── vite.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── turbo.json /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": [ 4 | "@changesets/changelog-github", 5 | { 6 | "repo": "alexbudure/queuedash" 7 | } 8 | ], 9 | "commit": false, 10 | "fixed": [], 11 | "linked": [], 12 | "ignore": ["@queuedash/dev"], 13 | "access": "public", 14 | "baseBranch": "main", 15 | "updateInternalDependencies": "patch" 16 | } 17 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Repo 16 | uses: actions/checkout@v3 17 | 18 | - name: Setup Node.js 18.x 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 18.x 22 | 23 | - uses: pnpm/action-setup@v2 24 | name: Install pnpm 25 | id: pnpm-install 26 | with: 27 | version: 8 28 | run_install: false 29 | 30 | - name: Get pnpm store directory 31 | id: pnpm-cache 32 | shell: bash 33 | run: | 34 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 35 | 36 | - uses: actions/cache@v3 37 | name: Setup pnpm cache 38 | with: 39 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 40 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 41 | restore-keys: | 42 | ${{ runner.os }}-pnpm-store- 43 | 44 | - name: Install dependencies 45 | run: pnpm install 46 | 47 | - name: Create Release Pull Request or Publish to npm 48 | id: changesets 49 | uses: changesets/action@v1 50 | with: 51 | publish: pnpm release 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 55 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: push 3 | 4 | jobs: 5 | tests: 6 | name: Tests 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout Repo 10 | uses: actions/checkout@v3 11 | 12 | - name: Setup Node.js 18.x 13 | uses: actions/setup-node@v3 14 | with: 15 | node-version: 18.x 16 | 17 | - uses: pnpm/action-setup@v2 18 | name: Install pnpm 19 | id: pnpm-install 20 | with: 21 | version: 8 22 | run_install: false 23 | 24 | - name: Get pnpm store directory 25 | id: pnpm-cache 26 | shell: bash 27 | run: | 28 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 29 | 30 | - uses: actions/cache@v3 31 | name: Setup pnpm cache 32 | with: 33 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 34 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 35 | restore-keys: | 36 | ${{ runner.os }}-pnpm-store- 37 | 38 | - name: Install dependencies 39 | run: pnpm install 40 | 41 | - name: Run docker 42 | run: pnpm docker 43 | 44 | - name: Run build 45 | run: pnpm build 46 | 47 | - name: Run tests 48 | run: pnpm test 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | package-lock.json 4 | .pnpm-store/ 5 | 6 | # Editors 7 | **/.#* 8 | .nvmrc 9 | **/.vscode 10 | dist 11 | .turbo 12 | .rollup.cache 13 | .idea 14 | .next 15 | .env 16 | 17 | # Misc 18 | .DS_Store -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Alex Budure 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | QueueDash 4 | 5 |

6 | 7 |

8 | A stunning, sleek dashboard for Bull, BullMQ, and Bee-Queue. 9 |

10 | 11 |

12 | 13 | 14 | 15 | 16 | 17 | 18 |

19 | 20 | ## Features 21 | 22 | - 😍  Simple, clean, and compact UI 23 | - 🧙  Add jobs to your queue with ease 24 | - 🪄  Retry, remove, and more convenient actions for your jobs 25 | - 📊  Stats for job counts, job durations, and job wait times 26 | - ✨  Top-level overview page of all queues 27 | - 🔋  Integrates with Next.js, Express.js, and Fastify 28 | - ⚡️  Compatible with Bull, BullMQ, and Bee-Queue 29 | 30 | ## Getting Started 31 | 32 | ### Express 33 | 34 | `pnpm install @queuedash/api` 35 | 36 | ```typescript 37 | import express from "express"; 38 | import Bull from "bull"; 39 | import { createQueueDashExpressMiddleware } from "@queuedash/api"; 40 | 41 | const app = express(); 42 | 43 | const reportQueue = new Bull("report-queue"); 44 | 45 | app.use( 46 | "/queuedash", 47 | createQueueDashExpressMiddleware({ 48 | ctx: { 49 | queues: [ 50 | { 51 | queue: reportQueue, 52 | displayName: "Reports", 53 | type: "bull" as const, 54 | }, 55 | ], 56 | }, 57 | }) 58 | ); 59 | 60 | app.listen(3000, () => { 61 | console.log("Listening on port 3000"); 62 | console.log("Visit http://localhost:3000/queuedash"); 63 | }); 64 | ``` 65 | 66 | ### Next.js 67 | 68 | `pnpm install @queuedash/api @queuedash/ui` 69 | 70 | ```typescript jsx 71 | // pages/admin/queuedash/[[...slug]].tsx 72 | import { QueueDashApp } from "@queuedash/ui"; 73 | 74 | function getBaseUrl() { 75 | if (process.env.VERCEL_URL) { 76 | return `https://${process.env.VERCEL_URL}/api/queuedash`; 77 | } 78 | 79 | return `http://localhost:${process.env.PORT ?? 3000}/api/queuedash`; 80 | } 81 | 82 | const QueueDashPages = () => { 83 | return ; 84 | }; 85 | 86 | export default QueueDashPages; 87 | 88 | // pages/api/queuedash/[trpc].ts 89 | import * as trpcNext from "@trpc/server/adapters/next"; 90 | import { appRouter } from "@queuedash/api"; 91 | 92 | const reportQueue = new Bull("report-queue"); 93 | 94 | export default trpcNext.createNextApiHandler({ 95 | router: appRouter, 96 | batching: { 97 | enabled: true, 98 | }, 99 | createContext: () => ({ 100 | queues: [ 101 | { 102 | queue: reportQueue, 103 | displayName: "Reports", 104 | type: "bull" as const, 105 | }, 106 | ], 107 | }), 108 | }); 109 | ``` 110 | 111 | See the [./examples](./examples) folder for more. 112 | 113 | --- 114 | 115 | ## API Reference 116 | 117 | ### `createQueueDash<*>Middleware` 118 | 119 | ```typescript 120 | type QueueDashMiddlewareOptions = { 121 | app: express.Application | FastifyInstance; // Express or Fastify app 122 | baseUrl: string; // Base path for the API and UI 123 | ctx: QueueDashContext; // Context for the UI 124 | }; 125 | 126 | type QueueDashContext = { 127 | queues: QueueDashQueue[]; // Array of queues to display 128 | }; 129 | 130 | type QueueDashQueue = { 131 | queue: Bull.Queue | BullMQ.Queue | BeeQueue; // Queue instance 132 | displayName: string; // Display name for the queue 133 | type: "bull" | "bullmq" | "bee"; // Queue type 134 | }; 135 | ``` 136 | 137 | ### `` 138 | 139 | ```typescript jsx 140 | type QueueDashAppProps = { 141 | apiUrl: string; // URL to the API endpoint 142 | basename: string; // Base path for the app 143 | }; 144 | ``` 145 | 146 | ## Roadmap 147 | 148 | - Supports Celery and other queueing systems 149 | - Command+K bar and shortcuts 150 | - Ability to whitelabel the UI 151 | 152 | ## Pro Version 153 | 154 | Right now, QueueDash simply taps into your Redis instance, making it very easy to set up, but also limited in functionality. 155 | 156 | I'm thinking about building a free-to-host version on top of this which will require external services (db, auth, etc.), but it will make the following features possible: 157 | 158 | - Alerts and notifications 159 | - Quick search and filtering 160 | - Queue trends and analytics 161 | - Invite team members 162 | 163 | If you're interested in this version, please let me know! 164 | 165 | ## Acknowledgements 166 | 167 | QueueDash was inspired by some great open source projects. Here's a few of them: 168 | 169 | - [bull-board](https://github.com/vcapretz/bull-board) 170 | - [bull-monitor](https://github.com/s-r-x/bull-monitor) 171 | - [bull-arena](https://github.com/bee-queue/arena) 172 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | redis: 5 | image: redis 6 | ports: 7 | - "6379:6379" 8 | -------------------------------------------------------------------------------- /examples/with-elysia-and-bun/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexbudure/queuedash/8416e1b42a7db0928ff79a5fef24599d02ff1051/examples/with-elysia-and-bun/bun.lockb -------------------------------------------------------------------------------- /examples/with-elysia-and-bun/main.ts: -------------------------------------------------------------------------------- 1 | import Bull from "bull"; 2 | import { Elysia } from "elysia"; 3 | import { queuedash } from "@queuedash/api"; 4 | 5 | const app = new Elysia().use( 6 | queuedash({ 7 | baseUrl: "/queuedash", 8 | ctx: { 9 | queues: [ 10 | { 11 | queue: new Bull("report-queue"), 12 | displayName: "Reports", 13 | type: "bull" as const, 14 | }, 15 | ], 16 | }, 17 | }) 18 | ); 19 | 20 | await app.listen(3000); 21 | console.log(`Running at http://${app.server?.hostname}:${app.server?.port}`); 22 | -------------------------------------------------------------------------------- /examples/with-elysia-and-bun/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-elysia-and-bun", 3 | "module": "main.ts", 4 | "type": "module", 5 | "scripts": { 6 | "start": "bun main.ts" 7 | }, 8 | "devDependencies": { 9 | "bun-types": "latest" 10 | }, 11 | "peerDependencies": { 12 | "typescript": "^5.2.2" 13 | }, 14 | "dependencies": { 15 | "@queuedash/api": "^2.0.3", 16 | "bull": "^4.11.3", 17 | "elysia": "^0.6.24" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /examples/with-elysia-and-bun/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "lib": ["ESNext", "dom"], 5 | "module": "esnext", 6 | "target": "esnext", 7 | "outDir": "dist", 8 | "allowJs": true, 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "noEmit": true, 13 | "incremental": true, 14 | "esModuleInterop": true, 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve", 19 | "composite": true, 20 | "declaration": true, 21 | "declarationMap": true, 22 | "inlineSources": false, 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "preserveWatchOutput": true, 26 | "baseUrl": ".", 27 | "types": ["bun-types"] 28 | }, 29 | "include": ["main.ts", "package.json"], 30 | "exclude": ["node_modules"] 31 | } 32 | -------------------------------------------------------------------------------- /examples/with-express/main.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import Bull from "bull"; 3 | import { createQueueDashExpressMiddleware } from "@queuedash/api"; 4 | 5 | const app = express(); 6 | 7 | app.use( 8 | "/queuedash", 9 | createQueueDashExpressMiddleware({ 10 | ctx: { 11 | queues: [ 12 | { 13 | queue: new Bull("report-queue"), 14 | displayName: "Reports", 15 | type: "bull" as const, 16 | }, 17 | ], 18 | }, 19 | }) 20 | ); 21 | 22 | app.listen(3000, () => { 23 | console.log("Listening on port 3000"); 24 | console.log("Visit http://localhost:3000/queuedash"); 25 | }); 26 | -------------------------------------------------------------------------------- /examples/with-express/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-express", 3 | "version": "1.0.0", 4 | "description": "Example of using QueueDash with Express", 5 | "author": "Alex Budure", 6 | "license": "MIT", 7 | "scripts": { 8 | "start": "ts-node main.ts" 9 | }, 10 | "dependencies": { 11 | "@queuedash/api": "^2.0.5", 12 | "bull": "^4.11.3", 13 | "express": "^4.18.2" 14 | }, 15 | "devDependencies": { 16 | "@types/express": "^4.17.18", 17 | "ts-node": "^10.9.1", 18 | "typescript": "^5.2.2" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/with-express/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "lib": ["ESNext", "dom"], 5 | "module": "commonjs", 6 | "target": "esnext", 7 | "outDir": "dist", 8 | "allowJs": true, 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "noEmit": true, 13 | "incremental": true, 14 | "esModuleInterop": true, 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve", 19 | "composite": true, 20 | "declaration": true, 21 | "declarationMap": true, 22 | "inlineSources": false, 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "preserveWatchOutput": true, 26 | "baseUrl": "." 27 | }, 28 | "include": ["main.ts", "package.json"], 29 | "exclude": ["node_modules"] 30 | } 31 | -------------------------------------------------------------------------------- /examples/with-fastify/main.ts: -------------------------------------------------------------------------------- 1 | import fastify from "fastify"; 2 | import Bull from "bull"; 3 | import { fastifyQueueDashPlugin } from "@queuedash/api"; 4 | 5 | const server = fastify(); 6 | 7 | server.register(fastifyQueueDashPlugin, { 8 | baseUrl: "/queuedash", 9 | ctx: { 10 | queues: [ 11 | { 12 | queue: new Bull("report-queue"), 13 | displayName: "Reports", 14 | type: "bull" as const, 15 | }, 16 | ], 17 | }, 18 | }); 19 | 20 | server.listen({ port: 3000 }, () => { 21 | console.log("Listening on port 3000"); 22 | console.log("Visit http://localhost:3000/queuedash"); 23 | }); 24 | -------------------------------------------------------------------------------- /examples/with-fastify/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-fastify", 3 | "version": "1.0.0", 4 | "description": "Example of using QueueDash with Fastify", 5 | "author": "Alex Budure", 6 | "license": "MIT", 7 | "scripts": { 8 | "start": "ts-node main.ts" 9 | }, 10 | "dependencies": { 11 | "@queuedash/api": "^2.0.5", 12 | "bull": "^4.11.3", 13 | "fastify": "^4.23.2" 14 | }, 15 | "devDependencies": { 16 | "ts-node": "^10.9.1", 17 | "typescript": "^5.2.2" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /examples/with-fastify/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "lib": ["ESNext", "dom"], 5 | "module": "commonjs", 6 | "target": "esnext", 7 | "outDir": "dist", 8 | "allowJs": true, 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "noEmit": true, 13 | "incremental": true, 14 | "esModuleInterop": true, 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve", 19 | "composite": true, 20 | "declaration": true, 21 | "declarationMap": true, 22 | "inlineSources": false, 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "preserveWatchOutput": true, 26 | "baseUrl": "." 27 | }, 28 | "include": ["main.ts", "package.json"], 29 | "exclude": ["node_modules"] 30 | } 31 | -------------------------------------------------------------------------------- /examples/with-hono/main.ts: -------------------------------------------------------------------------------- 1 | import { serve } from "@hono/node-server"; 2 | import { Hono } from "hono"; 3 | import Bull from "bull"; 4 | import { createHonoAdapter } from "@queuedash/api"; 5 | 6 | const app = new Hono(); 7 | 8 | app.route( 9 | "/queuedash", 10 | createHonoAdapter({ 11 | baseUrl: "/queuedash", 12 | ctx: { 13 | queues: [ 14 | { 15 | queue: new Bull("report-queue"), 16 | displayName: "Reports", 17 | type: "bull" as const, 18 | }, 19 | ], 20 | }, 21 | }) 22 | ); 23 | 24 | const port = 3000; 25 | console.log(`Server is running on http://localhost:${port}`); 26 | 27 | serve({ 28 | fetch: app.fetch, 29 | port, 30 | }); 31 | -------------------------------------------------------------------------------- /examples/with-hono/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-hono", 3 | "version": "1.0.0", 4 | "description": "Example of using QueueDash with Hono", 5 | "author": "Lukáš Huvar", 6 | "license": "MIT", 7 | "scripts": { 8 | "start": "tsx main.ts" 9 | }, 10 | "dependencies": { 11 | "@hono/node-server": "^1.13.7", 12 | "@queuedash/api": "^3.4.0", 13 | "bull": "^4.16.5", 14 | "hono": "^4.6.16" 15 | }, 16 | "devDependencies": { 17 | "@types/node": "^22.10.5", 18 | "tsx": "^4.19.2", 19 | "typescript": "^5.7.3" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/with-hono/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "NodeNext", 5 | "strict": true, 6 | "skipLibCheck": true, 7 | "types": ["node"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /examples/with-next/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /examples/with-next/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | }; 5 | 6 | module.exports = nextConfig; 7 | -------------------------------------------------------------------------------- /examples/with-next/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-next", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "dev": "next dev" 6 | }, 7 | "author": "Alex Budure", 8 | "license": "MIT", 9 | "dependencies": { 10 | "@queuedash/api": "^2.0.5", 11 | "@queuedash/ui": "^2.0.5", 12 | "@trpc/server": "^10.38.5", 13 | "bull": "^4.11.3", 14 | "next": "^13.5.3", 15 | "react": "^18.2.0", 16 | "react-dom": "^18.2.0" 17 | }, 18 | "devDependencies": { 19 | "@types/node": "^18.14.6", 20 | "@types/react": "^18.0.28", 21 | "typescript": "^5.2.2" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/with-next/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "@queuedash/ui/dist/styles.css"; 2 | 3 | import type { AppType } from "next/app"; 4 | 5 | const MyApp: AppType = ({ Component, pageProps }) => { 6 | return ; 7 | }; 8 | export default MyApp; 9 | -------------------------------------------------------------------------------- /examples/with-next/pages/api/queuedash/[trpc].ts: -------------------------------------------------------------------------------- 1 | import * as trpcNext from "@trpc/server/adapters/next"; 2 | import { appRouter } from "@queuedash/api"; 3 | import Bull from "bull"; 4 | 5 | export default trpcNext.createNextApiHandler({ 6 | router: appRouter, 7 | onError({ error }) { 8 | if (error.code === "INTERNAL_SERVER_ERROR") { 9 | // send to bug reporting 10 | console.error("Something went wrong", error); 11 | } 12 | }, 13 | batching: { 14 | enabled: true, 15 | }, 16 | createContext: () => ({ 17 | queues: [ 18 | { 19 | queue: new Bull("report-queue"), 20 | displayName: "Reports", 21 | type: "bull" as const, 22 | }, 23 | ], 24 | }), 25 | }); 26 | -------------------------------------------------------------------------------- /examples/with-next/pages/queuedash/[[...slug]].tsx: -------------------------------------------------------------------------------- 1 | import { QueueDashApp } from "@queuedash/ui"; 2 | import { NextPage } from "next"; 3 | 4 | function getBaseUrl() { 5 | if (process.env.VERCEL_URL) { 6 | return `https://${process.env.VERCEL_URL}/api/queuedash`; 7 | } 8 | 9 | return `http://localhost:${process.env.PORT ?? 3000}/api/queuedash`; 10 | } 11 | 12 | const Page: NextPage = () => { 13 | return ; 14 | }; 15 | 16 | export default Page; 17 | -------------------------------------------------------------------------------- /examples/with-next/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "module": "esnext", 6 | "target": "es5", 7 | "allowJs": true, 8 | "skipLibCheck": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noEmit": true, 12 | "incremental": true, 13 | "esModuleInterop": true, 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "jsx": "preserve", 18 | "plugins": [ 19 | { 20 | "name": "next" 21 | } 22 | ], 23 | "composite": false, 24 | "declaration": true, 25 | "declarationMap": true, 26 | "inlineSources": false, 27 | "noUnusedLocals": false, 28 | "noUnusedParameters": false, 29 | "preserveWatchOutput": true, 30 | "baseUrl": "." 31 | }, 32 | "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"], 33 | "exclude": ["node_modules"] 34 | } 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@queuedash/root", 3 | "version": "0.0.0", 4 | "description": "A stunning, sleek dashboard for Bull, BullMQ, and Bee-Queue", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/alexbudure/queuedash.git" 8 | }, 9 | "bugs": { 10 | "url": "https://github.com/alexbudure/queuedash/issues" 11 | }, 12 | "scripts": { 13 | "build": "turbo run build", 14 | "test": "turbo run test", 15 | "lint": "turbo run lint", 16 | "dev": "turbo run dev", 17 | "preinstall": "npx only-allow pnpm", 18 | "release": "pnpm build && changeset publish", 19 | "docker": "docker compose up -d" 20 | }, 21 | "packageManager": "pnpm@9.0.2", 22 | "keywords": [ 23 | "bull", 24 | "bee-queue", 25 | "queue", 26 | "bullmq", 27 | "dashboard" 28 | ], 29 | "author": "Alex Budure", 30 | "license": "MIT", 31 | "devDependencies": { 32 | "@changesets/changelog-github": "^0.5.1", 33 | "@changesets/cli": "^2.28.1", 34 | "turbo": "^2.5.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/api/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | parserOptions: { 5 | tsconfigRootDir: __dirname, 6 | project: "./tsconfig.json", 7 | }, 8 | ignorePatterns: ["**/node_modules", "**/.cache", "dist", ".eslintrc.js"], 9 | plugins: ["@typescript-eslint"], 10 | extends: [ 11 | "eslint:recommended", 12 | "plugin:@typescript-eslint/recommended", 13 | "prettier", 14 | ], 15 | rules: { 16 | "@typescript-eslint/consistent-type-imports": "warn", 17 | "@typescript-eslint/no-unused-vars": "error", 18 | "@typescript-eslint/no-explicit-any": "error", 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /packages/api/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @queuedash/api 2 | 3 | ## 3.6.0 4 | 5 | ### Minor Changes 6 | 7 | - [#50](https://github.com/alexbudure/queuedash/pull/50) [`1fd1326`](https://github.com/alexbudure/queuedash/commit/1fd1326ebc22045c78ecdbbceef2856fcce6cbb6) Thanks [@alexbudure](https://github.com/alexbudure)! - - Job scheduler support 8 | - Per-job logs 9 | - Global pause & resume 10 | 11 | ## 3.5.0 12 | 13 | ### Minor Changes 14 | 15 | - [#44](https://github.com/alexbudure/queuedash/pull/44) [`ba73bcf`](https://github.com/alexbudure/queuedash/commit/ba73bcf1afec112e6916a4c6beb132a8d9c7edd4) Thanks [@huv1k](https://github.com/huv1k)! - Add support for hono 16 | 17 | ## 3.4.0 18 | 19 | ### Minor Changes 20 | 21 | - [#40](https://github.com/alexbudure/queuedash/pull/40) [`0ccd37a`](https://github.com/alexbudure/queuedash/commit/0ccd37afe5a3d8b158109d1ec80a02cead1480bf) Thanks [@fukouda](https://github.com/fukouda)! - Add the ability to pass in hook handlers on Fastify 22 | 23 | ## 3.3.0 24 | 25 | ### Minor Changes 26 | 27 | - [#38](https://github.com/alexbudure/queuedash/pull/38) [`00346f4`](https://github.com/alexbudure/queuedash/commit/00346f4c11fdae742dae1981061f044aee66697b) Thanks [@alexbudure](https://github.com/alexbudure)! - Improve React 19 compatibility 28 | 29 | ## 3.2.0 30 | 31 | ### Minor Changes 32 | 33 | - [`8e5c7ac`](https://github.com/alexbudure/queuedash/commit/8e5c7ac06bb13674e32b4cd9b1b7c65913e122af) Thanks [@alexbudure](https://github.com/alexbudure)! - Fix importing API 34 | 35 | ## 3.1.0 36 | 37 | ### Minor Changes 38 | 39 | - [#33](https://github.com/alexbudure/queuedash/pull/33) [`011ad3b`](https://github.com/alexbudure/queuedash/commit/011ad3bca2b50b4568fa7edc7bf314948e84eeb9) Thanks [@alexbudure](https://github.com/alexbudure)! - Fix bundle strategy for API 40 | 41 | ## 3.0.0 42 | 43 | ### Major Changes 44 | 45 | - [#21](https://github.com/alexbudure/queuedash/pull/21) [`6692303`](https://github.com/alexbudure/queuedash/commit/6692303bde835b9934e2ae962e4727357f0d4afe) Thanks [@alexbudure](https://github.com/alexbudure)! - This version upgrades core dependencies to their latest major versions, including Elysia, BullMQ, and tRPC 46 | 47 | ## 2.1.1 48 | 49 | ### Patch Changes 50 | 51 | - [#26](https://github.com/alexbudure/queuedash/pull/26) [`2e0956c`](https://github.com/alexbudure/queuedash/commit/2e0956c586b9f5f3190f169e363f76230d037686) Thanks [@p3drosola](https://github.com/p3drosola)! - Improve UI support for long job ids 52 | 53 | ## 2.1.0 54 | 55 | ### Minor Changes 56 | 57 | - [#23](https://github.com/alexbudure/queuedash/pull/23) [`7a2e3c0`](https://github.com/alexbudure/queuedash/commit/7a2e3c000da0b34c4c3a4dd2471e2e19738d1e6d) Thanks [@alexbudure](https://github.com/alexbudure)! - Fix bull api differences 58 | 59 | ## 2.0.5 60 | 61 | ### Patch Changes 62 | 63 | - [`bc47dd5`](https://github.com/alexbudure/queuedash/commit/bc47dd5de7a5ed32cd82365dc27073282afc45be) Thanks [@alexbudure](https://github.com/alexbudure)! - Fix express bundling with app 64 | 65 | ## 2.0.4 66 | 67 | ### Patch Changes 68 | 69 | - [`0948ec2`](https://github.com/alexbudure/queuedash/commit/0948ec21985d33b3ffbb0ec220664493382579da) Thanks [@alexbudure](https://github.com/alexbudure)! - Move html into each adapter 70 | 71 | ## 2.0.3 72 | 73 | ### Patch Changes 74 | 75 | - [`dee7163`](https://github.com/alexbudure/queuedash/commit/dee71633d33c8bceee9bde84a0b340f899adeaf8) Thanks [@alexbudure](https://github.com/alexbudure)! - Remove unnecessary express app in adapter 76 | 77 | ## 2.0.2 78 | 79 | ### Patch Changes 80 | 81 | - [`6680a6a`](https://github.com/alexbudure/queuedash/commit/6680a6a5ece43fef248fedacb31f8fae2242d2d3) Thanks [@alexbudure](https://github.com/alexbudure)! - Fix the adapters.. again 82 | 83 | ## 2.0.1 84 | 85 | ### Patch Changes 86 | 87 | - [`8d2eadd`](https://github.com/alexbudure/queuedash/commit/8d2eadd9ad547ff2e893662474a228bf340f0728) Thanks [@alexbudure](https://github.com/alexbudure)! - Fix the middlewares for Fastify and Express 88 | 89 | ## 2.0.0 90 | 91 | ### Major Changes 92 | 93 | - [#5](https://github.com/alexbudure/queuedash/pull/5) [`1f794d1`](https://github.com/alexbudure/queuedash/commit/1f794d1679225718dcc670e9c7eb59564fee1bc6) Thanks [@alexbudure](https://github.com/alexbudure)! - Updated all adapters to use a more natural API and added real-time Redis info on the queue detail page 94 | 95 | *** 96 | 97 | ### Breaking changes 98 | 99 | **Express** 100 | 101 | Before: 102 | 103 | ```typescript 104 | createQueueDashExpressMiddleware({ 105 | app, 106 | baseUrl: "/queuedash", 107 | ctx: { 108 | queues: [ 109 | { 110 | queue: new Bull("report-queue"), 111 | displayName: "Reports", 112 | type: "bull" as const, 113 | }, 114 | ], 115 | }, 116 | }); 117 | ``` 118 | 119 | After: 120 | 121 | ```typescript 122 | app.use( 123 | "/queuedash", 124 | createQueueDashExpressMiddleware({ 125 | ctx: { 126 | queues: [ 127 | { 128 | queue: new Bull("report-queue"), 129 | displayName: "Reports", 130 | type: "bull" as const, 131 | }, 132 | ], 133 | }, 134 | }), 135 | ); 136 | ``` 137 | 138 | **Fastify** 139 | 140 | Before: 141 | 142 | ```typescript 143 | createQueueDashFastifyMiddleware({ 144 | server, 145 | baseUrl: "/queuedash", 146 | ctx: { 147 | queues: [ 148 | { 149 | queue: new Bull("report-queue"), 150 | displayName: "Reports", 151 | type: "bull" as const, 152 | }, 153 | ], 154 | }, 155 | }); 156 | ``` 157 | 158 | After: 159 | 160 | ```typescript 161 | server.register(fastifyQueueDashPlugin, { 162 | baseUrl: "/queuedash", 163 | ctx: { 164 | queues: [ 165 | { 166 | queue: new Bull("report-queue"), 167 | displayName: "Reports", 168 | type: "bull" as const, 169 | }, 170 | ], 171 | }, 172 | }); 173 | ``` 174 | 175 | ## 1.2.1 176 | 177 | ### Patch Changes 178 | 179 | - [#12](https://github.com/alexbudure/queuedash/pull/12) [`d79c8ff`](https://github.com/alexbudure/queuedash/commit/d79c8ffe34ae36c74d0663dd2e29e6c93327bf8c) Thanks [@alexbudure](https://github.com/alexbudure)! - Fix Elysia plugin 180 | 181 | ## 1.2.0 182 | 183 | ### Minor Changes 184 | 185 | - [`9aaec9a`](https://github.com/alexbudure/queuedash/commit/9aaec9a21c091680cb30a67e9322eedd3e16dbe8) Thanks [@alexbudure](https://github.com/alexbudure)! - Support for Elysia 186 | 187 | ## 1.1.0 188 | 189 | ### Minor Changes 190 | 191 | - [#7](https://github.com/alexbudure/queuedash/pull/7) [`885aee3`](https://github.com/alexbudure/queuedash/commit/885aee3cecac687d05f5b18cd1855fcb5522f899) Thanks [@alexbudure](https://github.com/alexbudure)! - Add support for prioritized jobs 192 | 193 | ## 1.0.1 194 | 195 | ### Patch Changes 196 | 197 | - [#3](https://github.com/alexbudure/queuedash/pull/3) [`a385f9f`](https://github.com/alexbudure/queuedash/commit/a385f9f9e76df4cea8e69d7e218b65915acef3bf) Thanks [@alexbudure](https://github.com/alexbudure)! - Tighten adapter types to work with NestJS 198 | 199 | ## 1.0.0 200 | 201 | ### Major Changes 202 | 203 | - [#1](https://github.com/alexbudure/queuedash/pull/1) [`c96b93d`](https://github.com/alexbudure/queuedash/commit/c96b93d9659bbb34248ab377e6659ebfb1fc3dd8) Thanks [@alexbudure](https://github.com/alexbudure)! - QueueDash v1 🎉 204 | 205 | - 😍  Simple, clean, and compact UI 206 | - 🧙  Add jobs to your queue with ease 207 | - 🪄  Retry, remove, and more convenient actions for your jobs 208 | - 📊  Stats for job counts, job durations, and job wait times 209 | - ✨  Top-level overview page of all queues 210 | - 🔋  Integrates with Next.js, Express.js, and Fastify 211 | - ⚡️  Compatible with Bull, BullMQ, and Bee-Queue 212 | -------------------------------------------------------------------------------- /packages/api/README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | QueueDash 4 | 5 |

6 | 7 |

8 | A stunning, sleek dashboard for Bull, BullMQ, and Bee-Queue. 9 |

10 | 11 |

12 | 13 | 14 | 15 | 16 | 17 | 18 |

19 | 20 | ## Features 21 | 22 | - 😍  Simple, clean, and compact UI 23 | - 🧙  Add jobs to your queue with ease 24 | - 🪄  Retry, remove, and more convenient actions for your jobs 25 | - 📊  Stats for job counts, job durations, and job wait times 26 | - ✨  Top-level overview page of all queues 27 | - 🔋  Integrates with Next.js, Express.js, and Fastify 28 | - ⚡️  Compatible with Bull, BullMQ, and Bee-Queue 29 | 30 | ## Getting Started 31 | 32 | ### Express 33 | 34 | `pnpm install @queuedash/api` 35 | 36 | ```typescript 37 | import express from "express"; 38 | import Bull from "bull"; 39 | import { createQueueDashExpressMiddleware } from "@queuedash/api"; 40 | 41 | const app = express(); 42 | 43 | const reportQueue = new Bull("report-queue"); 44 | 45 | app.use( 46 | "/queuedash", 47 | createQueueDashExpressMiddleware({ 48 | ctx: { 49 | queues: [ 50 | { 51 | queue: reportQueue, 52 | displayName: "Reports", 53 | type: "bull" as const, 54 | }, 55 | ], 56 | }, 57 | }) 58 | ); 59 | 60 | app.listen(3000, () => { 61 | console.log("Listening on port 3000"); 62 | console.log("Visit http://localhost:3000/queuedash"); 63 | }); 64 | ``` 65 | 66 | ### Next.js 67 | 68 | `pnpm install @queuedash/api @queuedash/ui` 69 | 70 | ```typescript jsx 71 | // pages/admin/queuedash/[[...slug]].tsx 72 | import { QueueDashApp } from "@queuedash/ui"; 73 | 74 | function getBaseUrl() { 75 | if (process.env.VERCEL_URL) { 76 | return `https://${process.env.VERCEL_URL}/api/queuedash`; 77 | } 78 | 79 | return `http://localhost:${process.env.PORT ?? 3000}/api/queuedash`; 80 | } 81 | 82 | const QueueDashPages = () => { 83 | return ; 84 | }; 85 | 86 | export default QueueDashPages; 87 | 88 | // pages/api/queuedash/[trpc].ts 89 | import * as trpcNext from "@trpc/server/adapters/next"; 90 | import { appRouter } from "@queuedash/api"; 91 | 92 | const reportQueue = new Bull("report-queue"); 93 | 94 | export default trpcNext.createNextApiHandler({ 95 | router: appRouter, 96 | batching: { 97 | enabled: true, 98 | }, 99 | createContext: () => ({ 100 | queues: [ 101 | { 102 | queue: reportQueue, 103 | displayName: "Reports", 104 | type: "bull" as const, 105 | }, 106 | ], 107 | }), 108 | }); 109 | ``` 110 | 111 | See the [./examples](./examples) folder for more. 112 | 113 | --- 114 | 115 | ## API Reference 116 | 117 | ### `createQueueDash<*>Middleware` 118 | 119 | ```typescript 120 | type QueueDashMiddlewareOptions = { 121 | app: express.Application | FastifyInstance; // Express or Fastify app 122 | baseUrl: string; // Base path for the API and UI 123 | ctx: QueueDashContext; // Context for the UI 124 | }; 125 | 126 | type QueueDashContext = { 127 | queues: QueueDashQueue[]; // Array of queues to display 128 | }; 129 | 130 | type QueueDashQueue = { 131 | queue: Bull.Queue | BullMQ.Queue | BeeQueue; // Queue instance 132 | displayName: string; // Display name for the queue 133 | type: "bull" | "bullmq" | "bee"; // Queue type 134 | }; 135 | ``` 136 | 137 | ### `` 138 | 139 | ```typescript jsx 140 | type QueueDashAppProps = { 141 | apiUrl: string; // URL to the API endpoint 142 | basename: string; // Base path for the app 143 | }; 144 | ``` 145 | 146 | ## Roadmap 147 | 148 | - Supports Celery and other queueing systems 149 | - Command+K bar and shortcuts 150 | - Ability to whitelabel the UI 151 | 152 | ## Pro Version 153 | 154 | Right now, QueueDash simply taps into your Redis instance, making it very easy to set up, but also limited in functionality. 155 | 156 | I'm thinking about building a free-to-host version on top of this which will require external services (db, auth, etc.), but it will make the following features possible: 157 | 158 | - Alerts and notifications 159 | - Quick search and filtering 160 | - Queue trends and analytics 161 | - Invite team members 162 | 163 | If you're interested in this version, please let me know! 164 | 165 | ## Acknowledgements 166 | 167 | QueueDash was inspired by some great open source projects. Here's a few of them: 168 | 169 | - [bull-board](https://github.com/vcapretz/bull-board) 170 | - [bull-monitor](https://github.com/s-r-x/bull-monitor) 171 | - [bull-arena](https://github.com/bee-queue/arena) 172 | -------------------------------------------------------------------------------- /packages/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@queuedash/api", 3 | "version": "3.6.0", 4 | "description": "A stunning, sleek dashboard for Bull, BullMQ, and Bee-Queue", 5 | "scripts": { 6 | "build": "tsc && vite build", 7 | "dev": "pnpm run build --watch", 8 | "test": "pnpm run test:bull && pnpm run test:bullmq && pnpm run test:bee", 9 | "test:bull": "QUEUE_TYPE=bull vitest run", 10 | "test:bullmq": "QUEUE_TYPE=bullmq vitest run", 11 | "test:bee": "QUEUE_TYPE=bee vitest run", 12 | "lint": "eslint ./ --fix" 13 | }, 14 | "main": "./dist/main.js", 15 | "module": "./dist/main.mjs", 16 | "types": "./dist/src/main.d.ts", 17 | "files": [ 18 | "dist" 19 | ], 20 | "keywords": [ 21 | "bull", 22 | "bee-queue", 23 | "queue", 24 | "bullmq", 25 | "dashboard" 26 | ], 27 | "dependencies": { 28 | "@trpc/server": "^11.0.2", 29 | "redis": "^4.7.0", 30 | "redis-info": "^3.1.0", 31 | "zod": "^3.24.2" 32 | }, 33 | "devDependencies": { 34 | "@elysiajs/trpc": "^1.1.0", 35 | "@faker-js/faker": "^9.6.0", 36 | "@hono/trpc-server": "^0.3.4", 37 | "@rollup/plugin-typescript": "^12.1.2", 38 | "@types/express": "^5.0.1", 39 | "@types/express-serve-static-core": "^5.0.6", 40 | "@types/node": "^22.14.0", 41 | "@types/redis-info": "^3.0.3", 42 | "@typescript-eslint/eslint-plugin": "^8.29.0", 43 | "@typescript-eslint/parser": "^8.29.0", 44 | "bee-queue": "^1.7.1", 45 | "bull": "^4.16.5", 46 | "bullmq": "^5.47.2", 47 | "elysia": "^1.2.25", 48 | "eslint": "^9.24.0", 49 | "eslint-config-prettier": "^10.1.1", 50 | "express": "^5.1.0", 51 | "fastify": "^5.2.2", 52 | "hono": "^4.7.5", 53 | "prettier": "^3.5.3", 54 | "rollup-plugin-typescript-paths": "^1.5.0", 55 | "typescript": "^5.8.3", 56 | "vite": "^5.4.19", 57 | "vitest": "^3.1.1" 58 | }, 59 | "license": "MIT" 60 | } 61 | -------------------------------------------------------------------------------- /packages/api/src/adapters/elysia.ts: -------------------------------------------------------------------------------- 1 | import { Elysia } from "elysia"; 2 | import { trpc } from "@elysiajs/trpc"; 3 | import type { Context } from "../routers/_app"; 4 | import { appRouter } from "../routers/_app"; 5 | import { createQueuedashHtml } from "./utils"; 6 | 7 | export function queuedash({ 8 | baseUrl, 9 | ctx, 10 | }: { 11 | ctx: Context; 12 | baseUrl: string; 13 | }): Elysia { 14 | return new Elysia({ 15 | name: "queuedash", 16 | }) 17 | .use( 18 | trpc(appRouter, { 19 | endpoint: `${baseUrl}/trpc`, 20 | createContext: (params) => { 21 | return { ...params, ...ctx }; 22 | }, 23 | }), 24 | ) 25 | .get( 26 | baseUrl, 27 | async () => 28 | new Response(createQueuedashHtml(baseUrl), { 29 | headers: { "Content-Type": "text/html; charset=utf8" }, 30 | }), 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /packages/api/src/adapters/express.ts: -------------------------------------------------------------------------------- 1 | import type { Handler } from "express"; 2 | import type { Context } from "../routers/_app"; 3 | import { appRouter } from "../routers/_app"; 4 | import * as trpcNodeHttp from "@trpc/server/adapters/node-http"; 5 | import { createQueuedashHtml } from "./utils"; 6 | 7 | export function createQueueDashExpressMiddleware({ 8 | ctx, 9 | }: { 10 | ctx: Context; 11 | }): Handler { 12 | return async (req, res, next) => { 13 | if (req.path.startsWith("/trpc")) { 14 | const endpoint = req.path.replace("/trpc", "").slice(1); 15 | await trpcNodeHttp.nodeHTTPRequestHandler({ 16 | router: appRouter, 17 | createContext: () => ctx, 18 | req, 19 | res, 20 | path: endpoint, 21 | }); 22 | } else { 23 | res.type("text/html").send(createQueuedashHtml(req.baseUrl)); 24 | next(); 25 | } 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /packages/api/src/adapters/fastify.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | FastifyInstance, 3 | onRequestHookHandler, 4 | preHandlerHookHandler, 5 | } from "fastify"; 6 | import type { Context } from "../trpc"; 7 | import { appRouter } from "../routers/_app"; 8 | import * as trpcFastify from "@trpc/server/adapters/fastify"; 9 | import { createQueuedashHtml } from "./utils"; 10 | 11 | export type FastifyQueueDashHooksOptions = Partial<{ 12 | onRequest?: onRequestHookHandler; 13 | preHandler?: preHandlerHookHandler; 14 | }>; 15 | 16 | export function fastifyQueueDashPlugin( 17 | fastify: FastifyInstance, 18 | { 19 | baseUrl, 20 | ctx, 21 | uiHooks, 22 | }: { 23 | ctx: Context; 24 | baseUrl: string; 25 | uiHooks?: FastifyQueueDashHooksOptions; 26 | }, 27 | done: () => void 28 | ): void { 29 | fastify.get(`${baseUrl}/*`, { ...uiHooks }, (_, res) => { 30 | res.type("text/html").send(createQueuedashHtml(baseUrl)); 31 | }); 32 | fastify.get(baseUrl, { ...uiHooks }, (_, res) => { 33 | res.type("text/html").send(createQueuedashHtml(baseUrl)); 34 | }); 35 | fastify.register(trpcFastify.fastifyTRPCPlugin, { 36 | prefix: `${baseUrl}/trpc`, 37 | trpcOptions: { router: appRouter, createContext: () => ctx }, 38 | }); 39 | 40 | done(); 41 | } 42 | -------------------------------------------------------------------------------- /packages/api/src/adapters/hono.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { trpcServer } from "@hono/trpc-server"; 3 | import { appRouter } from "../routers/_app"; 4 | import type { Context } from "../routers/_app"; 5 | import { createQueuedashHtml } from "./utils"; 6 | 7 | export const createHonoAdapter = ({ 8 | baseUrl, 9 | ctx, 10 | }: { 11 | baseUrl: string; 12 | ctx: Context; 13 | }) => 14 | new Hono() 15 | .use( 16 | "/trpc/*", 17 | trpcServer({ 18 | endpoint: `${baseUrl}/trpc`, 19 | router: appRouter, 20 | createContext: () => ctx, 21 | }), 22 | ) 23 | .get("*", (c) => { 24 | return c.html(createQueuedashHtml(baseUrl)); 25 | }); 26 | -------------------------------------------------------------------------------- /packages/api/src/adapters/utils.ts: -------------------------------------------------------------------------------- 1 | import { version } from "../../package.json"; 2 | 3 | export const createQueuedashHtml = ( 4 | baseUrl: string 5 | ) => /* HTML */ ` 6 | 7 | 8 | 9 | 10 | QueueDash App 11 | 12 | 13 |
14 | 20 | 24 | 28 | 29 | `; 30 | -------------------------------------------------------------------------------- /packages/api/src/main.ts: -------------------------------------------------------------------------------- 1 | export * from "./routers/_app"; 2 | export * from "./adapters/express"; 3 | export * from "./adapters/fastify"; 4 | export * from "./adapters/elysia"; 5 | export { createHonoAdapter } from "./adapters/hono"; 6 | -------------------------------------------------------------------------------- /packages/api/src/routers/_app.ts: -------------------------------------------------------------------------------- 1 | import { jobRouter } from "./job"; 2 | import { queueRouter } from "./queue"; 3 | export type { Context } from "../trpc"; 4 | import { router } from "../trpc"; 5 | import { schedulerRouter } from "./scheduler"; 6 | 7 | export const appRouter = router({ 8 | job: jobRouter, 9 | queue: queueRouter, 10 | scheduler: schedulerRouter, 11 | }); 12 | 13 | export type AppRouter = typeof appRouter; 14 | -------------------------------------------------------------------------------- /packages/api/src/routers/job.ts: -------------------------------------------------------------------------------- 1 | import { procedure, router } from "../trpc"; 2 | import type Bull from "bull"; 3 | import type BullMQ from "bullmq"; 4 | import { z } from "zod"; 5 | import { TRPCError } from "@trpc/server"; 6 | import { findQueueInCtxOrFail, formatJob } from "../utils/global.utils"; 7 | import type BeeQueue from "bee-queue"; 8 | import { createClient } from "redis"; 9 | const generateJobMutationProcedure = ( 10 | action: ( 11 | job: Bull.Job | BullMQ.Job | BeeQueue.Job>, 12 | ) => Promise | void, 13 | ) => { 14 | return procedure 15 | .input( 16 | z.object({ 17 | queueName: z.string(), 18 | jobId: z.string(), 19 | }), 20 | ) 21 | .mutation(async ({ input: { jobId, queueName }, ctx: { queues } }) => { 22 | const queueInCtx = findQueueInCtxOrFail({ 23 | queues, 24 | queueName, 25 | }); 26 | 27 | const job = await queueInCtx.queue.getJob(jobId); 28 | 29 | if (!job) { 30 | throw new TRPCError({ 31 | code: "BAD_REQUEST", 32 | }); 33 | } 34 | 35 | try { 36 | await action(job); 37 | } catch (e) { 38 | if (e instanceof TRPCError) { 39 | throw e; 40 | } else { 41 | throw new TRPCError({ 42 | code: "INTERNAL_SERVER_ERROR", 43 | message: e instanceof Error ? e.message : undefined, 44 | }); 45 | } 46 | } 47 | 48 | return formatJob({ job, queueInCtx }); 49 | }); 50 | }; 51 | 52 | export const jobRouter = router({ 53 | retry: generateJobMutationProcedure(async (job) => { 54 | if ("isFailed" in job) { 55 | if (await job.isFailed()) { 56 | await job.retry(); 57 | } else { 58 | throw new TRPCError({ 59 | code: "BAD_REQUEST", 60 | }); 61 | } 62 | } else { 63 | throw new TRPCError({ 64 | code: "BAD_REQUEST", 65 | message: "Bee-Queue does not support retrying jobs", 66 | }); 67 | } 68 | }), 69 | discard: generateJobMutationProcedure((job) => { 70 | if ("discard" in job) { 71 | return job.discard(); 72 | } else { 73 | return job.remove(); 74 | } 75 | }), 76 | rerun: procedure 77 | .input( 78 | z.object({ 79 | queueName: z.string(), 80 | jobId: z.string(), 81 | }), 82 | ) 83 | .mutation(async ({ input: { jobId, queueName }, ctx: { queues } }) => { 84 | const queueInCtx = findQueueInCtxOrFail({ 85 | queues, 86 | queueName, 87 | }); 88 | 89 | const job = await queueInCtx.queue.getJob(jobId); 90 | 91 | if (!job) { 92 | throw new TRPCError({ 93 | code: "BAD_REQUEST", 94 | }); 95 | } 96 | 97 | try { 98 | if (queueInCtx.type === "bee") { 99 | await queueInCtx.queue.createJob(job.data).save(); 100 | } else if (queueInCtx.type === "bullmq") { 101 | await queueInCtx.queue.add(job.name, job.data, {}); 102 | } else { 103 | await queueInCtx.queue.add(job.data, {}); 104 | } 105 | } catch (e) { 106 | if (e instanceof TRPCError) { 107 | throw e; 108 | } else { 109 | throw new TRPCError({ 110 | code: "INTERNAL_SERVER_ERROR", 111 | message: e instanceof Error ? e.message : undefined, 112 | }); 113 | } 114 | } 115 | 116 | return formatJob({ job, queueInCtx }); 117 | }), 118 | promote: generateJobMutationProcedure((job) => { 119 | if ("promote" in job) { 120 | return job.promote(); 121 | } else { 122 | throw new TRPCError({ 123 | code: "BAD_REQUEST", 124 | message: "Bee-Queue does not support promoting jobs", 125 | }); 126 | } 127 | }), 128 | remove: generateJobMutationProcedure((job) => job.remove()), 129 | bulkRemove: procedure 130 | .input( 131 | z.object({ 132 | queueName: z.string(), 133 | jobIds: z.array(z.string()), 134 | }), 135 | ) 136 | .mutation(async ({ input: { jobIds, queueName }, ctx: { queues } }) => { 137 | const queueInCtx = findQueueInCtxOrFail({ 138 | queues, 139 | queueName, 140 | }); 141 | 142 | try { 143 | const jobs = await Promise.all( 144 | jobIds.map(async (jobId) => { 145 | const job = await queueInCtx.queue.getJob(jobId); 146 | 147 | if (!job) { 148 | throw new TRPCError({ 149 | code: "BAD_REQUEST", 150 | }); 151 | } 152 | await job.remove(); 153 | 154 | return job; 155 | }), 156 | ); 157 | return jobs.map((job) => formatJob({ job, queueInCtx })); 158 | } catch (e) { 159 | if (e instanceof TRPCError) { 160 | throw e; 161 | } else { 162 | throw new TRPCError({ 163 | code: "INTERNAL_SERVER_ERROR", 164 | message: e instanceof Error ? e.message : undefined, 165 | }); 166 | } 167 | } 168 | }), 169 | logs: procedure 170 | .input( 171 | z.object({ 172 | queueName: z.string(), 173 | jobId: z.string(), 174 | }), 175 | ) 176 | .query(async ({ input: { queueName, jobId }, ctx: { queues } }) => { 177 | const queueInCtx = findQueueInCtxOrFail({ 178 | queues, 179 | queueName, 180 | }); 181 | if (queueInCtx.type !== "bullmq") { 182 | return null; 183 | } 184 | const { logs } = await queueInCtx.queue.getJobLogs(jobId); 185 | 186 | return logs; 187 | }), 188 | list: procedure 189 | .input( 190 | z.object({ 191 | queueName: z.string(), 192 | cursor: z.number().min(0).optional().default(0), 193 | limit: z.number().min(0).max(100), 194 | status: z.enum([ 195 | "completed", 196 | "failed", 197 | "delayed", 198 | "active", 199 | "prioritized", 200 | "waiting", 201 | "paused", 202 | ] as const), 203 | }), 204 | ) 205 | .query( 206 | async ({ 207 | input: { queueName, status, limit, cursor }, 208 | ctx: { queues }, 209 | }) => { 210 | const queueInCtx = findQueueInCtxOrFail({ 211 | queues, 212 | queueName, 213 | }); 214 | 215 | if (queueInCtx.type === "bee") { 216 | try { 217 | const normalizedStatus = 218 | status === "completed" ? "succeeded" : status; 219 | const client = createClient(queueInCtx.queue.settings.redis); 220 | 221 | const [jobs] = await Promise.all([ 222 | queueInCtx.queue.getJobs(normalizedStatus, { 223 | start: cursor, 224 | end: cursor + limit - 1, 225 | }), 226 | client.connect(), 227 | ]); 228 | 229 | const totalCount = await client.sCard( 230 | `${queueInCtx.queue.settings.keyPrefix}${normalizedStatus}`, 231 | ); 232 | 233 | await client.disconnect(); 234 | 235 | const hasNextPage = jobs.length > 0 && cursor + limit < totalCount; 236 | 237 | return { 238 | totalCount, 239 | numOfPages: Math.ceil(totalCount / limit), 240 | nextCursor: hasNextPage ? cursor + limit : undefined, 241 | jobs: jobs.map((job) => formatJob({ job, queueInCtx })), 242 | }; 243 | } catch (e) { 244 | throw new TRPCError({ 245 | code: "INTERNAL_SERVER_ERROR", 246 | message: e instanceof Error ? e.message : undefined, 247 | }); 248 | } 249 | } 250 | 251 | try { 252 | const isBullMq = queueInCtx.type === "bullmq"; 253 | 254 | const [jobs, totalCountWithWrongType] = await Promise.all([ 255 | status === "prioritized" && isBullMq 256 | ? queueInCtx.queue.getJobs([status], cursor, cursor + limit - 1) 257 | : status === "prioritized" 258 | ? [] 259 | : queueInCtx.queue.getJobs( 260 | [status], 261 | cursor, 262 | cursor + limit - 1, 263 | ), 264 | status === "prioritized" && isBullMq 265 | ? queueInCtx.queue.getJobCountByTypes(status) 266 | : status === "prioritized" 267 | ? 0 268 | : queueInCtx.queue.getJobCountByTypes(status), 269 | ]); 270 | const totalCount = totalCountWithWrongType as unknown as number; 271 | 272 | const hasNextPage = jobs.length > 0 && cursor + limit < totalCount; 273 | 274 | return { 275 | totalCount, 276 | numOfPages: Math.ceil(totalCount / limit), 277 | nextCursor: hasNextPage ? cursor + limit : undefined, 278 | jobs: jobs.map((job) => formatJob({ job, queueInCtx })), 279 | }; 280 | } catch (e) { 281 | throw new TRPCError({ 282 | code: "INTERNAL_SERVER_ERROR", 283 | message: e instanceof Error ? e.message : undefined, 284 | }); 285 | } 286 | }, 287 | ), 288 | }); 289 | -------------------------------------------------------------------------------- /packages/api/src/routers/queue.ts: -------------------------------------------------------------------------------- 1 | import { procedure, router } from "../trpc"; 2 | import { z } from "zod"; 3 | import { findQueueInCtxOrFail } from "../utils/global.utils"; 4 | import type Bull from "bull"; 5 | import type BullMQ from "bullmq"; 6 | import { TRPCError } from "@trpc/server"; 7 | import type BeeQueue from "bee-queue"; 8 | import type { RedisInfo } from "redis-info"; 9 | import { parse } from "redis-info"; 10 | 11 | const generateQueueMutationProcedure = ( 12 | action: (queue: Bull.Queue | BullMQ.Queue | BeeQueue) => void, 13 | ) => { 14 | return procedure 15 | .input( 16 | z.object({ 17 | queueName: z.string(), 18 | }), 19 | ) 20 | .mutation(async ({ input: { queueName }, ctx: { queues } }) => { 21 | const queueInCtx = findQueueInCtxOrFail({ 22 | queues, 23 | queueName, 24 | }); 25 | 26 | try { 27 | await action(queueInCtx.queue); 28 | } catch (e) { 29 | if (e instanceof TRPCError) { 30 | throw e; 31 | } else { 32 | throw new TRPCError({ 33 | code: "INTERNAL_SERVER_ERROR", 34 | message: e instanceof Error ? e.message : undefined, 35 | }); 36 | } 37 | } 38 | 39 | return { 40 | name: queueName, 41 | }; 42 | }); 43 | }; 44 | 45 | export const queueRouter = router({ 46 | clean: procedure 47 | .input( 48 | z.object({ 49 | queueName: z.string(), 50 | status: z.enum([ 51 | "completed", 52 | "failed", 53 | "delayed", 54 | "active", 55 | "waiting", 56 | "prioritized", 57 | "paused", 58 | ] as const), 59 | }), 60 | ) 61 | .mutation(async ({ input: { queueName, status }, ctx: { queues } }) => { 62 | const queueInCtx = findQueueInCtxOrFail({ 63 | queues, 64 | queueName, 65 | }); 66 | 67 | if (queueInCtx.type === "bee") { 68 | throw new TRPCError({ 69 | code: "BAD_REQUEST", 70 | message: "Cannot clean Bee-Queue queues", 71 | }); 72 | } 73 | 74 | try { 75 | if (queueInCtx.type === "bullmq") { 76 | await queueInCtx.queue.clean( 77 | 0, 78 | 0, 79 | status === "waiting" ? "wait" : status, 80 | ); 81 | } else if (status !== "prioritized") { 82 | await queueInCtx.queue.clean( 83 | 0, 84 | status === "waiting" ? "wait" : status, 85 | ); 86 | } 87 | } catch (e) { 88 | throw new TRPCError({ 89 | code: "INTERNAL_SERVER_ERROR", 90 | message: e instanceof Error ? e.message : undefined, 91 | }); 92 | } 93 | 94 | return { 95 | name: queueName, 96 | }; 97 | }), 98 | empty: generateQueueMutationProcedure((queue) => { 99 | if ("empty" in queue) { 100 | return queue.empty(); 101 | } else if ("drain" in queue) { 102 | return queue.drain(); 103 | } else { 104 | throw new TRPCError({ 105 | code: "BAD_REQUEST", 106 | message: "Cannot empty Bee-Queue queues", 107 | }); 108 | } 109 | }), 110 | pause: generateQueueMutationProcedure((queue) => { 111 | if ("pause" in queue) { 112 | return queue.pause(); 113 | } else { 114 | throw new TRPCError({ 115 | code: "BAD_REQUEST", 116 | message: "Cannot pause Bee-Queue queues", 117 | }); 118 | } 119 | }), 120 | pauseAll: procedure.mutation(async ({ ctx: { queues } }) => { 121 | await Promise.all([ 122 | queues.map((queue) => { 123 | if ("pause" in queue.queue) { 124 | return queue.queue.pause(); 125 | } else { 126 | return null; 127 | } 128 | }), 129 | ]); 130 | return "ok"; 131 | }), 132 | resume: generateQueueMutationProcedure((queue) => { 133 | if ("resume" in queue) { 134 | return queue.resume(); 135 | } else { 136 | throw new TRPCError({ 137 | code: "BAD_REQUEST", 138 | message: "Cannot resume Bee-Queue queues", 139 | }); 140 | } 141 | }), 142 | resumeAll: procedure.mutation(async ({ ctx: { queues } }) => { 143 | await Promise.all([ 144 | queues.map((queue) => { 145 | if ("resume" in queue.queue) { 146 | return queue.queue.resume(); 147 | } else { 148 | return null; 149 | } 150 | }), 151 | ]); 152 | return "ok"; 153 | }), 154 | addJob: procedure 155 | .input( 156 | z.object({ 157 | queueName: z.string(), 158 | data: z.object({}).passthrough(), 159 | }), 160 | ) 161 | .mutation(async ({ input: { queueName, data }, ctx: { queues } }) => { 162 | const queueInCtx = findQueueInCtxOrFail({ 163 | queues, 164 | queueName, 165 | }); 166 | 167 | try { 168 | if ("add" in queueInCtx.queue) { 169 | await queueInCtx.queue.add(data, {}); 170 | } else { 171 | await queueInCtx.queue.createJob(data).save(); 172 | } 173 | } catch (e) { 174 | throw new TRPCError({ 175 | code: "INTERNAL_SERVER_ERROR", 176 | message: e instanceof Error ? e.message : undefined, 177 | }); 178 | } 179 | 180 | return { 181 | name: queueName, 182 | }; 183 | }), 184 | 185 | byName: procedure 186 | .input( 187 | z.object({ 188 | queueName: z.string(), 189 | }), 190 | ) 191 | .query(async ({ input: { queueName }, ctx: { queues } }) => { 192 | const queueInCtx = findQueueInCtxOrFail({ 193 | queues, 194 | queueName, 195 | }); 196 | 197 | const isBee = queueInCtx.type === "bee"; 198 | 199 | try { 200 | const [counts, isPaused] = await Promise.all([ 201 | isBee 202 | ? queueInCtx.queue.checkHealth() 203 | : queueInCtx.queue.getJobCounts(), 204 | isBee ? queueInCtx.queue.paused : queueInCtx.queue.isPaused(), 205 | ]); 206 | 207 | const info: RedisInfo & { 208 | maxclients: string; 209 | } = isBee 210 | ? // @ts-expect-error Bee-Queue does not have a client property 211 | queueInCtx.queue.client.server_info 212 | : queueInCtx.type === "bullmq" 213 | ? parse(await (await queueInCtx.queue.client).info()) 214 | : parse(await queueInCtx.queue.client.info()); 215 | 216 | return { 217 | displayName: queueInCtx.displayName, 218 | name: queueInCtx.queue.name, 219 | paused: isPaused, 220 | type: queueInCtx.type, 221 | counts: { 222 | active: counts.active, 223 | completed: 224 | "completed" in counts ? counts.completed : counts.succeeded, 225 | delayed: counts.delayed, 226 | failed: counts.failed, 227 | ...("prioritized" in counts 228 | ? { prioritized: counts.prioritized } 229 | : {}), 230 | waiting: counts.waiting, 231 | paused: "paused" in counts ? counts.paused : 0, 232 | }, 233 | client: { 234 | usedMemoryPercentage: 235 | Number(info.used_memory) / Number(info.total_system_memory), 236 | usedMemoryHuman: info.used_memory_human, 237 | totalMemoryHuman: info.total_system_memory_human, 238 | uptimeInSeconds: Number(info.uptime_in_seconds), 239 | connectedClients: Number(info.connected_clients), 240 | blockedClients: Number(info.blocked_clients), 241 | maxClients: info.maxclients ? Number(info.maxclients) : 0, 242 | version: info.redis_version, 243 | }, 244 | }; 245 | } catch (e) { 246 | throw new TRPCError({ 247 | code: "INTERNAL_SERVER_ERROR", 248 | message: e instanceof Error ? e.message : undefined, 249 | }); 250 | } 251 | }), 252 | list: procedure.query(async ({ ctx: { queues } }) => { 253 | return queues.map((queue) => { 254 | return { 255 | displayName: queue.displayName, 256 | name: queue.queue.name, 257 | }; 258 | }); 259 | }), 260 | }); 261 | -------------------------------------------------------------------------------- /packages/api/src/routers/scheduler.ts: -------------------------------------------------------------------------------- 1 | import { procedure, router } from "../trpc"; 2 | import { z } from "zod"; 3 | import { TRPCError } from "@trpc/server"; 4 | import type { QueueDashScheduler } from "../utils/global.utils"; 5 | import { findQueueInCtxOrFail } from "../utils/global.utils"; 6 | 7 | export const schedulerRouter = router({ 8 | list: procedure 9 | .input( 10 | z.object({ 11 | queueName: z.string(), 12 | }), 13 | ) 14 | .query( 15 | async ({ 16 | input: { queueName }, 17 | ctx: { queues }, 18 | }): Promise => { 19 | const queueInCtx = findQueueInCtxOrFail({ queues, queueName }); 20 | 21 | if (queueInCtx.type !== "bullmq") { 22 | throw new TRPCError({ 23 | code: "BAD_REQUEST", 24 | message: "Scheduled jobs are only supported for BullMQ queues", 25 | }); 26 | } 27 | 28 | return queueInCtx.queue.getJobSchedulers(); 29 | }, 30 | ), 31 | 32 | add: procedure 33 | .input( 34 | z.object({ 35 | queueName: z.string(), 36 | jobName: z.string(), 37 | data: z.record(z.any()), 38 | pattern: z.string().optional(), 39 | every: z.number().optional(), 40 | tz: z.string().optional(), 41 | }), 42 | ) 43 | .mutation(async ({ input, ctx: { queues } }) => { 44 | const { queueName, jobName, data, pattern, every, tz } = input; 45 | 46 | if (!pattern && !every) { 47 | throw new TRPCError({ 48 | code: "BAD_REQUEST", 49 | message: "You must provide either `cron` or `every`", 50 | }); 51 | } 52 | 53 | const queueInCtx = findQueueInCtxOrFail({ queues, queueName }); 54 | 55 | if (queueInCtx.type !== "bullmq") { 56 | throw new TRPCError({ 57 | code: "BAD_REQUEST", 58 | message: "Scheduled jobs are only supported for BullMQ queues", 59 | }); 60 | } 61 | 62 | await queueInCtx.queue.add(jobName, data, { 63 | repeat: { pattern, every, tz }, 64 | }); 65 | 66 | return { success: true }; 67 | }), 68 | 69 | remove: procedure 70 | .input( 71 | z.object({ 72 | queueName: z.string(), 73 | jobSchedulerId: z.string(), 74 | }), 75 | ) 76 | .mutation( 77 | async ({ input: { queueName, jobSchedulerId }, ctx: { queues } }) => { 78 | const queueInCtx = findQueueInCtxOrFail({ queues, queueName }); 79 | 80 | if (queueInCtx.type !== "bullmq") { 81 | throw new TRPCError({ 82 | code: "BAD_REQUEST", 83 | message: "Scheduled jobs are only supported for BullMQ queues", 84 | }); 85 | } 86 | 87 | try { 88 | await queueInCtx.queue.removeJobScheduler(jobSchedulerId); 89 | return { success: true }; 90 | } catch (e) { 91 | throw new TRPCError({ 92 | code: "INTERNAL_SERVER_ERROR", 93 | message: e instanceof Error ? e.message : undefined, 94 | }); 95 | } 96 | }, 97 | ), 98 | 99 | bulkRemove: procedure 100 | .input( 101 | z.object({ 102 | queueName: z.string(), 103 | jobSchedulerIds: z.array(z.string()), 104 | }), 105 | ) 106 | .mutation( 107 | async ({ 108 | input: { jobSchedulerIds, queueName }, 109 | ctx: { queues }, 110 | }): Promise => { 111 | const queueInCtx = findQueueInCtxOrFail({ 112 | queues, 113 | queueName, 114 | }); 115 | 116 | if (queueInCtx.type !== "bullmq") { 117 | throw new TRPCError({ 118 | code: "BAD_REQUEST", 119 | message: "Scheduled jobs are only supported for BullMQ queues", 120 | }); 121 | } 122 | 123 | try { 124 | return Promise.all( 125 | jobSchedulerIds.map(async (jobSchedulerId) => { 126 | const scheduler = 127 | await queueInCtx.queue.getJobScheduler(jobSchedulerId); 128 | 129 | if (!scheduler) { 130 | throw new TRPCError({ 131 | code: "BAD_REQUEST", 132 | }); 133 | } 134 | await queueInCtx.queue.removeJobScheduler(jobSchedulerId); 135 | 136 | return scheduler; 137 | }), 138 | ); 139 | } catch (e) { 140 | if (e instanceof TRPCError) { 141 | throw e; 142 | } else { 143 | throw new TRPCError({ 144 | code: "INTERNAL_SERVER_ERROR", 145 | message: e instanceof Error ? e.message : undefined, 146 | }); 147 | } 148 | } 149 | }, 150 | ), 151 | }); 152 | -------------------------------------------------------------------------------- /packages/api/src/tests/jobs.test.ts: -------------------------------------------------------------------------------- 1 | import { appRouter } from "../routers/_app"; 2 | import { expect, test } from "vitest"; 3 | import { 4 | initRedisInstance, 5 | NUM_OF_COMPLETED_JOBS, 6 | NUM_OF_FAILED_JOBS, 7 | sleep, 8 | type, 9 | } from "./test.utils"; 10 | import { TRPCError } from "@trpc/server"; 11 | 12 | test("list completed jobs", async () => { 13 | const { ctx, firstQueue } = await initRedisInstance(); 14 | const caller = appRouter.createCaller(ctx); 15 | 16 | const list = await caller.job.list({ 17 | limit: 10, 18 | cursor: 0, 19 | status: "completed", 20 | queueName: firstQueue.queue.name, 21 | }); 22 | 23 | expect(list.totalCount).toBe(NUM_OF_COMPLETED_JOBS); 24 | }); 25 | 26 | test("retry job", async () => { 27 | const { ctx, firstQueue } = await initRedisInstance(); 28 | const caller = appRouter.createCaller(ctx); 29 | 30 | try { 31 | const { jobs } = await caller.job.list({ 32 | limit: 10, 33 | cursor: 0, 34 | status: "failed", 35 | queueName: firstQueue.queue.name, 36 | }); 37 | 38 | const job = jobs[0]; 39 | 40 | const newJob = await caller.job.retry({ 41 | queueName: firstQueue.queue.name, 42 | jobId: job.id, 43 | }); 44 | expect(newJob).toMatchObject({ 45 | id: job.id, 46 | ...(firstQueue.type === "bull" && { retriedAt: expect.any(Date) }), 47 | }); 48 | } catch (e) { 49 | expect(e).toBeInstanceOf(TRPCError); 50 | expect(type).toBe("bee"); 51 | if (e instanceof TRPCError) { 52 | expect(e.code).toBe("BAD_REQUEST"); 53 | } 54 | } 55 | }); 56 | 57 | test("remove job", async () => { 58 | const { ctx, firstQueue } = await initRedisInstance(); 59 | const caller = appRouter.createCaller(ctx); 60 | 61 | const { jobs } = await caller.job.list({ 62 | limit: 10, 63 | cursor: 0, 64 | status: "failed", 65 | queueName: firstQueue.queue.name, 66 | }); 67 | 68 | const job = jobs[0]; 69 | 70 | await caller.job.remove({ 71 | queueName: firstQueue.queue.name, 72 | jobId: job.id, 73 | }); 74 | 75 | const list = await caller.job.list({ 76 | limit: 10, 77 | cursor: 0, 78 | status: "failed", 79 | queueName: firstQueue.queue.name, 80 | }); 81 | 82 | expect(list.totalCount).toBe(NUM_OF_FAILED_JOBS - 1); 83 | }); 84 | 85 | test("bulk remove job", async () => { 86 | const { ctx, firstQueue } = await initRedisInstance(); 87 | const caller = appRouter.createCaller(ctx); 88 | 89 | const { jobs } = await caller.job.list({ 90 | limit: 10, 91 | cursor: 0, 92 | status: "failed", 93 | queueName: firstQueue.queue.name, 94 | }); 95 | 96 | await caller.job.bulkRemove({ 97 | queueName: firstQueue.queue.name, 98 | jobIds: jobs.map((job) => job.id), 99 | }); 100 | 101 | const list = await caller.job.list({ 102 | limit: 10, 103 | cursor: 0, 104 | status: "failed", 105 | queueName: firstQueue.queue.name, 106 | }); 107 | 108 | expect(list.totalCount).toBe(0); 109 | }); 110 | 111 | test("rerun job", async () => { 112 | const { ctx, firstQueue } = await initRedisInstance(); 113 | const caller = appRouter.createCaller(ctx); 114 | 115 | const { jobs } = await caller.job.list({ 116 | limit: 10, 117 | cursor: 0, 118 | status: "completed", 119 | queueName: firstQueue.queue.name, 120 | }); 121 | 122 | const job = jobs[0]; 123 | 124 | await caller.job.rerun({ 125 | queueName: firstQueue.queue.name, 126 | jobId: job.id, 127 | }); 128 | 129 | await sleep(200); 130 | 131 | const list = await caller.job.list({ 132 | limit: 10, 133 | cursor: 0, 134 | status: "completed", 135 | queueName: firstQueue.queue.name, 136 | }); 137 | 138 | expect(list.totalCount).toBe(NUM_OF_COMPLETED_JOBS + 1); 139 | }); 140 | 141 | // TODO: 142 | // promote job 143 | // discard job 144 | -------------------------------------------------------------------------------- /packages/api/src/tests/queue.test.ts: -------------------------------------------------------------------------------- 1 | import { appRouter } from "../routers/_app"; 2 | import { expect, test } from "vitest"; 3 | import { initRedisInstance, type } from "./test.utils"; 4 | import { TRPCError } from "@trpc/server"; 5 | 6 | test("list queues", async () => { 7 | const { ctx } = await initRedisInstance(); 8 | const caller = appRouter.createCaller(ctx); 9 | const queues = await caller.queue.list(); 10 | 11 | expect(queues).toMatchObject( 12 | ctx.queues.map((q) => { 13 | return { 14 | name: q.queue.name, 15 | displayName: q.displayName, 16 | }; 17 | }) 18 | ); 19 | }); 20 | 21 | test("get queue by name", async () => { 22 | const { ctx, firstQueue } = await initRedisInstance(); 23 | const caller = appRouter.createCaller(ctx); 24 | const queue = await caller.queue.byName({ 25 | queueName: firstQueue.queue.name, 26 | }); 27 | 28 | expect(queue).toMatchObject({ 29 | displayName: firstQueue.displayName, 30 | name: firstQueue.queue.name, 31 | }); 32 | }); 33 | 34 | test("pause queue", async () => { 35 | const { ctx, firstQueue } = await initRedisInstance(); 36 | const caller = appRouter.createCaller(ctx); 37 | 38 | try { 39 | await caller.queue.pause({ 40 | queueName: firstQueue.queue.name, 41 | }); 42 | 43 | const queue = await caller.queue.byName({ 44 | queueName: firstQueue.queue.name, 45 | }); 46 | 47 | expect(queue).toMatchObject({ 48 | displayName: firstQueue.displayName, 49 | name: firstQueue.queue.name, 50 | paused: true, 51 | }); 52 | } catch (e) { 53 | expect(e).toBeInstanceOf(TRPCError); 54 | expect(type).toBe("bee"); 55 | if (e instanceof TRPCError) { 56 | expect(e.code).toBe("BAD_REQUEST"); 57 | } 58 | } 59 | }); 60 | 61 | test("resume queue", async () => { 62 | const { ctx, firstQueue } = await initRedisInstance(); 63 | const caller = appRouter.createCaller(ctx); 64 | 65 | try { 66 | await caller.queue.pause({ 67 | queueName: firstQueue.queue.name, 68 | }); 69 | 70 | await caller.queue.resume({ 71 | queueName: firstQueue.queue.name, 72 | }); 73 | 74 | const queue = await caller.queue.byName({ 75 | queueName: firstQueue.queue.name, 76 | }); 77 | 78 | expect(queue).toMatchObject({ 79 | displayName: firstQueue.displayName, 80 | name: firstQueue.queue.name, 81 | paused: false, 82 | }); 83 | } catch (e) { 84 | expect(e).toBeInstanceOf(TRPCError); 85 | expect(type).toBe("bee"); 86 | if (e instanceof TRPCError) { 87 | expect(e.code).toBe("BAD_REQUEST"); 88 | } 89 | } 90 | }); 91 | 92 | test("clean completed jobs", async () => { 93 | const { ctx, firstQueue } = await initRedisInstance(); 94 | const caller = appRouter.createCaller(ctx); 95 | 96 | try { 97 | await caller.queue.clean({ 98 | queueName: firstQueue.queue.name, 99 | status: "completed", 100 | }); 101 | 102 | const queue = await caller.queue.byName({ 103 | queueName: firstQueue.queue.name, 104 | }); 105 | 106 | expect(queue).toMatchObject({ 107 | displayName: firstQueue.displayName, 108 | name: firstQueue.queue.name, 109 | counts: { 110 | completed: 0, 111 | }, 112 | }); 113 | } catch (e) { 114 | expect(e).toBeInstanceOf(TRPCError); 115 | expect(type).toBe("bee"); 116 | if (e instanceof TRPCError) { 117 | expect(e.code).toBe("BAD_REQUEST"); 118 | } 119 | } 120 | }); 121 | 122 | test("clean failed jobs", async () => { 123 | const { ctx, firstQueue } = await initRedisInstance(); 124 | const caller = appRouter.createCaller(ctx); 125 | 126 | try { 127 | await caller.queue.clean({ 128 | queueName: firstQueue.queue.name, 129 | status: "failed", 130 | }); 131 | 132 | const queue = await caller.queue.byName({ 133 | queueName: firstQueue.queue.name, 134 | }); 135 | 136 | expect(queue).toMatchObject({ 137 | displayName: firstQueue.displayName, 138 | name: firstQueue.queue.name, 139 | counts: { 140 | failed: 0, 141 | }, 142 | }); 143 | } catch (e) { 144 | expect(e).toBeInstanceOf(TRPCError); 145 | expect(type).toBe("bee"); 146 | if (e instanceof TRPCError) { 147 | expect(e.code).toBe("BAD_REQUEST"); 148 | } 149 | } 150 | }); 151 | 152 | // TODO: addJob 153 | // TODO: empty 154 | -------------------------------------------------------------------------------- /packages/api/src/tests/schedulers.test.ts: -------------------------------------------------------------------------------- 1 | import { appRouter } from "../routers/_app"; 2 | import { expect, test } from "vitest"; 3 | import { initRedisInstance, NUM_OF_SCHEDULERS } from "./test.utils"; 4 | import { TRPCError } from "@trpc/server"; 5 | 6 | test("list schedulers", async () => { 7 | const { ctx, firstQueue } = await initRedisInstance(); 8 | try { 9 | const caller = appRouter.createCaller(ctx); 10 | const list = await caller.scheduler.list({ 11 | queueName: firstQueue.queue.name, 12 | }); 13 | 14 | expect(list.length).toBe(NUM_OF_SCHEDULERS); 15 | } catch (e) { 16 | if (firstQueue.type !== "bullmq") { 17 | expect(e).toBeInstanceOf(TRPCError); 18 | if (e instanceof TRPCError) { 19 | expect(e.code).toBe("BAD_REQUEST"); 20 | } 21 | } else { 22 | throw e; 23 | } 24 | } 25 | }); 26 | 27 | test("remove scheduler", async () => { 28 | const { ctx, firstQueue } = await initRedisInstance(); 29 | try { 30 | const caller = appRouter.createCaller(ctx); 31 | 32 | const schedulers = await caller.scheduler.list({ 33 | queueName: firstQueue.queue.name, 34 | }); 35 | const scheduler = schedulers[0]; 36 | 37 | await caller.scheduler.remove({ 38 | queueName: firstQueue.queue.name, 39 | jobSchedulerId: scheduler.key, 40 | }); 41 | 42 | const list = await caller.scheduler.list({ 43 | queueName: firstQueue.queue.name, 44 | }); 45 | 46 | expect(list.length).toBe(NUM_OF_SCHEDULERS - 1); 47 | } catch (e) { 48 | if (firstQueue.type !== "bullmq") { 49 | expect(e).toBeInstanceOf(TRPCError); 50 | if (e instanceof TRPCError) { 51 | expect(e.code).toBe("BAD_REQUEST"); 52 | } 53 | } else { 54 | throw e; 55 | } 56 | } 57 | }); 58 | 59 | test("bulk remove schedulers", async () => { 60 | const { ctx, firstQueue } = await initRedisInstance(); 61 | try { 62 | const caller = appRouter.createCaller(ctx); 63 | 64 | const schedulers = await caller.scheduler.list({ 65 | queueName: firstQueue.queue.name, 66 | }); 67 | 68 | await caller.scheduler.bulkRemove({ 69 | queueName: firstQueue.queue.name, 70 | jobSchedulerIds: schedulers.map((scheduler) => scheduler.key), 71 | }); 72 | 73 | const list = await caller.scheduler.list({ 74 | queueName: firstQueue.queue.name, 75 | }); 76 | 77 | expect(list.length).toBe(0); 78 | } catch (e) { 79 | if (firstQueue.type !== "bullmq") { 80 | expect(e).toBeInstanceOf(TRPCError); 81 | if (e instanceof TRPCError) { 82 | expect(e.code).toBe("BAD_REQUEST"); 83 | } 84 | } else { 85 | throw e; 86 | } 87 | } 88 | }); 89 | -------------------------------------------------------------------------------- /packages/api/src/tests/test.utils.ts: -------------------------------------------------------------------------------- 1 | import Bull from "bull"; 2 | import { faker } from "@faker-js/faker"; 3 | import type { Context } from "../trpc"; 4 | import BullMQ from "bullmq"; 5 | import BeeQueue from "bee-queue"; 6 | 7 | export const NUM_OF_JOBS = 20; 8 | export const NUM_OF_SCHEDULERS = 3; 9 | export const NUM_OF_COMPLETED_JOBS = NUM_OF_JOBS / 2; 10 | export const NUM_OF_FAILED_JOBS = NUM_OF_JOBS / 2; 11 | const QUEUE_NAME_PREFIX = "flight-bookings"; 12 | const QUEUE_DISPLAY_NAME = "Flight bookings"; 13 | 14 | const getFakeQueueName = () => 15 | `${QUEUE_NAME_PREFIX}-${faker.string.alpha({ length: 5 })}`; 16 | 17 | export const sleep = (t: number) => 18 | new Promise((resolve) => setTimeout(resolve, t)); 19 | 20 | type QueueType = "bull" | "bullmq" | "bee"; 21 | 22 | export const type: QueueType = 23 | (process.env.QUEUE_TYPE as unknown as QueueType) || "bullmq"; 24 | 25 | export const initRedisInstance = async () => { 26 | switch (type) { 27 | case "bull": { 28 | const flightBookingsQueue = { 29 | queue: new Bull(getFakeQueueName()), 30 | displayName: QUEUE_DISPLAY_NAME, 31 | type: "bull" as const, 32 | }; 33 | 34 | flightBookingsQueue.queue.process(async (job) => { 35 | if (job.data.index > NUM_OF_COMPLETED_JOBS) { 36 | throw new Error("Generic error"); 37 | } 38 | 39 | return Promise.resolve(); 40 | }); 41 | 42 | await flightBookingsQueue.queue.addBulk( 43 | [...new Array(NUM_OF_JOBS)].map((_, index) => { 44 | return { 45 | data: { 46 | index: index + 1, 47 | }, 48 | }; 49 | }), 50 | ); 51 | 52 | await sleep(200); 53 | 54 | return { 55 | ctx: { 56 | queues: [flightBookingsQueue], 57 | } satisfies Context, 58 | firstQueue: flightBookingsQueue, 59 | }; 60 | } 61 | case "bullmq": { 62 | const flightBookingsQueue = { 63 | queue: new BullMQ.Queue(getFakeQueueName()), 64 | displayName: QUEUE_DISPLAY_NAME, 65 | type: "bullmq" as const, 66 | }; 67 | 68 | new BullMQ.Worker( 69 | flightBookingsQueue.queue.name, 70 | async (job) => { 71 | if (job.data.index > NUM_OF_COMPLETED_JOBS) { 72 | throw new Error("Generic error"); 73 | } 74 | 75 | return Promise.resolve(); 76 | }, 77 | { 78 | connection: {}, 79 | }, 80 | ); 81 | 82 | await flightBookingsQueue.queue.addBulk( 83 | [...new Array(NUM_OF_JOBS)].map((_, index) => { 84 | return { 85 | name: "test", 86 | data: { 87 | index: index + 1, 88 | }, 89 | }; 90 | }), 91 | ); 92 | 93 | const schedulers = [...new Array(NUM_OF_SCHEDULERS)].map(() => { 94 | return { 95 | name: faker.person.fullName(), 96 | template: { 97 | name: faker.person.fullName(), 98 | data: { 99 | name: faker.person.fullName(), 100 | }, 101 | }, 102 | opts: { 103 | pattern: "0 0 * * *", 104 | tz: "America/Los_Angeles", 105 | }, 106 | }; 107 | }); 108 | 109 | for (const scheduler of schedulers) { 110 | await flightBookingsQueue.queue.upsertJobScheduler( 111 | scheduler.name, 112 | scheduler.opts, 113 | scheduler.template, 114 | ); 115 | } 116 | 117 | await sleep(200); 118 | 119 | return { 120 | ctx: { 121 | queues: [flightBookingsQueue], 122 | } satisfies Context, 123 | firstQueue: flightBookingsQueue, 124 | }; 125 | } 126 | case "bee": { 127 | const flightBookingsQueue = { 128 | queue: new BeeQueue(getFakeQueueName()), 129 | displayName: QUEUE_DISPLAY_NAME, 130 | type: "bee" as const, 131 | }; 132 | 133 | flightBookingsQueue.queue.process(async (job) => { 134 | if (job.data.index > NUM_OF_COMPLETED_JOBS) { 135 | throw new Error("Generic error"); 136 | } 137 | 138 | return Promise.resolve(); 139 | }); 140 | 141 | await flightBookingsQueue.queue.saveAll( 142 | [...new Array(NUM_OF_JOBS)].map((_, index) => { 143 | return flightBookingsQueue.queue.createJob({ 144 | index: index + 1, 145 | }); 146 | }), 147 | ); 148 | 149 | await sleep(200); 150 | 151 | return { 152 | ctx: { 153 | queues: [flightBookingsQueue], 154 | } satisfies Context, 155 | firstQueue: flightBookingsQueue, 156 | }; 157 | } 158 | } 159 | }; 160 | -------------------------------------------------------------------------------- /packages/api/src/trpc.ts: -------------------------------------------------------------------------------- 1 | import { initTRPC } from "@trpc/server"; 2 | import type Bull from "bull"; 3 | import type BullMQ from "bullmq"; 4 | import type BeeQueue from "bee-queue"; 5 | 6 | type Queue = { 7 | // Display name of the queue in the UI 8 | displayName: string; 9 | // Function to get the job name from the job data 10 | jobName?: (data: Record) => string; 11 | } & ( 12 | | { 13 | queue: Bull.Queue; 14 | type: "bull"; 15 | } 16 | | { 17 | queue: BullMQ.Queue; 18 | type: "bullmq"; 19 | } 20 | | { 21 | queue: BeeQueue; 22 | type: "bee"; 23 | } 24 | ); 25 | 26 | export type Context = { 27 | // List of queues to expose 28 | queues: Queue[]; 29 | }; 30 | 31 | const t = initTRPC.context().create(); 32 | export const router = t.router; 33 | export const procedure = t.procedure; 34 | -------------------------------------------------------------------------------- /packages/api/src/utils/global.utils.ts: -------------------------------------------------------------------------------- 1 | import type Bull from "bull"; 2 | import type BullMQ from "bullmq"; 3 | import { TRPCError } from "@trpc/server"; 4 | import type { Context } from "../trpc"; 5 | import type BeeQueue from "bee-queue"; 6 | 7 | type QueueDashOptions = { 8 | priority?: number; 9 | delay?: number; 10 | attempts?: number; 11 | backoff?: number; 12 | lifo?: boolean; 13 | timeout?: number; 14 | removeOnComplete?: boolean | number; 15 | removeOnFail?: boolean | number; 16 | stackTraceLimit?: number; 17 | preventParsingData?: boolean; 18 | repeat?: { 19 | offset: number; 20 | tz: string; 21 | pattern: string; 22 | count: number; 23 | }; 24 | }; 25 | 26 | type QueueDashJob = { 27 | id: string; 28 | name: string; 29 | data: object; 30 | opts: QueueDashOptions; 31 | createdAt: Date; 32 | processedAt: Date | null; 33 | finishedAt: Date | null; 34 | failedReason?: string; 35 | stacktrace?: string[]; 36 | retriedAt: Date | null; 37 | }; 38 | 39 | export type QueueDashScheduler = { 40 | key: string; 41 | name: string; 42 | id?: string | null; 43 | iterationCount?: number; 44 | limit?: number; 45 | endDate?: number; 46 | tz?: string; 47 | pattern?: string; 48 | every?: string; 49 | next?: number; 50 | template?: { 51 | data?: Record; 52 | }; 53 | }; 54 | 55 | export const formatJob = ({ 56 | job, 57 | queueInCtx, 58 | }: { 59 | job: Bull.Job | BullMQ.Job | BeeQueue.Job>; 60 | queueInCtx: Context["queues"][0]; 61 | }): QueueDashJob => { 62 | if ("status" in job) { 63 | // TODO: 64 | return { 65 | id: job.id as string, 66 | name: queueInCtx.jobName 67 | ? queueInCtx.jobName(job.data) 68 | : job.id === "__default__" 69 | ? "Default" 70 | : job.id, 71 | data: job.data as object, 72 | opts: job.options, 73 | createdAt: new Date(), 74 | processedAt: new Date(), 75 | finishedAt: new Date(), 76 | failedReason: "", 77 | stacktrace: [""], 78 | retriedAt: new Date(), 79 | }; 80 | } 81 | 82 | return { 83 | id: job.id as string, 84 | name: queueInCtx.jobName 85 | ? queueInCtx.jobName(job.data) 86 | : job.name === "__default__" 87 | ? "Default" 88 | : job.name, 89 | data: job.data as object, 90 | opts: job.opts, 91 | createdAt: new Date(job.timestamp), 92 | processedAt: job.processedOn ? new Date(job.processedOn) : null, 93 | finishedAt: job.finishedOn ? new Date(job.finishedOn) : null, 94 | failedReason: job.failedReason, 95 | stacktrace: job.stacktrace, 96 | // @ts-expect-error 97 | retriedAt: job.retriedOn ? new Date(job.retriedOn) : null, 98 | } as QueueDashJob; 99 | }; 100 | 101 | export const findQueueInCtxOrFail = ({ 102 | queueName, 103 | queues, 104 | }: { 105 | queueName: string; 106 | queues: Context["queues"]; 107 | }) => { 108 | const queueInCtx = queues.find((q) => q.queue.name === queueName); 109 | if (!queueInCtx) { 110 | throw new TRPCError({ 111 | code: "NOT_FOUND", 112 | }); 113 | } 114 | return queueInCtx; 115 | }; 116 | -------------------------------------------------------------------------------- /packages/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "target": "ES2020", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "outDir": "dist", 7 | "allowJs": true, 8 | "skipLibCheck": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noEmit": true, 12 | "incremental": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve", 19 | "composite": true, 20 | "declaration": true, 21 | "downlevelIteration": true, 22 | "declarationMap": true, 23 | "inlineSources": false, 24 | "noUnusedLocals": false, 25 | "noUnusedParameters": false, 26 | "preserveWatchOutput": true, 27 | "baseUrl": "." 28 | }, 29 | "include": ["**/*.ts", "**/*.tsx", "vite.config.ts", "package.json"], 30 | "exclude": ["node_modules"] 31 | } 32 | -------------------------------------------------------------------------------- /packages/api/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import typescript from "@rollup/plugin-typescript"; 3 | import path from "path"; 4 | import { typescriptPaths } from "rollup-plugin-typescript-paths"; 5 | 6 | export default defineConfig({ 7 | define: { 8 | "process.env.NODE_ENV": JSON.stringify("production"), 9 | }, 10 | build: { 11 | minify: true, 12 | reportCompressedSize: true, 13 | lib: { 14 | entry: path.resolve(__dirname, "src/main.ts"), 15 | fileName: "main", 16 | name: "QueueDash API", 17 | formats: ["cjs", "es"], 18 | }, 19 | rollupOptions: { 20 | external: ["events"], 21 | plugins: [ 22 | typescriptPaths({ 23 | preserveExtensions: true, 24 | }), 25 | typescript({ 26 | sourceMap: false, 27 | declaration: true, 28 | outDir: "dist", 29 | }), 30 | ], 31 | }, 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /packages/api/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /packages/client/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | parserOptions: { 5 | tsconfigRootDir: __dirname, 6 | project: "./tsconfig.json", 7 | }, 8 | ignorePatterns: [ 9 | "**/node_modules", 10 | "**/.cache", 11 | "dist", 12 | ".eslintrc.js", 13 | "tailwind.config.js", 14 | "postcss.config.js", 15 | ], 16 | plugins: ["@typescript-eslint", "tailwindcss"], 17 | extends: [ 18 | "eslint:recommended", 19 | "plugin:@typescript-eslint/recommended", 20 | "plugin:tailwindcss/recommended", 21 | "prettier", 22 | ], 23 | rules: { 24 | "@typescript-eslint/consistent-type-imports": "warn", 25 | "@typescript-eslint/no-unused-vars": "error", 26 | "@typescript-eslint/no-explicit-any": "error", 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /packages/client/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @queuedash/client 2 | 3 | ## 3.6.0 4 | 5 | ### Minor Changes 6 | 7 | - [#50](https://github.com/alexbudure/queuedash/pull/50) [`1fd1326`](https://github.com/alexbudure/queuedash/commit/1fd1326ebc22045c78ecdbbceef2856fcce6cbb6) Thanks [@alexbudure](https://github.com/alexbudure)! - - Job scheduler support 8 | - Per-job logs 9 | - Global pause & resume 10 | 11 | ## 3.5.0 12 | 13 | ### Minor Changes 14 | 15 | - [#44](https://github.com/alexbudure/queuedash/pull/44) [`ba73bcf`](https://github.com/alexbudure/queuedash/commit/ba73bcf1afec112e6916a4c6beb132a8d9c7edd4) Thanks [@huv1k](https://github.com/huv1k)! - Add support for hono 16 | 17 | ## 3.4.0 18 | 19 | ### Minor Changes 20 | 21 | - [#40](https://github.com/alexbudure/queuedash/pull/40) [`0ccd37a`](https://github.com/alexbudure/queuedash/commit/0ccd37afe5a3d8b158109d1ec80a02cead1480bf) Thanks [@fukouda](https://github.com/fukouda)! - Add the ability to pass in hook handlers on Fastify 22 | 23 | ## 3.3.0 24 | 25 | ### Minor Changes 26 | 27 | - [#38](https://github.com/alexbudure/queuedash/pull/38) [`00346f4`](https://github.com/alexbudure/queuedash/commit/00346f4c11fdae742dae1981061f044aee66697b) Thanks [@alexbudure](https://github.com/alexbudure)! - Improve React 19 compatibility 28 | 29 | ## 3.2.0 30 | 31 | ### Minor Changes 32 | 33 | - [`8e5c7ac`](https://github.com/alexbudure/queuedash/commit/8e5c7ac06bb13674e32b4cd9b1b7c65913e122af) Thanks [@alexbudure](https://github.com/alexbudure)! - Fix importing API 34 | 35 | ## 3.1.0 36 | 37 | ### Minor Changes 38 | 39 | - [#33](https://github.com/alexbudure/queuedash/pull/33) [`011ad3b`](https://github.com/alexbudure/queuedash/commit/011ad3bca2b50b4568fa7edc7bf314948e84eeb9) Thanks [@alexbudure](https://github.com/alexbudure)! - Fix bundle strategy for API 40 | 41 | ## 3.0.0 42 | 43 | ### Major Changes 44 | 45 | - [#21](https://github.com/alexbudure/queuedash/pull/21) [`6692303`](https://github.com/alexbudure/queuedash/commit/6692303bde835b9934e2ae962e4727357f0d4afe) Thanks [@alexbudure](https://github.com/alexbudure)! - This version upgrades core dependencies to their latest major versions, including Elysia, BullMQ, and tRPC 46 | 47 | ## 2.1.1 48 | 49 | ### Patch Changes 50 | 51 | - [#26](https://github.com/alexbudure/queuedash/pull/26) [`2e0956c`](https://github.com/alexbudure/queuedash/commit/2e0956c586b9f5f3190f169e363f76230d037686) Thanks [@p3drosola](https://github.com/p3drosola)! - Improve UI support for long job ids 52 | 53 | ## 2.1.0 54 | 55 | ### Minor Changes 56 | 57 | - [#23](https://github.com/alexbudure/queuedash/pull/23) [`7a2e3c0`](https://github.com/alexbudure/queuedash/commit/7a2e3c000da0b34c4c3a4dd2471e2e19738d1e6d) Thanks [@alexbudure](https://github.com/alexbudure)! - Fix bull api differences 58 | 59 | ## 2.0.5 60 | 61 | ### Patch Changes 62 | 63 | - [`bc47dd5`](https://github.com/alexbudure/queuedash/commit/bc47dd5de7a5ed32cd82365dc27073282afc45be) Thanks [@alexbudure](https://github.com/alexbudure)! - Fix express bundling with app 64 | 65 | ## 2.0.4 66 | 67 | ### Patch Changes 68 | 69 | - [`0948ec2`](https://github.com/alexbudure/queuedash/commit/0948ec21985d33b3ffbb0ec220664493382579da) Thanks [@alexbudure](https://github.com/alexbudure)! - Move html into each adapter 70 | 71 | ## 2.0.3 72 | 73 | ### Patch Changes 74 | 75 | - [`dee7163`](https://github.com/alexbudure/queuedash/commit/dee71633d33c8bceee9bde84a0b340f899adeaf8) Thanks [@alexbudure](https://github.com/alexbudure)! - Remove unnecessary express app in adapter 76 | 77 | ## 2.0.2 78 | 79 | ### Patch Changes 80 | 81 | - [`6680a6a`](https://github.com/alexbudure/queuedash/commit/6680a6a5ece43fef248fedacb31f8fae2242d2d3) Thanks [@alexbudure](https://github.com/alexbudure)! - Fix the adapters.. again 82 | 83 | ## 2.0.1 84 | 85 | ### Patch Changes 86 | 87 | - [`8d2eadd`](https://github.com/alexbudure/queuedash/commit/8d2eadd9ad547ff2e893662474a228bf340f0728) Thanks [@alexbudure](https://github.com/alexbudure)! - Fix the middlewares for Fastify and Express 88 | 89 | ## 2.0.0 90 | 91 | ### Major Changes 92 | 93 | - [#5](https://github.com/alexbudure/queuedash/pull/5) [`1f794d1`](https://github.com/alexbudure/queuedash/commit/1f794d1679225718dcc670e9c7eb59564fee1bc6) Thanks [@alexbudure](https://github.com/alexbudure)! - Updated all adapters to use a more natural API and added real-time Redis info on the queue detail page 94 | 95 | *** 96 | 97 | ### Breaking changes 98 | 99 | **Express** 100 | 101 | Before: 102 | 103 | ```typescript 104 | createQueueDashExpressMiddleware({ 105 | app, 106 | baseUrl: "/queuedash", 107 | ctx: { 108 | queues: [ 109 | { 110 | queue: new Bull("report-queue"), 111 | displayName: "Reports", 112 | type: "bull" as const, 113 | }, 114 | ], 115 | }, 116 | }); 117 | ``` 118 | 119 | After: 120 | 121 | ```typescript 122 | app.use( 123 | "/queuedash", 124 | createQueueDashExpressMiddleware({ 125 | ctx: { 126 | queues: [ 127 | { 128 | queue: new Bull("report-queue"), 129 | displayName: "Reports", 130 | type: "bull" as const, 131 | }, 132 | ], 133 | }, 134 | }), 135 | ); 136 | ``` 137 | 138 | **Fastify** 139 | 140 | Before: 141 | 142 | ```typescript 143 | createQueueDashFastifyMiddleware({ 144 | server, 145 | baseUrl: "/queuedash", 146 | ctx: { 147 | queues: [ 148 | { 149 | queue: new Bull("report-queue"), 150 | displayName: "Reports", 151 | type: "bull" as const, 152 | }, 153 | ], 154 | }, 155 | }); 156 | ``` 157 | 158 | After: 159 | 160 | ```typescript 161 | server.register(fastifyQueueDashPlugin, { 162 | baseUrl: "/queuedash", 163 | ctx: { 164 | queues: [ 165 | { 166 | queue: new Bull("report-queue"), 167 | displayName: "Reports", 168 | type: "bull" as const, 169 | }, 170 | ], 171 | }, 172 | }); 173 | ``` 174 | 175 | ## 1.2.1 176 | 177 | ### Patch Changes 178 | 179 | - [#12](https://github.com/alexbudure/queuedash/pull/12) [`d79c8ff`](https://github.com/alexbudure/queuedash/commit/d79c8ffe34ae36c74d0663dd2e29e6c93327bf8c) Thanks [@alexbudure](https://github.com/alexbudure)! - Fix Elysia plugin 180 | 181 | ## 1.2.0 182 | 183 | ### Minor Changes 184 | 185 | - [`9aaec9a`](https://github.com/alexbudure/queuedash/commit/9aaec9a21c091680cb30a67e9322eedd3e16dbe8) Thanks [@alexbudure](https://github.com/alexbudure)! - Support for Elysia 186 | 187 | ## 1.0.1 188 | 189 | ### Patch Changes 190 | 191 | - [#3](https://github.com/alexbudure/queuedash/pull/3) [`a385f9f`](https://github.com/alexbudure/queuedash/commit/a385f9f9e76df4cea8e69d7e218b65915acef3bf) Thanks [@alexbudure](https://github.com/alexbudure)! - Tighten adapter types to work with NestJS 192 | 193 | ## 1.0.0 194 | 195 | ### Major Changes 196 | 197 | - [#1](https://github.com/alexbudure/queuedash/pull/1) [`c96b93d`](https://github.com/alexbudure/queuedash/commit/c96b93d9659bbb34248ab377e6659ebfb1fc3dd8) Thanks [@alexbudure](https://github.com/alexbudure)! - QueueDash v1 🎉 198 | 199 | - 😍  Simple, clean, and compact UI 200 | - 🧙  Add jobs to your queue with ease 201 | - 🪄  Retry, remove, and more convenient actions for your jobs 202 | - 📊  Stats for job counts, job durations, and job wait times 203 | - ✨  Top-level overview page of all queues 204 | - 🔋  Integrates with Next.js, Express.js, and Fastify 205 | - ⚡️  Compatible with Bull, BullMQ, and Bee-Queue 206 | -------------------------------------------------------------------------------- /packages/client/README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | QueueDash 4 | 5 |

6 | 7 |

8 | A stunning, sleek dashboard for Bull, BullMQ, and Bee-Queue. 9 |

10 | 11 |

12 | 13 | 14 | 15 | 16 | 17 | 18 |

19 | 20 | ## Features 21 | 22 | - 😍  Simple, clean, and compact UI 23 | - 🧙  Add jobs to your queue with ease 24 | - 🪄  Retry, remove, and more convenient actions for your jobs 25 | - 📊  Stats for job counts, job durations, and job wait times 26 | - ✨  Top-level overview page of all queues 27 | - 🔋  Integrates with Next.js, Express.js, and Fastify 28 | - ⚡️  Compatible with Bull, BullMQ, and Bee-Queue 29 | 30 | ## Getting Started 31 | 32 | ### Express 33 | 34 | `pnpm install @queuedash/api` 35 | 36 | ```typescript 37 | import express from "express"; 38 | import Bull from "bull"; 39 | import { createQueueDashExpressMiddleware } from "@queuedash/api"; 40 | 41 | const app = express(); 42 | 43 | const reportQueue = new Bull("report-queue"); 44 | 45 | app.use( 46 | "/queuedash", 47 | createQueueDashExpressMiddleware({ 48 | ctx: { 49 | queues: [ 50 | { 51 | queue: reportQueue, 52 | displayName: "Reports", 53 | type: "bull" as const, 54 | }, 55 | ], 56 | }, 57 | }) 58 | ); 59 | 60 | app.listen(3000, () => { 61 | console.log("Listening on port 3000"); 62 | console.log("Visit http://localhost:3000/queuedash"); 63 | }); 64 | ``` 65 | 66 | ### Next.js 67 | 68 | `pnpm install @queuedash/api @queuedash/ui` 69 | 70 | ```typescript jsx 71 | // pages/admin/queuedash/[[...slug]].tsx 72 | import { QueueDashApp } from "@queuedash/ui"; 73 | 74 | function getBaseUrl() { 75 | if (process.env.VERCEL_URL) { 76 | return `https://${process.env.VERCEL_URL}/api/queuedash`; 77 | } 78 | 79 | return `http://localhost:${process.env.PORT ?? 3000}/api/queuedash`; 80 | } 81 | 82 | const QueueDashPages = () => { 83 | return ; 84 | }; 85 | 86 | export default QueueDashPages; 87 | 88 | // pages/api/queuedash/[trpc].ts 89 | import * as trpcNext from "@trpc/server/adapters/next"; 90 | import { appRouter } from "@queuedash/api"; 91 | 92 | const reportQueue = new Bull("report-queue"); 93 | 94 | export default trpcNext.createNextApiHandler({ 95 | router: appRouter, 96 | batching: { 97 | enabled: true, 98 | }, 99 | createContext: () => ({ 100 | queues: [ 101 | { 102 | queue: reportQueue, 103 | displayName: "Reports", 104 | type: "bull" as const, 105 | }, 106 | ], 107 | }), 108 | }); 109 | ``` 110 | 111 | See the [./examples](./examples) folder for more. 112 | 113 | --- 114 | 115 | ## API Reference 116 | 117 | ### `createQueueDash<*>Middleware` 118 | 119 | ```typescript 120 | type QueueDashMiddlewareOptions = { 121 | app: express.Application | FastifyInstance; // Express or Fastify app 122 | baseUrl: string; // Base path for the API and UI 123 | ctx: QueueDashContext; // Context for the UI 124 | }; 125 | 126 | type QueueDashContext = { 127 | queues: QueueDashQueue[]; // Array of queues to display 128 | }; 129 | 130 | type QueueDashQueue = { 131 | queue: Bull.Queue | BullMQ.Queue | BeeQueue; // Queue instance 132 | displayName: string; // Display name for the queue 133 | type: "bull" | "bullmq" | "bee"; // Queue type 134 | }; 135 | ``` 136 | 137 | ### `` 138 | 139 | ```typescript jsx 140 | type QueueDashAppProps = { 141 | apiUrl: string; // URL to the API endpoint 142 | basename: string; // Base path for the app 143 | }; 144 | ``` 145 | 146 | ## Roadmap 147 | 148 | - Supports Celery and other queueing systems 149 | - Command+K bar and shortcuts 150 | - Ability to whitelabel the UI 151 | 152 | ## Pro Version 153 | 154 | Right now, QueueDash simply taps into your Redis instance, making it very easy to set up, but also limited in functionality. 155 | 156 | I'm thinking about building a free-to-host version on top of this which will require external services (db, auth, etc.), but it will make the following features possible: 157 | 158 | - Alerts and notifications 159 | - Quick search and filtering 160 | - Queue trends and analytics 161 | - Invite team members 162 | 163 | If you're interested in this version, please let me know! 164 | 165 | ## Acknowledgements 166 | 167 | QueueDash was inspired by some great open source projects. Here's a few of them: 168 | 169 | - [bull-board](https://github.com/vcapretz/bull-board) 170 | - [bull-monitor](https://github.com/s-r-x/bull-monitor) 171 | - [bull-arena](https://github.com/bee-queue/arena) 172 | -------------------------------------------------------------------------------- /packages/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@queuedash/client", 3 | "version": "3.6.0", 4 | "description": "A stunning, sleek dashboard for Bull, BullMQ, and Bee-Queue", 5 | "files": [ 6 | "dist" 7 | ], 8 | "scripts": { 9 | "build": "tsc && vite build", 10 | "lint": "eslint ./ --fix" 11 | }, 12 | "dependencies": { 13 | "react": "^19.1.0", 14 | "react-dom": "^19.1.0" 15 | }, 16 | "devDependencies": { 17 | "@queuedash/ui": "workspace:*", 18 | "@rollup/plugin-typescript": "^12.1.2", 19 | "@types/react": "^19.1.0", 20 | "@types/react-dom": "^19.1.1", 21 | "@typescript-eslint/eslint-plugin": "^8.29.0", 22 | "@typescript-eslint/parser": "^8.29.0", 23 | "@vitejs/plugin-react": "^4.3.4", 24 | "eslint": "^9.24.0", 25 | "eslint-config-prettier": "^10.1.1", 26 | "eslint-plugin-tailwindcss": "^3.18.0", 27 | "postcss": "^8.5.3", 28 | "prettier": "^3.5.3", 29 | "rollup-plugin-typescript-paths": "^1.5.0", 30 | "tailwindcss": "^3.4.16", 31 | "typescript": "^5.8.3", 32 | "vite": "^5.4.19" 33 | }, 34 | "license": "MIT" 35 | } 36 | -------------------------------------------------------------------------------- /packages/client/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import { StrictMode } from "react"; 3 | import { QueueDashApp } from "@queuedash/ui"; 4 | 5 | interface CustomWindow extends Window { 6 | __INITIAL_STATE__: { 7 | apiUrl: string; 8 | basename: string; 9 | }; 10 | } 11 | 12 | declare let window: CustomWindow; 13 | 14 | createRoot(document.getElementById("root")!).render( 15 | 16 | 20 | 21 | ); 22 | -------------------------------------------------------------------------------- /packages/client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "outDir": "dist", 7 | "allowJs": true, 8 | "skipLibCheck": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noEmit": true, 12 | "incremental": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve", 19 | "composite": true, 20 | "declaration": true, 21 | "declarationMap": true, 22 | "inlineSources": false, 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "preserveWatchOutput": true, 26 | "baseUrl": "." 27 | }, 28 | "include": ["**/*.ts", "**/*.tsx", "vite.config.ts"], 29 | "exclude": ["node_modules"] 30 | } 31 | -------------------------------------------------------------------------------- /packages/client/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import typescript from "@rollup/plugin-typescript"; 3 | import path from "path"; 4 | import { typescriptPaths } from "rollup-plugin-typescript-paths"; 5 | import react from "@vitejs/plugin-react"; 6 | 7 | export default defineConfig({ 8 | plugins: [react()], 9 | define: { 10 | "process.env.NODE_ENV": JSON.stringify("production"), 11 | }, 12 | build: { 13 | minify: true, 14 | reportCompressedSize: true, 15 | lib: { 16 | entry: path.resolve(__dirname, "src/main.tsx"), 17 | name: "QueueDash App", 18 | fileName: "main", 19 | formats: ["cjs", "es"], 20 | }, 21 | rollupOptions: { 22 | plugins: [ 23 | typescriptPaths({ 24 | preserveExtensions: true, 25 | }), 26 | typescript({ 27 | sourceMap: false, 28 | declaration: true, 29 | outDir: "dist", 30 | }), 31 | ], 32 | }, 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /packages/dev/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | parserOptions: { 5 | tsconfigRootDir: __dirname, 6 | project: "./tsconfig.json", 7 | }, 8 | ignorePatterns: [ 9 | "**/node_modules", 10 | "**/.cache", 11 | "build", 12 | ".next", 13 | ".eslintrc.js", 14 | "next.config.js", 15 | ], 16 | plugins: ["@typescript-eslint", "tailwindcss"], 17 | extends: [ 18 | "next/core-web-vitals", 19 | "eslint:recommended", 20 | "plugin:@typescript-eslint/recommended", 21 | "plugin:tailwindcss/recommended", 22 | "prettier", 23 | ], 24 | rules: { 25 | "@typescript-eslint/consistent-type-imports": "warn", 26 | "@typescript-eslint/no-unused-vars": "error", 27 | "@typescript-eslint/no-explicit-any": "error", 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /packages/dev/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @queuedash/dev 2 | 3 | ## 1.2.1 4 | 5 | ### Patch Changes 6 | 7 | - [#12](https://github.com/alexbudure/queuedash/pull/12) [`d79c8ff`](https://github.com/alexbudure/queuedash/commit/d79c8ffe34ae36c74d0663dd2e29e6c93327bf8c) Thanks [@alexbudure](https://github.com/alexbudure)! - Fix Elysia plugin 8 | 9 | - Updated dependencies [[`d79c8ff`](https://github.com/alexbudure/queuedash/commit/d79c8ffe34ae36c74d0663dd2e29e6c93327bf8c)]: 10 | - @queuedash/api@1.2.1 11 | - @queuedash/ui@1.2.1 12 | 13 | ## 1.2.0 14 | 15 | ### Minor Changes 16 | 17 | - [`9aaec9a`](https://github.com/alexbudure/queuedash/commit/9aaec9a21c091680cb30a67e9322eedd3e16dbe8) Thanks [@alexbudure](https://github.com/alexbudure)! - Support for Elysia 18 | 19 | ### Patch Changes 20 | 21 | - Updated dependencies [[`9aaec9a`](https://github.com/alexbudure/queuedash/commit/9aaec9a21c091680cb30a67e9322eedd3e16dbe8)]: 22 | - @queuedash/api@1.2.0 23 | - @queuedash/ui@1.2.0 24 | 25 | ## 1.1.0 26 | 27 | ### Minor Changes 28 | 29 | - [#7](https://github.com/alexbudure/queuedash/pull/7) [`885aee3`](https://github.com/alexbudure/queuedash/commit/885aee3cecac687d05f5b18cd1855fcb5522f899) Thanks [@alexbudure](https://github.com/alexbudure)! - Add support for prioritized jobs 30 | 31 | ### Patch Changes 32 | 33 | - Updated dependencies [[`885aee3`](https://github.com/alexbudure/queuedash/commit/885aee3cecac687d05f5b18cd1855fcb5522f899)]: 34 | - @queuedash/api@1.1.0 35 | - @queuedash/ui@1.1.0 36 | -------------------------------------------------------------------------------- /packages/dev/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /packages/dev/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | transpilePackages: ["@queuedash/api", "@queuedash/ui"], 5 | webpack: (config) => { 6 | config.node = { 7 | ...config.node, 8 | __dirname: true, 9 | }; 10 | return config; 11 | }, 12 | }; 13 | 14 | module.exports = nextConfig; 15 | -------------------------------------------------------------------------------- /packages/dev/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@queuedash/dev", 3 | "version": "1.2.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "ts-node-dev --respawn --transpile-only -O \"{\\\"module\\\":\\\"commonjs\\\"}\" utils/worker.ts & next dev", 7 | "build-next": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@faker-js/faker": "^9.6.0", 13 | "@queuedash/api": "workspace:*", 14 | "@queuedash/ui": "workspace:*", 15 | "@trpc/server": "^11.0.2", 16 | "bee-queue": "^1.7.1", 17 | "bull": "^4.16.5", 18 | "bullmq": "^5.47.2", 19 | "next": "^15.2.4", 20 | "react": "^19.1.0", 21 | "react-dom": "^19.1.0" 22 | }, 23 | "devDependencies": { 24 | "@types/node": "^22.14.0", 25 | "@types/react": "^19.1.0", 26 | "@typescript-eslint/eslint-plugin": "^8.29.0", 27 | "@typescript-eslint/parser": "^8.29.0", 28 | "eslint": "^9.24.0", 29 | "eslint-config-next": "^15.2.4", 30 | "eslint-config-prettier": "^10.1.1", 31 | "prettier": "^3.5.3", 32 | "ts-node-dev": "^2.0.0", 33 | "typescript": "^5.8.3" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/dev/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "@queuedash/ui/dist/styles.css"; 2 | 3 | import type { AppType } from "next/app"; 4 | 5 | const MyApp: AppType = ({ Component, pageProps }) => { 6 | return ; 7 | }; 8 | export default MyApp; 9 | -------------------------------------------------------------------------------- /packages/dev/pages/api/queuedash/[trpc].ts: -------------------------------------------------------------------------------- 1 | import * as trpcNext from "@trpc/server/adapters/next"; 2 | import { appRouter } from "@queuedash/api"; 3 | import { queues } from "../../../utils/fake-data"; 4 | 5 | export default trpcNext.createNextApiHandler({ 6 | router: appRouter, 7 | onError({ error }) { 8 | if (error.code === "INTERNAL_SERVER_ERROR") { 9 | // send to bug reporting 10 | console.error("Something went wrong", error); 11 | } 12 | }, 13 | batching: { 14 | enabled: true, 15 | }, 16 | createContext: () => ({ 17 | queues: queues.map((queue) => { 18 | return { 19 | queue: queue.queue, 20 | displayName: queue.displayName, 21 | type: queue.type, 22 | jobName: queue.jobName, 23 | }; 24 | }), 25 | }), 26 | }); 27 | -------------------------------------------------------------------------------- /packages/dev/pages/api/reset.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | 3 | import { queues } from "../../utils/fake-data"; 4 | 5 | export default async function handler( 6 | req: NextApiRequest, 7 | res: NextApiResponse, 8 | ) { 9 | const client = await queues[0].queue.client; 10 | const pipeline = client.pipeline(); 11 | const keys = await client.keys("bull*"); 12 | keys.forEach((key) => { 13 | pipeline.del(key); 14 | }); 15 | await pipeline.exec(); 16 | 17 | for (const item of queues) { 18 | if (item.type === "bull") { 19 | await item.queue.removeJobs("*"); 20 | await item.queue.addBulk(item.jobs); 21 | } else { 22 | await item.queue.obliterate({ force: true }); 23 | for (const scheduler of item.schedulers) { 24 | await item.queue.upsertJobScheduler( 25 | scheduler.name, 26 | scheduler.opts, 27 | scheduler.template, 28 | ); 29 | } 30 | await item.queue.addBulk( 31 | item.jobs.map((job) => { 32 | return { 33 | name: "test", 34 | ...job, 35 | }; 36 | }), 37 | ); 38 | } 39 | } 40 | res.status(200).json({ ok: "ok" }); 41 | } 42 | -------------------------------------------------------------------------------- /packages/dev/pages/queuedash/[[...slug]].tsx: -------------------------------------------------------------------------------- 1 | import { QueueDashApp } from "@queuedash/ui"; 2 | 3 | function getBaseUrl() { 4 | if (process.env.VERCEL_URL) { 5 | return `https://${process.env.VERCEL_URL}/api/queuedash`; 6 | } 7 | 8 | return `http://localhost:${process.env.PORT ?? 3000}/api/queuedash`; 9 | } 10 | 11 | const Pages = () => { 12 | return ( 13 |
14 | 15 |
16 | 24 |
25 |
26 | ); 27 | }; 28 | 29 | export default Pages; 30 | -------------------------------------------------------------------------------- /packages/dev/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "noEmit": true, 11 | "incremental": true, 12 | "esModuleInterop": true, 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "jsx": "preserve", 18 | "plugins": [ 19 | { 20 | "name": "next" 21 | } 22 | ], 23 | "composite": false, 24 | "declaration": true, 25 | "declarationMap": true, 26 | "inlineSources": false, 27 | "noUnusedLocals": false, 28 | "noUnusedParameters": false, 29 | "preserveWatchOutput": true, 30 | "baseUrl": ".", 31 | "paths": { 32 | "@queuedash/api": ["../api/src/main"], 33 | "@queuedash/ui": ["../ui/src/main"] 34 | } 35 | }, 36 | "references": [ 37 | { 38 | "path": "../api" 39 | }, 40 | { 41 | "path": "../ui" 42 | } 43 | ], 44 | "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"], 45 | "exclude": ["node_modules"] 46 | } 47 | -------------------------------------------------------------------------------- /packages/dev/utils/fake-data.ts: -------------------------------------------------------------------------------- 1 | import Bull from "bull"; 2 | import type { JobsOptions as BullMQJobOptions, RepeatOptions } from "bullmq"; 3 | import { Queue as BullMQQueue } from "bullmq"; 4 | import { faker } from "@faker-js/faker"; 5 | 6 | type FakeQueue = 7 | | { 8 | queue: Bull.Queue; 9 | type: "bull"; 10 | displayName: string; 11 | jobs: { opts: Bull.JobOptions; data: Record }[]; 12 | jobName: (job: Record) => string; 13 | } 14 | | { 15 | queue: BullMQQueue; 16 | type: "bullmq"; 17 | displayName: string; 18 | jobs: { opts: BullMQJobOptions; data: Record }[]; 19 | jobName: (job: Record) => string; 20 | schedulers: { 21 | name: string; 22 | opts: RepeatOptions; 23 | template: { 24 | name?: string | undefined; 25 | data?: Record; 26 | opts?: Omit< 27 | BullMQJobOptions, 28 | "jobId" | "repeat" | "delay" | "deduplication" | "debounce" 29 | >; 30 | }; 31 | }[]; 32 | }; 33 | 34 | export const queues: FakeQueue[] = [ 35 | { 36 | queue: new Bull("flight-bookings"), 37 | type: "bull" as const, 38 | displayName: "Flight bookings", 39 | jobs: [...new Array(50)].map(() => { 40 | return { 41 | data: { 42 | from: faker.location.city(), 43 | to: faker.location.city(), 44 | name: faker.person.fullName(), 45 | priority: faker.number.int({ 46 | min: 1, 47 | max: 4, 48 | }), 49 | bags: Math.random() > 0.5 ? 1 : 0, 50 | }, 51 | opts: { 52 | lifo: true, 53 | delay: 750, 54 | }, 55 | }; 56 | }), 57 | jobName: (job: Record) => { 58 | return `${job.from} to ${job.to}`; 59 | }, 60 | }, 61 | // { 62 | // queue: new Bull("check-in-reminders"), 63 | // type: "bull" as const, 64 | // displayName: "Check-in reminders", 65 | // jobs: [...new Array(50)].map(() => { 66 | // return { 67 | // data: { 68 | // from: faker.location.city(), 69 | // }, 70 | // opts: {}, 71 | // }; 72 | // }), 73 | // jobName: (job) => { 74 | // return `${job.from}`; 75 | // }, 76 | // }, 77 | // { 78 | // queue: new Bull("monthly-promos"), 79 | // type: "bull" as const, 80 | // displayName: "Monthly promos", 81 | // jobs: [...new Array(50)].map(() => { 82 | // return { 83 | // data: { 84 | // from: faker.location.city(), 85 | // }, 86 | // opts: {}, 87 | // }; 88 | // }), 89 | // jobName: (job: Record) => { 90 | // return `${job.from}`; 91 | // }, 92 | // }, 93 | { 94 | queue: new BullMQQueue("cancellation-follow-ups"), 95 | type: "bullmq" as const, 96 | displayName: "Cancellation follow-ups", 97 | jobs: [...new Array(50)].map((_, index) => { 98 | return { 99 | data: { 100 | name: faker.person.fullName(), 101 | }, 102 | opts: { 103 | priority: index === 4 ? undefined : 1, 104 | }, 105 | }; 106 | }), 107 | schedulers: [...new Array(3)].map(() => { 108 | return { 109 | name: faker.person.fullName(), 110 | template: { 111 | name: faker.person.fullName(), 112 | data: { 113 | name: faker.person.fullName(), 114 | }, 115 | }, 116 | opts: { 117 | pattern: "0 0 * * *", 118 | tz: "America/Los_Angeles", 119 | }, 120 | }; 121 | }), 122 | jobName: (job: Record) => { 123 | return `${job.name}`; 124 | }, 125 | }, 126 | ]; 127 | -------------------------------------------------------------------------------- /packages/dev/utils/worker.ts: -------------------------------------------------------------------------------- 1 | import { queues } from "./fake-data"; 2 | import Bull from "bull"; 3 | import { Queue as BullMQQueue, Worker } from "bullmq"; 4 | 5 | const sleep = (t: number) => 6 | new Promise((resolve) => setTimeout(resolve, t * 1000)); 7 | 8 | for (const item of queues) { 9 | if (item.type === "bull") { 10 | new Bull(item.queue.name).process(async (job) => { 11 | await sleep(Math.random() * 20); 12 | 13 | const completedCount = await job.queue.getCompletedCount(); 14 | 15 | if (completedCount === 48) { 16 | throw new Error("Generic error"); 17 | } 18 | 19 | return Promise.resolve(); 20 | }); 21 | } else { 22 | new Worker( 23 | item.queue.name, 24 | async (job) => { 25 | await sleep(Math.random() * 20); 26 | 27 | job.log("Test log 1"); 28 | job.log("Test log 2"); 29 | const queue = new BullMQQueue(item.queue.name); 30 | 31 | const completedCount = await queue.getCompletedCount(); 32 | 33 | if (completedCount === 48) { 34 | throw new Error("Generic error"); 35 | } 36 | 37 | return Promise.resolve(); 38 | }, 39 | { 40 | connection: {}, 41 | } 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/ui/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | parserOptions: { 5 | tsconfigRootDir: __dirname, 6 | project: "./tsconfig.json", 7 | }, 8 | ignorePatterns: [ 9 | "**/node_modules", 10 | "**/.cache", 11 | "dist", 12 | ".eslintrc.js", 13 | "tailwind.config.js", 14 | "postcss.config.js", 15 | ], 16 | plugins: ["@typescript-eslint", "tailwindcss"], 17 | extends: [ 18 | "eslint:recommended", 19 | "plugin:@typescript-eslint/recommended", 20 | "plugin:tailwindcss/recommended", 21 | "prettier", 22 | ], 23 | rules: { 24 | "@typescript-eslint/consistent-type-imports": "warn", 25 | "@typescript-eslint/no-unused-vars": "error", 26 | "@typescript-eslint/no-explicit-any": "error", 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /packages/ui/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @queuedash/ui 2 | 3 | ## 3.6.0 4 | 5 | ### Minor Changes 6 | 7 | - [#50](https://github.com/alexbudure/queuedash/pull/50) [`1fd1326`](https://github.com/alexbudure/queuedash/commit/1fd1326ebc22045c78ecdbbceef2856fcce6cbb6) Thanks [@alexbudure](https://github.com/alexbudure)! - - Job scheduler support 8 | - Per-job logs 9 | - Global pause & resume 10 | 11 | ### Patch Changes 12 | 13 | - Updated dependencies [[`1fd1326`](https://github.com/alexbudure/queuedash/commit/1fd1326ebc22045c78ecdbbceef2856fcce6cbb6)]: 14 | - @queuedash/api@3.6.0 15 | 16 | ## 3.5.0 17 | 18 | ### Minor Changes 19 | 20 | - [#44](https://github.com/alexbudure/queuedash/pull/44) [`ba73bcf`](https://github.com/alexbudure/queuedash/commit/ba73bcf1afec112e6916a4c6beb132a8d9c7edd4) Thanks [@huv1k](https://github.com/huv1k)! - Add support for hono 21 | 22 | ### Patch Changes 23 | 24 | - Updated dependencies [[`ba73bcf`](https://github.com/alexbudure/queuedash/commit/ba73bcf1afec112e6916a4c6beb132a8d9c7edd4)]: 25 | - @queuedash/api@3.5.0 26 | 27 | ## 3.4.0 28 | 29 | ### Minor Changes 30 | 31 | - [#40](https://github.com/alexbudure/queuedash/pull/40) [`0ccd37a`](https://github.com/alexbudure/queuedash/commit/0ccd37afe5a3d8b158109d1ec80a02cead1480bf) Thanks [@fukouda](https://github.com/fukouda)! - Add the ability to pass in hook handlers on Fastify 32 | 33 | ### Patch Changes 34 | 35 | - Updated dependencies [[`0ccd37a`](https://github.com/alexbudure/queuedash/commit/0ccd37afe5a3d8b158109d1ec80a02cead1480bf)]: 36 | - @queuedash/api@3.4.0 37 | 38 | ## 3.3.0 39 | 40 | ### Minor Changes 41 | 42 | - [#38](https://github.com/alexbudure/queuedash/pull/38) [`00346f4`](https://github.com/alexbudure/queuedash/commit/00346f4c11fdae742dae1981061f044aee66697b) Thanks [@alexbudure](https://github.com/alexbudure)! - Improve React 19 compatibility 43 | 44 | ### Patch Changes 45 | 46 | - Updated dependencies [[`00346f4`](https://github.com/alexbudure/queuedash/commit/00346f4c11fdae742dae1981061f044aee66697b)]: 47 | - @queuedash/api@3.3.0 48 | 49 | ## 3.2.0 50 | 51 | ### Minor Changes 52 | 53 | - [`8e5c7ac`](https://github.com/alexbudure/queuedash/commit/8e5c7ac06bb13674e32b4cd9b1b7c65913e122af) Thanks [@alexbudure](https://github.com/alexbudure)! - Fix importing API 54 | 55 | ### Patch Changes 56 | 57 | - Updated dependencies [[`8e5c7ac`](https://github.com/alexbudure/queuedash/commit/8e5c7ac06bb13674e32b4cd9b1b7c65913e122af)]: 58 | - @queuedash/api@3.2.0 59 | 60 | ## 3.1.0 61 | 62 | ### Minor Changes 63 | 64 | - [#33](https://github.com/alexbudure/queuedash/pull/33) [`011ad3b`](https://github.com/alexbudure/queuedash/commit/011ad3bca2b50b4568fa7edc7bf314948e84eeb9) Thanks [@alexbudure](https://github.com/alexbudure)! - Fix bundle strategy for API 65 | 66 | ### Patch Changes 67 | 68 | - Updated dependencies [[`011ad3b`](https://github.com/alexbudure/queuedash/commit/011ad3bca2b50b4568fa7edc7bf314948e84eeb9)]: 69 | - @queuedash/api@3.1.0 70 | 71 | ## 3.0.0 72 | 73 | ### Major Changes 74 | 75 | - [#21](https://github.com/alexbudure/queuedash/pull/21) [`6692303`](https://github.com/alexbudure/queuedash/commit/6692303bde835b9934e2ae962e4727357f0d4afe) Thanks [@alexbudure](https://github.com/alexbudure)! - This version upgrades core dependencies to their latest major versions, including Elysia, BullMQ, and tRPC 76 | 77 | ### Patch Changes 78 | 79 | - Updated dependencies [[`6692303`](https://github.com/alexbudure/queuedash/commit/6692303bde835b9934e2ae962e4727357f0d4afe)]: 80 | - @queuedash/api@3.0.0 81 | 82 | ## 2.1.1 83 | 84 | ### Patch Changes 85 | 86 | - [#26](https://github.com/alexbudure/queuedash/pull/26) [`2e0956c`](https://github.com/alexbudure/queuedash/commit/2e0956c586b9f5f3190f169e363f76230d037686) Thanks [@p3drosola](https://github.com/p3drosola)! - Improve UI support for long job ids 87 | 88 | - Updated dependencies [[`2e0956c`](https://github.com/alexbudure/queuedash/commit/2e0956c586b9f5f3190f169e363f76230d037686)]: 89 | - @queuedash/api@2.1.1 90 | 91 | ## 2.1.0 92 | 93 | ### Minor Changes 94 | 95 | - [#23](https://github.com/alexbudure/queuedash/pull/23) [`7a2e3c0`](https://github.com/alexbudure/queuedash/commit/7a2e3c000da0b34c4c3a4dd2471e2e19738d1e6d) Thanks [@alexbudure](https://github.com/alexbudure)! - Fix bull api differences 96 | 97 | ### Patch Changes 98 | 99 | - Updated dependencies [[`7a2e3c0`](https://github.com/alexbudure/queuedash/commit/7a2e3c000da0b34c4c3a4dd2471e2e19738d1e6d)]: 100 | - @queuedash/api@2.1.0 101 | 102 | ## 2.0.5 103 | 104 | ### Patch Changes 105 | 106 | - [`bc47dd5`](https://github.com/alexbudure/queuedash/commit/bc47dd5de7a5ed32cd82365dc27073282afc45be) Thanks [@alexbudure](https://github.com/alexbudure)! - Fix express bundling with app 107 | 108 | - Updated dependencies [[`bc47dd5`](https://github.com/alexbudure/queuedash/commit/bc47dd5de7a5ed32cd82365dc27073282afc45be)]: 109 | - @queuedash/api@2.0.5 110 | 111 | ## 2.0.4 112 | 113 | ### Patch Changes 114 | 115 | - [`0948ec2`](https://github.com/alexbudure/queuedash/commit/0948ec21985d33b3ffbb0ec220664493382579da) Thanks [@alexbudure](https://github.com/alexbudure)! - Move html into each adapter 116 | 117 | - Updated dependencies [[`0948ec2`](https://github.com/alexbudure/queuedash/commit/0948ec21985d33b3ffbb0ec220664493382579da)]: 118 | - @queuedash/api@2.0.4 119 | 120 | ## 2.0.3 121 | 122 | ### Patch Changes 123 | 124 | - [`dee7163`](https://github.com/alexbudure/queuedash/commit/dee71633d33c8bceee9bde84a0b340f899adeaf8) Thanks [@alexbudure](https://github.com/alexbudure)! - Remove unnecessary express app in adapter 125 | 126 | - Updated dependencies [[`dee7163`](https://github.com/alexbudure/queuedash/commit/dee71633d33c8bceee9bde84a0b340f899adeaf8)]: 127 | - @queuedash/api@2.0.3 128 | 129 | ## 2.0.2 130 | 131 | ### Patch Changes 132 | 133 | - [`6680a6a`](https://github.com/alexbudure/queuedash/commit/6680a6a5ece43fef248fedacb31f8fae2242d2d3) Thanks [@alexbudure](https://github.com/alexbudure)! - Fix the adapters.. again 134 | 135 | - Updated dependencies [[`6680a6a`](https://github.com/alexbudure/queuedash/commit/6680a6a5ece43fef248fedacb31f8fae2242d2d3)]: 136 | - @queuedash/api@2.0.2 137 | 138 | ## 2.0.1 139 | 140 | ### Patch Changes 141 | 142 | - [`8d2eadd`](https://github.com/alexbudure/queuedash/commit/8d2eadd9ad547ff2e893662474a228bf340f0728) Thanks [@alexbudure](https://github.com/alexbudure)! - Fix the middlewares for Fastify and Express 143 | 144 | - Updated dependencies [[`8d2eadd`](https://github.com/alexbudure/queuedash/commit/8d2eadd9ad547ff2e893662474a228bf340f0728)]: 145 | - @queuedash/api@2.0.1 146 | 147 | ## 2.0.0 148 | 149 | ### Major Changes 150 | 151 | - [#5](https://github.com/alexbudure/queuedash/pull/5) [`1f794d1`](https://github.com/alexbudure/queuedash/commit/1f794d1679225718dcc670e9c7eb59564fee1bc6) Thanks [@alexbudure](https://github.com/alexbudure)! - Updated all adapters to use a more natural API and added real-time Redis info on the queue detail page 152 | 153 | *** 154 | 155 | ### Breaking changes 156 | 157 | **Express** 158 | 159 | Before: 160 | 161 | ```typescript 162 | createQueueDashExpressMiddleware({ 163 | app, 164 | baseUrl: "/queuedash", 165 | ctx: { 166 | queues: [ 167 | { 168 | queue: new Bull("report-queue"), 169 | displayName: "Reports", 170 | type: "bull" as const, 171 | }, 172 | ], 173 | }, 174 | }); 175 | ``` 176 | 177 | After: 178 | 179 | ```typescript 180 | app.use( 181 | "/queuedash", 182 | createQueueDashExpressMiddleware({ 183 | ctx: { 184 | queues: [ 185 | { 186 | queue: new Bull("report-queue"), 187 | displayName: "Reports", 188 | type: "bull" as const, 189 | }, 190 | ], 191 | }, 192 | }), 193 | ); 194 | ``` 195 | 196 | **Fastify** 197 | 198 | Before: 199 | 200 | ```typescript 201 | createQueueDashFastifyMiddleware({ 202 | server, 203 | baseUrl: "/queuedash", 204 | ctx: { 205 | queues: [ 206 | { 207 | queue: new Bull("report-queue"), 208 | displayName: "Reports", 209 | type: "bull" as const, 210 | }, 211 | ], 212 | }, 213 | }); 214 | ``` 215 | 216 | After: 217 | 218 | ```typescript 219 | server.register(fastifyQueueDashPlugin, { 220 | baseUrl: "/queuedash", 221 | ctx: { 222 | queues: [ 223 | { 224 | queue: new Bull("report-queue"), 225 | displayName: "Reports", 226 | type: "bull" as const, 227 | }, 228 | ], 229 | }, 230 | }); 231 | ``` 232 | 233 | ### Patch Changes 234 | 235 | - Updated dependencies [[`1f794d1`](https://github.com/alexbudure/queuedash/commit/1f794d1679225718dcc670e9c7eb59564fee1bc6)]: 236 | - @queuedash/api@2.0.0 237 | 238 | ## 1.2.1 239 | 240 | ### Patch Changes 241 | 242 | - [#12](https://github.com/alexbudure/queuedash/pull/12) [`d79c8ff`](https://github.com/alexbudure/queuedash/commit/d79c8ffe34ae36c74d0663dd2e29e6c93327bf8c) Thanks [@alexbudure](https://github.com/alexbudure)! - Fix Elysia plugin 243 | 244 | - Updated dependencies [[`d79c8ff`](https://github.com/alexbudure/queuedash/commit/d79c8ffe34ae36c74d0663dd2e29e6c93327bf8c)]: 245 | - @queuedash/api@1.2.1 246 | 247 | ## 1.2.0 248 | 249 | ### Minor Changes 250 | 251 | - [`9aaec9a`](https://github.com/alexbudure/queuedash/commit/9aaec9a21c091680cb30a67e9322eedd3e16dbe8) Thanks [@alexbudure](https://github.com/alexbudure)! - Support for Elysia 252 | 253 | ### Patch Changes 254 | 255 | - Updated dependencies [[`9aaec9a`](https://github.com/alexbudure/queuedash/commit/9aaec9a21c091680cb30a67e9322eedd3e16dbe8)]: 256 | - @queuedash/api@1.2.0 257 | 258 | ## 1.1.0 259 | 260 | ### Minor Changes 261 | 262 | - [#7](https://github.com/alexbudure/queuedash/pull/7) [`885aee3`](https://github.com/alexbudure/queuedash/commit/885aee3cecac687d05f5b18cd1855fcb5522f899) Thanks [@alexbudure](https://github.com/alexbudure)! - Add support for prioritized jobs 263 | 264 | ### Patch Changes 265 | 266 | - Updated dependencies [[`885aee3`](https://github.com/alexbudure/queuedash/commit/885aee3cecac687d05f5b18cd1855fcb5522f899)]: 267 | - @queuedash/api@1.1.0 268 | 269 | ## 1.0.1 270 | 271 | ### Patch Changes 272 | 273 | - [#3](https://github.com/alexbudure/queuedash/pull/3) [`a385f9f`](https://github.com/alexbudure/queuedash/commit/a385f9f9e76df4cea8e69d7e218b65915acef3bf) Thanks [@alexbudure](https://github.com/alexbudure)! - Tighten adapter types to work with NestJS 274 | 275 | - Updated dependencies [[`a385f9f`](https://github.com/alexbudure/queuedash/commit/a385f9f9e76df4cea8e69d7e218b65915acef3bf)]: 276 | - @queuedash/api@1.0.1 277 | 278 | ## 1.0.0 279 | 280 | ### Major Changes 281 | 282 | - [#1](https://github.com/alexbudure/queuedash/pull/1) [`c96b93d`](https://github.com/alexbudure/queuedash/commit/c96b93d9659bbb34248ab377e6659ebfb1fc3dd8) Thanks [@alexbudure](https://github.com/alexbudure)! - QueueDash v1 🎉 283 | 284 | - 😍  Simple, clean, and compact UI 285 | - 🧙  Add jobs to your queue with ease 286 | - 🪄  Retry, remove, and more convenient actions for your jobs 287 | - 📊  Stats for job counts, job durations, and job wait times 288 | - ✨  Top-level overview page of all queues 289 | - 🔋  Integrates with Next.js, Express.js, and Fastify 290 | - ⚡️  Compatible with Bull, BullMQ, and Bee-Queue 291 | 292 | ### Patch Changes 293 | 294 | - Updated dependencies [[`c96b93d`](https://github.com/alexbudure/queuedash/commit/c96b93d9659bbb34248ab377e6659ebfb1fc3dd8)]: 295 | - @queuedash/api@1.0.0 296 | -------------------------------------------------------------------------------- /packages/ui/README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | QueueDash 4 | 5 |

6 | 7 |

8 | A stunning, sleek dashboard for Bull, BullMQ, and Bee-Queue. 9 |

10 | 11 |

12 | 13 | 14 | 15 | 16 | 17 | 18 |

19 | 20 | ## Features 21 | 22 | - 😍  Simple, clean, and compact UI 23 | - 🧙  Add jobs to your queue with ease 24 | - 🪄  Retry, remove, and more convenient actions for your jobs 25 | - 📊  Stats for job counts, job durations, and job wait times 26 | - ✨  Top-level overview page of all queues 27 | - 🔋  Integrates with Next.js, Express.js, and Fastify 28 | - ⚡️  Compatible with Bull, BullMQ, and Bee-Queue 29 | 30 | ## Getting Started 31 | 32 | ### Express 33 | 34 | `pnpm install @queuedash/api` 35 | 36 | ```typescript 37 | import express from "express"; 38 | import Bull from "bull"; 39 | import { createQueueDashExpressMiddleware } from "@queuedash/api"; 40 | 41 | const app = express(); 42 | 43 | const reportQueue = new Bull("report-queue"); 44 | 45 | app.use( 46 | "/queuedash", 47 | createQueueDashExpressMiddleware({ 48 | ctx: { 49 | queues: [ 50 | { 51 | queue: reportQueue, 52 | displayName: "Reports", 53 | type: "bull" as const, 54 | }, 55 | ], 56 | }, 57 | }) 58 | ); 59 | 60 | app.listen(3000, () => { 61 | console.log("Listening on port 3000"); 62 | console.log("Visit http://localhost:3000/queuedash"); 63 | }); 64 | ``` 65 | 66 | ### Next.js 67 | 68 | `pnpm install @queuedash/api @queuedash/ui` 69 | 70 | ```typescript jsx 71 | // pages/admin/queuedash/[[...slug]].tsx 72 | import { QueueDashApp } from "@queuedash/ui"; 73 | 74 | function getBaseUrl() { 75 | if (process.env.VERCEL_URL) { 76 | return `https://${process.env.VERCEL_URL}/api/queuedash`; 77 | } 78 | 79 | return `http://localhost:${process.env.PORT ?? 3000}/api/queuedash`; 80 | } 81 | 82 | const QueueDashPages = () => { 83 | return ; 84 | }; 85 | 86 | export default QueueDashPages; 87 | 88 | // pages/api/queuedash/[trpc].ts 89 | import * as trpcNext from "@trpc/server/adapters/next"; 90 | import { appRouter } from "@queuedash/api"; 91 | 92 | const reportQueue = new Bull("report-queue"); 93 | 94 | export default trpcNext.createNextApiHandler({ 95 | router: appRouter, 96 | batching: { 97 | enabled: true, 98 | }, 99 | createContext: () => ({ 100 | queues: [ 101 | { 102 | queue: reportQueue, 103 | displayName: "Reports", 104 | type: "bull" as const, 105 | }, 106 | ], 107 | }), 108 | }); 109 | ``` 110 | 111 | See the [./examples](./examples) folder for more. 112 | 113 | --- 114 | 115 | ## API Reference 116 | 117 | ### `createQueueDash<*>Middleware` 118 | 119 | ```typescript 120 | type QueueDashMiddlewareOptions = { 121 | app: express.Application | FastifyInstance; // Express or Fastify app 122 | baseUrl: string; // Base path for the API and UI 123 | ctx: QueueDashContext; // Context for the UI 124 | }; 125 | 126 | type QueueDashContext = { 127 | queues: QueueDashQueue[]; // Array of queues to display 128 | }; 129 | 130 | type QueueDashQueue = { 131 | queue: Bull.Queue | BullMQ.Queue | BeeQueue; // Queue instance 132 | displayName: string; // Display name for the queue 133 | type: "bull" | "bullmq" | "bee"; // Queue type 134 | }; 135 | ``` 136 | 137 | ### `` 138 | 139 | ```typescript jsx 140 | type QueueDashAppProps = { 141 | apiUrl: string; // URL to the API endpoint 142 | basename: string; // Base path for the app 143 | }; 144 | ``` 145 | 146 | ## Roadmap 147 | 148 | - Supports Celery and other queueing systems 149 | - Command+K bar and shortcuts 150 | - Ability to whitelabel the UI 151 | 152 | ## Pro Version 153 | 154 | Right now, QueueDash simply taps into your Redis instance, making it very easy to set up, but also limited in functionality. 155 | 156 | I'm thinking about building a free-to-host version on top of this which will require external services (db, auth, etc.), but it will make the following features possible: 157 | 158 | - Alerts and notifications 159 | - Quick search and filtering 160 | - Queue trends and analytics 161 | - Invite team members 162 | 163 | If you're interested in this version, please let me know! 164 | 165 | ## Acknowledgements 166 | 167 | QueueDash was inspired by some great open source projects. Here's a few of them: 168 | 169 | - [bull-board](https://github.com/vcapretz/bull-board) 170 | - [bull-monitor](https://github.com/s-r-x/bull-monitor) 171 | - [bull-arena](https://github.com/bee-queue/arena) 172 | -------------------------------------------------------------------------------- /packages/ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@queuedash/ui", 3 | "version": "3.6.0", 4 | "description": "A stunning, sleek dashboard for Bull, BullMQ, and Bee-Queue", 5 | "main": "./dist/main.js", 6 | "module": "./dist/main.mjs", 7 | "types": "./dist/src/main.d.ts", 8 | "files": [ 9 | "dist" 10 | ], 11 | "keywords": [ 12 | "bull", 13 | "bee-queue", 14 | "queue", 15 | "bullmq", 16 | "dashboard" 17 | ], 18 | "scripts": { 19 | "build": "tsc && vite build && npx tailwindcss -i src/styles/global.css -o ./dist/styles.css", 20 | "dev": "pnpm run build --watch", 21 | "tailwind": "npx tailwindcss -i src/styles/global.css -o ./dist/styles.css --watch", 22 | "lint": "eslint ./ --fix" 23 | }, 24 | "dependencies": { 25 | "@monaco-editor/react": "^4.7.0", 26 | "@queuedash/api": "workspace:*", 27 | "@radix-ui/react-checkbox": "^1.1.4", 28 | "@radix-ui/react-icons": "^1.3.2", 29 | "@tanstack/react-query": "^5.71.10", 30 | "@tanstack/react-table": "^8.21.2", 31 | "@trpc/client": "^11.0.2", 32 | "@trpc/react-query": "^11.0.2", 33 | "@trpc/server": "^11.0.2", 34 | "clsx": "^2.1.1", 35 | "cronstrue": "^2.61.0", 36 | "date-fns": "^4.1.0", 37 | "date-fns-tz": "^3.2.0", 38 | "monaco-editor": "^0.52.2", 39 | "react-aria-components": "^1.7.1", 40 | "react-intersection-observer": "^9.16.0", 41 | "react-json-tree": "^0.20.0", 42 | "react-router": "^7.5.0", 43 | "sonner": "^2.0.3", 44 | "zod": "^3.24.2" 45 | }, 46 | "devDependencies": { 47 | "@rollup/plugin-typescript": "^12.1.2", 48 | "@types/react": "^19.1.0", 49 | "@types/react-dom": "^19.1.1", 50 | "@typescript-eslint/eslint-plugin": "^8.29.0", 51 | "@typescript-eslint/parser": "^8.29.0", 52 | "@vitejs/plugin-react": "^4.3.4", 53 | "autoprefixer": "^10.4.21", 54 | "eslint": "^9.24.0", 55 | "eslint-config-prettier": "^10.1.1", 56 | "eslint-plugin-tailwindcss": "^3.18.0", 57 | "postcss": "^8.5.3", 58 | "prettier": "^3.5.3", 59 | "react": "^19.1.0", 60 | "react-dom": "^19.1.0", 61 | "rollup-plugin-typescript-paths": "^1.5.0", 62 | "tailwindcss": "^3.4.16", 63 | "tailwindcss-radix": "^3.0.5", 64 | "typescript": "^5.8.3", 65 | "vite": "^5.4.19" 66 | }, 67 | "peerDependencies": { 68 | "react": ">=18", 69 | "react-dom": ">=18" 70 | }, 71 | "license": "MIT" 72 | } 73 | -------------------------------------------------------------------------------- /packages/ui/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /packages/ui/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 2 | import { httpBatchLink } from "@trpc/client"; 3 | import { useEffect, useState } from "react"; 4 | import { trpc } from "./utils/trpc"; 5 | import { BrowserRouter, Route, Routes } from "react-router"; 6 | import { QueuePage } from "./pages/QueuePage"; 7 | import type { UserPreferences } from "./components/ThemeSwitcher"; 8 | import { HomePage } from "./pages/HomePage"; 9 | import { Toaster } from "sonner"; 10 | 11 | type QueueDashPagesProps = { 12 | // URL to the API 13 | apiUrl: string; 14 | // Base path for the app 15 | basename: string; 16 | }; 17 | export const App = ({ apiUrl, basename }: QueueDashPagesProps) => { 18 | const [ready, setReady] = useState(false); 19 | const [queryClient] = useState(() => new QueryClient()); 20 | const [trpcClient] = useState(() => 21 | trpc.createClient({ 22 | links: [ 23 | httpBatchLink({ 24 | url: apiUrl, 25 | }), 26 | ], 27 | }), 28 | ); 29 | 30 | useEffect(() => { 31 | const userPreferences: UserPreferences | null = JSON.parse( 32 | localStorage.getItem("user-preferences") || "null", 33 | ); 34 | 35 | if (userPreferences) { 36 | if ( 37 | userPreferences.theme === "dark" || 38 | (userPreferences.theme === "system" && 39 | window.matchMedia("(prefers-color-scheme: dark)").matches) 40 | ) { 41 | document.documentElement.classList.add("dark"); 42 | } else { 43 | document.documentElement.classList.remove("dark"); 44 | } 45 | } else { 46 | if (window.matchMedia("(prefers-color-scheme: dark)").matches) { 47 | document.documentElement.classList.add("dark"); 48 | } else { 49 | document.documentElement.classList.remove("dark"); 50 | } 51 | } 52 | 53 | setReady(true); 54 | }, []); 55 | 56 | if (!ready) { 57 | return null; 58 | } 59 | 60 | return ( 61 | 62 | 63 | 64 | 65 | 66 | } /> 67 | } /> 68 | 69 | 70 | 71 | 72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /packages/ui/src/components/ActionMenu.tsx: -------------------------------------------------------------------------------- 1 | import { DotsVerticalIcon } from "@radix-ui/react-icons"; 2 | import { 3 | Button, 4 | Menu, 5 | MenuItem, 6 | MenuTrigger, 7 | Popover, 8 | } from "react-aria-components"; 9 | 10 | type Action = { 11 | label: string; 12 | onSelect: () => void; 13 | icon: React.ReactNode; 14 | }; 15 | type ActionMenuProps = { 16 | actions: Action[]; 17 | // ariaLabel: todo: add aria-label 18 | }; 19 | export const ActionMenu = ({ actions }: ActionMenuProps) => { 20 | return ( 21 | 22 | 28 | 29 | 34 | 35 | {actions.map((action) => { 36 | return ( 37 | 42 | {action.icon} 43 | {action.label} 44 | 45 | ); 46 | })} 47 | 48 | 49 | 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /packages/ui/src/components/Alert.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren, ReactElement } from "react"; 2 | import { Button } from "./Button"; 3 | import { 4 | Button as AriaButton, 5 | Dialog, 6 | DialogTrigger, 7 | Heading, 8 | Modal, 9 | } from "react-aria-components"; 10 | 11 | type AlertProps = { 12 | title: string; 13 | description: string; 14 | action: ReactElement; 15 | }; 16 | export const Alert = ({ 17 | title, 18 | description, 19 | action, 20 | children, 21 | }: PropsWithChildren) => { 22 | return ( 23 | 24 | {children} 25 | 26 | 30 | {({ close }) => ( 31 | <> 32 | 33 | {title} 34 | 35 |

{description}

36 |
37 |
40 | 41 | )} 42 |
43 |
44 |
45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /packages/ui/src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { SlashIcon } from "@radix-ui/react-icons"; 2 | import type { ReactElement } from "react"; 3 | import { clsx } from "clsx"; 4 | 5 | type ButtonProps = { 6 | variant?: "outline" | "filled"; 7 | colorScheme?: "yellow" | "slate" | "red"; 8 | size?: "sm" | "md" | "lg"; 9 | icon?: ReactElement; 10 | label: string; 11 | isLoading?: boolean; 12 | disabled?: boolean; 13 | onClick?: () => void; 14 | }; 15 | export const Button = ({ 16 | colorScheme = "slate", 17 | variant = "outline", 18 | size = "md", 19 | icon, 20 | label, 21 | isLoading, 22 | onClick, 23 | disabled, 24 | }: ButtonProps) => { 25 | return ( 26 | 70 | ); 71 | }; 72 | -------------------------------------------------------------------------------- /packages/ui/src/components/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as RadixCheckbox from "@radix-ui/react-checkbox"; 2 | import { CheckIcon, DividerHorizontalIcon } from "@radix-ui/react-icons"; 3 | import { clsx } from "clsx"; 4 | 5 | export const Checkbox = ({ 6 | className, 7 | ...props 8 | }: RadixCheckbox.CheckboxProps) => { 9 | return ( 10 | e.stopPropagation()} 24 | {...props} 25 | > 26 | 27 | {props.checked === "indeterminate" && ( 28 | 29 | )} 30 | {props.checked === true && } 31 | 32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /packages/ui/src/components/ErrorCard.tsx: -------------------------------------------------------------------------------- 1 | type ErrorCardProps = { 2 | message: string; 3 | }; 4 | export const ErrorCard = ({ message }: ErrorCardProps) => { 5 | return ( 6 |
7 |
8 |

{message}

9 |
10 |
11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /packages/ui/src/components/JobActionMenu.tsx: -------------------------------------------------------------------------------- 1 | import { ActionMenu } from "./ActionMenu"; 2 | import type { Job } from "../utils/trpc"; 3 | import { trpc } from "../utils/trpc"; 4 | import { 5 | CounterClockwiseClockIcon, 6 | HobbyKnifeIcon, 7 | TrashIcon, 8 | } from "@radix-ui/react-icons"; 9 | import { useEffect } from "react"; 10 | 11 | type JobActionMenuProps = { 12 | job: Job; 13 | queueName: string; 14 | onRemove?: () => void; 15 | }; 16 | export const JobActionMenu = ({ 17 | job, 18 | queueName, 19 | onRemove, 20 | }: JobActionMenuProps) => { 21 | const { mutate: retry, isSuccess: retrySuccess } = 22 | trpc.job.retry.useMutation(); 23 | const { mutate: discard, isSuccess: discardSuccess } = 24 | trpc.job.discard.useMutation(); 25 | const { mutate: rerun, isSuccess: rerunSuccess } = 26 | trpc.job.rerun.useMutation(); 27 | const { mutate: remove, isSuccess: removeSuccess } = 28 | trpc.job.remove.useMutation(); 29 | 30 | useEffect(() => { 31 | if (retrySuccess || discardSuccess || rerunSuccess || removeSuccess) { 32 | onRemove?.(); 33 | } 34 | }, [retrySuccess, discardSuccess, rerunSuccess, removeSuccess, onRemove]); 35 | 36 | const input = { 37 | queueName, 38 | jobId: job.id, 39 | }; 40 | 41 | return ( 42 | { 49 | retry(input); 50 | }, 51 | icon: , 52 | }, 53 | ] 54 | : []), 55 | ...(job.finishedAt 56 | ? [ 57 | { 58 | label: "Rerun", 59 | onSelect: () => { 60 | rerun(input); 61 | }, 62 | icon: , 63 | }, 64 | ] 65 | : [ 66 | { 67 | label: "Discard", 68 | onSelect: () => { 69 | discard(input); 70 | }, 71 | icon: , 72 | }, 73 | ]), 74 | 75 | { 76 | label: "Remove", 77 | onSelect: () => { 78 | remove(input); 79 | }, 80 | icon: , 81 | }, 82 | ]} 83 | /> 84 | ); 85 | }; 86 | -------------------------------------------------------------------------------- /packages/ui/src/components/JobModal.tsx: -------------------------------------------------------------------------------- 1 | import { CounterClockwiseClockIcon, Cross2Icon } from "@radix-ui/react-icons"; 2 | import type { Job } from "../utils/trpc"; 3 | import { JSONTree } from "react-json-tree"; 4 | import { JobActionMenu } from "./JobActionMenu"; 5 | import { Button } from "./Button"; 6 | import { trpc } from "../utils/trpc"; 7 | import { Dialog, Heading, Modal } from "react-aria-components"; 8 | 9 | type JobModalProps = { 10 | job: Job; 11 | onDismiss: () => void; 12 | queueName: string; 13 | }; 14 | 15 | export const JobModal = ({ job, queueName, onDismiss }: JobModalProps) => { 16 | const queueReq = trpc.queue.byName.useQuery({ 17 | queueName, 18 | }); 19 | 20 | const { mutate: retry } = trpc.job.retry.useMutation(); 21 | const { data } = trpc.job.logs.useQuery({ 22 | jobId: job.id, 23 | queueName, 24 | }); 25 | 26 | return ( 27 | { 31 | if (!isOpen) { 32 | onDismiss(); 33 | } 34 | }} 35 | className="fixed inset-0 bg-black/10" 36 | > 37 | 38 | {({ close }) => ( 39 | <> 40 | 50 | 51 |
52 | 53 | 54 | {job.name} 55 | 56 | 57 | #{job.id} 58 | 59 | 64 |
65 | 66 |
67 | {job.failedReason ? ( 68 |
69 |

70 | Error 71 |

72 |

{job.failedReason}

73 | {/*TODO: Add stacktrace */} 74 |
87 | ) : null} 88 | 89 |
90 |

91 | Options 92 |

93 |
94 | 95 |
96 |
97 | 98 |
99 |

100 | Data 101 |

102 |
103 | 104 |
105 |
106 | 107 | {queueReq.data?.type === "bullmq" ? ( 108 |
109 |

110 | Logs 111 |

112 |
113 | {data?.map((log, index) => { 114 | return ( 115 |
119 |
120 |

{log}

121 |
122 | ); 123 | })} 124 |
125 |
126 | ) : null} 127 |
128 | 129 | )} 130 |
131 |
132 | ); 133 | }; 134 | -------------------------------------------------------------------------------- /packages/ui/src/components/JobOptionTag.tsx: -------------------------------------------------------------------------------- 1 | type JobOptionTagProps = { 2 | label: string | number; 3 | icon: React.ReactNode; 4 | }; 5 | export const JobOptionTag = ({ label, icon }: JobOptionTagProps) => { 6 | return ( 7 |
8 | {icon} 9 | {label} 10 |
11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /packages/ui/src/components/JobTableSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import { JOBS_PER_PAGE } from "../utils/config"; 2 | import { clsx } from "clsx"; 3 | import { Skeleton } from "./Skeleton"; 4 | 5 | export const JobTableSkeleton = () => { 6 | return ( 7 |
8 |
9 | Placeholder 10 |
11 | {[...new Array(JOBS_PER_PAGE)].map((_, i) => { 12 | return ( 13 |
22 | 23 |
24 | ); 25 | })} 26 |
27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /packages/ui/src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import type { FC, PropsWithChildren, ReactNode } from "react"; 2 | import { trpc } from "../utils/trpc"; 3 | import { NavLink } from "react-router"; 4 | import { Skeleton } from "./Skeleton"; 5 | import { 6 | GitHubLogoIcon, 7 | DashboardIcon, 8 | ShadowNoneIcon, 9 | } from "@radix-ui/react-icons"; 10 | import { ThemeSwitcher } from "./ThemeSwitcher"; 11 | import { ErrorCard } from "./ErrorCard"; 12 | 13 | type QueueNavLinkProps = { 14 | to: string; 15 | label: string; 16 | icon?: ReactNode; 17 | }; 18 | const QueueNavLink = ({ to, label, icon }: QueueNavLinkProps) => { 19 | return ( 20 | 23 | `relative -ml-px backdrop-blur-50 flex w-full items-center space-x-2 rounded-md pl-4 py-1 transition duration-150 ease-in-out ${ 24 | isActive 25 | ? "bg-slate-100/90 font-medium text-slate-900 dark:bg-slate-900 dark:text-brand-300" 26 | : "text-slate-500 dark:text-slate-400 border-slate-100 hover:bg-slate-50 dark:hover:bg-slate-900 hover:border-slate-300 hover:text-slate-900 dark:border-slate-700 dark:hover:border-slate-600 dark:hover:text-slate-100" 27 | }` 28 | } 29 | > 30 | {icon} 31 | {label} 32 | 33 | ); 34 | }; 35 | 36 | export const Layout: FC = ({ children }) => { 37 | const { data, isLoading, isError } = trpc.queue.list.useQuery(); 38 | 39 | return ( 40 |
41 |
42 |
43 | 67 |
68 |
69 | 70 |

QueueDash

71 |
72 | 73 |
74 | 78 | } 79 | to={`../`} 80 | /> 81 |
82 |
83 | {isLoading ? ( 84 | [...new Array(10)].map((_, i) => { 85 | return ; 86 | }) 87 | ) : isError ? ( 88 | 89 | ) : ( 90 | data?.map((queue) => { 91 | return ( 92 | 97 | ); 98 | }) 99 | )} 100 |
101 |
102 |
103 | 104 |
105 | 111 | 112 | 113 | 114 | 115 |
116 |
117 |
118 | 119 |
120 |
{children}
121 |
122 |
123 | ); 124 | }; 125 | -------------------------------------------------------------------------------- /packages/ui/src/components/QueueActionMenu.tsx: -------------------------------------------------------------------------------- 1 | import { ActionMenu } from "./ActionMenu"; 2 | import type { Queue } from "../utils/trpc"; 3 | import { trpc } from "../utils/trpc"; 4 | import { 5 | PauseIcon, 6 | PlayIcon, 7 | PlusIcon, 8 | TrashIcon, 9 | } from "@radix-ui/react-icons"; 10 | import { useState } from "react"; 11 | import { AddJobModal } from "./AddJobModal"; 12 | 13 | type QueueActionMenuProps = { 14 | queue: Queue; 15 | }; 16 | export const QueueActionMenu = ({ queue }: QueueActionMenuProps) => { 17 | const { mutate: pause } = trpc.queue.pause.useMutation(); 18 | const { mutate: resume } = trpc.queue.resume.useMutation(); 19 | const { mutate: empty } = trpc.queue.empty.useMutation(); 20 | const [showAddJobModal, setShowAddJobModal] = useState(false); 21 | const input = { 22 | queueName: queue.name, 23 | }; 24 | 25 | return ( 26 | <> 27 | { 32 | if (queue.paused) { 33 | resume(input); 34 | } else { 35 | pause(input); 36 | } 37 | }, 38 | icon: queue.paused ? : , 39 | }, 40 | { 41 | label: "Add job", 42 | onSelect: () => { 43 | setShowAddJobModal(true); 44 | }, 45 | icon: , 46 | }, 47 | { 48 | label: "Empty", 49 | onSelect: () => { 50 | empty(input); 51 | }, 52 | icon: , 53 | }, 54 | ]} 55 | /> 56 | {showAddJobModal ? ( 57 | setShowAddJobModal(false)} 60 | /> 61 | ) : null} 62 | 63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /packages/ui/src/components/QueueStatusTabs.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router"; 2 | import { clsx } from "clsx"; 3 | import { Button } from "./Button"; 4 | import { TrashIcon } from "@radix-ui/react-icons"; 5 | import type { RouterOutput, Status } from "../utils/trpc"; 6 | import { trpc } from "../utils/trpc"; 7 | import { Alert } from "./Alert"; 8 | import { toast } from "sonner"; 9 | 10 | type Tab = { 11 | name: string; 12 | status: Status; 13 | }; 14 | 15 | type QueueStatusTabsProps = { 16 | showCleanAllButton: boolean; 17 | status: Status; 18 | queueName: string; 19 | queue?: RouterOutput["queue"]["byName"]; 20 | }; 21 | export const QueueStatusTabs = ({ 22 | showCleanAllButton, 23 | queueName, 24 | status, 25 | queue, 26 | }: QueueStatusTabsProps) => { 27 | const { mutate: cleanQueue, status: cleanQueueStatus } = 28 | trpc.queue.clean.useMutation({ 29 | onSuccess() { 30 | toast.success(`All ${status} jobs have been removed`); 31 | }, 32 | }); 33 | 34 | const tabs: Tab[] = [ 35 | { 36 | name: "Completed", 37 | status: "completed", 38 | }, 39 | { 40 | name: "Failed", 41 | status: "failed", 42 | }, 43 | { 44 | name: "Active", 45 | status: "active", 46 | }, 47 | ...(queue && "prioritized" in queue.counts 48 | ? [{ name: "Prioritized", status: "prioritized" as const }] 49 | : []), 50 | { 51 | name: "Waiting", 52 | status: "waiting", 53 | }, 54 | { 55 | name: "Delayed", 56 | status: "delayed", 57 | }, 58 | { 59 | name: "Paused", 60 | status: "paused", 61 | }, 62 | ]; 63 | 64 | return ( 65 |
66 |
67 | {tabs.map((tab) => { 68 | const isActive = tab.status === status; 69 | return ( 70 | 95 | {tab.name} 96 | {queue?.counts[tab.status] ? ( 97 | 119 | {queue.counts[tab.status]} 120 | 121 | ) : null} 122 | 123 | ); 124 | })} 125 |
126 | {showCleanAllButton ? ( 127 | 136 | cleanQueue({ 137 | queueName, 138 | status, 139 | }) 140 | } 141 | /> 142 | } 143 | > 144 |
154 | ); 155 | }; 156 | -------------------------------------------------------------------------------- /packages/ui/src/components/SchedulerActionMenu.tsx: -------------------------------------------------------------------------------- 1 | import { ActionMenu } from "./ActionMenu"; 2 | import type { Scheduler } from "../utils/trpc"; 3 | import { trpc } from "../utils/trpc"; 4 | import { TrashIcon } from "@radix-ui/react-icons"; 5 | import { useEffect } from "react"; 6 | 7 | type SchedulerActionMenuProps = { 8 | scheduler: Scheduler; 9 | queueName: string; 10 | onRemove?: () => void; 11 | }; 12 | export const SchedulerActionMenu = ({ 13 | scheduler, 14 | queueName, 15 | onRemove, 16 | }: SchedulerActionMenuProps) => { 17 | const { mutate: remove, isSuccess: removeSuccess } = 18 | trpc.scheduler.remove.useMutation(); 19 | 20 | useEffect(() => { 21 | if (removeSuccess) { 22 | onRemove?.(); 23 | } 24 | }, [removeSuccess, onRemove]); 25 | 26 | return ( 27 | { 32 | remove({ 33 | queueName, 34 | jobSchedulerId: scheduler.key, 35 | }); 36 | }, 37 | icon: , 38 | }, 39 | ]} 40 | /> 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /packages/ui/src/components/SchedulerModal.tsx: -------------------------------------------------------------------------------- 1 | import { Cross2Icon } from "@radix-ui/react-icons"; 2 | import type { Scheduler } from "../utils/trpc"; 3 | import { JSONTree } from "react-json-tree"; 4 | import { Dialog, Heading, Modal } from "react-aria-components"; 5 | import { SchedulerActionMenu } from "./SchedulerActionMenu"; 6 | 7 | type SchedulerModalProps = { 8 | scheduler: Scheduler; 9 | queueName: string; 10 | onDismiss: () => void; 11 | }; 12 | 13 | export const SchedulerModal = ({ 14 | scheduler, 15 | queueName, 16 | onDismiss, 17 | }: SchedulerModalProps) => { 18 | const template = scheduler.template; 19 | const opts = { 20 | ...scheduler, 21 | }; 22 | delete opts.template; 23 | 24 | return ( 25 | { 29 | if (!isOpen) { 30 | onDismiss(); 31 | } 32 | }} 33 | className="fixed inset-0 bg-black/10" 34 | > 35 | 36 | {({ close }) => ( 37 | <> 38 | 48 | 49 |
50 | 51 | 52 | {scheduler.name} 53 | 54 | 55 | 56 | #{scheduler.id} 57 | 58 | 59 | 64 |
65 | 66 |
67 |
68 |

69 | Options 70 |

71 |
72 | 73 |
74 |
75 |
76 |

77 | Template 78 |

79 |
80 | 81 |
82 |
83 |
84 | 85 | )} 86 |
87 |
88 | ); 89 | }; 90 | -------------------------------------------------------------------------------- /packages/ui/src/components/SchedulerTable.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { 3 | createColumnHelper, 4 | flexRender, 5 | getCoreRowModel, 6 | useReactTable, 7 | } from "@tanstack/react-table"; 8 | import { TrashIcon } from "@radix-ui/react-icons"; 9 | import type { Scheduler } from "../utils/trpc"; 10 | import { trpc } from "../utils/trpc"; 11 | import { TableRow } from "./TableRow"; 12 | import { JobTableSkeleton } from "./JobTableSkeleton"; 13 | import { Checkbox } from "./Checkbox"; 14 | import { Button } from "./Button"; 15 | import cronstrue from "cronstrue"; 16 | import { Tooltip } from "./Tooltip"; 17 | import { format, formatDistanceToNow } from "date-fns"; 18 | import { SchedulerModal } from "./SchedulerModal"; 19 | 20 | const columnHelper = createColumnHelper(); 21 | 22 | function getTimezoneAbbreviation(timeZone: string, date: Date = new Date()) { 23 | const formatter = new Intl.DateTimeFormat("en-US", { 24 | timeZone, 25 | timeZoneName: "short", 26 | }); 27 | 28 | const parts = formatter.formatToParts(date); 29 | const tzPart = parts.find((part) => part.type === "timeZoneName"); 30 | return tzPart?.value || ""; 31 | } 32 | 33 | const columns = [ 34 | columnHelper.display({ 35 | id: "select", 36 | header: ({ table }) => ( 37 | { 43 | table.toggleAllRowsSelected(); 44 | }, 45 | }} 46 | /> 47 | ), 48 | cell: ({ row, table }) => ( 49 | 62 | ), 63 | }), 64 | columnHelper.accessor("name", { 65 | cell: (props) => ( 66 |
67 | 68 | {props.cell.row.original.name} 69 | 70 |
71 | ), 72 | header: "Scheduler", 73 | }), 74 | columnHelper.accessor("pattern", { 75 | cell: (props) => { 76 | return ( 77 | 78 | {props.cell.row.original.pattern 79 | ? cronstrue.toString(props.cell.row.original.pattern, { 80 | verbose: true, 81 | }) 82 | : props.cell.row.original.every 83 | ? `Every ${props.cell.row.original.every}` 84 | : ""}{" "} 85 | {props.cell.row.original.tz 86 | ? `(${getTimezoneAbbreviation(props.cell.row.original.tz)})` 87 | : ""} 88 | 89 | ); 90 | }, 91 | header: "Pattern", 92 | }), 93 | columnHelper.accessor("next", { 94 | cell: (props) => { 95 | if (!props.cell.row.original.next) { 96 | return ( 97 |

No next run

98 | ); 99 | } 100 | return ( 101 | 107 |
108 |

109 | In {formatDistanceToNow(new Date(props.cell.row.original.next))}{" "} 110 | 111 | ({props.cell.row.original.iterationCount} run 112 | {props.cell.row.original.iterationCount === 1 ? "" : "s"} total) 113 | 114 |

115 |
116 |
117 | ); 118 | }, 119 | header: "Next Run", 120 | }), 121 | ]; 122 | 123 | type SchedulerTableProps = { 124 | queueName: string; 125 | }; 126 | export const SchedulerTable = ({ queueName }: SchedulerTableProps) => { 127 | const [rowSelection, setRowSelection] = useState({}); 128 | const { data, isLoading } = trpc.scheduler.list.useQuery({ 129 | queueName, 130 | }); 131 | 132 | const isEmpty = data?.length === 0; 133 | 134 | const table = useReactTable({ 135 | data: data || [], 136 | columns, 137 | getCoreRowModel: getCoreRowModel(), 138 | state: { 139 | rowSelection, 140 | }, 141 | onRowSelectionChange: setRowSelection, 142 | }); 143 | 144 | const [selectedScheduler, setSelectedScheduler] = useState( 145 | null, 146 | ); 147 | 148 | const { mutate: bulkRemove } = trpc.scheduler.bulkRemove.useMutation(); 149 | 150 | if (!isLoading && isEmpty) return null; 151 | 152 | return ( 153 |
154 | {selectedScheduler ? ( 155 | setSelectedScheduler(null)} 159 | /> 160 | ) : null} 161 |
162 | {isLoading ? ( 163 | 164 | ) : ( 165 |
166 | {table.getHeaderGroups().map((headerGroup, headerIndex) => ( 167 |
171 | {headerGroup.headers.map((header) => ( 172 |
178 | {header.isPlaceholder 179 | ? null 180 | : flexRender( 181 | header.column.columnDef.header, 182 | header.getContext(), 183 | )} 184 |
185 | ))} 186 |
187 | ))} 188 | {table.getRowModel().rows.map((row, rowIndex) => ( 189 | setSelectedScheduler(row.original)} 194 | layoutVariant="scheduler" 195 | > 196 | {row.getVisibleCells().map((cell, cellIndex) => ( 197 |
203 | {flexRender(cell.column.columnDef.cell, cell.getContext())} 204 |
205 | ))} 206 |
207 | ))} 208 |
209 | )} 210 |
211 | 212 | {table.getSelectedRowModel().rows.length > 0 ? ( 213 |
214 |
215 |

{table.getSelectedRowModel().rows.length} selected

216 | 217 |
234 |
235 | ) : null} 236 |
237 | ); 238 | }; 239 | -------------------------------------------------------------------------------- /packages/ui/src/components/Skeleton.tsx: -------------------------------------------------------------------------------- 1 | type SkeletonProps = { 2 | className?: string; 3 | }; 4 | 5 | export const Skeleton = ({ className }: SkeletonProps) => { 6 | return ( 7 |
10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /packages/ui/src/components/TableRow.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from "react"; 2 | 3 | type TableRowProps = { 4 | isLastRow: boolean; 5 | isSelected: boolean; 6 | onClick: () => void; 7 | layoutVariant: "job" | "scheduler"; 8 | }; 9 | export const TableRow = ({ 10 | isLastRow, 11 | children, 12 | isSelected, 13 | onClick, 14 | layoutVariant, 15 | }: PropsWithChildren) => { 16 | return ( 17 |
28 | {children} 29 |
30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /packages/ui/src/components/ThemeSwitcher.tsx: -------------------------------------------------------------------------------- 1 | import { DesktopIcon, MoonIcon, SunIcon } from "@radix-ui/react-icons"; 2 | import { useState } from "react"; 3 | import { ToggleButtonGroup, ToggleButton } from "react-aria-components"; 4 | 5 | export function useLocalStorage>( 6 | key: string, 7 | initialValue: T, 8 | ) { 9 | // State to store our value 10 | // Pass initial state function to useState so logic is only executed once 11 | const [storedValue, setStoredValue] = useState(() => { 12 | if (typeof window === "undefined") { 13 | return initialValue; 14 | } 15 | try { 16 | // Get from local storage by key 17 | const item = window.localStorage.getItem(key); 18 | const parsedItem = item ? JSON.parse(item) : initialValue; 19 | 20 | for (const [key] of Object.entries(initialValue)) { 21 | if (typeof parsedItem[key] === "undefined") { 22 | throw new Error("Missing key in local storage"); 23 | } 24 | } 25 | 26 | // Parse stored json or if none return initialValue 27 | return parsedItem; 28 | } catch (error) { 29 | console.log(error); 30 | // If error also return initialValue 31 | return initialValue; 32 | } 33 | }); 34 | // Return a wrapped version of useState's setter function that ... 35 | // ... persists the new value to localStorage. 36 | const setValue = (value: T | ((val: T) => T)) => { 37 | try { 38 | // Allow value to be a function so we have same API as useState 39 | const valueToStore = 40 | value instanceof Function ? value(storedValue) : value; 41 | // Save state 42 | setStoredValue(valueToStore); 43 | // Save to local storage 44 | if (typeof window !== "undefined") { 45 | window.localStorage.setItem(key, JSON.stringify(valueToStore)); 46 | } 47 | } catch (error) { 48 | console.log(error); 49 | // A more advanced implementation would handle the error case 50 | } 51 | }; 52 | return [storedValue, setValue] as const; 53 | } 54 | 55 | export type UserPreferences = { 56 | theme: "light" | "dark" | "system"; 57 | }; 58 | 59 | export const ThemeSwitcher = () => { 60 | const [preferences, setPreferences] = useLocalStorage( 61 | "user-preferences", 62 | { 63 | theme: "system", 64 | }, 65 | ); 66 | 67 | return ( 68 | { 74 | if (value.has("system")) { 75 | setPreferences({ theme: "system" }); 76 | } else if (value.has("light")) { 77 | document.documentElement.classList.remove("dark"); 78 | setPreferences({ theme: "light" }); 79 | } else if (value.has("dark")) { 80 | document.documentElement.classList.add("dark"); 81 | setPreferences({ theme: "dark" }); 82 | } 83 | }} 84 | > 85 | {[ 86 | { 87 | value: "system", 88 | icon: () => , 89 | ariaLabel: "System theme", 90 | }, 91 | { 92 | value: "light", 93 | icon: () => , 94 | ariaLabel: "Light theme", 95 | }, 96 | { 97 | value: "dark", 98 | icon: () => , 99 | ariaLabel: "Dark theme", 100 | }, 101 | ].map((item) => { 102 | return ( 103 | 109 | {item.icon()} 110 | 111 | ); 112 | })} 113 | 114 | ); 115 | }; 116 | -------------------------------------------------------------------------------- /packages/ui/src/components/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from "react"; 2 | import { 3 | Button, 4 | Tooltip as ReactAriaTooltip, 5 | TooltipTrigger, 6 | } from "react-aria-components"; 7 | 8 | type TooltipProps = { 9 | message: string; 10 | }; 11 | 12 | export const Tooltip = ({ 13 | children, 14 | message, 15 | }: PropsWithChildren) => { 16 | return ( 17 | 18 | 19 | 23 | {message} 24 | 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /packages/ui/src/main.ts: -------------------------------------------------------------------------------- 1 | export { App as QueueDashApp } from "./App"; 2 | -------------------------------------------------------------------------------- /packages/ui/src/pages/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import { Layout } from "../components/Layout"; 2 | import { trpc } from "../utils/trpc"; 3 | import { NUM_OF_RETRIES, REFETCH_INTERVAL } from "../utils/config"; 4 | import { Link } from "react-router"; 5 | import { 6 | CheckCircledIcon, 7 | CrossCircledIcon, 8 | MinusCircledIcon, 9 | PauseIcon, 10 | PlayIcon, 11 | PlusCircledIcon, 12 | } from "@radix-ui/react-icons"; 13 | import { Tooltip } from "../components/Tooltip"; 14 | import type { ReactNode } from "react"; 15 | import { ActionMenu } from "../components/ActionMenu"; 16 | 17 | type CountStatProps = { 18 | count: number; 19 | variant: "completed" | "failed" | "waiting" | "active"; 20 | }; 21 | const CountStat = ({ count, variant }: CountStatProps) => { 22 | const colorMap: Record = { 23 | active: "text-cyan-600", 24 | completed: "text-green-600", 25 | failed: "text-red-600", 26 | waiting: "text-amber-600", 27 | }; 28 | const iconMap: Record = { 29 | active: , 30 | completed: , 31 | failed: , 32 | waiting: , 33 | }; 34 | return ( 35 |
36 | 37 |
40 |

{count}

41 | {iconMap[variant]} 42 |
43 |
44 |
45 | ); 46 | }; 47 | 48 | const QueueCard = ({ queueName }: { queueName: string }) => { 49 | const queueReq = trpc.queue.byName.useQuery( 50 | { 51 | queueName, 52 | }, 53 | { 54 | refetchInterval: REFETCH_INTERVAL, 55 | retry: NUM_OF_RETRIES, 56 | } 57 | ); 58 | 59 | if (!queueReq.data) return null; 60 | 61 | return ( 62 | 66 |
67 |

68 | {queueReq.data.displayName} 69 |

70 |
71 | {queueReq.data.paused ? ( 72 |
73 | Paused 74 |
75 | ) : null} 76 |
77 |
78 | 79 |
80 | 81 | 82 | 83 | 84 |
85 | 86 | ); 87 | }; 88 | export const HomePage = () => { 89 | const { data } = trpc.queue.list.useQuery(); 90 | const { mutate: pauseAll } = trpc.queue.pauseAll.useMutation(); 91 | const { mutate: resumeAll } = trpc.queue.resumeAll.useMutation(); 92 | 93 | return ( 94 | 95 |
96 |
97 |

98 | Queues 99 |

100 |
101 | , 106 | onSelect: () => resumeAll(), 107 | }, 108 | { 109 | label: "Pause all", 110 | icon: , 111 | onSelect: () => pauseAll(), 112 | }, 113 | ]} 114 | /> 115 |
116 |
117 | 118 |
119 | {data?.map((queue, index) => { 120 | return ( 121 |
129 | 130 |
131 | ); 132 | })} 133 |
134 |
135 |
136 | ); 137 | }; 138 | -------------------------------------------------------------------------------- /packages/ui/src/pages/QueuePage.tsx: -------------------------------------------------------------------------------- 1 | import type { Status } from "../utils/trpc"; 2 | import { trpc } from "../utils/trpc"; 3 | import { Layout } from "../components/Layout"; 4 | import { JobTable } from "../components/JobTable"; 5 | import { useEffect, useState } from "react"; 6 | import { 7 | JOBS_PER_PAGE, 8 | NUM_OF_RETRIES, 9 | REFETCH_INTERVAL, 10 | } from "../utils/config"; 11 | import { Skeleton } from "../components/Skeleton"; 12 | import { ErrorCard } from "../components/ErrorCard"; 13 | import { QueueStatusTabs } from "../components/QueueStatusTabs"; 14 | import { QueueActionMenu } from "../components/QueueActionMenu"; 15 | import { useParams, useSearchParams } from "react-router"; 16 | import { SchedulerTable } from "../components/SchedulerTable"; 17 | 18 | export const { format: numberFormat } = new Intl.NumberFormat("en-US"); 19 | 20 | export const QueuePage = () => { 21 | const { id } = useParams(); 22 | const queueName = id as string; 23 | 24 | const [searchParams] = useSearchParams(); 25 | 26 | const [status, setStatus] = useState("completed"); 27 | const { 28 | data, 29 | fetchNextPage, 30 | isLoading, 31 | isError, 32 | isFetchingNextPage, 33 | hasNextPage, 34 | } = trpc.job.list.useInfiniteQuery( 35 | { 36 | queueName, 37 | limit: JOBS_PER_PAGE, 38 | status, 39 | }, 40 | { 41 | getNextPageParam: (lastPage) => lastPage.nextCursor, 42 | enabled: !!queueName, 43 | refetchInterval: REFETCH_INTERVAL, 44 | retry: NUM_OF_RETRIES, 45 | }, 46 | ); 47 | 48 | useEffect(() => { 49 | const searchStatus = searchParams.get("status"); 50 | if (searchStatus) { 51 | setStatus(searchStatus as Status); 52 | } 53 | 54 | if (!searchStatus && status) { 55 | setStatus("completed"); 56 | } 57 | }, [searchParams, status]); 58 | 59 | const queueReq = trpc.queue.byName.useQuery( 60 | { 61 | queueName, 62 | }, 63 | { 64 | enabled: !!queueName, 65 | refetchInterval: REFETCH_INTERVAL, 66 | retry: NUM_OF_RETRIES, 67 | }, 68 | ); 69 | 70 | const jobs = 71 | data?.pages 72 | .map((page) => { 73 | return page.jobs; 74 | }) 75 | .flat() ?? []; 76 | 77 | return ( 78 | 79 | {queueReq.data === null ? ( 80 | 81 | ) : isError ? ( 82 | 83 | ) : ( 84 |
85 |
86 |
87 |

88 | {queueReq.data ? ( 89 | queueReq.data.displayName 90 | ) : ( 91 | 92 | )} 93 |

94 |
95 | {queueReq.data ? ( 96 | 97 | ) : null} 98 | {queueReq.data?.paused ? ( 99 |
100 | Paused 101 |
102 | ) : null} 103 |
104 |
105 |
106 |
107 |
108 | {queueReq.data ? ( 109 | <> 110 |

111 | 112 | {numberFormat(queueReq.data.client.connectedClients)}{" "} 113 | connected 114 | {" "} 115 | and{" "} 116 | 117 | {numberFormat(queueReq.data.client.blockedClients)}{" "} 118 | blocked 119 | {" "} 120 | out of{" "} 121 | 122 | {numberFormat(queueReq.data.client.maxClients)} max 123 | clients 124 | 125 |

126 |

·

127 |

128 | {queueReq.data.client.usedMemoryHuman} /{" "} 129 | {queueReq.data.client.totalMemoryHuman} ( 130 | {(queueReq.data.client.usedMemoryPercentage * 100).toFixed( 131 | 2, 132 | )} 133 | %) 134 |

135 |

·

136 |

137 | Redis v{queueReq.data.client.version} 138 |

139 | 140 | ) : ( 141 | 142 | )} 143 |
144 |
145 | 146 |
147 | {queueReq.data?.type === "bullmq" ? ( 148 | 149 | ) : null} 150 | 151 |
152 | 0} 154 | queueName={queueName} 155 | status={status} 156 | queue={queueReq.data} 157 | /> 158 | { 160 | if (isFetchingNextPage || !hasNextPage) return; 161 | fetchNextPage(); 162 | }} 163 | status={status} 164 | totalJobs={data?.pages.at(-1)?.totalCount || 0} 165 | jobs={jobs.map((j) => ({ ...j, status }))} 166 | isLoading={isLoading} 167 | isFetchingNextPage={isFetchingNextPage} 168 | queueName={queueName} 169 | /> 170 |
171 |
172 |
173 | )} 174 |
175 | ); 176 | }; 177 | -------------------------------------------------------------------------------- /packages/ui/src/styles/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .data-json-renderer * { 6 | font-family: monospace; 7 | } 8 | 9 | .data-json-renderer > ul { 10 | padding: 4px 16px 12px 16px !important; 11 | overflow-y: scroll; 12 | border-radius: 8px; 13 | margin: 0 !important; 14 | border: 1px solid #e2e8f0 !important; 15 | } -------------------------------------------------------------------------------- /packages/ui/src/utils/config.ts: -------------------------------------------------------------------------------- 1 | export const JOBS_PER_PAGE = 30; 2 | export const REFETCH_INTERVAL = 2000; 3 | export const NUM_OF_RETRIES = 2; 4 | -------------------------------------------------------------------------------- /packages/ui/src/utils/trpc.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCReact } from "@trpc/react-query"; 2 | import type { AppRouter } from "@queuedash/api"; 3 | import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; 4 | 5 | export type RouterOutput = inferRouterOutputs; 6 | export type RouterInput = inferRouterInputs; 7 | 8 | export type Job = RouterOutput["job"]["list"]["jobs"][0]; 9 | export type Queue = RouterOutput["queue"]["byName"]; 10 | export type Status = RouterInput["job"]["list"]["status"]; 11 | export type Scheduler = RouterOutput["scheduler"]["list"][0]; 12 | 13 | export const trpc = createTRPCReact({ 14 | overrides: { 15 | useMutation: { 16 | async onSuccess(opts) { 17 | await opts.originalFn(); 18 | await opts.queryClient.invalidateQueries(); 19 | }, 20 | }, 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /packages/ui/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./src/**/*.{js,ts,jsx,tsx}"], 4 | darkMode: "class", 5 | theme: { 6 | extend: { 7 | fontFamily: { 8 | sans: [ 9 | "Inter var, sans-serif", 10 | { fontFeatureSettings: '"cv11", "ss01", "rlig", "calt" 0, "tnum"' }, 11 | ], 12 | }, 13 | colors: { 14 | brand: { 15 | 50: "#edfbff", 16 | 100: "#d6f5ff", 17 | 200: "#b5efff", 18 | 300: "#83e8ff", 19 | 400: "#48d8ff", 20 | 500: "#1ebcff", 21 | 600: "#069fff", 22 | 700: "#008cff", 23 | 800: "#086ac5", 24 | 900: "#0d5b9b", 25 | }, 26 | }, 27 | }, 28 | }, 29 | plugins: [require("tailwindcss-radix")()], 30 | }; 31 | -------------------------------------------------------------------------------- /packages/ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "outDir": "dist", 7 | "allowJs": true, 8 | "skipLibCheck": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noEmit": true, 12 | "incremental": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve", 19 | "composite": true, 20 | "declaration": true, 21 | "declarationMap": true, 22 | "inlineSources": false, 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "preserveWatchOutput": true, 26 | "baseUrl": "." 27 | }, 28 | "include": ["**/*.ts", "**/*.tsx", "vite.config.ts"], 29 | "exclude": ["node_modules"] 30 | } 31 | -------------------------------------------------------------------------------- /packages/ui/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/ui/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import typescript from "@rollup/plugin-typescript"; 3 | import path from "path"; 4 | import { typescriptPaths } from "rollup-plugin-typescript-paths"; 5 | import react from "@vitejs/plugin-react"; 6 | 7 | export default defineConfig({ 8 | plugins: [react()], 9 | define: { 10 | "process.env.NODE_ENV": JSON.stringify("production"), 11 | }, 12 | build: { 13 | minify: true, 14 | reportCompressedSize: true, 15 | lib: { 16 | entry: path.resolve(__dirname, "src/main.ts"), 17 | name: "QueueDash App", 18 | fileName: "main", 19 | formats: ["cjs", "es"], 20 | }, 21 | rollupOptions: { 22 | external: ["react", "react-dom"], 23 | plugins: [ 24 | typescriptPaths({ 25 | preserveExtensions: true, 26 | }), 27 | typescript({ 28 | sourceMap: false, 29 | declaration: true, 30 | outDir: "dist", 31 | }), 32 | ], 33 | }, 34 | }, 35 | }); 36 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "tasks": { 4 | "build": { 5 | "dependsOn": ["^build"], 6 | "outputs": ["dist/**"] 7 | }, 8 | "test": { 9 | "outputs": [], 10 | "inputs": ["src/**/*.tsx", "src/**/*.ts"] 11 | }, 12 | "lint": { 13 | "outputs": [] 14 | }, 15 | "dev": { 16 | "cache": false 17 | } 18 | } 19 | } 20 | --------------------------------------------------------------------------------