diff --git a/.vscode/settings.json b/.vscode/settings.json index 450c669..a006e73 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,14 +4,30 @@ }, "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true, - "eslint.rules.customizations": [{ "rule": "*", "severity": "warn" }], + "eslint.rules.customizations": [ + { + "rule": "*", + "severity": "warn" + } + ], "eslint.workingDirectories": [ - { "pattern": "apps/*/" }, - { "pattern": "packages/*/" }, - { "pattern": "tooling/*/" } + { + "pattern": "apps/*/" + }, + { + "pattern": "packages/*/" + }, + { + "pattern": "tooling/*/" + } ], "tailwindCSS.experimental.configFile": { - "apps/docs/src/app/globals.css": ["apps/docs/**"] + "apps/docs/src/app/globals.css": [ + "apps/docs/**" + ], + "packages/monitoring-ui/src/styles/app.css": [ + "packages/monitoring-ui/**" + ] }, "tailwindCSS.emmetCompletions": true, "tailwindCSS.files.exclude": [ @@ -22,10 +38,15 @@ "**/.vscode/**" ], "tailwindCSS.experimental.classRegex": [ - ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], - ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] + [ + "cva\\(([^)]*)\\)", + "[\"'`]([^\"'`]*).*?[\"'`]" + ], + [ + "cx\\(([^)]*)\\)", + "(?:'|\"|`)([^']*)(?:'|\"|`)" + ] ], - "prettier.ignorePath": ".gitignore", "typescript.enablePromptUseWorkspaceTsdk": true, "typescript.preferences.autoImportFileExcludePatterns": [ @@ -48,4 +69,4 @@ "files.associations": { "*.css": "tailwindcss" } -} +} \ No newline at end of file diff --git a/apps/docs/package.json b/apps/docs/package.json index 0507e7a..fc9741c 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -26,44 +26,44 @@ "@next/mdx": "16.0.1", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.4", - "@radix-ui/react-separator": "^1.1.8", - "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-separator": "1.1.8", + "@radix-ui/react-slot": "1.2.4", "@radix-ui/react-tabs": "1.1.13", - "@radix-ui/react-tooltip": "^1.2.8", + "@radix-ui/react-tooltip": "1.2.8", "@vercel/og": "0.8.5", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "date-fns": "^4.1.0", "globby": "15.0.0", "interweave": "13.1.1", - "lucide-react": "0.552.0", + "lucide-react": "0.553.0", "multimatch": "7.0.0", "next": "16.0.1", "next-themes": "latest", "p-map": "7.0.3", "react": "19.2.0", "react-dom": "19.2.0", - "read-pkg": "^9.0.1", + "read-pkg": "^10.0.0", "rehype-mdx-import-media": "1.2.0", "remark-frontmatter": "5.0.0", "remark-gfm": "4.0.1", "remark-mdx-frontmatter": "5.2.0", "remark-squeeze-paragraphs": "6.0.0", "remark-strip-badges": "7.0.0", - "renoun": "10.9.0", + "renoun": "10.9.1", "tm-grammars": "1.25.3", "tm-themes": "1.10.12", "ts-morph": "27.0.2", - "tw-animate-css": "^1.4.0", + "tw-animate-css": "1.4.0", "use-debounce": "10.0.6", "zod": "4.1.12" }, "devDependencies": { - "@tailwindcss/postcss": "4.1.16", + "@tailwindcss/postcss": "4.1.17", "@tailwindcss/typography": "0.5.19", "@types/mdx": "2.0.13", "@types/node": "22.19.0", @@ -83,10 +83,10 @@ "postcss": "8.5.6", "prettier": "^3.6.2", "serve-handler": "6.1.6", - "tailwind-merge": "3.3.1", - "tailwindcss": "4.1.16", + "tailwind-merge": "3.4.0", + "tailwindcss": "4.1.17", "tailwindcss-animate": "1.0.7", "tsx": "4.20.6", - "typescript": "^5.9.3" + "typescript": "5.9.3" } } diff --git a/examples/batch-processing/package.json b/examples/batch-processing/package.json index 791ed76..f1586f4 100644 --- a/examples/batch-processing/package.json +++ b/examples/batch-processing/package.json @@ -17,6 +17,6 @@ "devDependencies": { "drizzle-kit": "^0.31.6", "tsx": "4.20.6", - "typescript": "^5.9.3" + "typescript": "5.9.3" } } diff --git a/examples/drizzle-custom-schema-table/package.json b/examples/drizzle-custom-schema-table/package.json index a1f84f3..dbaa7ba 100644 --- a/examples/drizzle-custom-schema-table/package.json +++ b/examples/drizzle-custom-schema-table/package.json @@ -20,6 +20,6 @@ "dotenv-cli": "11.0.0", "drizzle-kit": "^0.31.6", "tsx": "4.20.6", - "typescript": "^5.9.3" + "typescript": "5.9.3" } } diff --git a/examples/drizzle-pg/package.json b/examples/drizzle-pg/package.json index 532d55f..ad5910f 100644 --- a/examples/drizzle-pg/package.json +++ b/examples/drizzle-pg/package.json @@ -18,6 +18,6 @@ "@types/pg": "8.15.6", "drizzle-kit": "^0.31.6", "tsx": "4.20.6", - "typescript": "^5.9.3" + "typescript": "5.9.3" } } diff --git a/examples/drizzle-pglite/package.json b/examples/drizzle-pglite/package.json index 40b2878..b5d60d5 100644 --- a/examples/drizzle-pglite/package.json +++ b/examples/drizzle-pglite/package.json @@ -9,7 +9,7 @@ "db:push": "drizzle-kit push" }, "dependencies": { - "@electric-sql/pglite": "^0.3.13", + "@electric-sql/pglite": "^0.3.14", "@vorsteh-queue/adapter-drizzle": "workspace:*", "@vorsteh-queue/core": "workspace:*", "drizzle-orm": "^0.44.7" @@ -17,6 +17,6 @@ "devDependencies": { "@types/node": "22.19.0", "tsx": "4.20.6", - "typescript": "^5.9.3" + "typescript": "5.9.3" } } diff --git a/examples/drizzle-postgres/package.json b/examples/drizzle-postgres/package.json index ef25481..ab168b7 100644 --- a/examples/drizzle-postgres/package.json +++ b/examples/drizzle-postgres/package.json @@ -19,6 +19,6 @@ "dotenv-cli": "11.0.0", "drizzle-kit": "^0.31.6", "tsx": "4.20.6", - "typescript": "^5.9.3" + "typescript": "5.9.3" } } diff --git a/examples/event-system/package.json b/examples/event-system/package.json index d3a6b9d..10a5f69 100644 --- a/examples/event-system/package.json +++ b/examples/event-system/package.json @@ -17,6 +17,6 @@ "devDependencies": { "drizzle-kit": "^0.31.6", "tsx": "4.20.6", - "typescript": "^5.9.3" + "typescript": "5.9.3" } } diff --git a/examples/kysely-custom-schema-table/package.json b/examples/kysely-custom-schema-table/package.json index f17b077..a164009 100644 --- a/examples/kysely-custom-schema-table/package.json +++ b/examples/kysely-custom-schema-table/package.json @@ -20,6 +20,6 @@ "dotenv-cli": "11.0.0", "kysely-ctl": "^0.19.0", "tsx": "4.20.6", - "typescript": "^5.9.3" + "typescript": "5.9.3" } } diff --git a/examples/kysely-postgres/package.json b/examples/kysely-postgres/package.json index 42e0f70..a6683b7 100644 --- a/examples/kysely-postgres/package.json +++ b/examples/kysely-postgres/package.json @@ -20,6 +20,6 @@ "dotenv-cli": "11.0.0", "kysely-ctl": "^0.19.0", "tsx": "4.20.6", - "typescript": "^5.9.3" + "typescript": "5.9.3" } } diff --git a/examples/pm2-workers/package.json b/examples/pm2-workers/package.json index 3134088..71d29ed 100644 --- a/examples/pm2-workers/package.json +++ b/examples/pm2-workers/package.json @@ -27,6 +27,6 @@ "drizzle-kit": "^0.31.6", "pm2": "6.0.13", "tsx": "4.20.6", - "typescript": "^5.9.3" + "typescript": "5.9.3" } } diff --git a/examples/prisma-client-js/package.json b/examples/prisma-client-js/package.json index a7ba59b..e8cd324 100644 --- a/examples/prisma-client-js/package.json +++ b/examples/prisma-client-js/package.json @@ -19,6 +19,6 @@ "dotenv-cli": "11.0.0", "prisma": "^6.19.0", "tsx": "4.20.6", - "typescript": "^5.9.3" + "typescript": "5.9.3" } } diff --git a/examples/prisma-client/package.json b/examples/prisma-client/package.json index 90d3c20..9a1786b 100644 --- a/examples/prisma-client/package.json +++ b/examples/prisma-client/package.json @@ -20,6 +20,6 @@ "dotenv-cli": "11.0.0", "prisma": "^6.19.0", "tsx": "4.20.6", - "typescript": "^5.9.3" + "typescript": "5.9.3" } } diff --git a/examples/prisma-custom-schema-table/package.json b/examples/prisma-custom-schema-table/package.json index f60752d..366a241 100644 --- a/examples/prisma-custom-schema-table/package.json +++ b/examples/prisma-custom-schema-table/package.json @@ -20,6 +20,6 @@ "dotenv-cli": "11.0.0", "prisma": "^6.19.0", "tsx": "4.20.6", - "typescript": "^5.9.3" + "typescript": "5.9.3" } } diff --git a/examples/progress-tracking/package.json b/examples/progress-tracking/package.json index 024169d..640633f 100644 --- a/examples/progress-tracking/package.json +++ b/examples/progress-tracking/package.json @@ -17,6 +17,6 @@ "devDependencies": { "drizzle-kit": "^0.31.6", "tsx": "4.20.6", - "typescript": "^5.9.3" + "typescript": "5.9.3" } } diff --git a/examples/result-storage/package.json b/examples/result-storage/package.json index e289692..f3a60cd 100644 --- a/examples/result-storage/package.json +++ b/examples/result-storage/package.json @@ -9,7 +9,7 @@ "db:push": "drizzle-kit push" }, "dependencies": { - "@electric-sql/pglite": "^0.3.13", + "@electric-sql/pglite": "^0.3.14", "@vorsteh-queue/adapter-drizzle": "workspace:*", "@vorsteh-queue/core": "workspace:*", "drizzle-orm": "^0.44.7" @@ -17,6 +17,6 @@ "devDependencies": { "drizzle-kit": "^0.31.6", "tsx": "4.20.6", - "typescript": "^5.9.3" + "typescript": "5.9.3" } } diff --git a/package.json b/package.json index 204e3d4..70f313b 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "scripts": { "dev": "turbo dev --filter='@vorsteh-queue/*'", "dev:docs": "turbo dev --filter docs", + "dev:monitoring-ui": "turbo dev --filter '@vorsteh-queue/monitoring-ui'", "preview": "pnpm -F docs preview", "build": "turbo build", "build:docs": "turbo build --filter docs", @@ -41,21 +42,21 @@ }, "devDependencies": { "@changesets/cli": "2.29.7", - "@vitest/coverage-v8": "4.0.7", - "@vitest/ui": "4.0.7", + "@vitest/coverage-v8": "4.0.8", + "@vitest/ui": "4.0.8", "@vorsteh-queue/prettier-config": "workspace:*", "prettier": "^3.6.2", "sherif": "1.8.0", "turbo": "2.6.0", - "typescript": "^5.9.3", - "vite-tsconfig-paths": "^5.1.4", - "vitest": "^4.0.7" + "typescript": "5.9.3", + "vite-tsconfig-paths": "5.1.4", + "vitest": "^4.0.8" }, "prettier": "@vorsteh-queue/prettier-config", "pnpm": { "overrides": { "esbuild@<=0.24.2": ">=0.25.0", - "vite@>=6.0.0 <=6.3.5": "6.3.6" + "vite@>=6.0.0 <=6.4.0": "6.4.1" } } } diff --git a/packages/adapter-drizzle/package.json b/packages/adapter-drizzle/package.json index 512930d..f466a57 100644 --- a/packages/adapter-drizzle/package.json +++ b/packages/adapter-drizzle/package.json @@ -67,7 +67,7 @@ "@vorsteh-queue/core": "workspace:*" }, "devDependencies": { - "@electric-sql/pglite": "^0.3.13", + "@electric-sql/pglite": "^0.3.14", "@vorsteh-queue/eslint-config": "workspace:*", "@vorsteh-queue/prettier-config": "workspace:*", "@vorsteh-queue/shared-tests": "workspace:*", @@ -77,10 +77,10 @@ "eslint": "^9.39.1", "postgres": "^3.4.7", "prettier": "^3.6.2", - "rolldown": "1.0.0-beta.47", + "rolldown": "1.0.0-beta.49", "rollup-plugin-delete": "^3.0.1", - "typescript": "^5.9.3", - "vitest": "^4.0.7" + "typescript": "5.9.3", + "vitest": "^4.0.8" }, "peerDependencies": { "drizzle-orm": ">=0.44.3" diff --git a/packages/adapter-drizzle/src/postgres-adapter.ts b/packages/adapter-drizzle/src/postgres-adapter.ts index 8ac0971..a6659a2 100644 --- a/packages/adapter-drizzle/src/postgres-adapter.ts +++ b/packages/adapter-drizzle/src/postgres-adapter.ts @@ -198,6 +198,28 @@ export class PostgresQueueAdapter< return result } + async getQueueJobs(): Promise { + const jobs = await this.db + .select() + .from(this.model) + .where(eq(this.model.queueName, this.queueName)) + + return jobs.map((job) => this.transformJob(job as schema.QueueJob)) + } + + async getJobDetails(id: string): Promise { + const [job] = await this.db + .select() + .from(this.model) + .where(and(eq(this.model.queueName, this.queueName), eq(this.model.id, id))) + + if (!job) { + throw new Error(`Job with ID ${id} not found in queue ${this.queueName}`) + } + + return this.transformJob(job as schema.QueueJob) + } + async clearJobs(status?: JobStatus): Promise { const conditions = [eq(this.model.queueName, this.queueName)] if (status) { diff --git a/packages/adapter-kysely/package.json b/packages/adapter-kysely/package.json index 34cf72a..667f7c3 100644 --- a/packages/adapter-kysely/package.json +++ b/packages/adapter-kysely/package.json @@ -71,7 +71,7 @@ "@vorsteh-queue/core": "workspace:*" }, "devDependencies": { - "@electric-sql/pglite": "^0.3.13", + "@electric-sql/pglite": "^0.3.14", "@vorsteh-queue/eslint-config": "workspace:*", "@vorsteh-queue/prettier-config": "workspace:*", "@vorsteh-queue/shared-tests": "workspace:*", @@ -82,10 +82,10 @@ "kysely-postgres-js": "^3.0.0", "postgres": "^3.4.7", "prettier": "^3.6.2", - "rolldown": "1.0.0-beta.47", + "rolldown": "1.0.0-beta.49", "rollup-plugin-delete": "^3.0.1", - "typescript": "^5.9.3", - "vitest": "^4.0.7" + "typescript": "5.9.3", + "vitest": "^4.0.8" }, "peerDependencies": { "kysely": ">=0.28.0" diff --git a/packages/adapter-kysely/src/postgres-adapter.ts b/packages/adapter-kysely/src/postgres-adapter.ts index 4a6737a..8fd02eb 100644 --- a/packages/adapter-kysely/src/postgres-adapter.ts +++ b/packages/adapter-kysely/src/postgres-adapter.ts @@ -225,6 +225,30 @@ export class PostgresQueueAdapter extends BaseQueueAdapter { return result } + async getQueueJobs(): Promise { + const jobs = await this.customDbClient + .selectFrom(`${this.schemaName}.${this.tableName}` as unknown as "tablename") + .selectAll() + .where("queue_name", "=", this.queueName) + .execute() + + return jobs.map((job) => this.transformJob(job)) + } + + async getJobDetails(id: string): Promise { + const job = await this.customDbClient + .selectFrom(`${this.schemaName}.${this.tableName}` as unknown as "tablename") + .selectAll() + .where("queue_name", "=", this.queueName) + .where("id", "=", id) + .executeTakeFirst() + + if (!job) { + throw new Error(`Job with ID ${id} not found in queue ${this.queueName}`) + } + return this.transformJob(job) + } + async clearJobs(status?: JobStatus): Promise { const query = this.customDbClient .deleteFrom(`${this.schemaName}.${this.tableName}` as unknown as "tablename") diff --git a/packages/adapter-prisma/package.json b/packages/adapter-prisma/package.json index cb2b3da..c165d3f 100644 --- a/packages/adapter-prisma/package.json +++ b/packages/adapter-prisma/package.json @@ -69,11 +69,11 @@ "eslint": "^9.39.1", "prettier": "^3.6.2", "prisma": "^6.19.0", - "rolldown": "1.0.0-beta.47", + "rolldown": "1.0.0-beta.49", "rollup-plugin-delete": "^3.0.1", "testcontainers": "^11.8.0", - "typescript": "^5.9.3", - "vitest": "^4.0.7" + "typescript": "5.9.3", + "vitest": "^4.0.8" }, "peerDependencies": { "@prisma/client": ">=6.1.0" diff --git a/packages/adapter-prisma/src/postgres-adapter.ts b/packages/adapter-prisma/src/postgres-adapter.ts index fbf194f..09d52de 100644 --- a/packages/adapter-prisma/src/postgres-adapter.ts +++ b/packages/adapter-prisma/src/postgres-adapter.ts @@ -167,6 +167,29 @@ export class PostgresPrismaQueueAdapter extends BaseQueueAdapter { return result } + async getQueueJobs(): Promise { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const jobs = (await this.db[this.modelName]!.findMany({ + where: { queueName: this.queueName }, + orderBy: { createdAt: "desc" }, + })) as QueueJob[] + + return jobs.map((job) => this.transformJob(job)) + } + + async getJobDetails(id: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const job = (await this.db[this.modelName]!.findFirst({ + where: { id }, + })) as QueueJob | null + + if (!job) { + throw new Error(`Job with ID ${id} not found in queue ${this.queueName}`) + } + + return this.transformJob(job) + } + async clearJobs(status?: JobStatus): Promise { const where: Record = { queueName: this.queueName } if (status) where.status = status diff --git a/packages/core/package.json b/packages/core/package.json index 01afdb8..31cae2d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -72,10 +72,10 @@ "@vorsteh-queue/prettier-config": "workspace:*", "@vorsteh-queue/tsconfig": "workspace:*", "eslint": "^9.39.1", - "rolldown": "1.0.0-beta.47", + "rolldown": "1.0.0-beta.49", "rollup-plugin-delete": "^3.0.1", - "typescript": "^5.9.3", - "vitest": "^4.0.7" + "typescript": "5.9.3", + "vitest": "^4.0.8" }, "publishConfig": { "exports": { diff --git a/packages/core/src/adapters/base.ts b/packages/core/src/adapters/base.ts index 4d2870a..222b2df 100644 --- a/packages/core/src/adapters/base.ts +++ b/packages/core/src/adapters/base.ts @@ -79,6 +79,21 @@ export abstract class BaseQueueAdapter implements QueueAdapter { */ abstract getQueueStats(): Promise + /** + * Get all jobs in the queue + * + * @returns Promise resolving to an array of all jobs in the queue + */ + abstract getQueueJobs(): Promise + + /** + * Get detailed information about a specific job by ID + * + * @param id Job ID to retrieve + * @returns Promise resolving to the job details + */ + abstract getJobDetails(id: string): Promise + /** * Clear jobs from the queue * diff --git a/packages/core/src/adapters/memory.ts b/packages/core/src/adapters/memory.ts index 363b833..2a41d0c 100644 --- a/packages/core/src/adapters/memory.ts +++ b/packages/core/src/adapters/memory.ts @@ -120,6 +120,20 @@ export class MemoryQueueAdapter extends BaseQueueAdapter { return Promise.resolve(stats) } + getQueueJobs(): Promise { + return Promise.resolve(Array.from(this.jobs.values())) + } + + getJobDetails(id: string): Promise { + { + const job = this.jobs.get(id) + if (!job) { + return Promise.reject(new Error(`Job with ID ${id} not found`)) + } + return Promise.resolve(job) + } + } + clearJobs(status?: JobStatus): Promise { if (!status) { const count = this.jobs.size diff --git a/packages/core/src/core/queue.ts b/packages/core/src/core/queue.ts index e39f34f..534454f 100644 --- a/packages/core/src/core/queue.ts +++ b/packages/core/src/core/queue.ts @@ -351,6 +351,25 @@ export class Queue { return { ...this.config } } + /** + * Get all jobs in the queue. + * + * @return Promise resolving to an array of all jobs in the queue + */ + async getQueueJobs(): Promise { + return this.adapter.getQueueJobs() + } + + /** + * Get detailed information about a specific job by ID. + * + * @param id Job ID to retrieve + * @return Promise resolving to the job details + */ + async getJobDetails(id: string): Promise { + return this.adapter.getJobDetails(id) + } + /** * Returns the batch config for processing jobs in batches. */ diff --git a/packages/core/types/index.d.ts b/packages/core/types/index.d.ts index 5f36fe7..b32ac2a 100644 --- a/packages/core/types/index.d.ts +++ b/packages/core/types/index.d.ts @@ -226,6 +226,10 @@ export interface QueueAdapter { /** @internal Set the queue name for job isolation */ setQueueName(queueName: string): void + + getQueueJobs(): Promise + + getJobDetails(id: string): Promise } /** diff --git a/packages/create-vorsteh-queue/package.json b/packages/create-vorsteh-queue/package.json index c9564a7..d5f328d 100644 --- a/packages/create-vorsteh-queue/package.json +++ b/packages/create-vorsteh-queue/package.json @@ -39,7 +39,7 @@ "@clack/prompts": "^0.11.0", "giget": "^2.0.0", "picocolors": "^1.1.1", - "read-pkg": "^9.0.1", + "read-pkg": "^10.0.0", "terminal-link": "^5.0.0" }, "devDependencies": { @@ -49,10 +49,10 @@ "@vorsteh-queue/tsconfig": "workspace:*", "eslint": "^9.39.1", "prettier": "^3.6.2", - "rolldown": "1.0.0-beta.47", + "rolldown": "1.0.0-beta.49", "rollup-plugin-delete": "^3.0.1", "tsx": "4.20.6", - "typescript": "^5.9.3" + "typescript": "5.9.3" }, "engines": { "node": ">=18" diff --git a/packages/monitoring-ui/components.json b/packages/monitoring-ui/components.json new file mode 100644 index 0000000..a7f65b1 --- /dev/null +++ b/packages/monitoring-ui/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles/app.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/packages/monitoring-ui/demo-queue-config.ts b/packages/monitoring-ui/demo-queue-config.ts new file mode 100644 index 0000000..ff33534 --- /dev/null +++ b/packages/monitoring-ui/demo-queue-config.ts @@ -0,0 +1,81 @@ +const createMockQueue = (name: string) => { + let lastStats: Record | null = null + + function generateStats() { + return { + pending: Math.floor(Math.random() * 50), + processing: Math.floor(Math.random() * 10), + completed: Math.floor(Math.random() * 200), + failed: Math.floor(Math.random() * 5), + delayed: Math.floor(Math.random() * 5), + cancelled: Math.floor(Math.random() * 2), + } + } + + return { + getConfig: () => ({ + name, + maxTimeout: 30000, + concurrency: 5, + retryLimit: 3, + }), + getStats: () => { + lastStats = generateStats() + return lastStats + }, + getJobs: () => { + lastStats = generateStats() + const now = Date.now() + const jobs = [] + const statusList = Object.keys(lastStats) as (keyof typeof lastStats)[] + let jobCounter = 1 + for (const status of statusList) { + const count = lastStats[status] ?? 0 + for (let i = 0; i < count; i++) { + const baseTime = now - i * 10000 - jobCounter * 1000 + jobs.push({ + id: `job-${baseTime}-${jobCounter}`, + name: `${name}-job-${jobCounter}`, + status, + progress: + status === "completed" + ? 100 + : status === "processing" + ? Math.floor(Math.random() * 80) + 10 + : 0, + maxAttempts: 3, + attempts: status === "failed" ? 3 : status === "processing" ? 2 : 1, + createdAt: new Date(baseTime), + processAt: new Date(baseTime + 1000), + processedAt: ["processing", "completed", "failed", "cancelled"].includes(status) + ? new Date(baseTime + 2000) + : null, + failedAt: status === "failed" ? new Date(baseTime + 7000) : null, + completedAt: status === "completed" ? new Date(baseTime + 8000) : null, + }) + jobCounter++ + } + } + return jobs + }, + get: (id: string) => ({ + id, + status: "completed", + createdAt: new Date(Date.now() - 60000).toISOString(), + startedAt: new Date(Date.now() - 50000).toISOString(), + completedAt: new Date(Date.now() - 10000).toISOString(), + payload: { example: "payload data" }, + result: { success: true, data: "Job result" }, + error: null, + retries: 0, + }), + } +} + +export default [ + createMockQueue("email-queue"), + createMockQueue("video-processing-queue"), + createMockQueue("data-import-queue"), + createMockQueue("report-generation-queue"), + createMockQueue("notification-queue"), +] diff --git a/packages/monitoring-ui/eslint.config.js b/packages/monitoring-ui/eslint.config.js new file mode 100644 index 0000000..28fc9af --- /dev/null +++ b/packages/monitoring-ui/eslint.config.js @@ -0,0 +1,5 @@ +import baseConfig, { restrictEnvAccess } from "@vorsteh-queue/eslint-config/base" +import reactConfig from "@vorsteh-queue/eslint-config/react" + +/** @type {import('typescript-eslint').Config} */ +export default [...baseConfig, ...reactConfig, ...restrictEnvAccess] diff --git a/packages/monitoring-ui/package.json b/packages/monitoring-ui/package.json new file mode 100644 index 0000000..2a4830f --- /dev/null +++ b/packages/monitoring-ui/package.json @@ -0,0 +1,86 @@ +{ + "name": "@vorsteh-queue/monitoring-ui", + "description": "Monitoring UI for Vorsteh Queue", + "keywords": [ + "vorsteh-queue", + "queue", + "jobs", + "ui", + "monitoring" + ], + "homepage": "https://vorsteh-queue.dev", + "bugs": "https://github.com/noxify/vorsteh-queue/issues", + "repository": { + "type": "git", + "url": "git+https://github.com/noxify/vorsteh-queue.git", + "directory": "packages/monitoring-ui" + }, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "start": "node .output/server/index.mjs", + "lint": "eslint .", + "format": "prettier --check . --ignore-path ../../.gitignore --ignore-path ../../.prettierignore", + "typecheck": "tsc --noEmit" + }, + "files": [ + "dist/*" + ], + "prettier": "@vorsteh-queue/prettier-config", + "dependencies": { + "@icons-pack/react-simple-icons": "13.8.0", + "@radix-ui/react-accordion": "1.2.12", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-label": "2.1.8", + "@radix-ui/react-scroll-area": "1.2.10", + "@radix-ui/react-select": "2.2.6", + "@radix-ui/react-separator": "1.1.8", + "@radix-ui/react-slot": "1.2.4", + "@radix-ui/react-switch": "1.2.6", + "@radix-ui/react-tabs": "1.1.13", + "@radix-ui/react-tooltip": "1.2.8", + "@tanstack/devtools-vite": "0.3.11", + "@tanstack/react-devtools": "0.8.1", + "@tanstack/react-query": "5.90.7", + "@tanstack/react-query-devtools": "5.90.2", + "@tanstack/react-router": "1.135.0", + "@tanstack/react-router-devtools": "1.135.0", + "@tanstack/react-router-ssr-query": "1.135.0", + "@tanstack/react-start": "1.135.1", + "@tanstack/react-table": "8.21.3", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "date-fns": "^4.1.0", + "jiti": "2.6.1", + "lucide-react": "0.553.0", + "next-themes": "0.4.6", + "react": "19.2.0", + "react-dom": "19.2.0", + "redaxios": "0.5.1", + "sonner": "2.0.7", + "tailwind-merge": "3.4.0", + "vaul": "1.1.2", + "zod": "4.1.12" + }, + "devDependencies": { + "@tailwindcss/postcss": "4.1.17", + "@tailwindcss/vite": "4.1.17", + "@tanstack/nitro-v2-vite-plugin": "1.133.19", + "@types/node": "22.19.0", + "@types/react": "19.2.2", + "@types/react-dom": "19.2.2", + "@vitejs/plugin-react": "5.1.0", + "@vorsteh-queue/core": "workspace:*", + "@vorsteh-queue/eslint-config": "workspace:*", + "@vorsteh-queue/prettier-config": "workspace:*", + "postcss": "8.5.6", + "tailwindcss": "4.1.17", + "tw-animate-css": "1.4.0", + "typescript": "5.9.3", + "vite": "npm:rolldown-vite@latest", + "vite-tsconfig-paths": "5.1.4" + } +} diff --git a/packages/monitoring-ui/postcss.config.mjs b/packages/monitoring-ui/postcss.config.mjs new file mode 100644 index 0000000..fb05b56 --- /dev/null +++ b/packages/monitoring-ui/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +} diff --git a/packages/monitoring-ui/public/favicon.ico b/packages/monitoring-ui/public/favicon.ico new file mode 100644 index 0000000..9910ac2 Binary files /dev/null and b/packages/monitoring-ui/public/favicon.ico differ diff --git a/packages/monitoring-ui/public/vorsteh-queue-logo.svg b/packages/monitoring-ui/public/vorsteh-queue-logo.svg new file mode 100644 index 0000000..4afa9ef --- /dev/null +++ b/packages/monitoring-ui/public/vorsteh-queue-logo.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/monitoring-ui/src/components/DefaultCatchBoundary.tsx b/packages/monitoring-ui/src/components/DefaultCatchBoundary.tsx new file mode 100644 index 0000000..dc6f234 --- /dev/null +++ b/packages/monitoring-ui/src/components/DefaultCatchBoundary.tsx @@ -0,0 +1,47 @@ +import type { ErrorComponentProps } from "@tanstack/react-router" +import { ErrorComponent, Link, rootRouteId, useMatch, useRouter } from "@tanstack/react-router" + +export function DefaultCatchBoundary({ error }: ErrorComponentProps) { + const router = useRouter() + const isRoot = useMatch({ + strict: false, + select: (state) => state.id === rootRouteId, + }) + + console.error(error) + + return ( +
+ +
+ + {isRoot ? ( + + Home + + ) : ( + { + e.preventDefault() + window.history.back() + }} + > + Go Back + + )} +
+
+ ) +} diff --git a/packages/monitoring-ui/src/components/NotFound.tsx b/packages/monitoring-ui/src/components/NotFound.tsx new file mode 100644 index 0000000..dce1fdb --- /dev/null +++ b/packages/monitoring-ui/src/components/NotFound.tsx @@ -0,0 +1,25 @@ +import { Link } from "@tanstack/react-router" + +export function NotFound({ children }: { children?: any }) { + return ( +
+
+ {children ||

The page you are looking for does not exist.

} +
+

+ + + Start Over + +

+
+ ) +} diff --git a/packages/monitoring-ui/src/components/app-sidebar.tsx b/packages/monitoring-ui/src/components/app-sidebar.tsx new file mode 100644 index 0000000..1d545ad --- /dev/null +++ b/packages/monitoring-ui/src/components/app-sidebar.tsx @@ -0,0 +1,93 @@ +"use client" + +import * as React from "react" +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar" +import { QueueDetails } from "@/types" +import { queuesQueryOptions } from "@/utils/query-options" +import { SiGithub as GithubIcon } from "@icons-pack/react-simple-icons" +import { useSuspenseQuery } from "@tanstack/react-query" +import { Link } from "@tanstack/react-router" +import { InboxIcon, LayoutDashboardIcon } from "lucide-react" + +import { ThemeToggle } from "./theme-toggle" +import { Button } from "./ui/button" + +export function AppSidebar({ ...props }: React.ComponentProps) { + const queuesQuery = useSuspenseQuery(queuesQueryOptions()) + + return ( + + + + +
+ Vorsteh-Queue Logo + Vorsteh-Queue +
+
+
+
+ + + + + + + +
+ + Dashboard +
+ +
+
+
+ + + Queues + {[...queuesQuery.data].map((queue) => { + return ( + + + +
+ + {queue.config.name} +
+ +
+
+ ) + })} +
+
+
+
+ +
+ + +
+
+
+ ) +} diff --git a/packages/monitoring-ui/src/components/data-table.tsx b/packages/monitoring-ui/src/components/data-table.tsx new file mode 100644 index 0000000..85f9d64 --- /dev/null +++ b/packages/monitoring-ui/src/components/data-table.tsx @@ -0,0 +1,603 @@ +"use client" + +import * as React from "react" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet" +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { cn } from "@/lib/utils" +import { AvailableStatus } from "@/types" +import { statusColorMap } from "@/utils/colors" +import { + Column, + ColumnDef, + ColumnFiltersState, + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + Row, + SortingState, + useReactTable, + VisibilityState, +} from "@tanstack/react-table" +import { intlFormatDistance } from "date-fns" +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, + ChevronsLeftIcon, + ChevronsRightIcon, + Columns2Icon, + MoreVerticalIcon, +} from "lucide-react" + +import type { BaseJob } from "@vorsteh-queue/core" + +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./ui/accordion" +import { Label } from "./ui/label" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table" +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip" + +export function DataTable({ data }: { data: BaseJob[] }) { + const [currentTab, setCurrentTab] = React.useState("all") + const [openJobId, setOpenJobId] = React.useState(null) + + const columns: ColumnDef[] = [ + { + accessorKey: "id", + header: "ID", + meta: { + name: "ID", + }, + cell: ({ row }) => ( + setOpenJobId(open ? row.original.id : null)} + /> + ), + enableHiding: false, + }, + { + accessorKey: "name", + header: "Name", + meta: { + name: "Name", + }, + cell: ({ row }) => <>{row.original.status}, + }, + { + accessorKey: "status", + header: "Status", + meta: { + name: "Status", + }, + cell: ({ row }) => ( + + {row.original.status} + + ), + }, + { + accessorKey: "progress", + header: "Progress", + meta: { + name: "Progress", + }, + cell: ({ row }) => <>{row.original.progress} %, + }, + { + accessorKey: "attempts", + header: "Attempts", + meta: { + name: "Attempts", + }, + cell: ({ row }) => ( + <> + {row.original.attempts} / {row.original.maxAttempts} + + ), + }, + { + accessorKey: "createdAt", + header: "Created at", + meta: { + name: "Created at", + }, + cell: ({ row, cell }) => ( + <>{row.original.createdAt ? : ""} + ), + }, + { + accessorKey: "processAt", + header: "Process at", + meta: { + name: "Process at", + }, + cell: ({ row, cell }) => ( + <>{row.original.processAt ? : ""} + ), + }, + { + accessorKey: "processedAt", + header: "Processed at", + meta: { + name: "Processed at", + }, + cell: ({ row, cell }) => ( + <>{row.original.processedAt ? : ""} + ), + }, + { + accessorKey: "failedAt", + header: "Failed at", + meta: { + name: "Failed at", + }, + cell: ({ row, cell }) => ( + <>{row.original.failedAt ? : ""} + ), + }, + { + accessorKey: "completedAt", + header: "Completed at", + meta: { + name: "Completed at", + }, + cell: ({ row, cell }) => ( + <>{row.original.completedAt ? : ""} + ), + }, + { + id: "actions", + meta: { + headerClassName: "w-[40px] bg-muted", + cellClassName: "bg-background", + }, + cell: ({ row }) => ( + + + + + + setOpenJobId(open ? row.original.id : null)} + > + { + e.preventDefault() + setOpenJobId(row.original.id) + }} + > + Details + + + Make a copy + Favorite + + Delete + + + ), + }, + ] + + const [columnVisibility, setColumnVisibility] = React.useState({ + progress: false, + processAt: false, + failedAt: false, + completedAt: false, + }) + const [columnFilters, setColumnFilters] = React.useState([]) + const [sorting, setSorting] = React.useState([]) + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 10, + }) + + const table = useReactTable({ + data, + columns, + state: { + sorting, + columnVisibility, + columnFilters, + pagination, + }, + initialState: { + columnPinning: { right: ["actions"] }, + sorting: [{ id: "createdAt", desc: true }], + }, + getRowId: (row) => row.id.toString(), + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + }) + + const handleTabChange = (tab: string) => { + setCurrentTab(tab) + if (tab === "all") { + table.getColumn("status")?.setFilterValue(undefined) + } else { + table.getColumn("status")?.setFilterValue(tab) + } + } + + return ( + <> + +
+ + + + All + Pending + Processing + Completed + Failed + Delayed + Cancelled + + +
+ + + + + + {table + .getAllColumns() + .filter( + (column) => typeof column.accessorFn !== "undefined" && column.getCanHide(), + ) + .map((column) => { + return ( + column.toggleVisibility(!!value)} + > + {column.columnDef.meta?.name ?? column.id} + + ) + })} + + +
+
+
+
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row: Row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+
+
+
+
+ + +
+
+ Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()} +
+
+ + + + +
+
+
+ + ) +} + +function DateTooltip({ date }: { date: Date | null }) { + if (!date) { + return <> + } + + return ( + + {intlFormatDistance(date, new Date(), { locale: "en" })} + {date ? date.toLocaleString() : "No date available"} + + ) +} + +function getCommonPinningStyles({ column }: { column: Column }): React.CSSProperties { + const isPinned = column.getIsPinned() + + const isLastLeftPinnedColumn = isPinned === "left" && column.getIsLastColumn("left") + const isFirstRightPinnedColumn = isPinned === "right" && column.getIsFirstColumn("right") + + return { + boxShadow: isLastLeftPinnedColumn + ? "-3px 0 4px -5px var(--foreground) inset" + : isFirstRightPinnedColumn + ? "3px 0 4px -5px var(--foreground) inset" + : undefined, + left: isPinned === "left" ? `${column.getStart("left")}px` : undefined, + right: isPinned === "right" ? `${column.getAfter("right")}px` : undefined, + opacity: isPinned ? 0.97 : 1, + position: isPinned ? "sticky" : "relative", + background: isPinned ? "bg-white" : undefined, + zIndex: isPinned ? 1 : 0, + } +} + +function TableCellViewer({ + item, + triggerLabel, + children, + modal = true, + open, + setOpen, +}: { + item: BaseJob + triggerLabel?: string + children?: React.ReactNode + modal?: boolean + open?: boolean + setOpen?: (open: boolean) => void +}) { + const details = [ + { value: item.id, label: "ID" }, + { value: item.name, label: "Name" }, + { value: item.status, label: "Status" }, + { value: item.priority, label: "Priority" }, + { value: item.attempts, label: "Attempts" }, + { value: item.maxAttempts, label: "Max Attempts" }, + { value: item.createdAt, label: "Created At" }, + { value: item.processAt, label: "Process At" }, + { value: item.processedAt, label: "Processed At" }, + { value: item.completedAt, label: "Completed At" }, + { value: item.failedAt, label: "Failed At" }, + { value: item.progress, label: "Progress" }, + { value: item.cron, label: "Cron" }, + { value: item.repeatEvery, label: "Repeat Every" }, + { value: item.repeatLimit, label: "Repeat Limit" }, + { value: item.repeatCount, label: "Repeat Count" }, + { value: item.timeout, label: "Timeout" }, + ] + + return ( + + + {children ? ( + children + ) : ( + + )} + + + + Job details + Showing the job details + + +
+ + + Job Details + +
+ {details.map((item) => ( +
+
{item.label}
+
+ {item.value instanceof Date + ? item.value.toLocaleString() + : String(item.value ?? "")} +
+
+ ))} +
+
+
+ + Payload + +
+                  {JSON.stringify(item.payload, null, 2)}
+                
+
+
+ + Error + +
+                  {item.error ? JSON.stringify(item.error, null, 2) : "No error available"}
+                
+
+
+ + Result + +
+                  {item.result ? JSON.stringify(item.result, null, 2) : "No result available"}
+                
+
+
+
+
+
+
+ ) +} diff --git a/packages/monitoring-ui/src/components/refresh-controls.tsx b/packages/monitoring-ui/src/components/refresh-controls.tsx new file mode 100644 index 0000000..b958d62 --- /dev/null +++ b/packages/monitoring-ui/src/components/refresh-controls.tsx @@ -0,0 +1,157 @@ +"use client" + +import { useEffect, useState } from "react" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Spinner } from "@/components/ui/spinner" +import { cn } from "@/lib/utils" +import { ChevronDownIcon, Pause, Play, RotateCw } from "lucide-react" + +import { ButtonGroup } from "./ui/button-group" + +export function RefreshControls({ + refreshInterval, + setRefreshInterval, + isPaused, + setIsPaused, + lastUpdatedAt, + isFetching, + onManualRefresh, +}: { + refreshInterval: number + setRefreshInterval: (interval: number) => void + isPaused: boolean + setIsPaused: (paused: boolean) => void + lastUpdatedAt?: number // Max Timestamp über relevante Queries + isFetching?: boolean // Aggregierter Fetch-Status + onManualRefresh?: () => void // Manueller Refresh Trigger +}) { + // Countdown basiert direkt auf lastUpdatedAt. + const [cycleStart, setCycleStart] = useState(lastUpdatedAt ?? 0) + const [now, setNow] = useState(Date.now()) + + // Wenn sich der externe Timestamp ändert -> neuen Zyklus beginnen + useEffect(() => { + if (!isPaused && lastUpdatedAt && lastUpdatedAt !== cycleStart) { + setCycleStart(lastUpdatedAt) + setNow(Date.now()) + } + }, [lastUpdatedAt, isPaused, cycleStart]) + + // Sekundentakt für Countdown + useEffect(() => { + if (isPaused) return + const id = setInterval(() => setNow(Date.now()), 1000) + return () => clearInterval(id) + }, [isPaused]) + + const hasValidStart = cycleStart > 0 + const timeLeftMs = hasValidStart ? cycleStart + refreshInterval - now : refreshInterval + + const formatTimeLeft = (diffMs: number) => { + // Kleine negative Werte (Timing Drift) behandeln als volles Intervall-Neustart bereits erfolgt + if (diffMs <= 0) return "now" + const seconds = Math.floor(diffMs / 1000) + if (seconds < 60) return `${seconds}s` + return `${Math.floor(seconds / 60)}m ${seconds % 60}s` + } + + const refreshOptions = [ + { label: "5 seconds", value: 5000 }, + { label: "10 seconds", value: 10000 }, + { label: "15 seconds", value: 15000 }, + { label: "30 seconds", value: 30000 }, + { label: "1 minute", value: 60000 }, + { label: "5 minutes", value: 300000 }, + ] + + const handleRefreshChange = (interval: number) => { + setRefreshInterval(interval) + } + + const toggleAutoRefresh = () => { + const newPausedState = !isPaused + setIsPaused(newPausedState) + if (!newPausedState) setNow(Date.now()) + } + + return ( + <> + + {onManualRefresh && ( + + )} + + + + + + + + + {refreshOptions.map((option) => ( + handleRefreshChange(option.value)} + className="cursor-pointer text-foreground focus:bg-muted focus:text-foreground" + > + {option.label} + + ))} + + + + + ) +} diff --git a/packages/monitoring-ui/src/components/site-header.tsx b/packages/monitoring-ui/src/components/site-header.tsx new file mode 100644 index 0000000..754f2cd --- /dev/null +++ b/packages/monitoring-ui/src/components/site-header.tsx @@ -0,0 +1,13 @@ +import { Button } from "@/components/ui/button" +import { Separator } from "@/components/ui/separator" +import { SidebarTrigger } from "@/components/ui/sidebar" + +export function SiteHeader() { + return ( +
+
+ +
+
+ ) +} diff --git a/packages/monitoring-ui/src/components/stat-card.tsx b/packages/monitoring-ui/src/components/stat-card.tsx new file mode 100644 index 0000000..c0ff600 --- /dev/null +++ b/packages/monitoring-ui/src/components/stat-card.tsx @@ -0,0 +1,23 @@ +import { cn } from "@/lib/utils" + +export function StatCard({ + title, + value, + className, +}: { + title: string + value: string | number + className?: string +}) { + return ( +
+

{value}

+

{title}

+
+ ) +} diff --git a/packages/monitoring-ui/src/components/tailwind-indicator.tsx b/packages/monitoring-ui/src/components/tailwind-indicator.tsx new file mode 100644 index 0000000..1042997 --- /dev/null +++ b/packages/monitoring-ui/src/components/tailwind-indicator.tsx @@ -0,0 +1,14 @@ +export function TailwindIndicator() { + if (process.env.NODE_ENV === "production") return null + + return ( +
+
xs
+
sm
+
md
+
lg
+
xl
+
2xl
+
+ ) +} diff --git a/packages/monitoring-ui/src/components/theme-toggle.tsx b/packages/monitoring-ui/src/components/theme-toggle.tsx new file mode 100644 index 0000000..ae85e0a --- /dev/null +++ b/packages/monitoring-ui/src/components/theme-toggle.tsx @@ -0,0 +1,21 @@ +"use client" + +import { Button } from "@/components/ui/button" +import { Moon, Sun } from "lucide-react" +import { useTheme } from "next-themes" + +export function ThemeToggle() { + const { setTheme, resolvedTheme } = useTheme() + + return ( + + ) +} diff --git a/packages/monitoring-ui/src/components/ui/accordion.tsx b/packages/monitoring-ui/src/components/ui/accordion.tsx new file mode 100644 index 0000000..aa84f45 --- /dev/null +++ b/packages/monitoring-ui/src/components/ui/accordion.tsx @@ -0,0 +1,63 @@ +"use client" + +import * as React from "react" +import { cn } from "@/lib/utils" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDownIcon } from "lucide-react" + +function Accordion({ ...props }: React.ComponentProps) { + return +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180", + className, + )} + {...props} + > + {children} + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/packages/monitoring-ui/src/components/ui/badge.tsx b/packages/monitoring-ui/src/components/ui/badge.tsx new file mode 100644 index 0000000..58622df --- /dev/null +++ b/packages/monitoring-ui/src/components/ui/badge.tsx @@ -0,0 +1,37 @@ +import type { VariantProps } from "class-variance-authority" +import * as React from "react" +import { cn } from "@/lib/utils" +import { Slot } from "@radix-ui/react-slot" +import { cva } from "class-variance-authority" + +const badgeVariants = cva( + "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3", + { + variants: { + variant: { + default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90", + outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return +} + +export { Badge, badgeVariants } diff --git a/packages/monitoring-ui/src/components/ui/button-group.tsx b/packages/monitoring-ui/src/components/ui/button-group.tsx new file mode 100644 index 0000000..162c69b --- /dev/null +++ b/packages/monitoring-ui/src/components/ui/button-group.tsx @@ -0,0 +1,78 @@ +import type { VariantProps } from "class-variance-authority" +import { Separator } from "@/components/ui/separator" +import { cn } from "@/lib/utils" +import { Slot } from "@radix-ui/react-slot" +import { cva } from "class-variance-authority" + +const buttonGroupVariants = cva( + "flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1", + { + variants: { + orientation: { + horizontal: + "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none", + vertical: + "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none", + }, + }, + defaultVariants: { + orientation: "horizontal", + }, + }, +) + +function ButtonGroup({ + className, + orientation, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function ButtonGroupText({ + className, + asChild = false, + ...props +}: React.ComponentProps<"div"> & { + asChild?: boolean +}) { + const Comp = asChild ? Slot : "div" + + return ( + + ) +} + +function ButtonGroupSeparator({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants } diff --git a/packages/monitoring-ui/src/components/ui/button.tsx b/packages/monitoring-ui/src/components/ui/button.tsx new file mode 100644 index 0000000..ec8e315 --- /dev/null +++ b/packages/monitoring-ui/src/components/ui/button.tsx @@ -0,0 +1,58 @@ +import type { VariantProps } from "class-variance-authority" +import * as React from "react" +import { cn } from "@/lib/utils" +import { Slot } from "@radix-ui/react-slot" +import { cva } from "class-variance-authority" + +const buttonVariants = cva( + "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/packages/monitoring-ui/src/components/ui/card.tsx b/packages/monitoring-ui/src/components/ui/card.tsx new file mode 100644 index 0000000..3a262c6 --- /dev/null +++ b/packages/monitoring-ui/src/components/ui/card.tsx @@ -0,0 +1,74 @@ +import * as React from "react" +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return
+} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent } diff --git a/packages/monitoring-ui/src/components/ui/container.tsx b/packages/monitoring-ui/src/components/ui/container.tsx new file mode 100644 index 0000000..da44ae4 --- /dev/null +++ b/packages/monitoring-ui/src/components/ui/container.tsx @@ -0,0 +1,13 @@ +import React from "react" +import { cn } from "@/lib/utils" + +const Container = React.forwardRef>( + ({ children, className }, ref) => ( +
+
{children}
+
+ ), +) +Container.displayName = "Container" + +export { Container } diff --git a/packages/monitoring-ui/src/components/ui/dialog.tsx b/packages/monitoring-ui/src/components/ui/dialog.tsx new file mode 100644 index 0000000..fdb3451 --- /dev/null +++ b/packages/monitoring-ui/src/components/ui/dialog.tsx @@ -0,0 +1,128 @@ +"use client" + +import * as React from "react" +import { cn } from "@/lib/utils" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +function Dialog({ ...props }: React.ComponentProps) { + return +} + +function DialogTrigger({ ...props }: React.ComponentProps) { + return +} + +function DialogPortal({ ...props }: React.ComponentProps) { + return +} + +function DialogClose({ ...props }: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/packages/monitoring-ui/src/components/ui/drawer.tsx b/packages/monitoring-ui/src/components/ui/drawer.tsx new file mode 100644 index 0000000..5fd24b2 --- /dev/null +++ b/packages/monitoring-ui/src/components/ui/drawer.tsx @@ -0,0 +1,121 @@ +import * as React from "react" +import { cn } from "@/lib/utils" +import { Drawer as DrawerPrimitive } from "vaul" + +function Drawer({ ...props }: React.ComponentProps) { + return +} + +function DrawerTrigger({ ...props }: React.ComponentProps) { + return +} + +function DrawerPortal({ ...props }: React.ComponentProps) { + return +} + +function DrawerClose({ ...props }: React.ComponentProps) { + return +} + +function DrawerOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DrawerContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + +
+ {children} + + + ) +} + +function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DrawerTitle({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function DrawerDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} diff --git a/packages/monitoring-ui/src/components/ui/dropdown-menu.tsx b/packages/monitoring-ui/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..0ab43de --- /dev/null +++ b/packages/monitoring-ui/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,225 @@ +import * as React from "react" +import { cn } from "@/lib/utils" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" + +function DropdownMenu({ ...props }: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ ...props }: React.ComponentProps) { + return +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ ...props }: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/packages/monitoring-ui/src/components/ui/input.tsx b/packages/monitoring-ui/src/components/ui/input.tsx new file mode 100644 index 0000000..18dcf76 --- /dev/null +++ b/packages/monitoring-ui/src/components/ui/input.tsx @@ -0,0 +1,20 @@ +import * as React from "react" +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/packages/monitoring-ui/src/components/ui/label.tsx b/packages/monitoring-ui/src/components/ui/label.tsx new file mode 100644 index 0000000..6b1293f --- /dev/null +++ b/packages/monitoring-ui/src/components/ui/label.tsx @@ -0,0 +1,18 @@ +import * as React from "react" +import { cn } from "@/lib/utils" +import * as LabelPrimitive from "@radix-ui/react-label" + +function Label({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +export { Label } diff --git a/packages/monitoring-ui/src/components/ui/select.tsx b/packages/monitoring-ui/src/components/ui/select.tsx new file mode 100644 index 0000000..d64c6f0 --- /dev/null +++ b/packages/monitoring-ui/src/components/ui/select.tsx @@ -0,0 +1,169 @@ +import * as React from "react" +import { cn } from "@/lib/utils" +import * as SelectPrimitive from "@radix-ui/react-select" +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" + +function Select({ ...props }: React.ComponentProps) { + return +} + +function SelectGroup({ ...props }: React.ComponentProps) { + return +} + +function SelectValue({ ...props }: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "popper", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/packages/monitoring-ui/src/components/ui/separator.tsx b/packages/monitoring-ui/src/components/ui/separator.tsx new file mode 100644 index 0000000..5c1aecd --- /dev/null +++ b/packages/monitoring-ui/src/components/ui/separator.tsx @@ -0,0 +1,25 @@ +import * as React from "react" +import { cn } from "@/lib/utils" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Separator } diff --git a/packages/monitoring-ui/src/components/ui/sheet.tsx b/packages/monitoring-ui/src/components/ui/sheet.tsx new file mode 100644 index 0000000..69e40b3 --- /dev/null +++ b/packages/monitoring-ui/src/components/ui/sheet.tsx @@ -0,0 +1,129 @@ +"use client" + +import * as React from "react" +import { cn } from "@/lib/utils" +import * as SheetPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +function Sheet({ ...props }: React.ComponentProps) { + return +} + +function SheetTrigger({ ...props }: React.ComponentProps) { + return +} + +function SheetClose({ ...props }: React.ComponentProps) { + return +} + +function SheetPortal({ ...props }: React.ComponentProps) { + return +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = "right", + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left" +}) { + return ( + + + + {children} + + + Close + + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetTitle({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/packages/monitoring-ui/src/components/ui/sidebar.tsx b/packages/monitoring-ui/src/components/ui/sidebar.tsx new file mode 100644 index 0000000..5502909 --- /dev/null +++ b/packages/monitoring-ui/src/components/ui/sidebar.tsx @@ -0,0 +1,694 @@ +"use client" + +import type { VariantProps } from "class-variance-authority" +import * as React from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Skeleton } from "@/components/ui/skeleton" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import { useIsMobile } from "@/hooks/use-mobile" +import { cn } from "@/lib/utils" +import { Slot } from "@radix-ui/react-slot" +import { cva } from "class-variance-authority" +import { PanelLeftIcon } from "lucide-react" + +const SIDEBAR_COOKIE_NAME = "sidebar_state" +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = "16rem" +const SIDEBAR_WIDTH_MOBILE = "18rem" +const SIDEBAR_WIDTH_ICON = "3rem" +const SIDEBAR_KEYBOARD_SHORTCUT = "b" + +type SidebarContextProps = { + state: "expanded" | "collapsed" + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext(null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider.") + } + + return context +} + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<"div"> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void +}) { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open], + ) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) + }, [isMobile, setOpen, setOpenMobile]) + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed" + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar], + ) + + return ( + + +
+ {children} +
+
+
+ ) +} + +function Sidebar({ + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props +}: React.ComponentProps<"div"> & { + side?: "left" | "right" + variant?: "sidebar" | "floating" | "inset" + collapsible?: "offcanvas" | "icon" | "none" +}) { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === "none") { + return ( +
+ {children} +
+ ) + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ) + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ) +} + +function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps) { + const { toggleSidebar } = useSidebar() + + return ( + + ) +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar() + + return ( +