@luisrodrigues/nestjs-scheduler-dashboard 0.0.5 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -53
- package/dist/index.d.ts +1 -3
- package/dist/index.js +3 -36
- package/dist/jobs.service.js +24 -2
- package/dist/public/assets/index-BVFtTwJJ.css +1 -0
- package/dist/public/assets/index-Boe9HKvT.js +234 -0
- package/{ui → dist/public}/index.html +2 -2
- package/dist/public/public/assets/index-BVFtTwJJ.css +1 -0
- package/dist/public/public/assets/index-Boe9HKvT.js +234 -0
- package/dist/public/public/assets/index-DmY-xViB.js +234 -0
- package/dist/public/public/assets/index-cxgVvG8b.js +234 -0
- package/dist/public/public/assets/index-vJzJcDsD.css +1 -0
- package/dist/public/public/index.html +21 -0
- package/dist/scheduler-dash-root.module.d.ts +21 -0
- package/dist/scheduler-dash-root.module.js +191 -0
- package/dist/scheduler-dash.constants.d.ts +2 -0
- package/dist/scheduler-dash.constants.js +5 -0
- package/dist/scheduler-dash.module.d.ts +14 -0
- package/dist/scheduler-dash.module.js +108 -0
- package/dist/scheduler-dash.options.d.ts +1 -1
- package/dist/scheduler-dash.schema.d.ts +1 -1
- package/dist/scheduler-dash.schema.js +3 -5
- package/dist/standalone-server.js +20 -14
- package/package.json +10 -9
- package/ui/assets/index-BerSFPJX.css +0 -1
- package/ui/assets/index-C5PR13H0.js +0 -235
package/README.md
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
# @luisrodrigues/nestjs-scheduler-dashboard
|
|
2
2
|
|
|
3
|
-
A plug-and-play dashboard for [`@nestjs/schedule`](https://docs.nestjs.com/techniques/task-scheduling). Visualize cron job executions, track history and metrics, trigger jobs manually, and stop running executions — all from an embedded UI
|
|
4
|
-
|
|
5
|
-
The dashboard runs on its own dedicated port (default **3636**), completely isolated from your main application.
|
|
3
|
+
A plug-and-play dashboard for [`@nestjs/schedule`](https://docs.nestjs.com/techniques/task-scheduling). Visualize cron job executions, track history and metrics, trigger jobs manually, and stop running executions — all from an embedded UI served on your existing application port.
|
|
6
4
|
|
|
7
5
|
---
|
|
8
6
|
|
|
9
7
|
## Features
|
|
10
8
|
|
|
9
|
+
- Mounted directly on your app — no separate port or process
|
|
11
10
|
- Execution history per job with status, duration, and error details
|
|
12
11
|
- Persistent metrics: total runs, failed runs, average duration — independent of history retention
|
|
13
12
|
- Manual job triggering and execution stop from the UI
|
|
@@ -15,7 +14,6 @@ The dashboard runs on its own dedicated port (default **3636**), completely isol
|
|
|
15
14
|
- No-overlap mode: skip a job if it is already running
|
|
16
15
|
- Optional HTTP Basic Auth to protect the dashboard
|
|
17
16
|
- Light / dark mode
|
|
18
|
-
- Zero external runtime dependencies — served from a single self-contained HTML file
|
|
19
17
|
|
|
20
18
|
---
|
|
21
19
|
|
|
@@ -30,32 +28,27 @@ pnpm add @luisrodrigues/nestjs-scheduler-dashboard
|
|
|
30
28
|
**Peer dependencies** (install if not already present):
|
|
31
29
|
|
|
32
30
|
```bash
|
|
33
|
-
npm install @nestjs/common @nestjs/core @nestjs/schedule
|
|
31
|
+
npm install @nestjs/common @nestjs/core @nestjs/schedule express
|
|
34
32
|
```
|
|
35
33
|
|
|
36
34
|
---
|
|
37
35
|
|
|
38
36
|
## Quick start
|
|
39
37
|
|
|
40
|
-
### 1.
|
|
38
|
+
### 1. Import `SchedulerDashModule` in your `AppModule`
|
|
41
39
|
|
|
42
40
|
```ts
|
|
43
|
-
import {
|
|
44
|
-
import {
|
|
45
|
-
import {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
console.log('App running at http://localhost:3000');
|
|
55
|
-
console.log('Dashboard at http://localhost:3636');
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
bootstrap();
|
|
41
|
+
import { Module } from '@nestjs/common';
|
|
42
|
+
import { ScheduleModule } from '@nestjs/schedule';
|
|
43
|
+
import { SchedulerDashModule } from '@luisrodrigues/nestjs-scheduler-dashboard';
|
|
44
|
+
|
|
45
|
+
@Module({
|
|
46
|
+
imports: [
|
|
47
|
+
ScheduleModule.forRoot(),
|
|
48
|
+
SchedulerDashModule.forRoot({ route: '_scheduler' }),
|
|
49
|
+
],
|
|
50
|
+
})
|
|
51
|
+
export class AppModule {}
|
|
59
52
|
```
|
|
60
53
|
|
|
61
54
|
### 2. Decorate your jobs
|
|
@@ -76,22 +69,31 @@ export class ReportJob {
|
|
|
76
69
|
}
|
|
77
70
|
```
|
|
78
71
|
|
|
79
|
-
That's it.
|
|
72
|
+
That's it. Start your app and open `http://localhost:3000/_scheduler`.
|
|
80
73
|
|
|
81
74
|
---
|
|
82
75
|
|
|
83
76
|
## Configuration
|
|
84
77
|
|
|
85
|
-
`
|
|
78
|
+
`SchedulerDashModule.forRoot(options?)` accepts:
|
|
86
79
|
|
|
87
80
|
| Option | Type | Default | Description |
|
|
88
81
|
|---|---|---|---|
|
|
89
|
-
| `
|
|
82
|
+
| `route` | `string` | `'_scheduler'` | URL path where the dashboard is mounted |
|
|
90
83
|
| `storage` | `Storage` | `new MemoryStorage()` | Storage backend for execution history and metrics |
|
|
91
84
|
| `maxConcurrent` | `number` | — | Maximum number of jobs that can run simultaneously. Excess jobs are queued |
|
|
92
85
|
| `noOverlap` | `boolean` | `false` | Globally prevent a job from starting if it is already running |
|
|
93
86
|
| `auth` | `{ username, password }` | — | Protect the dashboard with HTTP Basic Auth |
|
|
94
87
|
|
|
88
|
+
### `route`
|
|
89
|
+
|
|
90
|
+
The URL path where the dashboard is served, relative to your app's root.
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
SchedulerDashModule.forRoot({ route: 'admin/scheduler' })
|
|
94
|
+
// dashboard → http://localhost:3000/admin/scheduler
|
|
95
|
+
```
|
|
96
|
+
|
|
95
97
|
### `storage`
|
|
96
98
|
|
|
97
99
|
The default `MemoryStorage` keeps everything in-process. You can limit how many history entries are kept per job:
|
|
@@ -99,11 +101,11 @@ The default `MemoryStorage` keeps everything in-process. You can limit how many
|
|
|
99
101
|
```ts
|
|
100
102
|
import { MemoryStorage } from '@luisrodrigues/nestjs-scheduler-dashboard';
|
|
101
103
|
|
|
102
|
-
|
|
104
|
+
SchedulerDashModule.forRoot({
|
|
103
105
|
storage: new MemoryStorage({
|
|
104
106
|
historyRetention: 50, // keep the last 50 executions per job (default: unlimited)
|
|
105
107
|
}),
|
|
106
|
-
})
|
|
108
|
+
})
|
|
107
109
|
```
|
|
108
110
|
|
|
109
111
|
> **Metrics are independent of `historyRetention`.** Even after old history entries are trimmed, the counters for total runs, failed runs, and average duration keep accumulating.
|
|
@@ -132,9 +134,7 @@ export class RedisStorage extends Storage {
|
|
|
132
134
|
Limits how many `@TrackJob` jobs can run simultaneously across the entire application. Jobs that exceed the limit are saved to storage with status `"queued"` and run in FIFO order as slots free up.
|
|
133
135
|
|
|
134
136
|
```ts
|
|
135
|
-
|
|
136
|
-
maxConcurrent: 5,
|
|
137
|
-
});
|
|
137
|
+
SchedulerDashModule.forRoot({ maxConcurrent: 5 })
|
|
138
138
|
```
|
|
139
139
|
|
|
140
140
|
### `noOverlap`
|
|
@@ -142,12 +142,10 @@ await setupSchedulerDash(app, {
|
|
|
142
142
|
Prevents a job from firing again if it is still running. Applies globally to all `@TrackJob` methods.
|
|
143
143
|
|
|
144
144
|
```ts
|
|
145
|
-
|
|
146
|
-
noOverlap: true,
|
|
147
|
-
});
|
|
145
|
+
SchedulerDashModule.forRoot({ noOverlap: true })
|
|
148
146
|
```
|
|
149
147
|
|
|
150
|
-
Can also be overridden per job
|
|
148
|
+
Can also be overridden per job via the decorator:
|
|
151
149
|
|
|
152
150
|
```ts
|
|
153
151
|
@TrackJob(CronExpression.EVERY_MINUTE, { name: 'sync', noOverlap: true })
|
|
@@ -157,12 +155,12 @@ async sync() { /* ... */ }
|
|
|
157
155
|
### `auth`
|
|
158
156
|
|
|
159
157
|
```ts
|
|
160
|
-
|
|
158
|
+
SchedulerDashModule.forRoot({
|
|
161
159
|
auth: {
|
|
162
160
|
username: process.env.DASH_USER ?? 'admin',
|
|
163
161
|
password: process.env.DASH_PASS ?? 'secret',
|
|
164
162
|
},
|
|
165
|
-
})
|
|
163
|
+
})
|
|
166
164
|
```
|
|
167
165
|
|
|
168
166
|
---
|
|
@@ -197,38 +195,54 @@ async cleanup() {
|
|
|
197
195
|
|
|
198
196
|
## API
|
|
199
197
|
|
|
200
|
-
The dashboard exposes a small REST API
|
|
198
|
+
The dashboard exposes a small REST API under the configured route.
|
|
201
199
|
|
|
202
200
|
| Method | Path | Description |
|
|
203
201
|
|---|---|---|
|
|
204
|
-
| `GET` |
|
|
205
|
-
| `
|
|
206
|
-
| `POST` |
|
|
202
|
+
| `GET` | `/<route>/api` | Returns all jobs with history and metrics |
|
|
203
|
+
| `GET` | `/<route>/api/:name` | Returns a single job with history and metrics |
|
|
204
|
+
| `POST` | `/<route>/api/:name/trigger` | Manually trigger a cron job by name |
|
|
205
|
+
| `POST` | `/<route>/api/executions/:id/stop` | Stop a running or queued execution by ID |
|
|
207
206
|
|
|
208
207
|
---
|
|
209
208
|
|
|
210
209
|
## Full example
|
|
211
210
|
|
|
212
211
|
```ts
|
|
212
|
+
// app.module.ts
|
|
213
|
+
import { Module } from '@nestjs/common';
|
|
214
|
+
import { ScheduleModule } from '@nestjs/schedule';
|
|
215
|
+
import { SchedulerDashModule, MemoryStorage } from '@luisrodrigues/nestjs-scheduler-dashboard';
|
|
216
|
+
import { ReportJob } from './jobs/report.job';
|
|
217
|
+
|
|
218
|
+
@Module({
|
|
219
|
+
imports: [
|
|
220
|
+
ScheduleModule.forRoot(),
|
|
221
|
+
SchedulerDashModule.forRoot({
|
|
222
|
+
route: '_scheduler',
|
|
223
|
+
storage: new MemoryStorage({ historyRetention: 100 }),
|
|
224
|
+
maxConcurrent: 3,
|
|
225
|
+
noOverlap: true,
|
|
226
|
+
auth: {
|
|
227
|
+
username: process.env.DASH_USER ?? 'admin',
|
|
228
|
+
password: process.env.DASH_PASS ?? 'secret',
|
|
229
|
+
},
|
|
230
|
+
}),
|
|
231
|
+
],
|
|
232
|
+
providers: [ReportJob],
|
|
233
|
+
})
|
|
234
|
+
export class AppModule {}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
// main.ts
|
|
213
239
|
import { NestFactory } from '@nestjs/core';
|
|
214
240
|
import { AppModule } from './app.module';
|
|
215
|
-
import { setupSchedulerDash, MemoryStorage } from '@luisrodrigues/nestjs-scheduler-dashboard';
|
|
216
241
|
|
|
217
242
|
async function bootstrap() {
|
|
218
243
|
const app = await NestFactory.create(AppModule);
|
|
219
|
-
|
|
220
|
-
await setupSchedulerDash(app, {
|
|
221
|
-
port: 3636,
|
|
222
|
-
storage: new MemoryStorage({ historyRetention: 100 }),
|
|
223
|
-
maxConcurrent: 3,
|
|
224
|
-
noOverlap: true,
|
|
225
|
-
auth: {
|
|
226
|
-
username: process.env.DASH_USER ?? 'admin',
|
|
227
|
-
password: process.env.DASH_PASS ?? 'secret',
|
|
228
|
-
},
|
|
229
|
-
});
|
|
230
|
-
|
|
231
244
|
await app.listen(3000);
|
|
245
|
+
// Dashboard at http://localhost:3000/_scheduler
|
|
232
246
|
}
|
|
233
247
|
|
|
234
248
|
bootstrap();
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
import { SchedulerDashOptions } from './scheduler-dash.options';
|
|
1
|
+
export { SchedulerDashModule } from './scheduler-dash.module';
|
|
3
2
|
export { TrackJob } from './decorators/track-job.decorator';
|
|
4
3
|
export { Storage } from './storage/storage.abstract';
|
|
5
4
|
export type { IStorageOptions } from './storage/storage.abstract';
|
|
@@ -7,4 +6,3 @@ export { MemoryStorage } from './storage/memory.storage';
|
|
|
7
6
|
export type { JobExecution } from './storage/job-execution.interface';
|
|
8
7
|
export type { JobMetrics } from './storage/job-metrics.interface';
|
|
9
8
|
export type { SchedulerDashOptions, SchedulerDashAuth } from './scheduler-dash.options';
|
|
10
|
-
export declare function setupSchedulerDash(app: INestApplication, options?: SchedulerDashOptions): Promise<void>;
|
package/dist/index.js
CHANGED
|
@@ -1,44 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
-
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
-
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
-
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
-
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
-
};
|
|
8
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
-
exports.MemoryStorage = exports.Storage = exports.TrackJob = void 0;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const common_1 = require("@nestjs/common");
|
|
13
|
-
const schedule_1 = require("@nestjs/schedule");
|
|
14
|
-
const scheduler_dash_context_1 = require("./scheduler-dash.context");
|
|
15
|
-
const scheduler_dash_schema_1 = require("./scheduler-dash.schema");
|
|
16
|
-
const dashboard_module_1 = require("./dashboard.module");
|
|
17
|
-
const jobs_service_1 = require("./jobs.service");
|
|
18
|
-
const standalone_server_1 = require("./standalone-server");
|
|
3
|
+
exports.MemoryStorage = exports.Storage = exports.TrackJob = exports.SchedulerDashModule = void 0;
|
|
4
|
+
var scheduler_dash_module_1 = require("./scheduler-dash.module");
|
|
5
|
+
Object.defineProperty(exports, "SchedulerDashModule", { enumerable: true, get: function () { return scheduler_dash_module_1.SchedulerDashModule; } });
|
|
19
6
|
var track_job_decorator_1 = require("./decorators/track-job.decorator");
|
|
20
7
|
Object.defineProperty(exports, "TrackJob", { enumerable: true, get: function () { return track_job_decorator_1.TrackJob; } });
|
|
21
8
|
var storage_abstract_1 = require("./storage/storage.abstract");
|
|
22
9
|
Object.defineProperty(exports, "Storage", { enumerable: true, get: function () { return storage_abstract_1.Storage; } });
|
|
23
10
|
var memory_storage_1 = require("./storage/memory.storage");
|
|
24
11
|
Object.defineProperty(exports, "MemoryStorage", { enumerable: true, get: function () { return memory_storage_1.MemoryStorage; } });
|
|
25
|
-
const DEFAULT_PORT = 3636;
|
|
26
|
-
async function setupSchedulerDash(app, options = {}) {
|
|
27
|
-
const parsed = scheduler_dash_schema_1.SchedulerDashOptionsSchema.safeParse(options);
|
|
28
|
-
if (!parsed.success) {
|
|
29
|
-
throw new Error(`[SchedulerDash] Invalid options:\n${parsed.error.issues.map(i => ` • ${i.path.join('.')}: ${i.message}`).join('\n')}`);
|
|
30
|
-
}
|
|
31
|
-
const port = options.port ?? DEFAULT_PORT;
|
|
32
|
-
const logger = new common_1.Logger('SchedulerDashboard', { timestamp: true });
|
|
33
|
-
let _InternalDashboardApp = class _InternalDashboardApp {
|
|
34
|
-
};
|
|
35
|
-
_InternalDashboardApp = __decorate([
|
|
36
|
-
(0, common_1.Module)({ imports: [dashboard_module_1.DashboardModule.forRoot(options)] })
|
|
37
|
-
], _InternalDashboardApp);
|
|
38
|
-
await core_1.NestFactory.createApplicationContext(_InternalDashboardApp, { logger: false });
|
|
39
|
-
const storage = scheduler_dash_context_1.SchedulerDashContext.storage;
|
|
40
|
-
const schedulerRegistry = app.get(schedule_1.SchedulerRegistry);
|
|
41
|
-
const jobsService = new jobs_service_1.JobsService(schedulerRegistry, storage);
|
|
42
|
-
logger.log(`Dashboard initialized`);
|
|
43
|
-
await (0, standalone_server_1.startStandaloneServer)(port, jobsService, options.auth, logger);
|
|
44
|
-
}
|
package/dist/jobs.service.js
CHANGED
|
@@ -1,8 +1,24 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
|
+
};
|
|
11
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
12
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
13
|
+
};
|
|
2
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
15
|
exports.JobsService = void 0;
|
|
16
|
+
const common_1 = require("@nestjs/common");
|
|
17
|
+
const schedule_1 = require("@nestjs/schedule");
|
|
18
|
+
const storage_abstract_1 = require("./storage/storage.abstract");
|
|
4
19
|
const job_concurrency_1 = require("./decorators/job-concurrency");
|
|
5
|
-
|
|
20
|
+
const scheduler_dash_constants_1 = require("./scheduler-dash.constants");
|
|
21
|
+
let JobsService = class JobsService {
|
|
6
22
|
constructor(schedulerRegistry, storage) {
|
|
7
23
|
this.schedulerRegistry = schedulerRegistry;
|
|
8
24
|
this.storage = storage;
|
|
@@ -43,5 +59,11 @@ class JobsService {
|
|
|
43
59
|
stopExecution(executionId) {
|
|
44
60
|
return (0, job_concurrency_1.stopExecutionById)(executionId);
|
|
45
61
|
}
|
|
46
|
-
}
|
|
62
|
+
};
|
|
47
63
|
exports.JobsService = JobsService;
|
|
64
|
+
exports.JobsService = JobsService = __decorate([
|
|
65
|
+
(0, common_1.Injectable)(),
|
|
66
|
+
__param(1, (0, common_1.Inject)(scheduler_dash_constants_1.STORAGE_TOKEN)),
|
|
67
|
+
__metadata("design:paramtypes", [schedule_1.SchedulerRegistry,
|
|
68
|
+
storage_abstract_1.Storage])
|
|
69
|
+
], JobsService);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}*{--tw-border-opacity: 1;border-color:rgb(228 228 231 / var(--tw-border-opacity, 1))}*:is(.dark *){--tw-border-opacity: 1;border-color:rgb(39 39 42 / var(--tw-border-opacity, 1))}body{--tw-bg-opacity: 1;background-color:rgb(250 250 250 / var(--tw-bg-opacity, 1));--tw-text-opacity: 1;color:rgb(24 24 27 / var(--tw-text-opacity, 1))}body:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(9 9 11 / var(--tw-bg-opacity, 1));--tw-text-opacity: 1;color:rgb(250 250 250 / var(--tw-text-opacity, 1))}.sticky{position:sticky}.top-0{top:0}.z-10{z-index:10}.mx-auto{margin-left:auto;margin-right:auto}.ml-2{margin-left:.5rem}.ml-auto{margin-left:auto}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.h-1\.5{height:.375rem}.h-14{height:3.5rem}.h-2{height:.5rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-1\.5{width:.375rem}.w-16{width:4rem}.w-2{width:.5rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-7{width:1.75rem}.w-8{width:2rem}.w-full{width:100%}.max-w-7xl{max-width:80rem}.shrink-0{flex-shrink:0}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.whitespace-pre-wrap{white-space:pre-wrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-sm{border-radius:.125rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-red-200{--tw-border-opacity: 1;border-color:rgb(254 202 202 / var(--tw-border-opacity, 1))}.border-red-500\/10{border-color:#ef44441a}.border-zinc-100{--tw-border-opacity: 1;border-color:rgb(244 244 245 / var(--tw-border-opacity, 1))}.border-zinc-200{--tw-border-opacity: 1;border-color:rgb(228 228 231 / var(--tw-border-opacity, 1))}.border-zinc-300{--tw-border-opacity: 1;border-color:rgb(212 212 216 / var(--tw-border-opacity, 1))}.bg-amber-400{--tw-bg-opacity: 1;background-color:rgb(251 191 36 / var(--tw-bg-opacity, 1))}.bg-amber-50{--tw-bg-opacity: 1;background-color:rgb(255 251 235 / var(--tw-bg-opacity, 1))}.bg-blue-50{--tw-bg-opacity: 1;background-color:rgb(239 246 255 / var(--tw-bg-opacity, 1))}.bg-blue-500{--tw-bg-opacity: 1;background-color:rgb(59 130 246 / var(--tw-bg-opacity, 1))}.bg-emerald-50{--tw-bg-opacity: 1;background-color:rgb(236 253 245 / var(--tw-bg-opacity, 1))}.bg-emerald-500{--tw-bg-opacity: 1;background-color:rgb(16 185 129 / var(--tw-bg-opacity, 1))}.bg-red-50{--tw-bg-opacity: 1;background-color:rgb(254 242 242 / var(--tw-bg-opacity, 1))}.bg-red-500{--tw-bg-opacity: 1;background-color:rgb(239 68 68 / var(--tw-bg-opacity, 1))}.bg-red-500\/5{background-color:#ef44440d}.bg-red-600{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-opacity, 1))}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.bg-zinc-100{--tw-bg-opacity: 1;background-color:rgb(244 244 245 / var(--tw-bg-opacity, 1))}.bg-zinc-200{--tw-bg-opacity: 1;background-color:rgb(228 228 231 / var(--tw-bg-opacity, 1))}.bg-zinc-400{--tw-bg-opacity: 1;background-color:rgb(161 161 170 / var(--tw-bg-opacity, 1))}.bg-zinc-50{--tw-bg-opacity: 1;background-color:rgb(250 250 250 / var(--tw-bg-opacity, 1))}.bg-zinc-900{--tw-bg-opacity: 1;background-color:rgb(24 24 27 / var(--tw-bg-opacity, 1))}.p-0{padding:0}.p-3{padding:.75rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-3\.5{padding-top:.875rem;padding-bottom:.875rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-2{padding-bottom:.5rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.tracking-wider{letter-spacing:.05em}.text-amber-500{--tw-text-opacity: 1;color:rgb(245 158 11 / var(--tw-text-opacity, 1))}.text-amber-700{--tw-text-opacity: 1;color:rgb(180 83 9 / var(--tw-text-opacity, 1))}.text-blue-500{--tw-text-opacity: 1;color:rgb(59 130 246 / var(--tw-text-opacity, 1))}.text-blue-600{--tw-text-opacity: 1;color:rgb(37 99 235 / var(--tw-text-opacity, 1))}.text-blue-700{--tw-text-opacity: 1;color:rgb(29 78 216 / var(--tw-text-opacity, 1))}.text-emerald-500{--tw-text-opacity: 1;color:rgb(16 185 129 / var(--tw-text-opacity, 1))}.text-emerald-600{--tw-text-opacity: 1;color:rgb(5 150 105 / var(--tw-text-opacity, 1))}.text-emerald-700{--tw-text-opacity: 1;color:rgb(4 120 87 / var(--tw-text-opacity, 1))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.text-red-500{--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.text-red-600{--tw-text-opacity: 1;color:rgb(220 38 38 / var(--tw-text-opacity, 1))}.text-red-700{--tw-text-opacity: 1;color:rgb(185 28 28 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.text-zinc-300{--tw-text-opacity: 1;color:rgb(212 212 216 / var(--tw-text-opacity, 1))}.text-zinc-400{--tw-text-opacity: 1;color:rgb(161 161 170 / var(--tw-text-opacity, 1))}.text-zinc-500{--tw-text-opacity: 1;color:rgb(113 113 122 / var(--tw-text-opacity, 1))}.text-zinc-600{--tw-text-opacity: 1;color:rgb(82 82 91 / var(--tw-text-opacity, 1))}.text-zinc-700{--tw-text-opacity: 1;color:rgb(63 63 70 / var(--tw-text-opacity, 1))}.text-zinc-800{--tw-text-opacity: 1;color:rgb(39 39 42 / var(--tw-text-opacity, 1))}.text-zinc-900{--tw-text-opacity: 1;color:rgb(24 24 27 / var(--tw-text-opacity, 1))}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.ring-1{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-inset{--tw-ring-inset: inset}.ring-amber-200{--tw-ring-opacity: 1;--tw-ring-color: rgb(253 230 138 / var(--tw-ring-opacity, 1))}.ring-blue-200{--tw-ring-opacity: 1;--tw-ring-color: rgb(191 219 254 / var(--tw-ring-opacity, 1))}.ring-emerald-200{--tw-ring-opacity: 1;--tw-ring-color: rgb(167 243 208 / var(--tw-ring-opacity, 1))}.ring-red-200{--tw-ring-opacity: 1;--tw-ring-color: rgb(254 202 202 / var(--tw-ring-opacity, 1))}.ring-zinc-200{--tw-ring-opacity: 1;--tw-ring-color: rgb(228 228 231 / var(--tw-ring-opacity, 1))}.ring-zinc-300{--tw-ring-opacity: 1;--tw-ring-color: rgb(212 212 216 / var(--tw-ring-opacity, 1))}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.hover\:bg-red-700:hover{--tw-bg-opacity: 1;background-color:rgb(185 28 28 / var(--tw-bg-opacity, 1))}.hover\:bg-zinc-100:hover{--tw-bg-opacity: 1;background-color:rgb(244 244 245 / var(--tw-bg-opacity, 1))}.hover\:bg-zinc-50:hover{--tw-bg-opacity: 1;background-color:rgb(250 250 250 / var(--tw-bg-opacity, 1))}.hover\:bg-zinc-700:hover{--tw-bg-opacity: 1;background-color:rgb(63 63 70 / var(--tw-bg-opacity, 1))}.hover\:text-emerald-600:hover{--tw-text-opacity: 1;color:rgb(5 150 105 / var(--tw-text-opacity, 1))}.hover\:text-red-400:hover{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.hover\:text-red-500:hover{--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.hover\:text-zinc-900:hover{--tw-text-opacity: 1;color:rgb(24 24 27 / var(--tw-text-opacity, 1))}.hover\:underline:hover{text-decoration-line:underline}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus-visible\:outline-none:focus-visible{outline:2px solid transparent;outline-offset:2px}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus-visible\:ring-zinc-400:focus-visible{--tw-ring-opacity: 1;--tw-ring-color: rgb(161 161 170 / var(--tw-ring-opacity, 1))}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.dark\:border-red-800:is(.dark *){--tw-border-opacity: 1;border-color:rgb(153 27 27 / var(--tw-border-opacity, 1))}.dark\:border-zinc-700:is(.dark *){--tw-border-opacity: 1;border-color:rgb(63 63 70 / var(--tw-border-opacity, 1))}.dark\:border-zinc-800:is(.dark *){--tw-border-opacity: 1;border-color:rgb(39 39 42 / var(--tw-border-opacity, 1))}.dark\:border-zinc-800\/60:is(.dark *){border-color:#27272a99}.dark\:bg-amber-950:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(69 26 3 / var(--tw-bg-opacity, 1))}.dark\:bg-blue-950:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(23 37 84 / var(--tw-bg-opacity, 1))}.dark\:bg-emerald-950:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(2 44 34 / var(--tw-bg-opacity, 1))}.dark\:bg-red-950:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(69 10 10 / var(--tw-bg-opacity, 1))}.dark\:bg-zinc-100:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(244 244 245 / var(--tw-bg-opacity, 1))}.dark\:bg-zinc-700:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(63 63 70 / var(--tw-bg-opacity, 1))}.dark\:bg-zinc-800:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(39 39 42 / var(--tw-bg-opacity, 1))}.dark\:bg-zinc-900:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(24 24 27 / var(--tw-bg-opacity, 1))}.dark\:bg-zinc-950:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(9 9 11 / var(--tw-bg-opacity, 1))}.dark\:text-amber-300:is(.dark *){--tw-text-opacity: 1;color:rgb(252 211 77 / var(--tw-text-opacity, 1))}.dark\:text-blue-300:is(.dark *){--tw-text-opacity: 1;color:rgb(147 197 253 / var(--tw-text-opacity, 1))}.dark\:text-blue-400:is(.dark *){--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.dark\:text-emerald-300:is(.dark *){--tw-text-opacity: 1;color:rgb(110 231 183 / var(--tw-text-opacity, 1))}.dark\:text-emerald-400:is(.dark *){--tw-text-opacity: 1;color:rgb(52 211 153 / var(--tw-text-opacity, 1))}.dark\:text-red-300:is(.dark *){--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}.dark\:text-red-400:is(.dark *){--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.dark\:text-zinc-100:is(.dark *){--tw-text-opacity: 1;color:rgb(244 244 245 / var(--tw-text-opacity, 1))}.dark\:text-zinc-200:is(.dark *){--tw-text-opacity: 1;color:rgb(228 228 231 / var(--tw-text-opacity, 1))}.dark\:text-zinc-300:is(.dark *){--tw-text-opacity: 1;color:rgb(212 212 216 / var(--tw-text-opacity, 1))}.dark\:text-zinc-400:is(.dark *){--tw-text-opacity: 1;color:rgb(161 161 170 / var(--tw-text-opacity, 1))}.dark\:text-zinc-500:is(.dark *){--tw-text-opacity: 1;color:rgb(113 113 122 / var(--tw-text-opacity, 1))}.dark\:text-zinc-600:is(.dark *){--tw-text-opacity: 1;color:rgb(82 82 91 / var(--tw-text-opacity, 1))}.dark\:text-zinc-700:is(.dark *){--tw-text-opacity: 1;color:rgb(63 63 70 / var(--tw-text-opacity, 1))}.dark\:text-zinc-900:is(.dark *){--tw-text-opacity: 1;color:rgb(24 24 27 / var(--tw-text-opacity, 1))}.dark\:ring-amber-800:is(.dark *){--tw-ring-opacity: 1;--tw-ring-color: rgb(146 64 14 / var(--tw-ring-opacity, 1))}.dark\:ring-blue-800:is(.dark *){--tw-ring-opacity: 1;--tw-ring-color: rgb(30 64 175 / var(--tw-ring-opacity, 1))}.dark\:ring-emerald-800:is(.dark *){--tw-ring-opacity: 1;--tw-ring-color: rgb(6 95 70 / var(--tw-ring-opacity, 1))}.dark\:ring-red-800:is(.dark *){--tw-ring-opacity: 1;--tw-ring-color: rgb(153 27 27 / var(--tw-ring-opacity, 1))}.dark\:ring-zinc-700:is(.dark *){--tw-ring-opacity: 1;--tw-ring-color: rgb(63 63 70 / var(--tw-ring-opacity, 1))}.dark\:hover\:bg-zinc-300:hover:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(212 212 216 / var(--tw-bg-opacity, 1))}.dark\:hover\:bg-zinc-800:hover:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(39 39 42 / var(--tw-bg-opacity, 1))}.dark\:hover\:bg-zinc-800\/20:hover:is(.dark *){background-color:#27272a33}.dark\:hover\:bg-zinc-800\/30:hover:is(.dark *){background-color:#27272a4d}.dark\:hover\:text-emerald-400:hover:is(.dark *){--tw-text-opacity: 1;color:rgb(52 211 153 / var(--tw-text-opacity, 1))}.dark\:hover\:text-zinc-100:hover:is(.dark *){--tw-text-opacity: 1;color:rgb(244 244 245 / var(--tw-text-opacity, 1))}@media(min-width:640px){.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}
|