Compare commits
164 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 700fe91bec | |||
| 6055c7d050 | |||
| cf1c311dea | |||
| fb94e1741f | |||
| 022f69c8c8 | |||
| d893b26ed2 | |||
| 6f26acb581 | |||
| bd449f75dc | |||
| cd2f536620 | |||
| 3056a51b82 | |||
| 23ec5ff8fa | |||
| 31e4753122 | |||
| 8bc6b99285 | |||
| 87167524cc | |||
| 36168b3eb6 | |||
| c859be9c53 | |||
| b7f7d88623 | |||
| 72594a46ee | |||
| 5504e7f8cc | |||
| 7a7c4b1a82 | |||
| 733894bb4f | |||
| b696bbb1df | |||
| aa69b9027d | |||
| 0b0df071bf | |||
| 936bddf198 | |||
| b1e21cf03b | |||
| c3f2d07708 | |||
| 4dc504d9d0 | |||
| a5339a1bac | |||
| 5b07bd11a0 | |||
| 260fb7cd70 | |||
| 9934627c0b | |||
| 495a2531f0 | |||
| 1bca378000 | |||
| b042b0b2f9 | |||
| 3be55db8f8 | |||
| bb253400a2 | |||
| c8686b5f1c | |||
| dfb46d8847 | |||
| 9942518e49 | |||
| a3c387697e | |||
| 7a9490893c | |||
| 768206c91b | |||
| 2c30c2defb | |||
| 839d5e8eba | |||
| 42dfb87ebb | |||
| 49df40cb11 | |||
| cf0aa9b3ca | |||
| a8024afd0a | |||
| 57ba2528b2 | |||
| 7d29f5aac9 | |||
| 36b0762100 | |||
| f5b2ccb494 | |||
| f73138fe6c | |||
| 7c2e78c9de | |||
| 6dfbe1d762 | |||
| 3f19b98f9f | |||
| 09460ab37d | |||
| 062766e974 | |||
| c68eb2d951 | |||
| 583d299891 | |||
| 17338050b1 | |||
| c7a238c664 | |||
| 8ba7526d51 | |||
| 47bed15255 | |||
| 752ad32293 | |||
| db0df17a6a | |||
| edf5b142fc | |||
| 4c3dfac223 | |||
| 7ccbbdb368 | |||
| 71279a7b12 | |||
| 2eba36c086 | |||
| 29827e96f2 | |||
| d416f340ad | |||
| 7a54a843e2 | |||
| 52e9baede5 | |||
| deb80890fe | |||
| d82089686c | |||
| acc10e847c | |||
| f1a83cffc4 | |||
| 61fee91830 | |||
| caa40ce925 | |||
| ba9b0e17c1 | |||
| 469808cf09 | |||
| 7574c69c2d | |||
| 06cbe6e3ca | |||
| 3875bb292a | |||
| ff80742ab7 | |||
| 11366e658c | |||
| e751f8832e | |||
| a999d9ea3f | |||
| 28a66ac021 | |||
| 7d34817473 | |||
| 6d6ed471d1 | |||
| 7f7f993587 | |||
| d28707826c | |||
| 8b00198f99 | |||
| 74e9246bfa | |||
| 07a31d3732 | |||
| da111e220f | |||
| 2c3cb4a43a | |||
| b71bf89444 | |||
| 28c0c78257 | |||
| c021d1db63 | |||
| f62cdfe00e | |||
| 66c6da7114 | |||
| 7fba9e9f6b | |||
| cf63fc9cd2 | |||
| d1c3bfb9c9 | |||
| d8f5853457 | |||
| a52566d9cc | |||
| 07a1d04d52 | |||
| 185f4ecaf9 | |||
| 0d764d4c46 | |||
| beb52b9800 | |||
| 3401a1d0a9 | |||
| 3f982ed366 | |||
| 4dc956470d | |||
| 7fddadcce9 | |||
| 6e038d4d1e | |||
| 05b53967ea | |||
| 702f3b4bca | |||
| 8fcd93001d | |||
| b01b8740cc | |||
| 357aa99309 | |||
| 7cbf82a4ae | |||
| e7fec4097f | |||
| d279988e38 | |||
| 748ae658f7 | |||
| 249878ed92 | |||
| 117b1d5a53 | |||
| 04173a93b9 | |||
| da4c753b68 | |||
| 6ae3b69745 | |||
| 652d95c7eb | |||
| 341e27d213 | |||
| 9821d02af7 | |||
| 255a73a2fe | |||
| c5f7a8daf7 | |||
| 8b0f51b317 | |||
| 53f21e1330 | |||
| 331882f59c | |||
| 1276491a83 | |||
| 5b7dfe23b5 | |||
| 49b2f683f4 | |||
| af9b3e1c99 | |||
| df4ab97d59 | |||
| 9f27422472 | |||
| 2f1a72b109 | |||
| 3c326a4b4a | |||
| 6ae1317b69 | |||
| 2e1c4f6df6 | |||
| 2bf86dc20e | |||
| 485782cae1 | |||
| 61b338bea7 | |||
| 83ddf721a4 | |||
| a4dd2562d2 | |||
| 5ff21a758c | |||
| 47a64c0335 | |||
| fc351cb6c4 | |||
| c4f2a9d065 | |||
| 2762c542c2 | |||
| 72e286af2f | |||
| f7240a99e7 |
@@ -0,0 +1,200 @@
|
||||
---
|
||||
name: trigger-dev-tasks
|
||||
description: Use this skill when writing, designing, or optimizing Trigger.dev background tasks and workflows. This includes creating reliable async tasks, implementing AI workflows, setting up scheduled jobs, structuring complex task hierarchies with subtasks, configuring build extensions for tools like ffmpeg or Puppeteer/Playwright, and handling task schemas with Zod validation.
|
||||
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
|
||||
---
|
||||
|
||||
# Trigger.dev Task Expert
|
||||
|
||||
You are an expert Trigger.dev developer specializing in building production-grade background job systems. Tasks deployed to Trigger.dev run in Node.js 21+ and use the `@trigger.dev/sdk` package.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
1. **Always use `@trigger.dev/sdk`** - Never use `@trigger.dev/sdk/v3` or deprecated `client.defineJob` pattern
|
||||
2. **Never use `node-fetch`** - Use the built-in `fetch` function
|
||||
3. **Export all tasks** - Every task must be exported, including subtasks
|
||||
4. **Never wrap wait/trigger calls in Promise.all** - `triggerAndWait`, `batchTriggerAndWait`, and `wait.*` calls cannot be wrapped in `Promise.all` or `Promise.allSettled`
|
||||
|
||||
## Basic Task Pattern
|
||||
|
||||
```ts
|
||||
import { task } from "@trigger.dev/sdk";
|
||||
|
||||
export const processData = task({
|
||||
id: "process-data",
|
||||
retry: {
|
||||
maxAttempts: 10,
|
||||
factor: 1.8,
|
||||
minTimeoutInMs: 500,
|
||||
maxTimeoutInMs: 30_000,
|
||||
},
|
||||
run: async (payload: { userId: string; data: any[] }) => {
|
||||
console.log(`Processing ${payload.data.length} items`);
|
||||
return { processed: payload.data.length };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Schema Task (with validation)
|
||||
|
||||
```ts
|
||||
import { schemaTask } from "@trigger.dev/sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
export const validatedTask = schemaTask({
|
||||
id: "validated-task",
|
||||
schema: z.object({
|
||||
name: z.string(),
|
||||
email: z.string().email(),
|
||||
}),
|
||||
run: async (payload) => {
|
||||
// Payload is automatically validated and typed
|
||||
return { message: `Hello ${payload.name}` };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Triggering Tasks
|
||||
|
||||
### From Backend Code (type-only import to prevent dependency leakage)
|
||||
|
||||
```ts
|
||||
import { tasks } from "@trigger.dev/sdk";
|
||||
import type { processData } from "./trigger/tasks";
|
||||
|
||||
const handle = await tasks.trigger<typeof processData>("process-data", {
|
||||
userId: "123",
|
||||
data: [{ id: 1 }],
|
||||
});
|
||||
```
|
||||
|
||||
### From Inside Tasks
|
||||
|
||||
```ts
|
||||
export const parentTask = task({
|
||||
id: "parent-task",
|
||||
run: async (payload) => {
|
||||
// Trigger and wait - returns Result object, NOT direct output
|
||||
const result = await childTask.triggerAndWait({ data: "value" });
|
||||
if (result.ok) {
|
||||
console.log("Output:", result.output);
|
||||
} else {
|
||||
console.error("Failed:", result.error);
|
||||
}
|
||||
|
||||
// Or unwrap directly (throws on error)
|
||||
const output = await childTask.triggerAndWait({ data: "value" }).unwrap();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Idempotency (Critical for Retries)
|
||||
|
||||
Always use idempotency keys when triggering tasks from inside other tasks:
|
||||
|
||||
```ts
|
||||
import { idempotencyKeys } from "@trigger.dev/sdk";
|
||||
|
||||
export const paymentTask = task({
|
||||
id: "process-payment",
|
||||
run: async (payload: { orderId: string }) => {
|
||||
// Scoped to current run - survives retries
|
||||
const key = await idempotencyKeys.create(`payment-${payload.orderId}`);
|
||||
|
||||
await chargeCustomer.trigger(payload, {
|
||||
idempotencyKey: key,
|
||||
idempotencyKeyTTL: "24h",
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Trigger Options
|
||||
|
||||
```ts
|
||||
await myTask.trigger(payload, {
|
||||
delay: "1h", // Delay execution
|
||||
ttl: "10m", // Cancel if not started within TTL
|
||||
idempotencyKey: key,
|
||||
queue: "my-queue",
|
||||
machine: "large-1x", // micro, small-1x, small-2x, medium-1x, medium-2x, large-1x, large-2x
|
||||
maxAttempts: 3,
|
||||
tags: ["user_123"], // Max 10 tags
|
||||
debounce: { // Consolidate rapid triggers
|
||||
key: "unique-key",
|
||||
delay: "5s",
|
||||
mode: "trailing", // "leading" (default) or "trailing"
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Debouncing
|
||||
|
||||
Consolidate multiple triggers into a single execution:
|
||||
|
||||
```ts
|
||||
// Rapid triggers with same key = single execution
|
||||
await myTask.trigger({ userId: "123" }, {
|
||||
debounce: {
|
||||
key: "user-123-update",
|
||||
delay: "5s",
|
||||
},
|
||||
});
|
||||
|
||||
// Trailing mode: use payload from LAST trigger
|
||||
await myTask.trigger({ data: "latest" }, {
|
||||
debounce: {
|
||||
key: "my-key",
|
||||
delay: "10s",
|
||||
mode: "trailing",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Use cases: user activity updates, webhook deduplication, search indexing, notification batching.
|
||||
|
||||
## Batch Triggering
|
||||
|
||||
Up to 1,000 items per batch, 3MB per payload:
|
||||
|
||||
```ts
|
||||
const results = await myTask.batchTriggerAndWait([
|
||||
{ payload: { userId: "1" } },
|
||||
{ payload: { userId: "2" } },
|
||||
]);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.ok) console.log(result.output);
|
||||
}
|
||||
```
|
||||
|
||||
## Machine Presets
|
||||
|
||||
| Preset | vCPU | Memory |
|
||||
|-------------|------|--------|
|
||||
| micro | 0.25 | 0.25GB |
|
||||
| small-1x | 0.5 | 0.5GB |
|
||||
| small-2x | 1 | 1GB |
|
||||
| medium-1x | 1 | 2GB |
|
||||
| medium-2x | 2 | 4GB |
|
||||
| large-1x | 4 | 8GB |
|
||||
| large-2x | 8 | 16GB |
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Break complex workflows into subtasks** that can be independently retried and made idempotent
|
||||
2. **Don't over-complicate** - Sometimes `Promise.allSettled` inside a single task is better than many subtasks (each task has dedicated process and is charged by millisecond)
|
||||
3. **Always configure retries** - Set appropriate `maxAttempts` based on the operation
|
||||
4. **Use idempotency keys** - Especially for payment/critical operations
|
||||
5. **Group related subtasks** - Keep subtasks only used by one parent in the same file, don't export them
|
||||
6. **Use logger** - Log at key execution points with `logger.info()`, `logger.error()`, etc.
|
||||
|
||||
## Reference Documentation
|
||||
|
||||
For detailed documentation on specific topics, read these files:
|
||||
|
||||
- `basic-tasks.md` - Task basics, triggering, waits
|
||||
- `advanced-tasks.md` - Tags, queues, concurrency, metadata, error handling
|
||||
- `scheduled-tasks.md` - Cron schedules, declarative and imperative
|
||||
- `realtime.md` - Real-time subscriptions, streams, React hooks
|
||||
- `config.md` - trigger.config.ts, build extensions (Prisma, Playwright, FFmpeg, etc.)
|
||||
@@ -0,0 +1,485 @@
|
||||
# Trigger.dev Advanced Tasks (v4)
|
||||
|
||||
**Advanced patterns and features for writing tasks**
|
||||
|
||||
## Tags & Organization
|
||||
|
||||
```ts
|
||||
import { task, tags } from "@trigger.dev/sdk";
|
||||
|
||||
export const processUser = task({
|
||||
id: "process-user",
|
||||
run: async (payload: { userId: string; orgId: string }, { ctx }) => {
|
||||
// Add tags during execution
|
||||
await tags.add(`user_${payload.userId}`);
|
||||
await tags.add(`org_${payload.orgId}`);
|
||||
|
||||
return { processed: true };
|
||||
},
|
||||
});
|
||||
|
||||
// Trigger with tags
|
||||
await processUser.trigger(
|
||||
{ userId: "123", orgId: "abc" },
|
||||
{ tags: ["priority", "user_123", "org_abc"] } // Max 10 tags per run
|
||||
);
|
||||
|
||||
// Subscribe to tagged runs
|
||||
for await (const run of runs.subscribeToRunsWithTag("user_123")) {
|
||||
console.log(`User task ${run.id}: ${run.status}`);
|
||||
}
|
||||
```
|
||||
|
||||
**Tag Best Practices:**
|
||||
|
||||
- Use prefixes: `user_123`, `org_abc`, `video:456`
|
||||
- Max 10 tags per run, 1-64 characters each
|
||||
- Tags don't propagate to child tasks automatically
|
||||
|
||||
## Batch Triggering v2
|
||||
|
||||
Enhanced batch triggering with larger payloads and streaming ingestion.
|
||||
|
||||
### Limits
|
||||
|
||||
- **Maximum batch size**: 1,000 items (increased from 500)
|
||||
- **Payload per item**: 3MB each (increased from 1MB combined)
|
||||
- Payloads > 512KB automatically offload to object storage
|
||||
|
||||
### Rate Limiting (per environment)
|
||||
|
||||
| Tier | Bucket Size | Refill Rate |
|
||||
|------|-------------|-------------|
|
||||
| Free | 1,200 runs | 100 runs/10 sec |
|
||||
| Hobby | 5,000 runs | 500 runs/5 sec |
|
||||
| Pro | 5,000 runs | 500 runs/5 sec |
|
||||
|
||||
### Concurrent Batch Processing
|
||||
|
||||
| Tier | Concurrent Batches |
|
||||
|------|-------------------|
|
||||
| Free | 1 |
|
||||
| Hobby | 10 |
|
||||
| Pro | 10 |
|
||||
|
||||
### Usage
|
||||
|
||||
```ts
|
||||
import { myTask } from "./trigger/myTask";
|
||||
|
||||
// Basic batch trigger (up to 1,000 items)
|
||||
const runs = await myTask.batchTrigger([
|
||||
{ payload: { userId: "user-1" } },
|
||||
{ payload: { userId: "user-2" } },
|
||||
{ payload: { userId: "user-3" } },
|
||||
]);
|
||||
|
||||
// Batch trigger with wait
|
||||
const results = await myTask.batchTriggerAndWait([
|
||||
{ payload: { userId: "user-1" } },
|
||||
{ payload: { userId: "user-2" } },
|
||||
]);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.ok) {
|
||||
console.log("Result:", result.output);
|
||||
}
|
||||
}
|
||||
|
||||
// With per-item options
|
||||
const batchHandle = await myTask.batchTrigger([
|
||||
{
|
||||
payload: { userId: "123" },
|
||||
options: {
|
||||
idempotencyKey: "user-123-batch",
|
||||
tags: ["priority"],
|
||||
},
|
||||
},
|
||||
{
|
||||
payload: { userId: "456" },
|
||||
options: {
|
||||
idempotencyKey: "user-456-batch",
|
||||
},
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
## Debouncing
|
||||
|
||||
Consolidate multiple triggers into a single execution by debouncing task runs with a unique key and delay window.
|
||||
|
||||
### Use Cases
|
||||
|
||||
- **User activity updates**: Batch rapid user actions into a single run
|
||||
- **Webhook deduplication**: Handle webhook bursts without redundant processing
|
||||
- **Search indexing**: Combine document updates instead of processing individually
|
||||
- **Notification batching**: Group notifications to prevent user spam
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```ts
|
||||
await myTask.trigger(
|
||||
{ userId: "123" },
|
||||
{
|
||||
debounce: {
|
||||
key: "user-123-update", // Unique identifier for debounce group
|
||||
delay: "5s", // Wait duration ("5s", "1m", or milliseconds)
|
||||
},
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Execution Modes
|
||||
|
||||
**Leading Mode** (default): Uses payload/options from the first trigger; subsequent triggers only reschedule execution time.
|
||||
|
||||
```ts
|
||||
// First trigger sets the payload
|
||||
await myTask.trigger({ action: "first" }, {
|
||||
debounce: { key: "my-key", delay: "10s" }
|
||||
});
|
||||
|
||||
// Second trigger only reschedules - payload remains "first"
|
||||
await myTask.trigger({ action: "second" }, {
|
||||
debounce: { key: "my-key", delay: "10s" }
|
||||
});
|
||||
// Task executes with { action: "first" }
|
||||
```
|
||||
|
||||
**Trailing Mode**: Uses payload/options from the most recent trigger.
|
||||
|
||||
```ts
|
||||
await myTask.trigger(
|
||||
{ data: "latest-value" },
|
||||
{
|
||||
debounce: {
|
||||
key: "trailing-example",
|
||||
delay: "10s",
|
||||
mode: "trailing",
|
||||
},
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
In trailing mode, these options update with each trigger:
|
||||
- `payload` — task input data
|
||||
- `metadata` — run metadata
|
||||
- `tags` — run tags (replaces existing)
|
||||
- `maxAttempts` — retry attempts
|
||||
- `maxDuration` — maximum compute time
|
||||
- `machine` — machine preset
|
||||
|
||||
### Important Notes
|
||||
|
||||
- Idempotency keys take precedence over debounce settings
|
||||
- Compatible with `triggerAndWait()` — parent runs block correctly on debounced execution
|
||||
- Debounce key is scoped to the task
|
||||
|
||||
## Concurrency & Queues
|
||||
|
||||
```ts
|
||||
import { task, queue } from "@trigger.dev/sdk";
|
||||
|
||||
// Shared queue for related tasks
|
||||
const emailQueue = queue({
|
||||
name: "email-processing",
|
||||
concurrencyLimit: 5, // Max 5 emails processing simultaneously
|
||||
});
|
||||
|
||||
// Task-level concurrency
|
||||
export const oneAtATime = task({
|
||||
id: "sequential-task",
|
||||
queue: { concurrencyLimit: 1 }, // Process one at a time
|
||||
run: async (payload) => {
|
||||
// Critical section - only one instance runs
|
||||
},
|
||||
});
|
||||
|
||||
// Per-user concurrency
|
||||
export const processUserData = task({
|
||||
id: "process-user-data",
|
||||
run: async (payload: { userId: string }) => {
|
||||
// Override queue with user-specific concurrency
|
||||
await childTask.trigger(payload, {
|
||||
queue: {
|
||||
name: `user-${payload.userId}`,
|
||||
concurrencyLimit: 2,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const emailTask = task({
|
||||
id: "send-email",
|
||||
queue: emailQueue, // Use shared queue
|
||||
run: async (payload: { to: string }) => {
|
||||
// Send email logic
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Error Handling & Retries
|
||||
|
||||
```ts
|
||||
import { task, retry, AbortTaskRunError } from "@trigger.dev/sdk";
|
||||
|
||||
export const resilientTask = task({
|
||||
id: "resilient-task",
|
||||
retry: {
|
||||
maxAttempts: 10,
|
||||
factor: 1.8, // Exponential backoff multiplier
|
||||
minTimeoutInMs: 500,
|
||||
maxTimeoutInMs: 30_000,
|
||||
randomize: false,
|
||||
},
|
||||
catchError: async ({ error, ctx }) => {
|
||||
// Custom error handling
|
||||
if (error.code === "FATAL_ERROR") {
|
||||
throw new AbortTaskRunError("Cannot retry this error");
|
||||
}
|
||||
|
||||
// Log error details
|
||||
console.error(`Task ${ctx.task.id} failed:`, error);
|
||||
|
||||
// Allow retry by returning nothing
|
||||
return { retryAt: new Date(Date.now() + 60000) }; // Retry in 1 minute
|
||||
},
|
||||
run: async (payload) => {
|
||||
// Retry specific operations
|
||||
const result = await retry.onThrow(
|
||||
async () => {
|
||||
return await unstableApiCall(payload);
|
||||
},
|
||||
{ maxAttempts: 3 }
|
||||
);
|
||||
|
||||
// Conditional HTTP retries
|
||||
const response = await retry.fetch("https://api.example.com", {
|
||||
retry: {
|
||||
maxAttempts: 5,
|
||||
condition: (response, error) => {
|
||||
return response?.status === 429 || response?.status >= 500;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Machines & Performance
|
||||
|
||||
```ts
|
||||
export const heavyTask = task({
|
||||
id: "heavy-computation",
|
||||
machine: { preset: "large-2x" }, // 8 vCPU, 16 GB RAM
|
||||
maxDuration: 1800, // 30 minutes timeout
|
||||
run: async (payload, { ctx }) => {
|
||||
// Resource-intensive computation
|
||||
if (ctx.machine.preset === "large-2x") {
|
||||
// Use all available cores
|
||||
return await parallelProcessing(payload);
|
||||
}
|
||||
|
||||
return await standardProcessing(payload);
|
||||
},
|
||||
});
|
||||
|
||||
// Override machine when triggering
|
||||
await heavyTask.trigger(payload, {
|
||||
machine: { preset: "medium-1x" }, // Override for this run
|
||||
});
|
||||
```
|
||||
|
||||
**Machine Presets:**
|
||||
|
||||
- `micro`: 0.25 vCPU, 0.25 GB RAM
|
||||
- `small-1x`: 0.5 vCPU, 0.5 GB RAM (default)
|
||||
- `small-2x`: 1 vCPU, 1 GB RAM
|
||||
- `medium-1x`: 1 vCPU, 2 GB RAM
|
||||
- `medium-2x`: 2 vCPU, 4 GB RAM
|
||||
- `large-1x`: 4 vCPU, 8 GB RAM
|
||||
- `large-2x`: 8 vCPU, 16 GB RAM
|
||||
|
||||
## Idempotency
|
||||
|
||||
```ts
|
||||
import { task, idempotencyKeys } from "@trigger.dev/sdk";
|
||||
|
||||
export const paymentTask = task({
|
||||
id: "process-payment",
|
||||
retry: {
|
||||
maxAttempts: 3,
|
||||
},
|
||||
run: async (payload: { orderId: string; amount: number }) => {
|
||||
// Automatically scoped to this task run, so if the task is retried, the idempotency key will be the same
|
||||
const idempotencyKey = await idempotencyKeys.create(`payment-${payload.orderId}`);
|
||||
|
||||
// Ensure payment is processed only once
|
||||
await chargeCustomer.trigger(payload, {
|
||||
idempotencyKey,
|
||||
idempotencyKeyTTL: "24h", // Key expires in 24 hours
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Payload-based idempotency
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
function createPayloadHash(payload: any): string {
|
||||
const hash = createHash("sha256");
|
||||
hash.update(JSON.stringify(payload));
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
export const deduplicatedTask = task({
|
||||
id: "deduplicated-task",
|
||||
run: async (payload) => {
|
||||
const payloadHash = createPayloadHash(payload);
|
||||
const idempotencyKey = await idempotencyKeys.create(payloadHash);
|
||||
|
||||
await processData.trigger(payload, { idempotencyKey });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Metadata & Progress Tracking
|
||||
|
||||
```ts
|
||||
import { task, metadata } from "@trigger.dev/sdk";
|
||||
|
||||
export const batchProcessor = task({
|
||||
id: "batch-processor",
|
||||
run: async (payload: { items: any[] }, { ctx }) => {
|
||||
const totalItems = payload.items.length;
|
||||
|
||||
// Initialize progress metadata
|
||||
metadata
|
||||
.set("progress", 0)
|
||||
.set("totalItems", totalItems)
|
||||
.set("processedItems", 0)
|
||||
.set("status", "starting");
|
||||
|
||||
const results = [];
|
||||
|
||||
for (let i = 0; i < payload.items.length; i++) {
|
||||
const item = payload.items[i];
|
||||
|
||||
// Process item
|
||||
const result = await processItem(item);
|
||||
results.push(result);
|
||||
|
||||
// Update progress
|
||||
const progress = ((i + 1) / totalItems) * 100;
|
||||
metadata
|
||||
.set("progress", progress)
|
||||
.increment("processedItems", 1)
|
||||
.append("logs", `Processed item ${i + 1}/${totalItems}`)
|
||||
.set("currentItem", item.id);
|
||||
}
|
||||
|
||||
// Final status
|
||||
metadata.set("status", "completed");
|
||||
|
||||
return { results, totalProcessed: results.length };
|
||||
},
|
||||
});
|
||||
|
||||
// Update parent metadata from child task
|
||||
export const childTask = task({
|
||||
id: "child-task",
|
||||
run: async (payload, { ctx }) => {
|
||||
// Update parent task metadata
|
||||
metadata.parent.set("childStatus", "processing");
|
||||
metadata.root.increment("childrenCompleted", 1);
|
||||
|
||||
return { processed: true };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Logging & Tracing
|
||||
|
||||
```ts
|
||||
import { task, logger } from "@trigger.dev/sdk";
|
||||
|
||||
export const tracedTask = task({
|
||||
id: "traced-task",
|
||||
run: async (payload, { ctx }) => {
|
||||
logger.info("Task started", { userId: payload.userId });
|
||||
|
||||
// Custom trace with attributes
|
||||
const user = await logger.trace(
|
||||
"fetch-user",
|
||||
async (span) => {
|
||||
span.setAttribute("user.id", payload.userId);
|
||||
span.setAttribute("operation", "database-fetch");
|
||||
|
||||
const userData = await database.findUser(payload.userId);
|
||||
span.setAttribute("user.found", !!userData);
|
||||
|
||||
return userData;
|
||||
},
|
||||
{ userId: payload.userId }
|
||||
);
|
||||
|
||||
logger.debug("User fetched", { user: user.id });
|
||||
|
||||
try {
|
||||
const result = await processUser(user);
|
||||
logger.info("Processing completed", { result });
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error("Processing failed", {
|
||||
error: error.message,
|
||||
userId: payload.userId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Hidden Tasks
|
||||
|
||||
```ts
|
||||
// Hidden task - not exported, only used internally
|
||||
const internalProcessor = task({
|
||||
id: "internal-processor",
|
||||
run: async (payload: { data: string }) => {
|
||||
return { processed: payload.data.toUpperCase() };
|
||||
},
|
||||
});
|
||||
|
||||
// Public task that uses hidden task
|
||||
export const publicWorkflow = task({
|
||||
id: "public-workflow",
|
||||
run: async (payload: { input: string }) => {
|
||||
// Use hidden task internally
|
||||
const result = await internalProcessor.triggerAndWait({
|
||||
data: payload.input,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
return { output: result.output.processed };
|
||||
}
|
||||
|
||||
throw new Error("Internal processing failed");
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Concurrency**: Use queues to prevent overwhelming external services
|
||||
- **Retries**: Configure exponential backoff for transient failures
|
||||
- **Idempotency**: Always use for payment/critical operations
|
||||
- **Metadata**: Track progress for long-running tasks
|
||||
- **Machines**: Match machine size to computational requirements
|
||||
- **Tags**: Use consistent naming patterns for filtering
|
||||
- **Debouncing**: Use for user activity, webhooks, and notification batching
|
||||
- **Batch triggering**: Use for bulk operations up to 1,000 items
|
||||
- **Error Handling**: Distinguish between retryable and fatal errors
|
||||
|
||||
Design tasks to be stateless, idempotent, and resilient to failures. Use metadata for state tracking and queues for resource management.
|
||||
@@ -0,0 +1,199 @@
|
||||
# Trigger.dev Basic Tasks (v4)
|
||||
|
||||
**MUST use `@trigger.dev/sdk`, NEVER `client.defineJob`**
|
||||
|
||||
## Basic Task
|
||||
|
||||
```ts
|
||||
import { task } from "@trigger.dev/sdk";
|
||||
|
||||
export const processData = task({
|
||||
id: "process-data",
|
||||
retry: {
|
||||
maxAttempts: 10,
|
||||
factor: 1.8,
|
||||
minTimeoutInMs: 500,
|
||||
maxTimeoutInMs: 30_000,
|
||||
randomize: false,
|
||||
},
|
||||
run: async (payload: { userId: string; data: any[] }) => {
|
||||
// Task logic - runs for long time, no timeouts
|
||||
console.log(`Processing ${payload.data.length} items for user ${payload.userId}`);
|
||||
return { processed: payload.data.length };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Schema Task (with validation)
|
||||
|
||||
```ts
|
||||
import { schemaTask } from "@trigger.dev/sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
export const validatedTask = schemaTask({
|
||||
id: "validated-task",
|
||||
schema: z.object({
|
||||
name: z.string(),
|
||||
age: z.number(),
|
||||
email: z.string().email(),
|
||||
}),
|
||||
run: async (payload) => {
|
||||
// Payload is automatically validated and typed
|
||||
return { message: `Hello ${payload.name}, age ${payload.age}` };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Triggering Tasks
|
||||
|
||||
### From Backend Code
|
||||
|
||||
```ts
|
||||
import { tasks } from "@trigger.dev/sdk";
|
||||
import type { processData } from "./trigger/tasks";
|
||||
|
||||
// Single trigger
|
||||
const handle = await tasks.trigger<typeof processData>("process-data", {
|
||||
userId: "123",
|
||||
data: [{ id: 1 }, { id: 2 }],
|
||||
});
|
||||
|
||||
// Batch trigger (up to 1,000 items, 3MB per payload)
|
||||
const batchHandle = await tasks.batchTrigger<typeof processData>("process-data", [
|
||||
{ payload: { userId: "123", data: [{ id: 1 }] } },
|
||||
{ payload: { userId: "456", data: [{ id: 2 }] } },
|
||||
]);
|
||||
```
|
||||
|
||||
### Debounced Triggering
|
||||
|
||||
Consolidate multiple triggers into a single execution:
|
||||
|
||||
```ts
|
||||
// Multiple rapid triggers with same key = single execution
|
||||
await myTask.trigger(
|
||||
{ userId: "123" },
|
||||
{
|
||||
debounce: {
|
||||
key: "user-123-update", // Unique key for debounce group
|
||||
delay: "5s", // Wait before executing
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Trailing mode: use payload from LAST trigger
|
||||
await myTask.trigger(
|
||||
{ data: "latest-value" },
|
||||
{
|
||||
debounce: {
|
||||
key: "trailing-example",
|
||||
delay: "10s",
|
||||
mode: "trailing", // Default is "leading" (first payload)
|
||||
},
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
**Debounce modes:**
|
||||
- `leading` (default): Uses payload from first trigger, subsequent triggers only reschedule
|
||||
- `trailing`: Uses payload from most recent trigger
|
||||
|
||||
### From Inside Tasks (with Result handling)
|
||||
|
||||
```ts
|
||||
export const parentTask = task({
|
||||
id: "parent-task",
|
||||
run: async (payload) => {
|
||||
// Trigger and continue
|
||||
const handle = await childTask.trigger({ data: "value" });
|
||||
|
||||
// Trigger and wait - returns Result object, NOT task output
|
||||
const result = await childTask.triggerAndWait({ data: "value" });
|
||||
if (result.ok) {
|
||||
console.log("Task output:", result.output); // Actual task return value
|
||||
} else {
|
||||
console.error("Task failed:", result.error);
|
||||
}
|
||||
|
||||
// Quick unwrap (throws on error)
|
||||
const output = await childTask.triggerAndWait({ data: "value" }).unwrap();
|
||||
|
||||
// Batch trigger and wait
|
||||
const results = await childTask.batchTriggerAndWait([
|
||||
{ payload: { data: "item1" } },
|
||||
{ payload: { data: "item2" } },
|
||||
]);
|
||||
|
||||
for (const run of results) {
|
||||
if (run.ok) {
|
||||
console.log("Success:", run.output);
|
||||
} else {
|
||||
console.log("Failed:", run.error);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const childTask = task({
|
||||
id: "child-task",
|
||||
run: async (payload: { data: string }) => {
|
||||
return { processed: payload.data };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
> Never wrap triggerAndWait or batchTriggerAndWait calls in a Promise.all or Promise.allSettled as this is not supported in Trigger.dev tasks.
|
||||
|
||||
## Waits
|
||||
|
||||
```ts
|
||||
import { task, wait } from "@trigger.dev/sdk";
|
||||
|
||||
export const taskWithWaits = task({
|
||||
id: "task-with-waits",
|
||||
run: async (payload) => {
|
||||
console.log("Starting task");
|
||||
|
||||
// Wait for specific duration
|
||||
await wait.for({ seconds: 30 });
|
||||
await wait.for({ minutes: 5 });
|
||||
await wait.for({ hours: 1 });
|
||||
await wait.for({ days: 1 });
|
||||
|
||||
// Wait until specific date
|
||||
await wait.until({ date: new Date("2024-12-25") });
|
||||
|
||||
// Wait for token (from external system)
|
||||
await wait.forToken({
|
||||
token: "user-approval-token",
|
||||
timeoutInSeconds: 3600, // 1 hour timeout
|
||||
});
|
||||
|
||||
console.log("All waits completed");
|
||||
return { status: "completed" };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
> Never wrap wait calls in a Promise.all or Promise.allSettled as this is not supported in Trigger.dev tasks.
|
||||
|
||||
## Key Points
|
||||
|
||||
- **Result vs Output**: `triggerAndWait()` returns a `Result` object with `ok`, `output`, `error` properties - NOT the direct task output
|
||||
- **Type safety**: Use `import type` for task references when triggering from backend
|
||||
- **Waits > 5 seconds**: Automatically checkpointed, don't count toward compute usage
|
||||
- **Debounce + idempotency**: Idempotency keys take precedence over debounce settings
|
||||
|
||||
## NEVER Use (v2 deprecated)
|
||||
|
||||
```ts
|
||||
// BREAKS APPLICATION
|
||||
client.defineJob({
|
||||
id: "job-id",
|
||||
run: async (payload, io) => {
|
||||
/* ... */
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Use SDK (`@trigger.dev/sdk`), check `result.ok` before accessing `result.output`
|
||||
@@ -0,0 +1,346 @@
|
||||
# Trigger.dev Configuration
|
||||
|
||||
**Complete guide to configuring `trigger.config.ts` with build extensions**
|
||||
|
||||
## Basic Configuration
|
||||
|
||||
```ts
|
||||
import { defineConfig } from "@trigger.dev/sdk";
|
||||
|
||||
export default defineConfig({
|
||||
project: "<project-ref>", // Required: Your project reference
|
||||
dirs: ["./trigger"], // Task directories
|
||||
runtime: "node", // "node", "node-22", or "bun"
|
||||
logLevel: "info", // "debug", "info", "warn", "error"
|
||||
|
||||
// Default retry settings
|
||||
retries: {
|
||||
enabledInDev: false,
|
||||
default: {
|
||||
maxAttempts: 3,
|
||||
minTimeoutInMs: 1000,
|
||||
maxTimeoutInMs: 10000,
|
||||
factor: 2,
|
||||
randomize: true,
|
||||
},
|
||||
},
|
||||
|
||||
// Build configuration
|
||||
build: {
|
||||
autoDetectExternal: true,
|
||||
keepNames: true,
|
||||
minify: false,
|
||||
extensions: [], // Build extensions go here
|
||||
},
|
||||
|
||||
// Global lifecycle hooks
|
||||
onStartAttempt: async ({ payload, ctx }) => {
|
||||
console.log("Global task start");
|
||||
},
|
||||
onSuccess: async ({ payload, output, ctx }) => {
|
||||
console.log("Global task success");
|
||||
},
|
||||
onFailure: async ({ payload, error, ctx }) => {
|
||||
console.log("Global task failure");
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Build Extensions
|
||||
|
||||
### Database & ORM
|
||||
|
||||
#### Prisma
|
||||
|
||||
```ts
|
||||
import { prismaExtension } from "@trigger.dev/build/extensions/prisma";
|
||||
|
||||
extensions: [
|
||||
prismaExtension({
|
||||
schema: "prisma/schema.prisma",
|
||||
version: "5.19.0", // Optional: specify version
|
||||
migrate: true, // Run migrations during build
|
||||
directUrlEnvVarName: "DIRECT_DATABASE_URL",
|
||||
typedSql: true, // Enable TypedSQL support
|
||||
}),
|
||||
];
|
||||
```
|
||||
|
||||
#### TypeScript Decorators (for TypeORM)
|
||||
|
||||
```ts
|
||||
import { emitDecoratorMetadata } from "@trigger.dev/build/extensions/typescript";
|
||||
|
||||
extensions: [
|
||||
emitDecoratorMetadata(), // Enables decorator metadata
|
||||
];
|
||||
```
|
||||
|
||||
### Scripting Languages
|
||||
|
||||
#### Python
|
||||
|
||||
```ts
|
||||
import { pythonExtension } from "@trigger.dev/build/extensions/python";
|
||||
|
||||
extensions: [
|
||||
pythonExtension({
|
||||
scripts: ["./python/**/*.py"], // Copy Python files
|
||||
requirementsFile: "./requirements.txt", // Install packages
|
||||
devPythonBinaryPath: ".venv/bin/python", // Dev mode binary
|
||||
}),
|
||||
];
|
||||
|
||||
// Usage in tasks
|
||||
const result = await python.runInline(`print("Hello, world!")`);
|
||||
const output = await python.runScript("./python/script.py", ["arg1"]);
|
||||
```
|
||||
|
||||
### Browser Automation
|
||||
|
||||
#### Playwright
|
||||
|
||||
```ts
|
||||
import { playwright } from "@trigger.dev/build/extensions/playwright";
|
||||
|
||||
extensions: [
|
||||
playwright({
|
||||
browsers: ["chromium", "firefox", "webkit"], // Default: ["chromium"]
|
||||
headless: true, // Default: true
|
||||
}),
|
||||
];
|
||||
```
|
||||
|
||||
#### Puppeteer
|
||||
|
||||
```ts
|
||||
import { puppeteer } from "@trigger.dev/build/extensions/puppeteer";
|
||||
|
||||
extensions: [puppeteer()];
|
||||
|
||||
// Environment variable needed:
|
||||
// PUPPETEER_EXECUTABLE_PATH: "/usr/bin/google-chrome-stable"
|
||||
```
|
||||
|
||||
#### Lightpanda
|
||||
|
||||
```ts
|
||||
import { lightpanda } from "@trigger.dev/build/extensions/lightpanda";
|
||||
|
||||
extensions: [
|
||||
lightpanda({
|
||||
version: "latest", // or "nightly"
|
||||
disableTelemetry: false,
|
||||
}),
|
||||
];
|
||||
```
|
||||
|
||||
### Media Processing
|
||||
|
||||
#### FFmpeg
|
||||
|
||||
```ts
|
||||
import { ffmpeg } from "@trigger.dev/build/extensions/core";
|
||||
|
||||
extensions: [
|
||||
ffmpeg({ version: "7" }), // Static build, or omit for Debian version
|
||||
];
|
||||
|
||||
// Automatically sets FFMPEG_PATH and FFPROBE_PATH
|
||||
// Add fluent-ffmpeg to external packages if using
|
||||
```
|
||||
|
||||
#### Audio Waveform
|
||||
|
||||
```ts
|
||||
import { audioWaveform } from "@trigger.dev/build/extensions/audioWaveform";
|
||||
|
||||
extensions: [
|
||||
audioWaveform(), // Installs Audio Waveform 1.1.0
|
||||
];
|
||||
```
|
||||
|
||||
### System & Package Management
|
||||
|
||||
#### System Packages (apt-get)
|
||||
|
||||
```ts
|
||||
import { aptGet } from "@trigger.dev/build/extensions/core";
|
||||
|
||||
extensions: [
|
||||
aptGet({
|
||||
packages: ["ffmpeg", "imagemagick", "curl=7.68.0-1"], // Can specify versions
|
||||
}),
|
||||
];
|
||||
```
|
||||
|
||||
#### Additional NPM Packages
|
||||
|
||||
Only use this for installing CLI tools, NOT packages you import in your code.
|
||||
|
||||
```ts
|
||||
import { additionalPackages } from "@trigger.dev/build/extensions/core";
|
||||
|
||||
extensions: [
|
||||
additionalPackages({
|
||||
packages: ["wrangler"], // CLI tools and specific versions
|
||||
}),
|
||||
];
|
||||
```
|
||||
|
||||
#### Additional Files
|
||||
|
||||
```ts
|
||||
import { additionalFiles } from "@trigger.dev/build/extensions/core";
|
||||
|
||||
extensions: [
|
||||
additionalFiles({
|
||||
files: ["wrangler.toml", "./assets/**", "./fonts/**"], // Glob patterns supported
|
||||
}),
|
||||
];
|
||||
```
|
||||
|
||||
### Environment & Build Tools
|
||||
|
||||
#### Environment Variable Sync
|
||||
|
||||
```ts
|
||||
import { syncEnvVars } from "@trigger.dev/build/extensions/core";
|
||||
|
||||
extensions: [
|
||||
syncEnvVars(async (ctx) => {
|
||||
// ctx contains: environment, projectRef, env
|
||||
return [
|
||||
{ name: "SECRET_KEY", value: await getSecret(ctx.environment) },
|
||||
{ name: "API_URL", value: ctx.environment === "prod" ? "api.prod.com" : "api.dev.com" },
|
||||
];
|
||||
}),
|
||||
];
|
||||
```
|
||||
|
||||
#### ESBuild Plugins
|
||||
|
||||
```ts
|
||||
import { esbuildPlugin } from "@trigger.dev/build/extensions";
|
||||
import { sentryEsbuildPlugin } from "@sentry/esbuild-plugin";
|
||||
|
||||
extensions: [
|
||||
esbuildPlugin(
|
||||
sentryEsbuildPlugin({
|
||||
org: process.env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT,
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
}),
|
||||
{ placement: "last", target: "deploy" } // Optional config
|
||||
),
|
||||
];
|
||||
```
|
||||
|
||||
## Custom Build Extensions
|
||||
|
||||
```ts
|
||||
import { defineConfig } from "@trigger.dev/sdk";
|
||||
|
||||
const customExtension = {
|
||||
name: "my-custom-extension",
|
||||
|
||||
externalsForTarget: (target) => {
|
||||
return ["some-native-module"]; // Add external dependencies
|
||||
},
|
||||
|
||||
onBuildStart: async (context) => {
|
||||
console.log(`Build starting for ${context.target}`);
|
||||
// Register esbuild plugins, modify build context
|
||||
},
|
||||
|
||||
onBuildComplete: async (context, manifest) => {
|
||||
console.log("Build complete, adding layers");
|
||||
// Add build layers, modify deployment
|
||||
context.addLayer({
|
||||
id: "my-layer",
|
||||
files: [{ source: "./custom-file", destination: "/app/custom" }],
|
||||
commands: ["chmod +x /app/custom"],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
project: "my-project",
|
||||
build: {
|
||||
extensions: [customExtension],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Telemetry
|
||||
|
||||
```ts
|
||||
import { PrismaInstrumentation } from "@prisma/instrumentation";
|
||||
import { OpenAIInstrumentation } from "@langfuse/openai";
|
||||
|
||||
export default defineConfig({
|
||||
// ... other config
|
||||
telemetry: {
|
||||
instrumentations: [new PrismaInstrumentation(), new OpenAIInstrumentation()],
|
||||
exporters: [customExporter], // Optional custom exporters
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Machine & Performance
|
||||
|
||||
```ts
|
||||
export default defineConfig({
|
||||
// ... other config
|
||||
defaultMachine: "large-1x", // Default machine for all tasks
|
||||
maxDuration: 300, // Default max duration (seconds)
|
||||
enableConsoleLogging: true, // Console logging in development
|
||||
});
|
||||
```
|
||||
|
||||
## Common Extension Combinations
|
||||
|
||||
### Full-Stack Web App
|
||||
|
||||
```ts
|
||||
extensions: [
|
||||
prismaExtension({ schema: "prisma/schema.prisma", migrate: true }),
|
||||
additionalFiles({ files: ["./public/**", "./assets/**"] }),
|
||||
syncEnvVars(async (ctx) => [...envVars]),
|
||||
];
|
||||
```
|
||||
|
||||
### AI/ML Processing
|
||||
|
||||
```ts
|
||||
extensions: [
|
||||
pythonExtension({
|
||||
scripts: ["./ai/**/*.py"],
|
||||
requirementsFile: "./requirements.txt",
|
||||
}),
|
||||
ffmpeg({ version: "7" }),
|
||||
additionalPackages({ packages: ["wrangler"] }),
|
||||
];
|
||||
```
|
||||
|
||||
### Web Scraping
|
||||
|
||||
```ts
|
||||
extensions: [
|
||||
playwright({ browsers: ["chromium"] }),
|
||||
puppeteer(),
|
||||
additionalFiles({ files: ["./selectors.json", "./proxies.txt"] }),
|
||||
];
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Use specific versions**: Pin extension versions for reproducible builds
|
||||
- **External packages**: Add modules with native addons to the `build.external` array
|
||||
- **Environment sync**: Use `syncEnvVars` for dynamic secrets
|
||||
- **File paths**: Use glob patterns for flexible file inclusion
|
||||
- **Debug builds**: Use `--log-level debug --dry-run` for troubleshooting
|
||||
|
||||
Extensions only affect deployment, not local development. Use `external` array for packages that shouldn't be bundled.
|
||||
@@ -0,0 +1,244 @@
|
||||
# Trigger.dev Realtime
|
||||
|
||||
**Real-time monitoring and updates for runs**
|
||||
|
||||
## Core Concepts
|
||||
|
||||
Realtime allows you to:
|
||||
|
||||
- Subscribe to run status changes, metadata updates, and streams
|
||||
- Build real-time dashboards and UI updates
|
||||
- Monitor task progress from frontend and backend
|
||||
|
||||
## Authentication
|
||||
|
||||
### Public Access Tokens
|
||||
|
||||
```ts
|
||||
import { auth } from "@trigger.dev/sdk";
|
||||
|
||||
// Read-only token for specific runs
|
||||
const publicToken = await auth.createPublicToken({
|
||||
scopes: {
|
||||
read: {
|
||||
runs: ["run_123", "run_456"],
|
||||
tasks: ["my-task-1", "my-task-2"],
|
||||
},
|
||||
},
|
||||
expirationTime: "1h", // Default: 15 minutes
|
||||
});
|
||||
```
|
||||
|
||||
### Trigger Tokens (Frontend only)
|
||||
|
||||
```ts
|
||||
// Single-use token for triggering tasks
|
||||
const triggerToken = await auth.createTriggerPublicToken("my-task", {
|
||||
expirationTime: "30m",
|
||||
});
|
||||
```
|
||||
|
||||
## Backend Usage
|
||||
|
||||
### Subscribe to Runs
|
||||
|
||||
```ts
|
||||
import { runs, tasks } from "@trigger.dev/sdk";
|
||||
|
||||
// Trigger and subscribe
|
||||
const handle = await tasks.trigger("my-task", { data: "value" });
|
||||
|
||||
// Subscribe to specific run
|
||||
for await (const run of runs.subscribeToRun<typeof myTask>(handle.id)) {
|
||||
console.log(`Status: ${run.status}, Progress: ${run.metadata?.progress}`);
|
||||
if (run.status === "COMPLETED") break;
|
||||
}
|
||||
|
||||
// Subscribe to runs with tag
|
||||
for await (const run of runs.subscribeToRunsWithTag("user-123")) {
|
||||
console.log(`Tagged run ${run.id}: ${run.status}`);
|
||||
}
|
||||
|
||||
// Subscribe to batch
|
||||
for await (const run of runs.subscribeToBatch(batchId)) {
|
||||
console.log(`Batch run ${run.id}: ${run.status}`);
|
||||
}
|
||||
```
|
||||
|
||||
### Realtime Streams v2
|
||||
|
||||
```ts
|
||||
import { streams, InferStreamType } from "@trigger.dev/sdk";
|
||||
|
||||
// 1. Define streams (shared location)
|
||||
export const aiStream = streams.define<string>({
|
||||
id: "ai-output",
|
||||
});
|
||||
|
||||
export type AIStreamPart = InferStreamType<typeof aiStream>;
|
||||
|
||||
// 2. Pipe from task
|
||||
export const streamingTask = task({
|
||||
id: "streaming-task",
|
||||
run: async (payload) => {
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: "gpt-4",
|
||||
messages: [{ role: "user", content: payload.prompt }],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
const { waitUntilComplete } = aiStream.pipe(completion);
|
||||
await waitUntilComplete();
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Read from backend
|
||||
const stream = await aiStream.read(runId, {
|
||||
timeoutInSeconds: 300,
|
||||
startIndex: 0, // Resume from specific chunk
|
||||
});
|
||||
|
||||
for await (const chunk of stream) {
|
||||
console.log("Chunk:", chunk); // Fully typed
|
||||
}
|
||||
```
|
||||
|
||||
## React Frontend Usage
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
npm add @trigger.dev/react-hooks
|
||||
```
|
||||
|
||||
### Triggering Tasks
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
import { useTaskTrigger, useRealtimeTaskTrigger } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "../trigger/tasks";
|
||||
|
||||
function TriggerComponent({ accessToken }: { accessToken: string }) {
|
||||
// Basic trigger
|
||||
const { submit, handle, isLoading } = useTaskTrigger<typeof myTask>("my-task", {
|
||||
accessToken,
|
||||
});
|
||||
|
||||
// Trigger with realtime updates
|
||||
const {
|
||||
submit: realtimeSubmit,
|
||||
run,
|
||||
isLoading: isRealtimeLoading,
|
||||
} = useRealtimeTaskTrigger<typeof myTask>("my-task", { accessToken });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => submit({ data: "value" })} disabled={isLoading}>
|
||||
Trigger Task
|
||||
</button>
|
||||
|
||||
<button onClick={() => realtimeSubmit({ data: "realtime" })} disabled={isRealtimeLoading}>
|
||||
Trigger with Realtime
|
||||
</button>
|
||||
|
||||
{run && <div>Status: {run.status}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Subscribing to Runs
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
import { useRealtimeRun, useRealtimeRunsWithTag } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "../trigger/tasks";
|
||||
|
||||
function SubscribeComponent({ runId, accessToken }: { runId: string; accessToken: string }) {
|
||||
// Subscribe to specific run
|
||||
const { run, error } = useRealtimeRun<typeof myTask>(runId, {
|
||||
accessToken,
|
||||
onComplete: (run) => {
|
||||
console.log("Task completed:", run.output);
|
||||
},
|
||||
});
|
||||
|
||||
// Subscribe to tagged runs
|
||||
const { runs } = useRealtimeRunsWithTag("user-123", { accessToken });
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
if (!run) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>Status: {run.status}</div>
|
||||
<div>Progress: {run.metadata?.progress || 0}%</div>
|
||||
{run.output && <div>Result: {JSON.stringify(run.output)}</div>}
|
||||
|
||||
<h3>Tagged Runs:</h3>
|
||||
{runs.map((r) => (
|
||||
<div key={r.id}>
|
||||
{r.id}: {r.status}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Realtime Streams with React
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
import { useRealtimeStream } from "@trigger.dev/react-hooks";
|
||||
import { aiStream } from "../trigger/streams";
|
||||
|
||||
function StreamComponent({ runId, accessToken }: { runId: string; accessToken: string }) {
|
||||
// Pass defined stream directly for type safety
|
||||
const { parts, error } = useRealtimeStream(aiStream, runId, {
|
||||
accessToken,
|
||||
timeoutInSeconds: 300,
|
||||
throttleInMs: 50, // Control re-render frequency
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
if (!parts) return <div>Loading...</div>;
|
||||
|
||||
const text = parts.join(""); // parts is typed as AIStreamPart[]
|
||||
|
||||
return <div>Streamed Text: {text}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Wait Tokens
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
import { useWaitToken } from "@trigger.dev/react-hooks";
|
||||
|
||||
function WaitTokenComponent({ tokenId, accessToken }: { tokenId: string; accessToken: string }) {
|
||||
const { complete } = useWaitToken(tokenId, { accessToken });
|
||||
|
||||
return <button onClick={() => complete({ approved: true })}>Approve Task</button>;
|
||||
}
|
||||
```
|
||||
|
||||
## Run Object Properties
|
||||
|
||||
Key properties available in run subscriptions:
|
||||
|
||||
- `id`: Unique run identifier
|
||||
- `status`: `QUEUED`, `EXECUTING`, `COMPLETED`, `FAILED`, `CANCELED`, etc.
|
||||
- `payload`: Task input data (typed)
|
||||
- `output`: Task result (typed, when completed)
|
||||
- `metadata`: Real-time updatable data
|
||||
- `createdAt`, `updatedAt`: Timestamps
|
||||
- `costInCents`: Execution cost
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Use Realtime over SWR**: Recommended for most use cases due to rate limits
|
||||
- **Scope tokens properly**: Only grant necessary read/trigger permissions
|
||||
- **Handle errors**: Always check for errors in hooks and subscriptions
|
||||
- **Type safety**: Use task types for proper payload/output typing
|
||||
- **Cleanup subscriptions**: Backend subscriptions auto-complete, frontend hooks auto-cleanup
|
||||
@@ -0,0 +1,113 @@
|
||||
# Scheduled Tasks (Cron)
|
||||
|
||||
Recurring tasks using cron. For one-off future runs, use the **delay** option.
|
||||
|
||||
## Define a Scheduled Task
|
||||
|
||||
```ts
|
||||
import { schedules } from "@trigger.dev/sdk";
|
||||
|
||||
export const task = schedules.task({
|
||||
id: "first-scheduled-task",
|
||||
run: async (payload) => {
|
||||
payload.timestamp; // Date (scheduled time, UTC)
|
||||
payload.lastTimestamp; // Date | undefined
|
||||
payload.timezone; // IANA, e.g. "America/New_York" (default "UTC")
|
||||
payload.scheduleId; // string
|
||||
payload.externalId; // string | undefined
|
||||
payload.upcoming; // Date[]
|
||||
|
||||
payload.timestamp.toLocaleString("en-US", { timeZone: payload.timezone });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
> Scheduled tasks need at least one schedule attached to run.
|
||||
|
||||
## Attach Schedules
|
||||
|
||||
**Declarative (sync on dev/deploy):**
|
||||
|
||||
```ts
|
||||
schedules.task({
|
||||
id: "every-2h",
|
||||
cron: "0 */2 * * *", // UTC
|
||||
run: async () => {},
|
||||
});
|
||||
|
||||
schedules.task({
|
||||
id: "tokyo-5am",
|
||||
cron: { pattern: "0 5 * * *", timezone: "Asia/Tokyo", environments: ["PRODUCTION", "STAGING"] },
|
||||
run: async () => {},
|
||||
});
|
||||
```
|
||||
|
||||
**Imperative (SDK or dashboard):**
|
||||
|
||||
```ts
|
||||
await schedules.create({
|
||||
task: task.id,
|
||||
cron: "0 0 * * *",
|
||||
timezone: "America/New_York", // DST-aware
|
||||
externalId: "user_123",
|
||||
deduplicationKey: "user_123-daily", // updates if reused
|
||||
});
|
||||
```
|
||||
|
||||
### Dynamic / Multi-tenant Example
|
||||
|
||||
```ts
|
||||
// /trigger/reminder.ts
|
||||
export const reminderTask = schedules.task({
|
||||
id: "todo-reminder",
|
||||
run: async (p) => {
|
||||
if (!p.externalId) throw new Error("externalId is required");
|
||||
const user = await db.getUser(p.externalId);
|
||||
await sendReminderEmail(user);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// app/reminders/route.ts
|
||||
export async function POST(req: Request) {
|
||||
const data = await req.json();
|
||||
return Response.json(
|
||||
await schedules.create({
|
||||
task: reminderTask.id,
|
||||
cron: "0 8 * * *",
|
||||
timezone: data.timezone,
|
||||
externalId: data.userId,
|
||||
deduplicationKey: `${data.userId}-reminder`,
|
||||
})
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Cron Syntax (no seconds)
|
||||
|
||||
```
|
||||
* * * * *
|
||||
| | | | └ day of week (0–7 or 1L–7L; 0/7=Sun; L=last)
|
||||
| | | └── month (1–12)
|
||||
| | └──── day of month (1–31 or L)
|
||||
| └────── hour (0–23)
|
||||
└──────── minute (0–59)
|
||||
```
|
||||
|
||||
## When Schedules Won't Trigger
|
||||
|
||||
- **Dev:** only when the dev CLI is running.
|
||||
- **Staging/Production:** only for tasks in the **latest deployment**.
|
||||
|
||||
## SDK Management
|
||||
|
||||
```ts
|
||||
await schedules.retrieve(id);
|
||||
await schedules.list();
|
||||
await schedules.update(id, { cron: "0 0 1 * *", externalId: "ext", deduplicationKey: "key" });
|
||||
await schedules.deactivate(id);
|
||||
await schedules.activate(id);
|
||||
await schedules.del(id);
|
||||
await schedules.timezones(); // list of IANA timezones
|
||||
```
|
||||
@@ -0,0 +1,11 @@
|
||||
# Remove AI code slop
|
||||
|
||||
Check the diff against main, and remove all AI generated slop introduced in this branch.
|
||||
|
||||
This includes:
|
||||
- Extra comments that a human wouldn't add or is inconsistent with the rest of the file
|
||||
- Extra defensive checks or try/catch blocks that are abnormal for that area of the codebase (especially if called by trusted / validated codepaths)
|
||||
- Casts to any to get around type issues
|
||||
- Any other style that is inconsistent with the file
|
||||
|
||||
Report at the end with only a 1-3 sentence summary of what you changed
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description: how to create and apply database migrations
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
Follow our [migrations.md](mdc:ai/references/migrations.md) guide for how to create and apply database migrations.
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
description: Guidelines for creating OpenTelemetry metrics to avoid cardinality issues
|
||||
globs:
|
||||
- "**/*.ts"
|
||||
---
|
||||
|
||||
# OpenTelemetry Metrics Guidelines
|
||||
|
||||
When creating or editing OTEL metrics (counters, histograms, gauges), always ensure metric attributes have **low cardinality**.
|
||||
|
||||
## What is Cardinality?
|
||||
|
||||
Cardinality refers to the number of unique values an attribute can have. Each unique combination of attribute values creates a new time series, which consumes memory and storage in your metrics backend.
|
||||
|
||||
## Rules
|
||||
|
||||
### DO use low-cardinality attributes:
|
||||
- **Enums**: `environment_type` (PRODUCTION, STAGING, DEVELOPMENT, PREVIEW)
|
||||
- **Booleans**: `hasFailures`, `streaming`, `success`
|
||||
- **Bounded error codes**: A finite, controlled set of error types
|
||||
- **Shard IDs**: When sharding is bounded (e.g., 0-15)
|
||||
|
||||
### DO NOT use high-cardinality attributes:
|
||||
- **UUIDs/IDs**: `envId`, `userId`, `runId`, `projectId`, `organizationId`
|
||||
- **Unbounded integers**: `itemCount`, `batchSize`, `retryCount`
|
||||
- **Timestamps**: `createdAt`, `startTime`
|
||||
- **Free-form strings**: `errorMessage`, `taskName`, `queueName`
|
||||
|
||||
## Example
|
||||
|
||||
```typescript
|
||||
// BAD - High cardinality
|
||||
this.counter.add(1, {
|
||||
envId: options.environmentId, // UUID - unbounded
|
||||
itemCount: options.runCount, // Integer - unbounded
|
||||
});
|
||||
|
||||
// GOOD - Low cardinality
|
||||
this.counter.add(1, {
|
||||
environment_type: options.environmentType, // Enum - 4 values
|
||||
streaming: true, // Boolean - 2 values
|
||||
});
|
||||
```
|
||||
|
||||
## Prometheus Metric Naming
|
||||
|
||||
When metrics are exported via OTLP to Prometheus, the exporter automatically adds unit suffixes to metric names:
|
||||
|
||||
| OTel Metric Name | Unit | Prometheus Name |
|
||||
|------------------|------|-----------------|
|
||||
| `my_duration_ms` | `ms` | `my_duration_ms_milliseconds` |
|
||||
| `my_counter` | counter | `my_counter_total` |
|
||||
| `items_inserted` | counter | `items_inserted_inserts_total` |
|
||||
| `batch_size` | histogram | `batch_size_items_bucket` |
|
||||
|
||||
Keep this in mind when writing Grafana dashboards or Prometheus queries—the metric names in Prometheus will differ from the names defined in code.
|
||||
|
||||
## Reference
|
||||
|
||||
See the schedule engine (`internal-packages/schedule-engine/src/engine/index.ts`) for a good example of low-cardinality metric attributes.
|
||||
|
||||
High cardinality metrics can cause:
|
||||
- Memory bloat in metrics backends (Axiom, Prometheus, etc.)
|
||||
- Slow queries and dashboard timeouts
|
||||
- Increased costs (many backends charge per time series)
|
||||
- Potential data loss or crashes at scale
|
||||
+9
-3
@@ -34,9 +34,9 @@ DEPLOY_REGISTRY_HOST=localhost:5000
|
||||
|
||||
# OPTIONAL VARIABLES
|
||||
# This is used for validating emails that are allowed to log in. Every email that do not match this regex will be rejected.
|
||||
# WHITELISTED_EMAILS="authorized@yahoo\.com|authorized@gmail\.com"
|
||||
# WHITELISTED_EMAILS="^(authorized@yahoo\.com|authorized@gmail\.com)$"
|
||||
# Accounts with these emails will get global admin rights. This grants access to the admin UI.
|
||||
# ADMIN_EMAILS="admin@example\.com|another-admin@example\.com"
|
||||
# ADMIN_EMAILS="^(admin@example\.com|another-admin@example\.com)$"
|
||||
# This is used for logging in via GitHub. You can leave these commented out if you don't want to use GitHub for authentication.
|
||||
# AUTH_GITHUB_CLIENT_ID=
|
||||
# AUTH_GITHUB_CLIENT_SECRET=
|
||||
@@ -85,4 +85,10 @@ POSTHOG_PROJECT_KEY=
|
||||
# These control the server-side internal telemetry
|
||||
# INTERNAL_OTEL_TRACE_EXPORTER_URL=<URL to send traces to>
|
||||
# INTERNAL_OTEL_TRACE_LOGGING_ENABLED=1
|
||||
# INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED=0,
|
||||
# INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED=0
|
||||
|
||||
# Enable local observability stack (requires `pnpm run docker` to start otel-collector)
|
||||
# Uncomment these to send metrics to the local Prometheus via OTEL Collector:
|
||||
# INTERNAL_OTEL_METRIC_EXPORTER_ENABLED=1
|
||||
# INTERNAL_OTEL_METRIC_EXPORTER_URL=http://localhost:4318/v1/metrics
|
||||
# INTERNAL_OTEL_METRIC_EXPORTER_INTERVAL_MS=15000
|
||||
@@ -0,0 +1,102 @@
|
||||
name: 🦋 Changesets PR
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "packages/**"
|
||||
- ".changeset/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
release-pr:
|
||||
name: Create Release PR
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
if: github.repository == 'triggerdotdev/trigger.dev'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.20.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Create release PR
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
version: pnpm run changeset:version
|
||||
commit: "chore: release"
|
||||
title: "chore: release"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update PR title with version
|
||||
if: steps.changesets.outputs.published != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PR_NUMBER=$(gh pr list --head changeset-release/main --json number --jq '.[0].number')
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
git fetch origin changeset-release/main
|
||||
# we arbitrarily reference the version of the cli package here; it is the same for all package releases
|
||||
VERSION=$(git show origin/changeset-release/main:packages/cli-v3/package.json | jq -r '.version')
|
||||
gh pr edit "$PR_NUMBER" --title "chore: release v$VERSION"
|
||||
fi
|
||||
|
||||
update-lockfile:
|
||||
name: Update lockfile on release PR
|
||||
runs-on: ubuntu-latest
|
||||
needs: release-pr
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout release branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: changeset-release/main
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
|
||||
- name: Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.20.0
|
||||
|
||||
- name: Install and update lockfile
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Commit and push lockfile
|
||||
run: |
|
||||
set -e
|
||||
if git diff --quiet pnpm-lock.yaml; then
|
||||
echo "No lockfile changes"
|
||||
else
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add pnpm-lock.yaml
|
||||
git commit -m "chore: update lockfile for release"
|
||||
git push origin changeset-release/main
|
||||
fi
|
||||
@@ -0,0 +1,70 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.20.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 📀 Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
claude_args: |
|
||||
--model claude-opus-4-5-20251101
|
||||
--allowedTools "Bash(pnpm:*),Bash(turbo:*),Bash(git:*),Bash(gh:*),Bash(npx:*),Bash(docker:*),Edit,MultiEdit,Read,Write,Glob,Grep,LS,Task"
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
@@ -31,12 +31,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
node-version: 20.20.0
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile --filter trigger.dev...
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
name: 🧭 Helm Chart PR Prerelease
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- "hosting/k8s/helm/**"
|
||||
|
||||
concurrency:
|
||||
group: helm-prerelease-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
CHART_NAME: trigger
|
||||
|
||||
jobs:
|
||||
lint-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@v4
|
||||
with:
|
||||
version: "3.18.3"
|
||||
|
||||
- name: Build dependencies
|
||||
run: helm dependency build ./hosting/k8s/helm/
|
||||
|
||||
- name: Extract dependency charts
|
||||
run: |
|
||||
cd ./hosting/k8s/helm/
|
||||
for file in ./charts/*.tgz; do echo "Extracting $file"; tar -xzf "$file" -C ./charts; done
|
||||
|
||||
- name: Lint Helm Chart
|
||||
run: |
|
||||
helm lint ./hosting/k8s/helm/
|
||||
|
||||
- name: Render templates
|
||||
run: |
|
||||
helm template test-release ./hosting/k8s/helm/ \
|
||||
--values ./hosting/k8s/helm/values.yaml \
|
||||
--output-dir ./helm-output
|
||||
|
||||
- name: Validate manifests
|
||||
uses: docker://ghcr.io/yannh/kubeconform:v0.7.0
|
||||
with:
|
||||
entrypoint: "/kubeconform"
|
||||
args: "-summary -output json ./helm-output"
|
||||
|
||||
prerelease:
|
||||
needs: lint-and-test
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@v4
|
||||
with:
|
||||
version: "3.18.3"
|
||||
|
||||
- name: Build dependencies
|
||||
run: helm dependency build ./hosting/k8s/helm/
|
||||
|
||||
- name: Extract dependency charts
|
||||
run: |
|
||||
cd ./hosting/k8s/helm/
|
||||
for file in ./charts/*.tgz; do echo "Extracting $file"; tar -xzf "$file" -C ./charts; done
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Generate prerelease version
|
||||
id: version
|
||||
run: |
|
||||
BASE_VERSION=$(grep '^version:' ./hosting/k8s/helm/Chart.yaml | awk '{print $2}')
|
||||
PR_NUMBER=${{ github.event.pull_request.number }}
|
||||
SHORT_SHA=$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-7)
|
||||
PRERELEASE_VERSION="${BASE_VERSION}-pr${PR_NUMBER}.${SHORT_SHA}"
|
||||
echo "version=$PRERELEASE_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Prerelease version: $PRERELEASE_VERSION"
|
||||
|
||||
- name: Update Chart.yaml with prerelease version
|
||||
run: |
|
||||
sed -i "s/^version:.*/version: ${{ steps.version.outputs.version }}/" ./hosting/k8s/helm/Chart.yaml
|
||||
|
||||
- name: Package Helm Chart
|
||||
run: |
|
||||
helm package ./hosting/k8s/helm/ --destination /tmp/
|
||||
|
||||
- name: Push Helm Chart to GHCR
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
CHART_PACKAGE="/tmp/${{ env.CHART_NAME }}-${VERSION}.tgz"
|
||||
|
||||
# Push to GHCR OCI registry
|
||||
helm push "$CHART_PACKAGE" "oci://${{ env.REGISTRY }}/${{ github.repository_owner }}/charts"
|
||||
|
||||
- name: Find existing comment
|
||||
uses: peter-evans/find-comment@v3
|
||||
id: find-comment
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-author: "github-actions[bot]"
|
||||
body-includes: "Helm Chart Prerelease Published"
|
||||
|
||||
- name: Create or update PR comment
|
||||
uses: peter-evans/create-or-update-comment@v4
|
||||
with:
|
||||
comment-id: ${{ steps.find-comment.outputs.comment-id }}
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body: |
|
||||
### 🧭 Helm Chart Prerelease Published
|
||||
|
||||
**Version:** `${{ steps.version.outputs.version }}`
|
||||
|
||||
**Install:**
|
||||
```bash
|
||||
helm upgrade --install trigger \
|
||||
oci://ghcr.io/${{ github.repository_owner }}/charts/trigger \
|
||||
--version "${{ steps.version.outputs.version }}"
|
||||
```
|
||||
|
||||
> ⚠️ This is a prerelease for testing. Do not use in production.
|
||||
edit-mode: replace
|
||||
@@ -1,6 +1,7 @@
|
||||
name: 🚀 Publish Trigger.dev Docker
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
image_tag:
|
||||
|
||||
+135
-48
@@ -1,98 +1,185 @@
|
||||
name: 🦋 Changesets Release
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- ".github/CODEOWNERS"
|
||||
- ".github/ISSUE_TEMPLATE/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
type:
|
||||
description: "Select release type"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- release
|
||||
- prerelease
|
||||
default: "prerelease"
|
||||
ref:
|
||||
description: "The ref (branch, tag, or SHA) to checkout and release from"
|
||||
required: true
|
||||
type: string
|
||||
prerelease_tag:
|
||||
description: "The npm dist-tag for the prerelease (e.g., 'v4-prerelease')"
|
||||
required: false
|
||||
type: string
|
||||
default: "prerelease"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: 🦋 Changesets Release
|
||||
show-release-summary:
|
||||
name: 📋 Release Summary
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.repository == 'triggerdotdev/trigger.dev' &&
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.merged == true &&
|
||||
github.event.pull_request.head.ref == 'changeset-release/main'
|
||||
steps:
|
||||
- name: Show release summary
|
||||
env:
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
run: |
|
||||
echo "$PR_BODY" | sed -n '/^# Releases/,$p' >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
release:
|
||||
name: 🚀 Release npm packages
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-publish
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
pull-requests: write
|
||||
if: github.repository == 'triggerdotdev/trigger.dev'
|
||||
id-token: write
|
||||
if: |
|
||||
github.repository == 'triggerdotdev/trigger.dev' &&
|
||||
(
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.type == 'release') ||
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'changeset-release/main')
|
||||
)
|
||||
outputs:
|
||||
published: ${{ steps.changesets.outputs.published }}
|
||||
published_packages: ${{ steps.changesets.outputs.publishedPackages }}
|
||||
published_package_version: ${{ steps.get_version.outputs.package_version }}
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.sha }}
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
- name: Verify ref is on main
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
if ! git merge-base --is-ancestor ${{ github.event.inputs.ref }} origin/main; then
|
||||
echo "Error: ref must be an ancestor of main (i.e., already merged)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
- name: Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
node-version: 20.20.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
# npm v11.5.1 or newer is required for OIDC support
|
||||
# https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/#whats-new
|
||||
- name: Setup npm 11.x for OIDC
|
||||
run: npm install -g npm@11.6.4
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 📀 Generate Prisma Client
|
||||
- name: Generate Prisma client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: 🏗️ Build
|
||||
- name: Build
|
||||
run: pnpm run build --filter "@trigger.dev/*" --filter "trigger.dev"
|
||||
|
||||
- name: 🔎 Type check
|
||||
- name: Type check
|
||||
run: pnpm run typecheck --filter "@trigger.dev/*" --filter "trigger.dev"
|
||||
|
||||
- name: 🔐 Setup npm auth
|
||||
run: |
|
||||
echo "registry=https://registry.npmjs.org" >> ~/.npmrc
|
||||
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc
|
||||
|
||||
# This action has two responsibilities. The first time the workflow runs
|
||||
# (initial push to the `main` branch) it will create a new branch and
|
||||
# then open a PR with the related changes for the new version. After the
|
||||
# PR is merged, the workflow will run again and this action will build +
|
||||
# publish to npm.
|
||||
- name: 🚀 PR / Publish
|
||||
if: ${{ !env.ACT }}
|
||||
- name: Publish
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
version: pnpm run changeset:version
|
||||
commit: "chore: Update version for release"
|
||||
title: "chore: Update version for release"
|
||||
publish: pnpm run changeset:release
|
||||
createGithubReleases: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
# - name: 🚀 PR / Publish (mock)
|
||||
# if: ${{ env.ACT }}
|
||||
# id: changesets
|
||||
# run: |
|
||||
# echo "published=true" >> "$GITHUB_OUTPUT"
|
||||
# echo "publishedPackages=[{\"name\": \"@xx/xx\", \"version\": \"1.2.0\"}, {\"name\": \"@xx/xy\", \"version\": \"0.8.9\"}]" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 📦 Get package version
|
||||
- name: Show package version
|
||||
if: steps.changesets.outputs.published == 'true'
|
||||
id: get_version
|
||||
run: |
|
||||
package_version=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[0].version')
|
||||
echo "package_version=${package_version}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# this triggers the publish workflow for the docker images
|
||||
- name: Create and push Docker tag
|
||||
if: steps.changesets.outputs.published == 'true'
|
||||
run: |
|
||||
set -e
|
||||
git tag "v.docker.${{ steps.get_version.outputs.package_version }}"
|
||||
git push origin "v.docker.${{ steps.get_version.outputs.package_version }}"
|
||||
|
||||
# The prerelease job needs to be on the same workflow file due to a limitation related to how npm verifies OIDC claims.
|
||||
prerelease:
|
||||
name: 🧪 Prerelease
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-publish
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
if: github.repository == 'triggerdotdev/trigger.dev' && github.event_name == 'workflow_dispatch' && github.event.inputs.type == 'prerelease'
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
|
||||
- name: Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.20.0
|
||||
cache: "pnpm"
|
||||
|
||||
# npm v11.5.1 or newer is required for OIDC support
|
||||
# https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/#whats-new
|
||||
- name: Setup npm 11.x for OIDC
|
||||
run: npm install -g npm@11.6.4
|
||||
|
||||
- name: Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: Snapshot version
|
||||
run: pnpm exec changeset version --snapshot ${{ github.event.inputs.prerelease_tag }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Clean
|
||||
run: pnpm run clean --filter "@trigger.dev/*" --filter "trigger.dev"
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build --filter "@trigger.dev/*" --filter "trigger.dev"
|
||||
|
||||
- name: Publish prerelease
|
||||
run: pnpm exec changeset publish --no-git-tag --snapshot --tag ${{ github.event.inputs.prerelease_tag }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -19,12 +19,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
node-version: 20.20.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
@@ -35,6 +35,8 @@ jobs:
|
||||
|
||||
- name: 🔎 Type check
|
||||
run: pnpm run typecheck
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
|
||||
- name: 🔎 Check exports
|
||||
run: pnpm run check-exports
|
||||
|
||||
@@ -53,12 +53,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
node-version: 20.20.0
|
||||
cache: "pnpm"
|
||||
|
||||
# ..to avoid rate limits when pulling images
|
||||
@@ -72,6 +72,17 @@ jobs:
|
||||
if: ${{ !env.DOCKERHUB_USERNAME }}
|
||||
run: echo "DockerHub login skipped because secrets are not available."
|
||||
|
||||
- name: 🐳 Pre-pull testcontainer images
|
||||
if: ${{ env.DOCKERHUB_USERNAME }}
|
||||
run: |
|
||||
echo "Pre-pulling Docker images with authenticated session..."
|
||||
docker pull postgres:14
|
||||
docker pull clickhouse/clickhouse-server:25.4-alpine
|
||||
docker pull redis:7-alpine
|
||||
docker pull testcontainers/ryuk:0.11.0
|
||||
docker pull electricsql/electric:1.2.4
|
||||
echo "Image pre-pull complete"
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -111,12 +122,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
node-version: 20.20.0
|
||||
# no cache enabled, we're not installing deps
|
||||
|
||||
- name: Download blob reports from GitHub Actions Artifacts
|
||||
|
||||
@@ -53,12 +53,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
node-version: 20.20.0
|
||||
cache: "pnpm"
|
||||
|
||||
# ..to avoid rate limits when pulling images
|
||||
@@ -72,6 +72,17 @@ jobs:
|
||||
if: ${{ !env.DOCKERHUB_USERNAME }}
|
||||
run: echo "DockerHub login skipped because secrets are not available."
|
||||
|
||||
- name: 🐳 Pre-pull testcontainer images
|
||||
if: ${{ env.DOCKERHUB_USERNAME }}
|
||||
run: |
|
||||
echo "Pre-pulling Docker images with authenticated session..."
|
||||
docker pull postgres:14
|
||||
docker pull clickhouse/clickhouse-server:25.4-alpine
|
||||
docker pull redis:7-alpine
|
||||
docker pull testcontainers/ryuk:0.11.0
|
||||
docker pull electricsql/electric:1.2.4
|
||||
echo "Image pre-pull complete"
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -111,12 +122,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
node-version: 20.20.0
|
||||
# no cache enabled, we're not installing deps
|
||||
|
||||
- name: Download blob reports from GitHub Actions Artifacts
|
||||
|
||||
@@ -53,12 +53,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
node-version: 20.20.0
|
||||
cache: "pnpm"
|
||||
|
||||
# ..to avoid rate limits when pulling images
|
||||
@@ -72,6 +72,17 @@ jobs:
|
||||
if: ${{ !env.DOCKERHUB_USERNAME }}
|
||||
run: echo "DockerHub login skipped because secrets are not available."
|
||||
|
||||
- name: 🐳 Pre-pull testcontainer images
|
||||
if: ${{ env.DOCKERHUB_USERNAME }}
|
||||
run: |
|
||||
echo "Pre-pulling Docker images with authenticated session..."
|
||||
docker pull postgres:14
|
||||
docker pull clickhouse/clickhouse-server:25.4-alpine
|
||||
docker pull redis:7-alpine
|
||||
docker pull testcontainers/ryuk:0.11.0
|
||||
docker pull electricsql/electric:1.2.4
|
||||
echo "Image pre-pull complete"
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -119,12 +130,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
node-version: 20.20.0
|
||||
# no cache enabled, we're not installing deps
|
||||
|
||||
- name: Download blob reports from GitHub Actions Artifacts
|
||||
|
||||
+7
-7
@@ -29,12 +29,10 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env.docker
|
||||
.env
|
||||
.env.*
|
||||
.docker/*.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
!.env.example
|
||||
|
||||
# turbo
|
||||
.turbo
|
||||
@@ -63,5 +61,7 @@ apps/**/public/build
|
||||
/packages/core/src/package.json
|
||||
/packages/trigger-sdk/src/package.json
|
||||
/packages/python/src/package.json
|
||||
.claude
|
||||
.mcp.log
|
||||
**/.claude/settings.local.json
|
||||
.mcp.log
|
||||
.mcp.json
|
||||
.cursor/debug.log
|
||||
@@ -1,5 +0,0 @@
|
||||
link-workspace-packages=false
|
||||
public-hoist-pattern[]=*prisma*
|
||||
prefer-workspace-packages=true
|
||||
update-notifier=false
|
||||
side-effects-cache=false
|
||||
@@ -13,7 +13,7 @@ This repository is a pnpm monorepo managed with Turbo. It contains multiple apps
|
||||
See `ai/references/repo.md` for a more complete explanation of the workspaces.
|
||||
|
||||
## Development setup
|
||||
1. Install dependencies with `pnpm i` (pnpm `8.15.5` and Node.js `20.11.1` are required).
|
||||
1. Install dependencies with `pnpm i` (pnpm `10.23.0` and Node.js `20.20.0` are required).
|
||||
2. Copy `.env.example` to `.env` and generate a random 16 byte hex string for `ENCRYPTION_KEY` (`openssl rand -hex 16`). Update other secrets if needed.
|
||||
3. Start the local services with Docker:
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Build and Development Commands
|
||||
|
||||
This is a pnpm 10.23.0 monorepo using Turborepo. Run commands from root with `pnpm run`.
|
||||
|
||||
### Essential Commands
|
||||
|
||||
```bash
|
||||
# Start Docker services (PostgreSQL, Redis, Electric)
|
||||
pnpm run docker
|
||||
|
||||
# Run database migrations
|
||||
pnpm run db:migrate
|
||||
|
||||
# Seed the database (required for reference projects)
|
||||
pnpm run db:seed
|
||||
|
||||
# Build packages (required before running)
|
||||
pnpm run build --filter webapp && pnpm run build --filter trigger.dev && pnpm run build --filter @trigger.dev/sdk
|
||||
|
||||
# Run webapp in development mode (http://localhost:3030)
|
||||
pnpm run dev --filter webapp
|
||||
|
||||
# Build and watch for changes (CLI and packages)
|
||||
pnpm run dev --filter trigger.dev --filter "@trigger.dev/*"
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
We use vitest exclusively. **Never mock anything** - use testcontainers instead.
|
||||
|
||||
```bash
|
||||
# Run all tests for a package
|
||||
pnpm run test --filter webapp
|
||||
|
||||
# Run a single test file (preferred - cd into directory first)
|
||||
cd internal-packages/run-engine
|
||||
pnpm run test ./src/engine/tests/ttl.test.ts --run
|
||||
|
||||
# May need to build dependencies first
|
||||
pnpm run build --filter @internal/run-engine
|
||||
```
|
||||
|
||||
Test files go next to source files (e.g., `MyService.ts` → `MyService.test.ts`).
|
||||
|
||||
#### Testcontainers for Redis/PostgreSQL
|
||||
|
||||
```typescript
|
||||
import { redisTest, postgresTest, containerTest } from "@internal/testcontainers";
|
||||
|
||||
// Redis only
|
||||
redisTest("should use redis", async ({ redisOptions }) => {
|
||||
/* ... */
|
||||
});
|
||||
|
||||
// PostgreSQL only
|
||||
postgresTest("should use postgres", async ({ prisma }) => {
|
||||
/* ... */
|
||||
});
|
||||
|
||||
// Both Redis and PostgreSQL
|
||||
containerTest("should use both", async ({ prisma, redisOptions }) => {
|
||||
/* ... */
|
||||
});
|
||||
```
|
||||
|
||||
### Changesets
|
||||
|
||||
When modifying any public package (`packages/*` or `integrations/*`), add a changeset:
|
||||
|
||||
```bash
|
||||
pnpm run changeset:add
|
||||
```
|
||||
|
||||
- Default to **patch** for bug fixes and minor changes
|
||||
- Confirm with maintainers before selecting **minor** (new features)
|
||||
- **Never** select major (breaking changes) without explicit approval
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Apps
|
||||
|
||||
- **apps/webapp**: Remix 2.1.0 app - main API, dashboard, and Docker image. Uses Express server.
|
||||
- **apps/supervisor**: Node.js app handling task execution, interfacing with Docker/Kubernetes.
|
||||
|
||||
### Public Packages
|
||||
|
||||
- **packages/trigger-sdk** (`@trigger.dev/sdk`): Main SDK
|
||||
- **packages/cli-v3** (`trigger.dev`): CLI package
|
||||
- **packages/core** (`@trigger.dev/core`): Shared code between SDK and webapp. Import subpaths only (never root).
|
||||
- **packages/build**: Build extensions and types
|
||||
- **packages/react-hooks**: React hooks for realtime and triggering
|
||||
- **packages/redis-worker** (`@trigger.dev/redis-worker`): Custom Redis-based background job system
|
||||
|
||||
### Internal Packages
|
||||
|
||||
- **internal-packages/database** (`@trigger.dev/database`): Prisma 6.14.0 client and schema
|
||||
- **internal-packages/clickhouse** (`@internal/clickhouse`): ClickHouse client and schema migrations
|
||||
- **internal-packages/run-engine** (`@internal/run-engine`): "Run Engine 2.0" - run lifecycle management
|
||||
- **internal-packages/redis** (`@internal/redis`): Redis client creation utilities
|
||||
- **internal-packages/testcontainers** (`@internal/testcontainers`): Test helpers for Redis/PostgreSQL containers
|
||||
- **internal-packages/zodworker** (`@internal/zodworker`): Graphile-worker wrapper (being replaced by redis-worker)
|
||||
|
||||
### Reference Projects
|
||||
|
||||
The `references/` directory contains test workspaces for developing and testing new SDK and platform features. Use these projects (e.g., `references/hello-world`) to manually test changes to the CLI, SDK, core packages, and webapp before submitting PRs.
|
||||
|
||||
## Webapp Development
|
||||
|
||||
### Key Locations
|
||||
|
||||
- Trigger API: `apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts`
|
||||
- Batch trigger: `apps/webapp/app/routes/api.v1.tasks.batch.ts`
|
||||
- Prisma setup: `apps/webapp/app/db.server.ts`
|
||||
- Run engine config: `apps/webapp/app/v3/runEngine.server.ts`
|
||||
- Services: `apps/webapp/app/v3/services/**/*.server.ts`
|
||||
- Presenters: `apps/webapp/app/v3/presenters/**/*.server.ts`
|
||||
- OTEL endpoints: `apps/webapp/app/routes/otel.v1.logs.ts`, `otel.v1.traces.ts`
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Access via `env` export from `apps/webapp/app/env.server.ts`, never `process.env` directly.
|
||||
|
||||
For testable code, **never import env.server.ts** in test files. Pass configuration as options instead. Example pattern:
|
||||
|
||||
- `realtimeClient.server.ts` (testable service)
|
||||
- `realtimeClientGlobal.server.ts` (configuration)
|
||||
|
||||
### Legacy vs Run Engine 2.0
|
||||
|
||||
The codebase is transitioning from the "legacy run engine" (spread across codebase) to "Run Engine 2.0" (`@internal/run-engine`). Focus on Run Engine 2.0 for new work.
|
||||
|
||||
## Docker Image Guidelines
|
||||
|
||||
When updating Docker image references in `docker/Dockerfile` or other container files:
|
||||
|
||||
- **Always use multiplatform/index digests**, not architecture-specific digests
|
||||
- Architecture-specific digests (e.g., for `linux/amd64` only) will cause CI failures on different build environments
|
||||
- On Docker Hub, the multiplatform digest is shown on the main image page, while architecture-specific digests are listed under "OS/ARCH"
|
||||
- Example: Use `node:20.20-bullseye-slim@sha256:abc123...` where the digest is from the multiplatform index, not from a specific OS/ARCH variant
|
||||
|
||||
## Database Migrations (PostgreSQL)
|
||||
|
||||
1. Edit `internal-packages/database/prisma/schema.prisma`
|
||||
2. Create migration:
|
||||
```bash
|
||||
cd internal-packages/database
|
||||
pnpm run db:migrate:dev:create --name "add_new_column"
|
||||
```
|
||||
3. **Important**: Generated migration includes extraneous changes. Remove lines related to:
|
||||
- `_BackgroundWorkerToBackgroundWorkerFile`
|
||||
- `_BackgroundWorkerToTaskQueue`
|
||||
- `_TaskRunToTaskRunTag`
|
||||
- `_WaitpointRunConnections`
|
||||
- `_completedWaitpoints`
|
||||
- `SecretStore_key_idx`
|
||||
- Various `TaskRun` indexes unless you added them
|
||||
4. Apply migration:
|
||||
```bash
|
||||
pnpm run db:migrate:deploy && pnpm run generate
|
||||
```
|
||||
|
||||
### Index Migration Rules
|
||||
|
||||
- Indexes **must use CONCURRENTLY** to avoid table locks
|
||||
- **CONCURRENTLY indexes must be in their own separate migration file** - they cannot be combined with other schema changes
|
||||
|
||||
## ClickHouse Migrations
|
||||
|
||||
ClickHouse migrations use Goose format and live in `internal-packages/clickhouse/schema/`.
|
||||
|
||||
1. Create a new numbered SQL file (e.g., `010_add_new_column.sql`)
|
||||
2. Use Goose markers:
|
||||
|
||||
```sql
|
||||
-- +goose Up
|
||||
ALTER TABLE trigger_dev.your_table
|
||||
ADD COLUMN new_column String DEFAULT '';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE trigger_dev.your_table
|
||||
DROP COLUMN new_column;
|
||||
```
|
||||
|
||||
Follow naming conventions in `internal-packages/clickhouse/README.md`:
|
||||
|
||||
- `raw_` prefix for input tables
|
||||
- `_v1`, `_v2` suffixes for versioning
|
||||
- `_mv_v1` suffix for materialized views
|
||||
|
||||
## Writing Trigger.dev Tasks
|
||||
|
||||
Always import from `@trigger.dev/sdk`. Never use `@trigger.dev/sdk/v3` or deprecated `client.defineJob` pattern.
|
||||
|
||||
```typescript
|
||||
import { task } from "@trigger.dev/sdk";
|
||||
|
||||
// Every task must be exported
|
||||
export const myTask = task({
|
||||
id: "my-task", // Unique ID
|
||||
run: async (payload: { message: string }) => {
|
||||
// Task logic - no timeouts
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### SDK Documentation Rules
|
||||
|
||||
The `rules/` directory contains versioned documentation for writing Trigger.dev tasks, distributed to users via the SDK installer. Current version is defined in `rules/manifest.json`.
|
||||
|
||||
- `rules/4.3.0/` - Latest: batch trigger v2 (1,000 items, 3MB payloads), debouncing
|
||||
- `rules/4.1.0/` - Realtime streams v2, updated config
|
||||
- `rules/4.0.0/` - Base v4 SDK documentation
|
||||
|
||||
When adding new SDK features, create a new version directory with only the files that changed from the previous version. Update `manifest.json` to point unchanged files to previous versions.
|
||||
|
||||
### Claude Code Skill
|
||||
|
||||
The `.claude/skills/trigger-dev-tasks/` skill provides Claude Code with Trigger.dev task expertise. It includes:
|
||||
|
||||
- `SKILL.md` - Core instructions and patterns
|
||||
- Reference files for basic tasks, advanced tasks, scheduled tasks, realtime, and config
|
||||
|
||||
Keep the skill in sync with the latest rules version when SDK features change.
|
||||
|
||||
## Testing with hello-world Reference Project
|
||||
|
||||
First-time setup:
|
||||
|
||||
1. Run `pnpm run db:seed` to seed the database (creates the hello-world project)
|
||||
2. Build CLI: `pnpm run build --filter trigger.dev && pnpm i`
|
||||
3. Authorize CLI: `cd references/hello-world && pnpm exec trigger login -a http://localhost:3030`
|
||||
|
||||
Running:
|
||||
|
||||
```bash
|
||||
cd references/hello-world
|
||||
pnpm exec trigger dev # or with --log-level debug
|
||||
```
|
||||
|
||||
## Local Task Testing Workflow
|
||||
|
||||
This workflow enables Claude Code to run the webapp and trigger dev simultaneously, trigger tasks, and inspect results for testing code changes.
|
||||
|
||||
### Step 1: Start Webapp in Background
|
||||
|
||||
```bash
|
||||
# Run from repo root with run_in_background: true
|
||||
pnpm run dev --filter webapp
|
||||
```
|
||||
|
||||
Verify webapp is running:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:3030/healthcheck # Should return 200
|
||||
```
|
||||
|
||||
### Step 2: Start Trigger Dev in Background
|
||||
|
||||
```bash
|
||||
# Run from hello-world directory with run_in_background: true
|
||||
cd references/hello-world && pnpm exec trigger dev
|
||||
```
|
||||
|
||||
The worker will build and register tasks. Check output for "Local worker ready [node]" message.
|
||||
|
||||
### Step 3: Trigger and Monitor Tasks via MCP
|
||||
|
||||
Use the Trigger.dev MCP tools to interact with tasks:
|
||||
|
||||
```
|
||||
# Get current worker and registered tasks
|
||||
mcp__trigger__get_current_worker(projectRef: "proj_rrkpdguyagvsoktglnod", environment: "dev")
|
||||
|
||||
# Trigger a task
|
||||
mcp__trigger__trigger_task(
|
||||
projectRef: "proj_rrkpdguyagvsoktglnod",
|
||||
environment: "dev",
|
||||
taskId: "hello-world",
|
||||
payload: {"message": "Hello from Claude"}
|
||||
)
|
||||
|
||||
# List runs to see status
|
||||
mcp__trigger__list_runs(
|
||||
projectRef: "proj_rrkpdguyagvsoktglnod",
|
||||
environment: "dev",
|
||||
taskIdentifier: "hello-world",
|
||||
limit: 5
|
||||
)
|
||||
```
|
||||
|
||||
### Step 4: Monitor Execution
|
||||
|
||||
- Check trigger dev output file for real-time execution logs
|
||||
- Successful runs show: `Task | Run ID | Success (Xms)`
|
||||
- Dashboard available at: http://localhost:3030/orgs/references-9dfd/projects/hello-world-97DT/env/dev/runs
|
||||
|
||||
### Key Project Refs
|
||||
|
||||
- hello-world: `proj_rrkpdguyagvsoktglnod`
|
||||
+6
-6
@@ -14,8 +14,8 @@ branch are tagged into a release periodically.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/en) version 20.11.1
|
||||
- [pnpm package manager](https://pnpm.io/installation) version 8.15.5
|
||||
- [Node.js](https://nodejs.org/en) version 20.20.0
|
||||
- [pnpm package manager](https://pnpm.io/installation) version 10.23.0
|
||||
- [Docker](https://www.docker.com/get-started/)
|
||||
- [protobuf](https://github.com/protocolbuffers/protobuf)
|
||||
|
||||
@@ -34,9 +34,9 @@ branch are tagged into a release periodically.
|
||||
```
|
||||
cd trigger.dev
|
||||
```
|
||||
3. Ensure you are on the correct version of Node.js (20.11.1). If you are using `nvm`, there is an `.nvmrc` file that will automatically select the correct version of Node.js when you navigate to the repository.
|
||||
3. Ensure you are on the correct version of Node.js (20.20.0). If you are using `nvm`, there is an `.nvmrc` file that will automatically select the correct version of Node.js when you navigate to the repository.
|
||||
|
||||
4. Run `corepack enable` to use the correct version of pnpm (`8.15.5`) as specified in the root `package.json` file.
|
||||
4. Run `corepack enable` to use the correct version of pnpm (`10.23.0`) as specified in the root `package.json` file.
|
||||
|
||||
5. Install the required packages using pnpm.
|
||||
```
|
||||
@@ -92,7 +92,7 @@ We use the `<root>/references/hello-world` subdirectory as a staging ground for
|
||||
|
||||
First, make sure you are running the webapp according to the instructions above. Then:
|
||||
|
||||
1. Visit http://localhost:3030 in your browser and create a new V3 project called "hello-world".
|
||||
1. Visit http://localhost:3030 in your browser and create a new project called "hello-world".
|
||||
|
||||
2. In Postgres go to the "Projects" table and for the project you create change the `externalRef` to `proj_rrkpdguyagvsoktglnod`.
|
||||
|
||||
@@ -127,7 +127,7 @@ pnpm exec trigger deploy --profile local
|
||||
|
||||
### Running
|
||||
|
||||
The following steps should be followed any time you start working on a new feature you want to test in v3:
|
||||
The following steps should be followed any time you start working on a new feature you want to test:
|
||||
|
||||
1. Make sure the webapp is running on localhost:3030
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
### Build and deploy fully‑managed AI agents and workflows
|
||||
|
||||
[Website](https://trigger.dev) | [Docs](https://trigger.dev/docs) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Feature requests](https://triggerdev.featurebase.app/) | [Public roadmap](https://triggerdev.featurebase.app/roadmap) | [Self-hosting](https://trigger.dev/docs/self-hosting/overview)
|
||||
[Website](https://trigger.dev) | [Docs](https://trigger.dev/docs) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Example projects](https://github.com/triggerdotdev/examples) | [Feature requests](https://triggerdev.featurebase.app/) | [Public roadmap](https://triggerdev.featurebase.app/roadmap) | [Self-hosting](https://trigger.dev/docs/self-hosting/overview)
|
||||
|
||||
[](https://github.com/triggerdotdev/trigger.dev)
|
||||
[](https://github.com/triggerdotdev/trigger.dev/blob/main/LICENSE)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
## Creating and applying migrations
|
||||
|
||||
We use prisma migrations to manage the database schema. Please follow the following steps when editing the `internal-packages/database/prisma/schema.prisma` file:
|
||||
|
||||
Edit the `schema.prisma` file to add or modify the schema.
|
||||
|
||||
Create a new migration file but don't apply it yet:
|
||||
|
||||
```bash
|
||||
cd internal-packages/database
|
||||
pnpm run db:migrate:dev:create --name "add_new_column_to_table"
|
||||
```
|
||||
|
||||
The migration file will be created in the `prisma/migrations` directory, but it will have a bunch of edits to the schema that are not needed and will need to be removed before we can apply the migration. Here's an example of what the migration file might look like:
|
||||
|
||||
```sql
|
||||
-- AlterEnum
|
||||
ALTER TYPE "public"."TaskRunExecutionStatus" ADD VALUE 'DELAYED';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."TaskRun" ADD COLUMN "debounce" JSONB;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_BackgroundWorkerToBackgroundWorkerFile" ADD CONSTRAINT "_BackgroundWorkerToBackgroundWorkerFile_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_BackgroundWorkerToBackgroundWorkerFile_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_BackgroundWorkerToTaskQueue" ADD CONSTRAINT "_BackgroundWorkerToTaskQueue_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_BackgroundWorkerToTaskQueue_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_TaskRunToTaskRunTag" ADD CONSTRAINT "_TaskRunToTaskRunTag_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_TaskRunToTaskRunTag_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_WaitpointRunConnections" ADD CONSTRAINT "_WaitpointRunConnections_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_WaitpointRunConnections_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_completedWaitpoints" ADD CONSTRAINT "_completedWaitpoints_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_completedWaitpoints_AB_unique";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SecretStore_key_idx" ON "public"."SecretStore"("key" text_pattern_ops);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TaskRun_runtimeEnvironmentId_id_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "id" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TaskRun_runtimeEnvironmentId_createdAt_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "createdAt" DESC);
|
||||
```
|
||||
|
||||
All the following lines should be removed:
|
||||
|
||||
```sql
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_BackgroundWorkerToBackgroundWorkerFile" ADD CONSTRAINT "_BackgroundWorkerToBackgroundWorkerFile_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_BackgroundWorkerToBackgroundWorkerFile_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_BackgroundWorkerToTaskQueue" ADD CONSTRAINT "_BackgroundWorkerToTaskQueue_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_BackgroundWorkerToTaskQueue_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_TaskRunToTaskRunTag" ADD CONSTRAINT "_TaskRunToTaskRunTag_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_TaskRunToTaskRunTag_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_WaitpointRunConnections" ADD CONSTRAINT "_WaitpointRunConnections_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_WaitpointRunConnections_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_completedWaitpoints" ADD CONSTRAINT "_completedWaitpoints_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_completedWaitpoints_AB_unique";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SecretStore_key_idx" ON "public"."SecretStore"("key" text_pattern_ops);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TaskRun_runtimeEnvironmentId_id_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "id" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TaskRun_runtimeEnvironmentId_createdAt_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "createdAt" DESC);
|
||||
```
|
||||
|
||||
Leaving only this:
|
||||
|
||||
```sql
|
||||
-- AlterEnum
|
||||
ALTER TYPE "public"."TaskRunExecutionStatus" ADD VALUE 'DELAYED';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."TaskRun" ADD COLUMN "debounce" JSONB;
|
||||
```
|
||||
|
||||
After editing the migration file, apply the migration:
|
||||
|
||||
```bash
|
||||
cd internal-packages/database
|
||||
pnpm run db:migrate:deploy && pnpm run generate
|
||||
```
|
||||
@@ -1,6 +1,6 @@
|
||||
## Repo Overview
|
||||
|
||||
This is a pnpm 8.15.5 monorepo that uses turborepo @turbo.json. The following workspaces are relevant
|
||||
This is a pnpm 10.23.0 monorepo that uses turborepo @turbo.json. The following workspaces are relevant
|
||||
|
||||
## Apps
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ WORKDIR /app
|
||||
FROM node-22-alpine AS pruner
|
||||
|
||||
COPY --chown=node:node . .
|
||||
RUN npx -q turbo@1.10.9 prune --scope=supervisor --docker
|
||||
RUN npx -q turbo@2.5.4 prune --scope=supervisor --docker
|
||||
|
||||
FROM node-22-alpine AS base
|
||||
|
||||
@@ -16,7 +16,7 @@ COPY --from=pruner --chown=node:node /app/out/json/ .
|
||||
COPY --from=pruner --chown=node:node /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
COPY --from=pruner --chown=node:node /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
|
||||
RUN corepack enable && corepack prepare --activate
|
||||
RUN corepack enable && corepack prepare pnpm@10.23.0 --activate
|
||||
|
||||
FROM base AS deps-fetcher
|
||||
RUN apk add --no-cache python3-dev py3-setuptools make g++ gcc linux-headers
|
||||
@@ -37,7 +37,7 @@ COPY --chown=node:node scripts/updateVersion.ts scripts/updateVersion.ts
|
||||
|
||||
RUN pnpm run generate && \
|
||||
pnpm run --filter supervisor... build&& \
|
||||
pnpm deploy --filter=supervisor --prod /prod/supervisor
|
||||
pnpm deploy --legacy --filter=supervisor --prod /prod/supervisor
|
||||
|
||||
FROM base AS runner
|
||||
|
||||
|
||||
@@ -89,8 +89,28 @@ const Env = z.object({
|
||||
KUBERNETES_CPU_REQUEST_RATIO: z.coerce.number().min(0).max(1).default(0.75), // Ratio of CPU limit, so 0.75 = 75% of CPU limit
|
||||
KUBERNETES_MEMORY_REQUEST_MIN_GB: z.coerce.number().min(0).default(0),
|
||||
KUBERNETES_MEMORY_REQUEST_RATIO: z.coerce.number().min(0).max(1).default(1), // Ratio of memory limit, so 1 = 100% of memory limit
|
||||
|
||||
// Per-preset overrides of the global KUBERNETES_CPU_REQUEST_RATIO
|
||||
KUBERNETES_CPU_REQUEST_RATIO_MICRO: z.coerce.number().min(0).max(1).optional(),
|
||||
KUBERNETES_CPU_REQUEST_RATIO_SMALL_1X: z.coerce.number().min(0).max(1).optional(),
|
||||
KUBERNETES_CPU_REQUEST_RATIO_SMALL_2X: z.coerce.number().min(0).max(1).optional(),
|
||||
KUBERNETES_CPU_REQUEST_RATIO_MEDIUM_1X: z.coerce.number().min(0).max(1).optional(),
|
||||
KUBERNETES_CPU_REQUEST_RATIO_MEDIUM_2X: z.coerce.number().min(0).max(1).optional(),
|
||||
KUBERNETES_CPU_REQUEST_RATIO_LARGE_1X: z.coerce.number().min(0).max(1).optional(),
|
||||
KUBERNETES_CPU_REQUEST_RATIO_LARGE_2X: z.coerce.number().min(0).max(1).optional(),
|
||||
|
||||
// Per-preset overrides of the global KUBERNETES_MEMORY_REQUEST_RATIO
|
||||
KUBERNETES_MEMORY_REQUEST_RATIO_MICRO: z.coerce.number().min(0).max(1).optional(),
|
||||
KUBERNETES_MEMORY_REQUEST_RATIO_SMALL_1X: z.coerce.number().min(0).max(1).optional(),
|
||||
KUBERNETES_MEMORY_REQUEST_RATIO_SMALL_2X: z.coerce.number().min(0).max(1).optional(),
|
||||
KUBERNETES_MEMORY_REQUEST_RATIO_MEDIUM_1X: z.coerce.number().min(0).max(1).optional(),
|
||||
KUBERNETES_MEMORY_REQUEST_RATIO_MEDIUM_2X: z.coerce.number().min(0).max(1).optional(),
|
||||
KUBERNETES_MEMORY_REQUEST_RATIO_LARGE_1X: z.coerce.number().min(0).max(1).optional(),
|
||||
KUBERNETES_MEMORY_REQUEST_RATIO_LARGE_2X: z.coerce.number().min(0).max(1).optional(),
|
||||
|
||||
KUBERNETES_MEMORY_OVERHEAD_GB: z.coerce.number().min(0).optional(), // Optional memory overhead to add to the limit in GB
|
||||
KUBERNETES_SCHEDULER_NAME: z.string().optional(), // Custom scheduler name for pods
|
||||
KUBERNETES_LARGE_MACHINE_POOL_LABEL: z.string().optional(), // if set, large-* presets affinity for machinepool=<value>
|
||||
|
||||
// Placement tags settings
|
||||
PLACEMENT_TAGS_ENABLED: BoolEnv.default(false),
|
||||
|
||||
@@ -244,6 +244,12 @@ class ManagedSupervisor {
|
||||
}
|
||||
|
||||
try {
|
||||
if (!message.deployment.friendlyId) {
|
||||
// mostly a type guard, deployments always exists for deployed environments
|
||||
// a proper fix would be to use a discriminated union schema to differentiate between dequeued runs in dev and in deployed environments.
|
||||
throw new Error("Deployment is missing");
|
||||
}
|
||||
|
||||
await this.workloadManager.create({
|
||||
dequeuedAt: message.dequeuedAt,
|
||||
envId: message.environment.id,
|
||||
@@ -252,6 +258,8 @@ class ManagedSupervisor {
|
||||
machine: message.run.machine,
|
||||
orgId: message.organization.id,
|
||||
projectId: message.project.id,
|
||||
deploymentFriendlyId: message.deployment.friendlyId,
|
||||
deploymentVersion: message.backgroundWorker.version,
|
||||
runId: message.run.id,
|
||||
runFriendlyId: message.run.friendlyId,
|
||||
version: message.version,
|
||||
|
||||
@@ -72,6 +72,8 @@ export class DockerWorkloadManager implements WorkloadManager {
|
||||
`TRIGGER_DEQUEUED_AT_MS=${opts.dequeuedAt.getTime()}`,
|
||||
`TRIGGER_POD_SCHEDULED_AT_MS=${Date.now()}`,
|
||||
`TRIGGER_ENV_ID=${opts.envId}`,
|
||||
`TRIGGER_DEPLOYMENT_ID=${opts.deploymentFriendlyId}`,
|
||||
`TRIGGER_DEPLOYMENT_VERSION=${opts.deploymentVersion}`,
|
||||
`TRIGGER_RUN_ID=${opts.runFriendlyId}`,
|
||||
`TRIGGER_SNAPSHOT_ID=${opts.snapshotFriendlyId}`,
|
||||
`TRIGGER_SUPERVISOR_API_PROTOCOL=${this.opts.workloadApiProtocol}`,
|
||||
|
||||
@@ -4,7 +4,12 @@ import {
|
||||
type WorkloadManagerCreateOptions,
|
||||
type WorkloadManagerOptions,
|
||||
} from "./types.js";
|
||||
import type { EnvironmentType, MachinePreset, PlacementTag } from "@trigger.dev/core/v3";
|
||||
import type {
|
||||
EnvironmentType,
|
||||
MachinePreset,
|
||||
MachinePresetName,
|
||||
PlacementTag,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { PlacementTagProcessor } from "@trigger.dev/core/v3/serverOnly";
|
||||
import { env } from "../env.js";
|
||||
import { type K8sApi, createK8sApi, type k8s } from "../clients/kubernetes.js";
|
||||
@@ -14,6 +19,26 @@ type ResourceQuantities = {
|
||||
[K in "cpu" | "memory" | "ephemeral-storage"]?: string;
|
||||
};
|
||||
|
||||
const cpuRequestRatioByMachinePreset: Record<MachinePresetName, number | undefined> = {
|
||||
micro: env.KUBERNETES_CPU_REQUEST_RATIO_MICRO,
|
||||
"small-1x": env.KUBERNETES_CPU_REQUEST_RATIO_SMALL_1X,
|
||||
"small-2x": env.KUBERNETES_CPU_REQUEST_RATIO_SMALL_2X,
|
||||
"medium-1x": env.KUBERNETES_CPU_REQUEST_RATIO_MEDIUM_1X,
|
||||
"medium-2x": env.KUBERNETES_CPU_REQUEST_RATIO_MEDIUM_2X,
|
||||
"large-1x": env.KUBERNETES_CPU_REQUEST_RATIO_LARGE_1X,
|
||||
"large-2x": env.KUBERNETES_CPU_REQUEST_RATIO_LARGE_2X,
|
||||
};
|
||||
|
||||
const memoryRequestRatioByMachinePreset: Record<MachinePresetName, number | undefined> = {
|
||||
micro: env.KUBERNETES_MEMORY_REQUEST_RATIO_MICRO,
|
||||
"small-1x": env.KUBERNETES_MEMORY_REQUEST_RATIO_SMALL_1X,
|
||||
"small-2x": env.KUBERNETES_MEMORY_REQUEST_RATIO_SMALL_2X,
|
||||
"medium-1x": env.KUBERNETES_MEMORY_REQUEST_RATIO_MEDIUM_1X,
|
||||
"medium-2x": env.KUBERNETES_MEMORY_REQUEST_RATIO_MEDIUM_2X,
|
||||
"large-1x": env.KUBERNETES_MEMORY_REQUEST_RATIO_LARGE_1X,
|
||||
"large-2x": env.KUBERNETES_MEMORY_REQUEST_RATIO_LARGE_2X,
|
||||
};
|
||||
|
||||
export class KubernetesWorkloadManager implements WorkloadManager {
|
||||
private readonly logger = new SimpleStructuredLogger("kubernetes-workload-provider");
|
||||
private k8s: K8sApi;
|
||||
@@ -95,6 +120,7 @@ export class KubernetesWorkloadManager implements WorkloadManager {
|
||||
},
|
||||
spec: {
|
||||
...this.addPlacementTags(this.#defaultPodSpec, opts.placementTags),
|
||||
affinity: this.#getNodeAffinity(opts.machine),
|
||||
terminationGracePeriodSeconds: 60 * 60,
|
||||
containers: [
|
||||
{
|
||||
@@ -123,6 +149,14 @@ export class KubernetesWorkloadManager implements WorkloadManager {
|
||||
name: "TRIGGER_ENV_ID",
|
||||
value: opts.envId,
|
||||
},
|
||||
{
|
||||
name: "TRIGGER_DEPLOYMENT_ID",
|
||||
value: opts.deploymentFriendlyId,
|
||||
},
|
||||
{
|
||||
name: "TRIGGER_DEPLOYMENT_VERSION",
|
||||
value: opts.deploymentVersion,
|
||||
},
|
||||
{
|
||||
name: "TRIGGER_SNAPSHOT_ID",
|
||||
value: opts.snapshotFriendlyId,
|
||||
@@ -312,8 +346,11 @@ export class KubernetesWorkloadManager implements WorkloadManager {
|
||||
}
|
||||
|
||||
#getResourceRequestsForMachine(preset: MachinePreset): ResourceQuantities {
|
||||
const cpuRequest = preset.cpu * this.cpuRequestRatio;
|
||||
const memoryRequest = preset.memory * this.memoryRequestRatio;
|
||||
const cpuRatio = cpuRequestRatioByMachinePreset[preset.name] ?? this.cpuRequestRatio;
|
||||
const memoryRatio = memoryRequestRatioByMachinePreset[preset.name] ?? this.memoryRequestRatio;
|
||||
|
||||
const cpuRequest = preset.cpu * cpuRatio;
|
||||
const memoryRequest = preset.memory * memoryRatio;
|
||||
|
||||
// Clamp between min and max
|
||||
const clampedCpu = this.clamp(cpuRequest, this.cpuRequestMinCores, preset.cpu);
|
||||
@@ -348,4 +385,55 @@ export class KubernetesWorkloadManager implements WorkloadManager {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#isLargeMachine(preset: MachinePreset): boolean {
|
||||
return preset.name.startsWith("large-");
|
||||
}
|
||||
|
||||
#getNodeAffinity(preset: MachinePreset): k8s.V1Affinity | undefined {
|
||||
if (!env.KUBERNETES_LARGE_MACHINE_POOL_LABEL) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (this.#isLargeMachine(preset)) {
|
||||
// soft preference for the large-machine pool, falls back to standard if unavailable
|
||||
return {
|
||||
nodeAffinity: {
|
||||
preferredDuringSchedulingIgnoredDuringExecution: [
|
||||
{
|
||||
weight: 100,
|
||||
preference: {
|
||||
matchExpressions: [
|
||||
{
|
||||
key: "node.cluster.x-k8s.io/machinepool",
|
||||
operator: "In",
|
||||
values: [env.KUBERNETES_LARGE_MACHINE_POOL_LABEL],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// not schedulable in the large-machine pool
|
||||
return {
|
||||
nodeAffinity: {
|
||||
requiredDuringSchedulingIgnoredDuringExecution: {
|
||||
nodeSelectorTerms: [
|
||||
{
|
||||
matchExpressions: [
|
||||
{
|
||||
key: "node.cluster.x-k8s.io/machinepool",
|
||||
operator: "NotIn",
|
||||
values: [env.KUBERNETES_LARGE_MACHINE_POOL_LABEL],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface WorkloadManagerCreateOptions {
|
||||
envType: EnvironmentType;
|
||||
orgId: string;
|
||||
projectId: string;
|
||||
deploymentFriendlyId: string;
|
||||
deploymentVersion: string;
|
||||
runId: string;
|
||||
runFriendlyId: string;
|
||||
snapshotId: string;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
export function AbacusIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clipPath="url(#clip0_16909_120578)">
|
||||
<path
|
||||
d="M4 3V21"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M20 21V3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8 5L8 6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M14 5L14 6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M15 10L15 11"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9 10L9 11"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 15L12 16"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8 15L8 16"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M3 21H21"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export function ArrowTopRightBottomLeftIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M14.8258 10.5L20.125 5.20083V8.5625C20.125 9.08027 20.5447 9.5 21.0625 9.5C21.5803 9.5 22 9.08027 22 8.5625V2.9375C22 2.41973 21.5803 2 21.0625 2H15.4375C14.9197 2 14.5 2.41973 14.5 2.9375C14.5 3.45527 14.9197 3.875 15.4375 3.875H18.7992L13.5 9.17417C13.1339 9.54029 13.1339 10.1339 13.5 10.5C13.8661 10.8661 14.4597 10.8661 14.8258 10.5Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M2 21.0625V15.4375C2 14.9197 2.41973 14.5 2.9375 14.5C3.45527 14.5 3.875 14.9197 3.875 15.4375V18.7992L9.17417 13.5C9.54029 13.1339 10.1339 13.1339 10.5 13.5C10.8661 13.8661 10.8661 14.4597 10.5 14.8258L5.20083 20.125H8.5625C9.08027 20.125 9.5 20.5447 9.5 21.0625C9.5 21.5803 9.08027 22 8.5625 22H2.9375C2.69757 22 2.45765 21.9085 2.27459 21.7254C2.1847 21.6355 2.11689 21.5319 2.07114 21.4214C2.0253 21.3108 2 21.1896 2 21.0625Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M14.8258 10.5L20.125 5.20083V10C20.125 10.5178 20.5447 10.9375 21.0625 10.9375C21.5803 10.9375 22 10.5178 22 10V2.9375C22 2.41973 21.5803 2 21.0625 2H14C13.4822 2 13.0625 2.41973 13.0625 2.9375C13.0625 3.45527 13.4822 3.875 14 3.875H18.7992L13.5 9.17417C13.1339 9.54029 13.1339 10.1339 13.5 10.5C13.8661 10.8661 14.4597 10.8661 14.8258 10.5Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M2 21.0625V13.9375C2 13.4197 2.41973 13 2.9375 13C3.45527 13 3.875 13.4197 3.875 13.9375V18.7992L9.17417 13.5C9.54029 13.1339 10.1339 13.1339 10.5 13.5C10.8661 13.8661 10.8661 14.4597 10.5 14.8258L5.20083 20.125H10.0625C10.5803 20.125 11 20.5447 11 21.0625C11 21.5803 10.5803 22 10.0625 22H2.9375C2.69757 22 2.45765 21.9085 2.27459 21.7254C2.1847 21.6355 2.11689 21.5319 2.07114 21.4214C2.0253 21.3108 2 21.1896 2 21.0625Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export function ChevronExtraSmallDown({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M15 6L9.75926 12.1142C9.36016 12.5798 8.63984 12.5798 8.24074 12.1142L3 6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeMiterlimit="1.00244"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export function ChevronExtraSmallUp({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M3 12L8.24074 5.8858C8.63984 5.42019 9.36016 5.42019 9.75926 5.8858L15 12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeMiterlimit="1.00244"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
export function LogsIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="4" cy="10" r="1" fill="currentColor" />
|
||||
<circle cx="4" cy="5" r="1" fill="currentColor" />
|
||||
<circle cx="4" cy="14" r="1" fill="currentColor" />
|
||||
<circle cx="4" cy="19" r="1" fill="currentColor" />
|
||||
<path
|
||||
d="M7 9.75L10 9.75"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7 5L10 5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7 14.25H10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7 19H10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M13 5H20"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M13 9.75H20"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M13 14.25H20"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M13 19H20"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export function GoogleLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M19.9075 21.0983C22.7427 18.4521 24.0028 14.0417 23.2468 9.82031H11.9688V14.4827H18.3953C18.1433 15.9949 17.2612 17.255 16.0011 18.0741L19.9075 21.0983Z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M1.25781 17.3802C2.08665 19.013 3.27532 20.4362 4.73421 21.5428C6.1931 22.6493 7.88415 23.4102 9.67988 23.7681C11.4756 24.1261 13.3292 24.0717 15.1008 23.6091C16.8725 23.1465 18.516 22.2877 19.9075 21.0976L16.0011 18.0733C12.6618 20.2785 7.11734 19.4594 5.22717 14.293L1.25781 17.3802Z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.22701 14.2922C4.72297 12.717 4.72297 11.2679 5.22701 9.69275L1.25765 6.60547C-0.191479 9.50373 -0.632519 13.5991 1.25765 17.3794L5.22701 14.2922Z"
|
||||
fill="#FBBC02"
|
||||
/>
|
||||
<path
|
||||
d="M5.22717 9.69209C6.6133 5.34469 12.5358 2.82446 16.5052 6.5418L19.9705 3.13949C15.0561 -1.58594 5.47919 -1.39692 1.25781 6.60481L5.22717 9.69209Z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Badge } from "./primitives/Badge";
|
||||
import { SimpleTooltip } from "./primitives/Tooltip";
|
||||
|
||||
export function AlphaBadge({
|
||||
inline = false,
|
||||
className,
|
||||
}: {
|
||||
inline?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<Badge variant="extra-small" className={cn(inline ? "inline-grid" : "", className)}>
|
||||
Alpha
|
||||
</Badge>
|
||||
}
|
||||
content="This feature is in Alpha."
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlphaTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<span>{children}</span>
|
||||
<AlphaBadge />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,13 @@ import {
|
||||
} from "./SetupCommands";
|
||||
import { StepContentContainer } from "./StepContentContainer";
|
||||
import { V4Badge } from "./V4Badge";
|
||||
import {
|
||||
ClientTabs,
|
||||
ClientTabsContent,
|
||||
ClientTabsList,
|
||||
ClientTabsTrigger,
|
||||
} from "./primitives/ClientTabs";
|
||||
import { GitHubSettingsPanel } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
|
||||
|
||||
export function HasNoTasksDev() {
|
||||
return (
|
||||
@@ -93,62 +100,7 @@ export function HasNoTasksDev() {
|
||||
}
|
||||
|
||||
export function HasNoTasksDeployed({ environment }: { environment: MinimumEnvironment }) {
|
||||
return (
|
||||
<PackageManagerProvider>
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between border-b">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
|
||||
<Header1>Deploy your tasks to {environmentFullTitle(environment)}</Header1>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("deployment/overview")}
|
||||
/>
|
||||
}
|
||||
content="Deploy docs"
|
||||
/>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={QuestionMarkCircleIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("troubleshooting#deployment")}
|
||||
/>
|
||||
}
|
||||
content="Troubleshooting docs"
|
||||
/>
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
<StepNumber stepNumber="1a" title="Run the CLI 'deploy' command" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
This will deploy your tasks to the {environmentFullTitle(environment)} environment. Read
|
||||
the <TextLink to={docsPath("deployment/overview")}>full guide</TextLink>.
|
||||
</Paragraph>
|
||||
<TriggerDeployStep environment={environment} />
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="1b" title="Or deploy using GitHub Actions" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
Read the <TextLink to={docsPath("github-actions")}>GitHub Actions guide</TextLink> to
|
||||
get started.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Waiting for tasks to deploy" displaySpinner />
|
||||
<StepContentContainer>
|
||||
<Paragraph>This page will automatically refresh when your tasks are deployed.</Paragraph>
|
||||
</StepContentContainer>
|
||||
</div>
|
||||
</PackageManagerProvider>
|
||||
);
|
||||
return <DeploymentOnboardingSteps />;
|
||||
}
|
||||
|
||||
export function SchedulesNoPossibleTaskPanel() {
|
||||
@@ -266,45 +218,7 @@ export function TestHasNoTasks() {
|
||||
}
|
||||
|
||||
export function DeploymentsNone() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<InfoPanel
|
||||
icon={ServerStackIcon}
|
||||
iconClassName="text-deployments"
|
||||
title="Deploy for the first time"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
There are several ways to deploy your tasks. You can use the CLI or a Continuous Integration
|
||||
service like GitHub Actions. Make sure you{" "}
|
||||
<TextLink href={v3EnvironmentVariablesPath(organization, project, environment)}>
|
||||
set your environment variables
|
||||
</TextLink>{" "}
|
||||
first.
|
||||
</Paragraph>
|
||||
<div className="flex gap-3">
|
||||
<LinkButton
|
||||
to={docsPath("v3/cli-deploy")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with the CLI
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={docsPath("v3/github-actions")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with GitHub actions
|
||||
</LinkButton>
|
||||
</div>
|
||||
</InfoPanel>
|
||||
);
|
||||
return <DeploymentOnboardingSteps />;
|
||||
}
|
||||
|
||||
export function DeploymentsNoneDev() {
|
||||
@@ -313,46 +227,52 @@ export function DeploymentsNoneDev() {
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<InfoPanel
|
||||
icon={ServerStackIcon}
|
||||
iconClassName="text-deployments"
|
||||
title="Deploying tasks"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
<>
|
||||
<div className="mb-6 flex items-center justify-between border-b">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
|
||||
<Header1>Deploy your tasks</Header1>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("deployment/overview")}
|
||||
/>
|
||||
}
|
||||
content="Deploy docs"
|
||||
/>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={QuestionMarkCircleIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("troubleshooting#deployment")}
|
||||
/>
|
||||
}
|
||||
content="Troubleshooting docs"
|
||||
/>
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
<StepNumber stepNumber="→" title="Switch to a deployed environment" />
|
||||
<StepContentContainer className="mb-4 flex flex-col gap-4">
|
||||
<Paragraph>
|
||||
This is the Development environment. When you're ready to deploy your tasks, switch to a
|
||||
different environment.
|
||||
</Paragraph>
|
||||
<Paragraph spacing variant="small">
|
||||
There are several ways to deploy your tasks. You can use the CLI or a Continuous
|
||||
Integration service like GitHub Actions. Make sure you{" "}
|
||||
<TextLink href={v3EnvironmentVariablesPath(organization, project, environment)}>
|
||||
set your environment variables
|
||||
</TextLink>{" "}
|
||||
first.
|
||||
</Paragraph>
|
||||
<div className="flex gap-3">
|
||||
<LinkButton
|
||||
to={docsPath("v3/cli-deploy")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with the CLI
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={docsPath("v3/github-actions")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with GitHub actions
|
||||
</LinkButton>
|
||||
</div>
|
||||
</InfoPanel>
|
||||
<SwitcherPanel />
|
||||
</div>
|
||||
<EnvironmentSelector
|
||||
organization={organization}
|
||||
project={project}
|
||||
environment={environment}
|
||||
className="w-fit border border-charcoal-600 bg-secondary hover:border-charcoal-550 hover:bg-charcoal-600"
|
||||
/>
|
||||
</StepContentContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -670,3 +590,99 @@ export function BulkActionsNone() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeploymentOnboardingSteps() {
|
||||
const environment = useEnvironment();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
|
||||
return (
|
||||
<PackageManagerProvider>
|
||||
<div className="mb-2 flex items-center justify-between border-b">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
|
||||
<Header1>Deploy your tasks to {environmentFullTitle(environment)}</Header1>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("deployment/overview")}
|
||||
/>
|
||||
}
|
||||
content="Deploy docs"
|
||||
/>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={QuestionMarkCircleIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("troubleshooting#deployment")}
|
||||
/>
|
||||
}
|
||||
content="Troubleshooting docs"
|
||||
/>
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
<ClientTabs defaultValue="github">
|
||||
<ClientTabsList variant="segmented" className="mb-6">
|
||||
<ClientTabsTrigger value={"github"} variant="segmented" layoutId="deploy-tabs">
|
||||
GitHub
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"cli"} variant="segmented" layoutId="deploy-tabs">
|
||||
Manual
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"github-actions"} variant="segmented" layoutId="deploy-tabs">
|
||||
GitHub Actions
|
||||
</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
<ClientTabsContent value={"github"}>
|
||||
<StepNumber stepNumber="1" title="Connect your GitHub repository" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
Deploy automatically with every push. Read the{" "}
|
||||
<TextLink to={docsPath("github-integration")}>full guide</TextLink>.
|
||||
</Paragraph>
|
||||
<div className="w-fit">
|
||||
<GitHubSettingsPanel
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
billingPath={v3BillingPath({ slug: organization.slug })}
|
||||
/>
|
||||
</div>
|
||||
</StepContentContainer>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"cli"}>
|
||||
<StepNumber stepNumber="1" title="Run the CLI 'deploy' command" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
This will deploy your tasks to the {environmentFullTitle(environment)} environment.
|
||||
Read the <TextLink to={docsPath("deployment/overview")}>full guide</TextLink>.
|
||||
</Paragraph>
|
||||
<TriggerDeployStep environment={environment} />
|
||||
</StepContentContainer>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"github-actions"}>
|
||||
<StepNumber stepNumber="1" title="Deploy using GitHub Actions" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
Read the <TextLink to={docsPath("github-actions")}>GitHub Actions guide</TextLink> to
|
||||
get started.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
|
||||
<StepNumber stepNumber="2" title="Waiting for tasks to deploy" displaySpinner />
|
||||
<StepContentContainer>
|
||||
<Paragraph>This page will automatically refresh when your tasks are deployed.</Paragraph>
|
||||
</StepContentContainer>
|
||||
</PackageManagerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { HomeIcon } from "@heroicons/react/20/solid";
|
||||
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { friendlyErrorDisplay } from "~/utils/httpErrors";
|
||||
import { LinkButton } from "./primitives/Buttons";
|
||||
import { Header1 } from "./primitives/Headers";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import Spline from "@splinetool/react-spline";
|
||||
import { TriggerRotatingLogo } from "./TriggerRotatingLogo";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
type ErrorDisplayOptions = {
|
||||
@@ -57,14 +56,7 @@ export function ErrorDisplay({ title, message, button }: DisplayOptionsProps) {
|
||||
{button ? button.title : "Go to homepage"}
|
||||
</LinkButton>
|
||||
</div>
|
||||
<motion.div
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5, duration: 2, ease: "easeOut" }}
|
||||
>
|
||||
<Spline scene="https://prod.spline.design/wRly8TZN-e0Twb8W/scene.splinecode" />
|
||||
</motion.div>
|
||||
<TriggerRotatingLogo />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -134,6 +134,10 @@ function ShortcutContent() {
|
||||
<ShortcutKey shortcut={{ key: "arrowleft" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "arrowright" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Jump to next/previous run">
|
||||
<ShortcutKey shortcut={{ key: "[" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "]" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Expand all">
|
||||
<ShortcutKey shortcut={{ key: "e" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
declare global {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
"spline-viewer": React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLElement> & {
|
||||
url?: string;
|
||||
"loading-anim-type"?: string;
|
||||
},
|
||||
HTMLElement
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
||||
interface Window {
|
||||
__splineLoader?: Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
export function TriggerRotatingLogo() {
|
||||
const [isSplineReady, setIsSplineReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Already registered from a previous render
|
||||
if (customElements.get("spline-viewer")) {
|
||||
setIsSplineReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Another mount already started loading - share the same promise
|
||||
if (window.__splineLoader) {
|
||||
window.__splineLoader.then(() => setIsSplineReady(true)).catch(() => setIsSplineReady(false));
|
||||
return;
|
||||
}
|
||||
|
||||
// First mount: create script and shared loader promise
|
||||
const script = document.createElement("script");
|
||||
script.type = "module";
|
||||
// Version pinned; SRI hash omitted as unpkg doesn't guarantee hash stability across deploys
|
||||
script.src = "https://unpkg.com/@splinetool/viewer@1.12.29/build/spline-viewer.js";
|
||||
|
||||
window.__splineLoader = new Promise<void>((resolve, reject) => {
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject();
|
||||
});
|
||||
|
||||
window.__splineLoader.then(() => setIsSplineReady(true)).catch(() => setIsSplineReady(false));
|
||||
|
||||
document.head.appendChild(script);
|
||||
|
||||
// Intentionally no cleanup: once the custom element is registered globally,
|
||||
// removing the script would break re-mounts while providing no benefit
|
||||
}, []);
|
||||
|
||||
if (!isSplineReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5, duration: 2, ease: "easeOut" }}
|
||||
>
|
||||
<spline-viewer
|
||||
loading-anim-type="spinner-small-light"
|
||||
url="https://prod.spline.design/wRly8TZN-e0Twb8W/scene.splinecode"
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export function UserAvatar({
|
||||
className={cn("aspect-square rounded-full p-[7%]")}
|
||||
src={avatarUrl}
|
||||
alt={name ?? "User"}
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
import { PencilSquareIcon, PlusIcon, SparklesIcon } from "@heroicons/react/20/solid";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
|
||||
|
||||
// Lazy load streamdown components to avoid SSR issues
|
||||
const StreamdownRenderer = lazy(() =>
|
||||
import("streamdown").then((mod) => ({
|
||||
default: ({ children, isAnimating }: { children: string; isAnimating: boolean }) => (
|
||||
<mod.ShikiThemeContext.Provider value={["one-dark-pro", "one-dark-pro"]}>
|
||||
<mod.Streamdown isAnimating={isAnimating}>{children}</mod.Streamdown>
|
||||
</mod.ShikiThemeContext.Provider>
|
||||
),
|
||||
}))
|
||||
);
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import type { AITimeFilter } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/types";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type StreamEventType =
|
||||
| { type: "thinking"; content: string }
|
||||
| { type: "tool_call"; tool: string; args: unknown }
|
||||
| { type: "time_filter"; filter: AITimeFilter }
|
||||
| { type: "result"; success: true; query: string; timeFilter?: AITimeFilter }
|
||||
| { type: "result"; success: false; error: string };
|
||||
|
||||
export type AIQueryMode = "new" | "edit";
|
||||
|
||||
interface AIQueryInputProps {
|
||||
onQueryGenerated: (query: string) => void;
|
||||
/** Called when the AI sets a time filter - updates URL search params */
|
||||
onTimeFilterChange?: (filter: AITimeFilter) => void;
|
||||
/** Set this to a prompt to auto-populate and immediately submit */
|
||||
autoSubmitPrompt?: string;
|
||||
/** Change this to force re-submission even if prompt is the same */
|
||||
autoSubmitKey?: number;
|
||||
/** Get the current query in the editor (used for edit mode) */
|
||||
getCurrentQuery?: () => string;
|
||||
}
|
||||
|
||||
export function AIQueryInput({
|
||||
onQueryGenerated,
|
||||
onTimeFilterChange,
|
||||
autoSubmitPrompt,
|
||||
autoSubmitKey,
|
||||
getCurrentQuery,
|
||||
}: AIQueryInputProps) {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [mode, setMode] = useState<AIQueryMode>("new");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [thinking, setThinking] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showThinking, setShowThinking] = useState(false);
|
||||
const [lastResult, setLastResult] = useState<"success" | "error" | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const lastAutoSubmitRef = useRef<{ prompt: string; key?: number } | null>(null);
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const resourcePath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/query/ai-generate`;
|
||||
|
||||
// Can only use edit mode if there's a current query
|
||||
const canEdit = Boolean(getCurrentQuery?.()?.trim());
|
||||
|
||||
// If mode is edit but there's no current query, switch to new
|
||||
useEffect(() => {
|
||||
if (mode === "edit" && !canEdit) {
|
||||
setMode("new");
|
||||
}
|
||||
}, [mode, canEdit]);
|
||||
|
||||
const submitQuery = useCallback(
|
||||
async (queryPrompt: string, submitMode: AIQueryMode = mode) => {
|
||||
if (!queryPrompt.trim() || isLoading) return;
|
||||
const currentQuery = getCurrentQuery?.();
|
||||
if (submitMode === "edit" && !currentQuery?.trim()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setThinking("");
|
||||
setError(null);
|
||||
setShowThinking(true);
|
||||
setLastResult(null);
|
||||
|
||||
// Abort any existing request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("prompt", queryPrompt);
|
||||
formData.append("mode", submitMode);
|
||||
if (submitMode === "edit" && currentQuery) {
|
||||
formData.append("currentQuery", currentQuery);
|
||||
}
|
||||
|
||||
const response = await fetch(resourcePath, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = (await response.json()) as { error?: string };
|
||||
setError(errorData.error || "Failed to generate query");
|
||||
setIsLoading(false);
|
||||
setLastResult("error");
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
setError("No response stream");
|
||||
setIsLoading(false);
|
||||
setLastResult("error");
|
||||
return;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Process complete events from buffer
|
||||
const lines = buffer.split("\n\n");
|
||||
buffer = lines.pop() || ""; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
try {
|
||||
const event = JSON.parse(line.slice(6)) as StreamEventType;
|
||||
processStreamEvent(event);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining data
|
||||
if (buffer.startsWith("data: ")) {
|
||||
try {
|
||||
const event = JSON.parse(buffer.slice(6)) as StreamEventType;
|
||||
processStreamEvent(event);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
// Request was aborted, ignore
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
setLastResult("error");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[isLoading, resourcePath, mode, getCurrentQuery]
|
||||
);
|
||||
|
||||
const processStreamEvent = useCallback(
|
||||
(event: StreamEventType) => {
|
||||
switch (event.type) {
|
||||
case "thinking":
|
||||
setThinking((prev) => prev + event.content);
|
||||
break;
|
||||
case "tool_call":
|
||||
if (event.tool === "setTimeFilter") {
|
||||
setThinking((prev) => {
|
||||
if (prev.trimEnd().endsWith("Setting time filter...")) {
|
||||
return prev;
|
||||
}
|
||||
return prev + `\nSetting time filter...\n`;
|
||||
});
|
||||
} else {
|
||||
setThinking((prev) => {
|
||||
if (prev.trimEnd().endsWith("Validating query...")) {
|
||||
return prev;
|
||||
}
|
||||
return prev + `\nValidating query...\n`;
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "time_filter":
|
||||
// Apply time filter immediately when the AI sets it
|
||||
onTimeFilterChange?.(event.filter);
|
||||
break;
|
||||
case "result":
|
||||
if (event.success) {
|
||||
// Apply time filter if included in result (backup in case time_filter event was missed)
|
||||
if (event.timeFilter) {
|
||||
onTimeFilterChange?.(event.timeFilter);
|
||||
}
|
||||
onQueryGenerated(event.query);
|
||||
setPrompt("");
|
||||
setLastResult("success");
|
||||
// Keep thinking visible to show what happened
|
||||
} else {
|
||||
setError(event.error);
|
||||
setLastResult("error");
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[onQueryGenerated, onTimeFilterChange]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
submitQuery(prompt);
|
||||
},
|
||||
[prompt, submitQuery]
|
||||
);
|
||||
|
||||
// Auto-submit when autoSubmitPrompt or autoSubmitKey changes
|
||||
useEffect(() => {
|
||||
if (!autoSubmitPrompt || !autoSubmitPrompt.trim() || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const last = lastAutoSubmitRef.current;
|
||||
const isDifferent =
|
||||
last === null || autoSubmitPrompt !== last.prompt || autoSubmitKey !== last.key;
|
||||
|
||||
if (isDifferent) {
|
||||
lastAutoSubmitRef.current = { prompt: autoSubmitPrompt, key: autoSubmitKey };
|
||||
setPrompt(autoSubmitPrompt);
|
||||
submitQuery(autoSubmitPrompt);
|
||||
}
|
||||
}, [autoSubmitPrompt, autoSubmitKey, isLoading, submitQuery]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-hide error after delay
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
const timer = setTimeout(() => setError(null), 15000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Gradient border wrapper like the schedules AI input */}
|
||||
<div
|
||||
className="rounded-md p-px"
|
||||
style={{ background: "linear-gradient(to bottom right, #E543FF, #286399)" }}
|
||||
>
|
||||
<div className="overflow-hidden rounded-[5px] bg-background-bright">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
name="prompt"
|
||||
placeholder={
|
||||
mode === "edit"
|
||||
? "e.g. add a filter for failed runs, change the limit to 50"
|
||||
: "e.g. show me failed runs from the last 7 days"
|
||||
}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
disabled={isLoading}
|
||||
rows={8}
|
||||
className="m-0 min-h-10 w-full resize-none border-0 bg-background-bright px-3 py-2.5 text-sm text-text-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600 file:border-0 file:bg-transparent file:text-base file:font-medium placeholder:text-text-dimmed focus:border-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && prompt.trim() && !isLoading) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end gap-2 px-2 pb-2">
|
||||
{isLoading ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/small"
|
||||
disabled={true}
|
||||
LeadingIcon={Spinner}
|
||||
className="pl-1.5"
|
||||
iconSpacing="gap-1.5"
|
||||
>
|
||||
{mode === "edit" ? "Editing..." : "Generating..."}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/small"
|
||||
disabled={!prompt.trim()}
|
||||
LeadingIcon={PlusIcon}
|
||||
iconSpacing="gap-1.5"
|
||||
onClick={() => {
|
||||
setMode("new");
|
||||
submitQuery(prompt, "new");
|
||||
}}
|
||||
>
|
||||
New query
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/small"
|
||||
disabled={!prompt.trim() || !canEdit}
|
||||
LeadingIcon={PencilSquareIcon}
|
||||
className={cn(!canEdit && "opacity-50")}
|
||||
iconSpacing="gap-2"
|
||||
tooltip={!canEdit ? "Write a query first to enable editing" : undefined}
|
||||
onClick={() => {
|
||||
setMode("edit");
|
||||
submitQuery(prompt, "edit");
|
||||
}}
|
||||
>
|
||||
Edit query
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-sm text-error">
|
||||
{error}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Thinking panel - stays visible after completion */}
|
||||
<AnimatePresence>
|
||||
{showThinking && thinking && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="rounded-md border border-grid-dimmed bg-charcoal-850 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoading ? (
|
||||
<Spinner
|
||||
color={{
|
||||
background: "rgba(99, 102, 241, 0.3)",
|
||||
foreground: "rgba(99, 102, 241, 1)",
|
||||
}}
|
||||
className="size-3"
|
||||
/>
|
||||
) : lastResult === "success" ? (
|
||||
<div className="size-3 rounded-full bg-success" />
|
||||
) : lastResult === "error" ? (
|
||||
<div className="size-3 rounded-full bg-error" />
|
||||
) : null}
|
||||
<span className="text-xs font-medium text-text-dimmed">
|
||||
{isLoading
|
||||
? "AI is thinking..."
|
||||
: lastResult === "success"
|
||||
? "Query generated"
|
||||
: lastResult === "error"
|
||||
? "Generation failed"
|
||||
: "AI response"}
|
||||
</span>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
setIsLoading(false);
|
||||
setShowThinking(false);
|
||||
setThinking("");
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={() => {
|
||||
setShowThinking(false);
|
||||
setThinking("");
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="streamdown-container max-h-96 overflow-y-auto text-xs text-text-dimmed scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<Suspense fallback={<p className="whitespace-pre-wrap">{thinking}</p>}>
|
||||
<StreamdownRenderer isAnimating={isLoading}>{thinking}</StreamdownRenderer>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
import type { OutputColumnMetadata } from "@internal/clickhouse";
|
||||
import { BarChart, LineChart, Plus, XIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Header3 } from "../primitives/Headers";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Select, SelectItem } from "../primitives/Select";
|
||||
import { Switch } from "../primitives/Switch";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
|
||||
export type ChartType = "bar" | "line";
|
||||
export type SortDirection = "asc" | "desc";
|
||||
export type AggregationType = "sum" | "avg" | "count" | "min" | "max";
|
||||
|
||||
export interface ChartConfiguration {
|
||||
chartType: ChartType;
|
||||
xAxisColumn: string | null;
|
||||
yAxisColumns: string[];
|
||||
groupByColumn: string | null;
|
||||
stacked: boolean;
|
||||
sortByColumn: string | null;
|
||||
sortDirection: SortDirection;
|
||||
aggregation: AggregationType;
|
||||
}
|
||||
|
||||
export const defaultChartConfig: ChartConfiguration = {
|
||||
chartType: "bar",
|
||||
xAxisColumn: null,
|
||||
yAxisColumns: [],
|
||||
groupByColumn: null,
|
||||
stacked: false,
|
||||
sortByColumn: null,
|
||||
sortDirection: "asc",
|
||||
aggregation: "sum",
|
||||
};
|
||||
|
||||
interface ChartConfigPanelProps {
|
||||
columns: OutputColumnMetadata[];
|
||||
config: ChartConfiguration;
|
||||
onChange: (config: ChartConfiguration) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Type detection helpers
|
||||
function isNumericType(type: string): boolean {
|
||||
return (
|
||||
type.startsWith("Int") ||
|
||||
type.startsWith("UInt") ||
|
||||
type.startsWith("Float") ||
|
||||
type.startsWith("Decimal") ||
|
||||
type.startsWith("Nullable(Int") ||
|
||||
type.startsWith("Nullable(UInt") ||
|
||||
type.startsWith("Nullable(Float") ||
|
||||
type.startsWith("Nullable(Decimal")
|
||||
);
|
||||
}
|
||||
|
||||
function isDateTimeType(type: string): boolean {
|
||||
return (
|
||||
type === "DateTime" ||
|
||||
type === "DateTime64" ||
|
||||
type === "Date" ||
|
||||
type === "Date32" ||
|
||||
type.startsWith("DateTime64(") ||
|
||||
type.startsWith("Nullable(DateTime") ||
|
||||
type.startsWith("Nullable(Date")
|
||||
);
|
||||
}
|
||||
|
||||
function isStringType(type: string): boolean {
|
||||
return (
|
||||
type === "String" ||
|
||||
type === "LowCardinality(String)" ||
|
||||
type === "Nullable(String)" ||
|
||||
type.startsWith("Enum") ||
|
||||
type.startsWith("FixedString")
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartConfigPanel({ columns, config, onChange, className }: ChartConfigPanelProps) {
|
||||
// Categorize columns by type
|
||||
const { numericColumns, dateTimeColumns, categoricalColumns, allColumns } = useMemo(() => {
|
||||
const numeric: OutputColumnMetadata[] = [];
|
||||
const dateTime: OutputColumnMetadata[] = [];
|
||||
const categorical: OutputColumnMetadata[] = [];
|
||||
|
||||
for (const col of columns) {
|
||||
if (isNumericType(col.type)) {
|
||||
numeric.push(col);
|
||||
}
|
||||
if (isDateTimeType(col.type)) {
|
||||
dateTime.push(col);
|
||||
}
|
||||
if (isStringType(col.type) || isDateTimeType(col.type)) {
|
||||
categorical.push(col);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
numericColumns: numeric,
|
||||
dateTimeColumns: dateTime,
|
||||
categoricalColumns: categorical,
|
||||
allColumns: columns,
|
||||
};
|
||||
}, [columns]);
|
||||
|
||||
// Create a stable key from column names and types to detect actual changes
|
||||
const columnsKey = useMemo(() => columns.map((c) => `${c.name}:${c.type}`).join(","), [columns]);
|
||||
|
||||
// Use refs to access current config/onChange without adding them as dependencies
|
||||
const configRef = useRef(config);
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
configRef.current = config;
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
|
||||
// Auto-select defaults when columns change
|
||||
useEffect(() => {
|
||||
if (columns.length === 0) return;
|
||||
|
||||
const currentConfig = configRef.current;
|
||||
let needsUpdate = false;
|
||||
const updates: Partial<ChartConfiguration> = {};
|
||||
|
||||
// Auto-select X-axis (prefer datetime, then first categorical)
|
||||
if (!currentConfig.xAxisColumn) {
|
||||
const defaultX = dateTimeColumns[0] ?? categoricalColumns[0] ?? columns[0];
|
||||
if (defaultX) {
|
||||
updates.xAxisColumn = defaultX.name;
|
||||
needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-select Y-axis (first numeric column)
|
||||
if (currentConfig.yAxisColumns.length === 0 && numericColumns.length > 0) {
|
||||
updates.yAxisColumns = [numericColumns[0].name];
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
// Determine the effective x-axis column (either existing or newly selected)
|
||||
const effectiveXAxis = updates.xAxisColumn ?? currentConfig.xAxisColumn;
|
||||
|
||||
// Auto-set sort to x-axis ASC if it's a datetime column and no sort is configured
|
||||
if (
|
||||
effectiveXAxis &&
|
||||
!currentConfig.sortByColumn &&
|
||||
dateTimeColumns.some((col) => col.name === effectiveXAxis)
|
||||
) {
|
||||
updates.sortByColumn = effectiveXAxis;
|
||||
updates.sortDirection = "asc";
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
onChangeRef.current({ ...currentConfig, ...updates });
|
||||
}
|
||||
// Only re-run when the actual column structure changes, not on every config change
|
||||
}, [columnsKey, columns, dateTimeColumns, categoricalColumns, numericColumns]);
|
||||
|
||||
const updateConfig = useCallback(
|
||||
(updates: Partial<ChartConfiguration>) => {
|
||||
onChange({ ...config, ...updates });
|
||||
},
|
||||
[config, onChange]
|
||||
);
|
||||
|
||||
// X-axis options: prefer datetime and string columns at the top
|
||||
const xAxisOptions = useMemo(() => {
|
||||
const preferred = [
|
||||
...dateTimeColumns,
|
||||
...categoricalColumns.filter((c) => !isDateTimeType(c.type)),
|
||||
];
|
||||
const preferredNames = new Set(preferred.map((c) => c.name));
|
||||
const other = allColumns.filter((c) => !preferredNames.has(c.name));
|
||||
|
||||
const options: Array<{ value: string; label: string; type: string }> = [];
|
||||
|
||||
for (const col of preferred) {
|
||||
options.push({ value: col.name, label: col.name, type: col.type });
|
||||
}
|
||||
for (const col of other) {
|
||||
options.push({ value: col.name, label: col.name, type: col.type });
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [allColumns, dateTimeColumns, categoricalColumns]);
|
||||
|
||||
// Y-axis options: numeric columns only
|
||||
const yAxisOptions = useMemo(() => {
|
||||
return numericColumns.map((col) => ({
|
||||
value: col.name,
|
||||
label: col.name,
|
||||
type: col.type,
|
||||
}));
|
||||
}, [numericColumns]);
|
||||
|
||||
// Aggregation options
|
||||
const aggregationOptions = [
|
||||
{ value: "sum", label: "Sum" },
|
||||
{ value: "avg", label: "Average" },
|
||||
{ value: "count", label: "Count" },
|
||||
{ value: "min", label: "Min" },
|
||||
{ value: "max", label: "Max" },
|
||||
];
|
||||
|
||||
// Group by options: categorical columns (excluding selected X axis)
|
||||
const groupByOptions = useMemo(() => {
|
||||
const options = categoricalColumns
|
||||
.filter((col) => col.name !== config.xAxisColumn)
|
||||
.map((col) => ({
|
||||
value: col.name,
|
||||
label: col.name,
|
||||
type: col.type,
|
||||
}));
|
||||
|
||||
return [{ value: "__none__", label: "None", type: "" }, ...options];
|
||||
}, [categoricalColumns, config.xAxisColumn]);
|
||||
|
||||
// Sort by options: all columns
|
||||
const sortByOptions = useMemo(() => {
|
||||
const options = allColumns.map((col) => ({
|
||||
value: col.name,
|
||||
label: col.name,
|
||||
type: col.type,
|
||||
}));
|
||||
|
||||
return [{ value: "__none__", label: "None", type: "" }, ...options];
|
||||
}, [allColumns]);
|
||||
|
||||
if (columns.length === 0) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center p-4", className)}>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Run a query to configure the chart
|
||||
</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2 p-2", className)}>
|
||||
{/* Chart Type */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<ConfigField label="Type">
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/small"
|
||||
className={cn(
|
||||
"rounded-r-none border-b pl-1 pr-2",
|
||||
config.chartType === "bar" ? "border-indigo-500" : "border-transparent"
|
||||
)}
|
||||
iconSpacing="gap-x-1"
|
||||
onClick={() => updateConfig({ chartType: "bar" })}
|
||||
LeadingIcon={BarChart}
|
||||
leadingIconClassName={
|
||||
config.chartType === "bar" ? "text-indigo-500" : "text-text-dimmed"
|
||||
}
|
||||
>
|
||||
<span className={config.chartType === "bar" ? "text-indigo-500" : "text-text-dimmed"}>
|
||||
Bar
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/small"
|
||||
className={cn(
|
||||
"rounded-l-none border-b pl-1 pr-2",
|
||||
config.chartType === "line" ? "border-indigo-500" : "border-transparent"
|
||||
)}
|
||||
iconSpacing="gap-x-1"
|
||||
onClick={() => updateConfig({ chartType: "line" })}
|
||||
LeadingIcon={LineChart}
|
||||
leadingIconClassName={
|
||||
config.chartType === "line" ? "text-indigo-500" : "text-text-dimmed"
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={config.chartType === "line" ? "text-indigo-500" : "text-text-dimmed"}
|
||||
>
|
||||
Line
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</ConfigField>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* X-Axis */}
|
||||
<ConfigField label="X-Axis">
|
||||
<Select
|
||||
value={config.xAxisColumn ?? ""}
|
||||
setValue={(value) => {
|
||||
const updates: Partial<ChartConfiguration> = { xAxisColumn: value || null };
|
||||
// Auto-set sort to x-axis ASC if selecting a datetime column
|
||||
if (value) {
|
||||
const selectedCol = columns.find((c) => c.name === value);
|
||||
if (selectedCol && isDateTimeType(selectedCol.type)) {
|
||||
updates.sortByColumn = value;
|
||||
updates.sortDirection = "asc";
|
||||
}
|
||||
}
|
||||
updateConfig(updates);
|
||||
}}
|
||||
variant="tertiary/small"
|
||||
placeholder="Select column"
|
||||
items={xAxisOptions}
|
||||
dropdownIcon
|
||||
className="min-w-[140px]"
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{item.label}</span>
|
||||
<TypeBadge type={item.type} />
|
||||
</span>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
</ConfigField>
|
||||
|
||||
{/* Y-Axis / Series */}
|
||||
<ConfigField label={config.yAxisColumns.length > 1 ? "Series" : "Y-Axis"}>
|
||||
{yAxisOptions.length === 0 ? (
|
||||
<span className="text-xs text-text-dimmed">No numeric columns</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{/* Always show at least one dropdown, even if yAxisColumns is empty */}
|
||||
{(config.yAxisColumns.length === 0 ? [""] : config.yAxisColumns).map(
|
||||
(col, index) => (
|
||||
<div key={index} className="flex items-center gap-1">
|
||||
<Select
|
||||
value={col}
|
||||
setValue={(value) => {
|
||||
const newColumns = [...config.yAxisColumns];
|
||||
if (value) {
|
||||
// If this is a new slot (empty string), add it
|
||||
if (index >= config.yAxisColumns.length) {
|
||||
newColumns.push(value);
|
||||
} else {
|
||||
newColumns[index] = value;
|
||||
}
|
||||
} else if (index < config.yAxisColumns.length) {
|
||||
newColumns.splice(index, 1);
|
||||
}
|
||||
updateConfig({ yAxisColumns: newColumns });
|
||||
}}
|
||||
variant="tertiary/small"
|
||||
placeholder="Select column"
|
||||
items={yAxisOptions.filter(
|
||||
(opt) => opt.value === col || !config.yAxisColumns.includes(opt.value)
|
||||
)}
|
||||
dropdownIcon
|
||||
className="min-w-[140px] flex-1"
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{item.label}</span>
|
||||
<TypeBadge type={item.type} />
|
||||
</span>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
{index > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newColumns = config.yAxisColumns.filter((_, i) => i !== index);
|
||||
updateConfig({ yAxisColumns: newColumns });
|
||||
}}
|
||||
className="rounded p-1 text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright"
|
||||
title="Remove series"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Add another series button - only show when we have at least one series and not grouped */}
|
||||
{config.yAxisColumns.length > 0 &&
|
||||
config.yAxisColumns.length < yAxisOptions.length &&
|
||||
!config.groupByColumn && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const availableColumns = yAxisOptions.filter(
|
||||
(opt) => !config.yAxisColumns.includes(opt.value)
|
||||
);
|
||||
if (availableColumns.length > 0) {
|
||||
updateConfig({
|
||||
yAxisColumns: [...config.yAxisColumns, availableColumns[0].value],
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1 self-start rounded px-1 py-0.5 text-xs text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add series
|
||||
</button>
|
||||
)}
|
||||
|
||||
{config.groupByColumn && config.yAxisColumns.length === 1 && (
|
||||
<span className="text-xxs text-text-dimmed">
|
||||
Remove group by to add multiple series
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ConfigField>
|
||||
|
||||
{/* Aggregation */}
|
||||
<ConfigField label="Aggregation">
|
||||
<Select
|
||||
value={config.aggregation}
|
||||
setValue={(value) => updateConfig({ aggregation: value as AggregationType })}
|
||||
variant="tertiary/small"
|
||||
items={aggregationOptions}
|
||||
dropdownIcon
|
||||
className="min-w-[100px]"
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
</ConfigField>
|
||||
|
||||
{/* Group By - disabled when multiple series are selected */}
|
||||
<ConfigField label="Group by">
|
||||
{config.yAxisColumns.length > 1 ? (
|
||||
<span className="text-xs text-text-dimmed">
|
||||
Not available with multiple series
|
||||
</span>
|
||||
) : (
|
||||
<Select
|
||||
value={config.groupByColumn ?? "__none__"}
|
||||
setValue={(value) =>
|
||||
updateConfig({ groupByColumn: value === "__none__" ? null : value })
|
||||
}
|
||||
variant="tertiary/small"
|
||||
placeholder="None"
|
||||
items={groupByOptions}
|
||||
dropdownIcon
|
||||
className="min-w-[140px]"
|
||||
text={(t) => (t === "__none__" ? "None" : t)}
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{item.label}</span>
|
||||
{item.type && <TypeBadge type={item.type} />}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
)}
|
||||
</ConfigField>
|
||||
|
||||
{/* Stacked toggle (when grouped or multiple series) */}
|
||||
{(config.groupByColumn || config.yAxisColumns.length > 1) && (
|
||||
<ConfigField label={config.groupByColumn ? "Stack groups" : "Stack series"}>
|
||||
<Switch
|
||||
variant="medium"
|
||||
checked={config.stacked}
|
||||
onCheckedChange={(checked) => updateConfig({ stacked: checked })}
|
||||
/>
|
||||
</ConfigField>
|
||||
)}
|
||||
|
||||
{/* Order By */}
|
||||
<ConfigField label="Order by">
|
||||
<Select
|
||||
value={config.sortByColumn ?? "__none__"}
|
||||
setValue={(value) =>
|
||||
updateConfig({ sortByColumn: value === "__none__" ? null : value })
|
||||
}
|
||||
variant="tertiary/small"
|
||||
placeholder="None"
|
||||
items={sortByOptions}
|
||||
dropdownIcon
|
||||
className="min-w-[140px]"
|
||||
text={(t) => (t === "__none__" ? "None" : t)}
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{item.label}</span>
|
||||
{item.type && <TypeBadge type={item.type} />}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
</ConfigField>
|
||||
|
||||
{/* Sort Direction (only when sorting) */}
|
||||
{config.sortByColumn && (
|
||||
<ConfigField label="Sort direction">
|
||||
<SortDirectionToggle
|
||||
direction={config.sortDirection}
|
||||
onChange={(direction) => updateConfig({ sortDirection: direction })}
|
||||
/>
|
||||
</ConfigField>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigField({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{label && <span className="text-xs text-text-dimmed">{label}</span>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortDirectionToggle({
|
||||
direction,
|
||||
onChange,
|
||||
}: {
|
||||
direction: SortDirection;
|
||||
onChange: (direction: SortDirection) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("asc")}
|
||||
className={cn(
|
||||
"rounded px-2 py-1 text-xs transition-colors",
|
||||
direction === "asc"
|
||||
? "bg-charcoal-700 text-text-bright"
|
||||
: "text-text-dimmed hover:bg-charcoal-800 hover:text-text-bright"
|
||||
)}
|
||||
title="Ascending"
|
||||
>
|
||||
Asc
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("desc")}
|
||||
className={cn(
|
||||
"rounded px-2 py-1 text-xs transition-colors",
|
||||
direction === "desc"
|
||||
? "bg-charcoal-700 text-text-bright"
|
||||
: "text-text-dimmed hover:bg-charcoal-800 hover:text-text-bright"
|
||||
)}
|
||||
title="Descending"
|
||||
>
|
||||
Desc
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: string }) {
|
||||
// Simplify type for display
|
||||
let displayType = type;
|
||||
if (type.startsWith("Nullable(")) {
|
||||
displayType = type.slice(9, -1) + "?";
|
||||
}
|
||||
if (type.startsWith("LowCardinality(")) {
|
||||
displayType = type.slice(15, -1);
|
||||
}
|
||||
|
||||
// Shorten long type names
|
||||
if (displayType.length > 12) {
|
||||
displayType = displayType.slice(0, 10) + "…";
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="rounded bg-charcoal-750 px-1 py-0.5 font-mono text-xxs text-text-dimmed">
|
||||
{displayType}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Highlight, Prism } from "prism-react-renderer";
|
||||
import { forwardRef, ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import { TextWrapIcon } from "~/assets/icons/TextWrapIcon";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { highlightSearchText } from "~/utils/logUtils";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../primitives/Dialog";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
@@ -20,6 +21,8 @@ async function setup() {
|
||||
await import("prismjs/components/prism-json");
|
||||
//@ts-ignore
|
||||
await import("prismjs/components/prism-typescript");
|
||||
//@ts-ignore
|
||||
await import("prismjs/components/prism-sql.js");
|
||||
}
|
||||
setup();
|
||||
|
||||
@@ -62,6 +65,9 @@ type CodeBlockProps = {
|
||||
|
||||
/** Whether to show the open in modal button */
|
||||
showOpenInModal?: boolean;
|
||||
|
||||
/** Search term to highlight in the code */
|
||||
searchTerm?: string;
|
||||
};
|
||||
|
||||
const dimAmount = 0.5;
|
||||
@@ -200,6 +206,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
showChrome = false,
|
||||
fileName,
|
||||
rowTitle,
|
||||
searchTerm,
|
||||
...props
|
||||
}: CodeBlockProps,
|
||||
ref
|
||||
@@ -236,7 +243,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
[code]
|
||||
);
|
||||
|
||||
code = code.trim();
|
||||
code = code?.trim() ?? "";
|
||||
const lineCount = code.split("\n").length;
|
||||
const maxLineWidth = lineCount.toString().length;
|
||||
let maxHeight: string | undefined = undefined;
|
||||
@@ -338,6 +345,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
className="px-2 py-3"
|
||||
preClassName="text-xs"
|
||||
isWrapped={isWrapped}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
@@ -358,7 +366,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
)}
|
||||
dir="ltr"
|
||||
>
|
||||
{code}
|
||||
{highlightSearchText(code, searchTerm)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
@@ -400,7 +408,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
className="overflow-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
>
|
||||
<pre className="relative mr-2 p-2 font-mono text-base leading-relaxed" dir="ltr">
|
||||
{code}
|
||||
{highlightSearchText(code, searchTerm)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
@@ -449,6 +457,7 @@ type HighlightCodeProps = {
|
||||
className?: string;
|
||||
preClassName?: string;
|
||||
isWrapped: boolean;
|
||||
searchTerm?: string;
|
||||
};
|
||||
|
||||
function HighlightCode({
|
||||
@@ -461,6 +470,7 @@ function HighlightCode({
|
||||
className,
|
||||
preClassName,
|
||||
isWrapped,
|
||||
searchTerm,
|
||||
}: HighlightCodeProps) {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
@@ -470,6 +480,8 @@ function HighlightCode({
|
||||
import("prismjs/components/prism-json"),
|
||||
//@ts-ignore
|
||||
import("prismjs/components/prism-typescript"),
|
||||
//@ts-ignore
|
||||
import("prismjs/components/prism-sql.js"),
|
||||
]).then(() => setIsLoaded(true));
|
||||
}, []);
|
||||
|
||||
@@ -552,6 +564,10 @@ function HighlightCode({
|
||||
<div className="flex-1">
|
||||
{line.map((token, key) => {
|
||||
const tokenProps = getTokenProps({ token, key });
|
||||
|
||||
// Highlight search term matches in token
|
||||
const content = highlightSearchText(token.content, searchTerm);
|
||||
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
@@ -560,7 +576,9 @@ function HighlightCode({
|
||||
color: tokenProps?.style?.color as string,
|
||||
...tokenProps.style,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,972 @@
|
||||
import type { OutputColumnMetadata } from "@internal/clickhouse";
|
||||
import { memo, useMemo } from "react";
|
||||
import type { ChartConfig } from "~/components/primitives/charts/Chart";
|
||||
import { Chart } from "~/components/primitives/charts/ChartCompound";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import type { AggregationType, ChartConfiguration } from "./ChartConfigPanel";
|
||||
|
||||
// Color palette for chart series
|
||||
const CHART_COLORS = [
|
||||
"#7655fd", // Primary purple
|
||||
"#22c55e", // Green
|
||||
"#f59e0b", // Amber
|
||||
"#ef4444", // Red
|
||||
"#06b6d4", // Cyan
|
||||
"#ec4899", // Pink
|
||||
"#8b5cf6", // Violet
|
||||
"#14b8a6", // Teal
|
||||
"#f97316", // Orange
|
||||
"#6366f1", // Indigo
|
||||
];
|
||||
|
||||
function getSeriesColor(index: number): string {
|
||||
return CHART_COLORS[index % CHART_COLORS.length];
|
||||
}
|
||||
|
||||
interface QueryResultsChartProps {
|
||||
rows: Record<string, unknown>[];
|
||||
columns: OutputColumnMetadata[];
|
||||
config: ChartConfiguration;
|
||||
fullLegend?: boolean;
|
||||
/** Callback when "View all" legend button is clicked */
|
||||
onViewAllLegendItems?: () => void;
|
||||
}
|
||||
|
||||
interface TransformedData {
|
||||
data: Record<string, unknown>[];
|
||||
series: string[];
|
||||
/** Raw date values for determining formatting granularity */
|
||||
dateValues: Date[];
|
||||
/** Whether the x-axis is date-based (continuous time scale) */
|
||||
isDateBased: boolean;
|
||||
/** The data key to use for x-axis (column name or '__timestamp' for dates) */
|
||||
xDataKey: string;
|
||||
/** Min/max timestamps for domain when date-based */
|
||||
timeDomain: [number, number] | null;
|
||||
/** Pre-calculated tick values for the time axis */
|
||||
timeTicks: number[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Time granularity levels for date formatting
|
||||
*/
|
||||
type TimeGranularity = "seconds" | "minutes" | "hours" | "days" | "weeks" | "months" | "years";
|
||||
|
||||
/**
|
||||
* Determines the appropriate time granularity based on the date range
|
||||
*/
|
||||
function detectTimeGranularity(dates: Date[]): TimeGranularity {
|
||||
if (dates.length < 2) return "days";
|
||||
|
||||
const sorted = [...dates].sort((a, b) => a.getTime() - b.getTime());
|
||||
const minDate = sorted[0];
|
||||
const maxDate = sorted[sorted.length - 1];
|
||||
const rangeMs = maxDate.getTime() - minDate.getTime();
|
||||
|
||||
const SECOND = 1000;
|
||||
const MINUTE = 60 * SECOND;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
const WEEK = 7 * DAY;
|
||||
const MONTH = 30 * DAY;
|
||||
const YEAR = 365 * DAY;
|
||||
|
||||
// Choose granularity based on range
|
||||
if (rangeMs <= 5 * MINUTE) return "seconds"; // < 5 minutes → show seconds
|
||||
if (rangeMs <= 2 * HOUR) return "minutes"; // < 2 hours → show minutes
|
||||
if (rangeMs <= 2 * DAY) return "hours"; // < 2 days → show hours
|
||||
if (rangeMs <= 2 * WEEK) return "days"; // < 2 weeks → show days
|
||||
if (rangeMs <= 3 * MONTH) return "weeks"; // < 3 months → show weeks
|
||||
if (rangeMs <= 2 * YEAR) return "months"; // < 2 years → show months
|
||||
return "years"; // >= 2 years → show years
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date for the X-axis based on the detected granularity
|
||||
*/
|
||||
function formatDateByGranularity(date: Date, granularity: TimeGranularity): string {
|
||||
switch (granularity) {
|
||||
case "seconds":
|
||||
// "10:30:45"
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
case "minutes":
|
||||
// "10:30"
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
case "hours":
|
||||
// "Jan 15 10:00"
|
||||
return `${date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})} ${date.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})}`;
|
||||
case "days":
|
||||
// "Jan 15"
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
case "weeks":
|
||||
// "Jan 15"
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
case "months":
|
||||
// "Jan 2024"
|
||||
return date.toLocaleDateString("en-US", { month: "short", year: "numeric" });
|
||||
case "years":
|
||||
// "2024"
|
||||
return date.toLocaleDateString("en-US", { year: "numeric" });
|
||||
default:
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the most common interval between consecutive data points
|
||||
* This helps us understand the natural granularity of the data
|
||||
*/
|
||||
function detectDataInterval(timestamps: number[]): number {
|
||||
if (timestamps.length < 2) return 60 * 1000; // Default to 1 minute
|
||||
|
||||
const sorted = [...timestamps].sort((a, b) => a - b);
|
||||
const gaps: number[] = [];
|
||||
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const gap = sorted[i] - sorted[i - 1];
|
||||
if (gap > 0) {
|
||||
gaps.push(gap);
|
||||
}
|
||||
}
|
||||
|
||||
if (gaps.length === 0) return 60 * 1000;
|
||||
|
||||
// Find the most common small gap (this is likely the data's natural interval)
|
||||
// We use the minimum gap as a heuristic for the data interval
|
||||
const minGap = Math.min(...gaps);
|
||||
|
||||
// Round to a nice interval
|
||||
const MINUTE = 60 * 1000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
// Snap to common intervals
|
||||
if (minGap <= MINUTE) return MINUTE;
|
||||
if (minGap <= 5 * MINUTE) return 5 * MINUTE;
|
||||
if (minGap <= 10 * MINUTE) return 10 * MINUTE;
|
||||
if (minGap <= 15 * MINUTE) return 15 * MINUTE;
|
||||
if (minGap <= 30 * MINUTE) return 30 * MINUTE;
|
||||
if (minGap <= HOUR) return HOUR;
|
||||
if (minGap <= 2 * HOUR) return 2 * HOUR;
|
||||
if (minGap <= 4 * HOUR) return 4 * HOUR;
|
||||
if (minGap <= 6 * HOUR) return 6 * HOUR;
|
||||
if (minGap <= 12 * HOUR) return 12 * HOUR;
|
||||
if (minGap <= DAY) return DAY;
|
||||
|
||||
return minGap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill in missing time slots with zero values
|
||||
* This ensures the chart shows gaps as zeros rather than connecting distant points
|
||||
*/
|
||||
function fillTimeGaps(
|
||||
data: Record<string, unknown>[],
|
||||
xDataKey: string,
|
||||
series: string[],
|
||||
minTime: number,
|
||||
maxTime: number,
|
||||
interval: number,
|
||||
granularity: TimeGranularity,
|
||||
aggregation: AggregationType,
|
||||
maxPoints = 1000
|
||||
): Record<string, unknown>[] {
|
||||
const range = maxTime - minTime;
|
||||
const estimatedPoints = Math.ceil(range / interval);
|
||||
|
||||
// If filling would create too many points, increase the interval to stay within limits
|
||||
let effectiveInterval = interval;
|
||||
if (estimatedPoints > maxPoints) {
|
||||
effectiveInterval = Math.ceil(range / maxPoints);
|
||||
// Round up to a nice interval
|
||||
const MINUTE = 60 * 1000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
if (effectiveInterval < 5 * MINUTE) effectiveInterval = 5 * MINUTE;
|
||||
else if (effectiveInterval < 10 * MINUTE) effectiveInterval = 10 * MINUTE;
|
||||
else if (effectiveInterval < 15 * MINUTE) effectiveInterval = 15 * MINUTE;
|
||||
else if (effectiveInterval < 30 * MINUTE) effectiveInterval = 30 * MINUTE;
|
||||
else if (effectiveInterval < HOUR) effectiveInterval = HOUR;
|
||||
else if (effectiveInterval < 2 * HOUR) effectiveInterval = 2 * HOUR;
|
||||
else if (effectiveInterval < 4 * HOUR) effectiveInterval = 4 * HOUR;
|
||||
else if (effectiveInterval < 6 * HOUR) effectiveInterval = 6 * HOUR;
|
||||
else if (effectiveInterval < 12 * HOUR) effectiveInterval = 12 * HOUR;
|
||||
else effectiveInterval = 24 * HOUR;
|
||||
}
|
||||
|
||||
// Create a map to collect values for each bucket (for aggregation)
|
||||
const bucketData = new Map<
|
||||
number,
|
||||
{ values: Record<string, number[]>; rawDate: Date; originalX: string }
|
||||
>();
|
||||
|
||||
for (const point of data) {
|
||||
const timestamp = point[xDataKey] as number;
|
||||
// Bucket to the nearest interval
|
||||
const bucketedTime = Math.floor(timestamp / effectiveInterval) * effectiveInterval;
|
||||
|
||||
if (!bucketData.has(bucketedTime)) {
|
||||
bucketData.set(bucketedTime, {
|
||||
values: Object.fromEntries(series.map((s) => [s, []])),
|
||||
rawDate: new Date(bucketedTime),
|
||||
originalX: new Date(bucketedTime).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const bucket = bucketData.get(bucketedTime)!;
|
||||
for (const s of series) {
|
||||
const val = point[s] as number;
|
||||
if (typeof val === "number") {
|
||||
bucket.values[s].push(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate all time slots and fill with zeros where missing
|
||||
const filledData: Record<string, unknown>[] = [];
|
||||
const startTime = Math.floor(minTime / effectiveInterval) * effectiveInterval;
|
||||
|
||||
for (let t = startTime; t <= maxTime; t += effectiveInterval) {
|
||||
const bucket = bucketData.get(t);
|
||||
if (bucket) {
|
||||
// Apply aggregation to collected values
|
||||
const point: Record<string, unknown> = {
|
||||
[xDataKey]: t,
|
||||
__rawDate: bucket.rawDate,
|
||||
__granularity: granularity,
|
||||
__originalX: bucket.originalX,
|
||||
};
|
||||
for (const s of series) {
|
||||
point[s] = aggregateValues(bucket.values[s], aggregation);
|
||||
}
|
||||
filledData.push(point);
|
||||
} else {
|
||||
// Create a zero-filled data point
|
||||
const zeroPoint: Record<string, unknown> = {
|
||||
[xDataKey]: t,
|
||||
__rawDate: new Date(t),
|
||||
__granularity: granularity,
|
||||
__originalX: new Date(t).toISOString(),
|
||||
};
|
||||
for (const s of series) {
|
||||
zeroPoint[s] = 0;
|
||||
}
|
||||
filledData.push(zeroPoint);
|
||||
}
|
||||
}
|
||||
|
||||
return filledData;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Nice" intervals for time axes - these create human-friendly tick marks
|
||||
*/
|
||||
const NICE_TIME_INTERVALS = [
|
||||
{ value: 1000, label: "1s" }, // 1 second
|
||||
{ value: 5 * 1000, label: "5s" }, // 5 seconds
|
||||
{ value: 10 * 1000, label: "10s" }, // 10 seconds
|
||||
{ value: 30 * 1000, label: "30s" }, // 30 seconds
|
||||
{ value: 60 * 1000, label: "1m" }, // 1 minute
|
||||
{ value: 5 * 60 * 1000, label: "5m" }, // 5 minutes
|
||||
{ value: 10 * 60 * 1000, label: "10m" }, // 10 minutes
|
||||
{ value: 15 * 60 * 1000, label: "15m" }, // 15 minutes
|
||||
{ value: 30 * 60 * 1000, label: "30m" }, // 30 minutes
|
||||
{ value: 60 * 60 * 1000, label: "1h" }, // 1 hour
|
||||
{ value: 2 * 60 * 60 * 1000, label: "2h" }, // 2 hours
|
||||
{ value: 3 * 60 * 60 * 1000, label: "3h" }, // 3 hours
|
||||
{ value: 4 * 60 * 60 * 1000, label: "4h" }, // 4 hours
|
||||
{ value: 6 * 60 * 60 * 1000, label: "6h" }, // 6 hours
|
||||
{ value: 12 * 60 * 60 * 1000, label: "12h" }, // 12 hours
|
||||
{ value: 24 * 60 * 60 * 1000, label: "1d" }, // 1 day
|
||||
{ value: 2 * 24 * 60 * 60 * 1000, label: "2d" }, // 2 days
|
||||
{ value: 7 * 24 * 60 * 60 * 1000, label: "1w" }, // 1 week
|
||||
{ value: 14 * 24 * 60 * 60 * 1000, label: "2w" }, // 2 weeks
|
||||
{ value: 30 * 24 * 60 * 60 * 1000, label: "1mo" }, // ~1 month
|
||||
{ value: 90 * 24 * 60 * 60 * 1000, label: "3mo" }, // ~3 months
|
||||
{ value: 180 * 24 * 60 * 60 * 1000, label: "6mo" }, // ~6 months
|
||||
{ value: 365 * 24 * 60 * 60 * 1000, label: "1y" }, // 1 year
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate evenly-spaced tick values for a time axis using "nice" intervals
|
||||
* that align to natural time boundaries (midnight, noon, hour marks, etc.)
|
||||
*/
|
||||
function generateTimeTicks(minTime: number, maxTime: number, maxTicks = 8): number[] {
|
||||
const range = maxTime - minTime;
|
||||
|
||||
if (range <= 0) {
|
||||
return [minTime];
|
||||
}
|
||||
|
||||
// Find the best "nice" interval that gives us a reasonable number of ticks
|
||||
// Target: between 4 and maxTicks ticks
|
||||
let chosenInterval = NICE_TIME_INTERVALS[NICE_TIME_INTERVALS.length - 1].value;
|
||||
|
||||
for (const { value: interval } of NICE_TIME_INTERVALS) {
|
||||
const tickCount = Math.ceil(range / interval);
|
||||
if (tickCount <= maxTicks && tickCount >= 2) {
|
||||
chosenInterval = interval;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Align the start tick to a nice boundary
|
||||
// For intervals >= 1 day, align to midnight
|
||||
// For intervals >= 1 hour, align to hour boundary
|
||||
// For intervals >= 1 minute, align to minute boundary
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const MINUTE = 60 * 1000;
|
||||
|
||||
let alignTo: number;
|
||||
if (chosenInterval >= DAY) {
|
||||
// Align to midnight UTC (or we could use local midnight)
|
||||
alignTo = DAY;
|
||||
} else if (chosenInterval >= HOUR) {
|
||||
alignTo = chosenInterval; // Align to the interval itself for hours
|
||||
} else if (chosenInterval >= MINUTE) {
|
||||
alignTo = chosenInterval;
|
||||
} else {
|
||||
alignTo = chosenInterval;
|
||||
}
|
||||
|
||||
// Round down to the alignment boundary, then find first tick at or before minTime
|
||||
const startTick = Math.floor(minTime / alignTo) * alignTo;
|
||||
|
||||
// Generate ticks
|
||||
const ticks: number[] = [];
|
||||
for (let t = startTick; t <= maxTime + chosenInterval; t += chosenInterval) {
|
||||
if (t >= minTime - chosenInterval * 0.1 && t <= maxTime + chosenInterval * 0.1) {
|
||||
ticks.push(t);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure we have at least 2 ticks
|
||||
if (ticks.length < 2) {
|
||||
return [minTime, maxTime];
|
||||
}
|
||||
|
||||
return ticks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date for tooltips (always shows full precision)
|
||||
*/
|
||||
function formatDateForTooltip(date: Date, granularity: TimeGranularity): string {
|
||||
// For shorter time ranges, include time
|
||||
if (granularity === "seconds" || granularity === "minutes" || granularity === "hours") {
|
||||
return date.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: granularity === "seconds" ? "2-digit" : undefined,
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
// For longer ranges, just show date
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse a value as a Date
|
||||
*/
|
||||
function tryParseDate(value: unknown): Date | null {
|
||||
if (value instanceof Date) {
|
||||
return isNaN(value.getTime()) ? null : value;
|
||||
}
|
||||
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) {
|
||||
const date = new Date(value);
|
||||
return isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
// First, try treating the number as milliseconds
|
||||
const dateAsMs = new Date(value);
|
||||
if (
|
||||
!isNaN(dateAsMs.getTime()) &&
|
||||
dateAsMs.getFullYear() >= 1970 &&
|
||||
dateAsMs.getFullYear() <= 2100
|
||||
) {
|
||||
return dateAsMs;
|
||||
}
|
||||
// If that fails, try treating the number as seconds (Unix timestamp)
|
||||
const dateAsSec = new Date(value * 1000);
|
||||
if (
|
||||
!isNaN(dateAsSec.getTime()) &&
|
||||
dateAsSec.getFullYear() >= 1970 &&
|
||||
dateAsSec.getFullYear() <= 2100
|
||||
) {
|
||||
return dateAsSec;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform raw query results into chart-ready data
|
||||
*
|
||||
* When grouped:
|
||||
* - Pivots data so each unique group value becomes a separate series
|
||||
* - Each row in output has xAxis value + one key per group value
|
||||
*
|
||||
* When not grouped:
|
||||
* - Uses Y-axis columns directly as series
|
||||
*
|
||||
* For date-based x-axes:
|
||||
* - Uses numeric timestamps so the chart renders with a continuous time scale
|
||||
* - This ensures gaps in data are visually apparent
|
||||
*/
|
||||
function transformDataForChart(
|
||||
rows: Record<string, unknown>[],
|
||||
config: ChartConfiguration
|
||||
): TransformedData {
|
||||
const { xAxisColumn, yAxisColumns, groupByColumn, aggregation } = config;
|
||||
|
||||
if (!xAxisColumn || yAxisColumns.length === 0) {
|
||||
return {
|
||||
data: [],
|
||||
series: [],
|
||||
dateValues: [],
|
||||
isDateBased: false,
|
||||
xDataKey: xAxisColumn || "",
|
||||
timeDomain: null,
|
||||
timeTicks: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Collect date values for granularity detection
|
||||
const dateValues: Date[] = [];
|
||||
for (const row of rows) {
|
||||
const date = tryParseDate(row[xAxisColumn]);
|
||||
if (date) {
|
||||
dateValues.push(date);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if X-axis is date-based (most values should be parseable as dates)
|
||||
const isDateBased = dateValues.length >= rows.length * 0.8; // At least 80% are dates
|
||||
const granularity = isDateBased ? detectTimeGranularity(dateValues) : "days";
|
||||
|
||||
// For date-based axes, use a special key for the timestamp
|
||||
const xDataKey = isDateBased ? "__timestamp" : xAxisColumn;
|
||||
|
||||
// Calculate time domain and ticks for date-based axes
|
||||
let timeDomain: [number, number] | null = null;
|
||||
let timeTicks: number[] | null = null;
|
||||
if (isDateBased && dateValues.length > 0) {
|
||||
const timestamps = dateValues.map((d) => d.getTime());
|
||||
const minTime = Math.min(...timestamps);
|
||||
const maxTime = Math.max(...timestamps);
|
||||
// Add a small padding (2% on each side) so points aren't at the very edge
|
||||
const padding = (maxTime - minTime) * 0.02;
|
||||
timeDomain = [minTime - padding, maxTime + padding];
|
||||
// Generate evenly-spaced ticks across the entire range using nice intervals
|
||||
timeTicks = generateTimeTicks(minTime, maxTime);
|
||||
}
|
||||
|
||||
// Helper to format X value for categorical axes (non-date)
|
||||
const formatX = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return "N/A";
|
||||
return String(value);
|
||||
};
|
||||
|
||||
// No grouping: use Y columns directly as series
|
||||
// Group rows by X value first, then aggregate
|
||||
if (!groupByColumn) {
|
||||
// Group rows by X-axis value to handle duplicates
|
||||
const groupedByX = new Map<
|
||||
string | number,
|
||||
{ yValues: Record<string, number[]>; rawDate: Date | null; originalX: unknown }
|
||||
>();
|
||||
|
||||
for (const row of rows) {
|
||||
const rawDate = tryParseDate(row[xAxisColumn]);
|
||||
|
||||
// Skip rows with invalid dates for date-based axes
|
||||
if (isDateBased && !rawDate) continue;
|
||||
|
||||
const xKey = isDateBased && rawDate ? rawDate.getTime() : formatX(row[xAxisColumn]);
|
||||
|
||||
if (!groupedByX.has(xKey)) {
|
||||
groupedByX.set(xKey, {
|
||||
yValues: Object.fromEntries(yAxisColumns.map((col) => [col, []])),
|
||||
rawDate,
|
||||
originalX: row[xAxisColumn],
|
||||
});
|
||||
}
|
||||
|
||||
const existing = groupedByX.get(xKey)!;
|
||||
for (const yCol of yAxisColumns) {
|
||||
existing.yValues[yCol].push(toNumber(row[yCol]));
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to array format with aggregation applied
|
||||
let data = Array.from(groupedByX.entries()).map(([xKey, { yValues, rawDate, originalX }]) => {
|
||||
const point: Record<string, unknown> = {
|
||||
[xDataKey]: xKey,
|
||||
__rawDate: rawDate,
|
||||
__granularity: granularity,
|
||||
__originalX: originalX,
|
||||
};
|
||||
for (const yCol of yAxisColumns) {
|
||||
point[yCol] = aggregateValues(yValues[yCol], aggregation);
|
||||
}
|
||||
return point;
|
||||
});
|
||||
|
||||
// Fill in gaps with zeros for date-based data
|
||||
if (isDateBased && timeDomain) {
|
||||
const timestamps = dateValues.map((d) => d.getTime());
|
||||
const dataInterval = detectDataInterval(timestamps);
|
||||
data = fillTimeGaps(
|
||||
data,
|
||||
xDataKey,
|
||||
yAxisColumns,
|
||||
timeDomain[0],
|
||||
timeDomain[1],
|
||||
dataInterval,
|
||||
granularity,
|
||||
aggregation
|
||||
);
|
||||
}
|
||||
|
||||
return { data, series: yAxisColumns, dateValues, isDateBased, xDataKey, timeDomain, timeTicks };
|
||||
}
|
||||
|
||||
// With grouping: pivot data so each group value becomes a series
|
||||
const yCol = yAxisColumns[0]; // Use first Y column when grouping
|
||||
const groupValues = new Set<string>();
|
||||
|
||||
// For date-based, key by timestamp; otherwise by formatted string
|
||||
// Collect all values for aggregation
|
||||
const groupedByX = new Map<
|
||||
string | number,
|
||||
{ values: Record<string, number[]>; rawDate: Date | null; originalX: unknown }
|
||||
>();
|
||||
|
||||
for (const row of rows) {
|
||||
const rawDate = tryParseDate(row[xAxisColumn]);
|
||||
|
||||
// Skip rows with invalid dates for date-based axes
|
||||
if (isDateBased && !rawDate) continue;
|
||||
|
||||
const xKey = isDateBased && rawDate ? rawDate.getTime() : formatX(row[xAxisColumn]);
|
||||
const groupValue = String(row[groupByColumn] ?? "Unknown");
|
||||
const yValue = toNumber(row[yCol]);
|
||||
|
||||
groupValues.add(groupValue);
|
||||
|
||||
if (!groupedByX.has(xKey)) {
|
||||
groupedByX.set(xKey, { values: {}, rawDate, originalX: row[xAxisColumn] });
|
||||
}
|
||||
|
||||
const existing = groupedByX.get(xKey)!;
|
||||
// Collect values for aggregation
|
||||
if (!existing.values[groupValue]) {
|
||||
existing.values[groupValue] = [];
|
||||
}
|
||||
existing.values[groupValue].push(yValue);
|
||||
}
|
||||
|
||||
// Convert to array format with aggregation applied
|
||||
const series = Array.from(groupValues).sort();
|
||||
let data = Array.from(groupedByX.entries()).map(([xKey, { values, rawDate, originalX }]) => {
|
||||
const point: Record<string, unknown> = {
|
||||
[xDataKey]: xKey,
|
||||
__rawDate: rawDate,
|
||||
__granularity: granularity,
|
||||
__originalX: originalX,
|
||||
};
|
||||
for (const group of series) {
|
||||
point[group] = values[group] ? aggregateValues(values[group], aggregation) : 0;
|
||||
}
|
||||
return point;
|
||||
});
|
||||
|
||||
// Fill in gaps with zeros for date-based data
|
||||
if (isDateBased && timeDomain) {
|
||||
const timestamps = dateValues.map((d) => d.getTime());
|
||||
const dataInterval = detectDataInterval(timestamps);
|
||||
data = fillTimeGaps(
|
||||
data,
|
||||
xDataKey,
|
||||
series,
|
||||
timeDomain[0],
|
||||
timeDomain[1],
|
||||
dataInterval,
|
||||
granularity,
|
||||
aggregation
|
||||
);
|
||||
}
|
||||
|
||||
return { data, series, dateValues, isDateBased, xDataKey, timeDomain, timeTicks };
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number {
|
||||
if (typeof value === "number") return value;
|
||||
if (typeof value === "string") {
|
||||
const parsed = parseFloat(value);
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate an array of numbers using the specified aggregation function
|
||||
*/
|
||||
function aggregateValues(values: number[], aggregation: AggregationType): number {
|
||||
if (values.length === 0) return 0;
|
||||
switch (aggregation) {
|
||||
case "sum":
|
||||
return values.reduce((a, b) => a + b, 0);
|
||||
case "avg":
|
||||
return values.reduce((a, b) => a + b, 0) / values.length;
|
||||
case "count":
|
||||
return values.length;
|
||||
case "min":
|
||||
return Math.min(...values);
|
||||
case "max":
|
||||
return Math.max(...values);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort data array by a specified column
|
||||
*/
|
||||
function sortData(
|
||||
data: Record<string, unknown>[],
|
||||
sortByColumn: string | null,
|
||||
sortDirection: "asc" | "desc",
|
||||
xAxisColumn?: string | null
|
||||
): Record<string, unknown>[] {
|
||||
if (!sortByColumn) return data;
|
||||
|
||||
return [...data].sort((a, b) => {
|
||||
const aVal = a[sortByColumn];
|
||||
const bVal = b[sortByColumn];
|
||||
|
||||
// Handle null/undefined
|
||||
if (aVal == null && bVal == null) return 0;
|
||||
if (aVal == null) return sortDirection === "asc" ? -1 : 1;
|
||||
if (bVal == null) return sortDirection === "asc" ? 1 : -1;
|
||||
|
||||
// Only use date comparison when sorting by the X-axis column
|
||||
if (sortByColumn === xAxisColumn) {
|
||||
const aDate = a.__rawDate as Date | null;
|
||||
const bDate = b.__rawDate as Date | null;
|
||||
if (aDate && bDate) {
|
||||
const diff = aDate.getTime() - bDate.getTime();
|
||||
return sortDirection === "asc" ? diff : -diff;
|
||||
}
|
||||
}
|
||||
|
||||
// Compare as numbers if possible
|
||||
const aNum = typeof aVal === "number" ? aVal : parseFloat(String(aVal));
|
||||
const bNum = typeof bVal === "number" ? bVal : parseFloat(String(bVal));
|
||||
if (!isNaN(aNum) && !isNaN(bNum)) {
|
||||
return sortDirection === "asc" ? aNum - bNum : bNum - aNum;
|
||||
}
|
||||
|
||||
// Fall back to string comparison
|
||||
const aStr = String(aVal);
|
||||
const bStr = String(bVal);
|
||||
const cmp = aStr.localeCompare(bStr);
|
||||
return sortDirection === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}
|
||||
|
||||
export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
rows,
|
||||
columns,
|
||||
config,
|
||||
fullLegend = false,
|
||||
onViewAllLegendItems,
|
||||
}: QueryResultsChartProps) {
|
||||
const {
|
||||
xAxisColumn,
|
||||
yAxisColumns,
|
||||
chartType,
|
||||
groupByColumn,
|
||||
stacked,
|
||||
sortByColumn,
|
||||
sortDirection,
|
||||
} = config;
|
||||
|
||||
// Transform data for charting
|
||||
const {
|
||||
data: unsortedData,
|
||||
series,
|
||||
dateValues,
|
||||
isDateBased,
|
||||
xDataKey,
|
||||
timeDomain,
|
||||
timeTicks,
|
||||
} = useMemo(() => transformDataForChart(rows, config), [rows, config]);
|
||||
|
||||
// Apply sorting (for date-based, sort by timestamp to ensure correct order)
|
||||
const data = useMemo(() => {
|
||||
if (isDateBased) {
|
||||
// Always sort by timestamp for date-based axes
|
||||
return sortData(unsortedData, xDataKey, "asc", xDataKey);
|
||||
}
|
||||
return sortData(unsortedData, sortByColumn, sortDirection, xDataKey);
|
||||
}, [unsortedData, sortByColumn, sortDirection, isDateBased, xDataKey]);
|
||||
|
||||
// Detect time granularity for the data
|
||||
const timeGranularity = useMemo(
|
||||
() => (dateValues.length > 0 ? detectTimeGranularity(dateValues) : null),
|
||||
[dateValues]
|
||||
);
|
||||
|
||||
// X-axis tick formatter for date-based axes
|
||||
const xAxisTickFormatter = useMemo(() => {
|
||||
if (!isDateBased || !timeGranularity) return undefined;
|
||||
return (value: number) => {
|
||||
const date = new Date(value);
|
||||
return formatDateByGranularity(date, timeGranularity);
|
||||
};
|
||||
}, [isDateBased, timeGranularity]);
|
||||
|
||||
// Create dynamic Y-axis formatter based on data range
|
||||
const yAxisFormatter = useMemo(() => createYAxisFormatter(data, series), [data, series]);
|
||||
|
||||
// Build chart config for colors/labels
|
||||
const chartConfig = useMemo(() => {
|
||||
const cfg: ChartConfig = {};
|
||||
series.forEach((s, i) => {
|
||||
cfg[s] = {
|
||||
label: s,
|
||||
color: getSeriesColor(i),
|
||||
};
|
||||
});
|
||||
return cfg;
|
||||
}, [series]);
|
||||
|
||||
// Custom tooltip label formatter for better date display
|
||||
const tooltipLabelFormatter = useMemo(() => {
|
||||
return (label: string, payload: Array<{ payload?: Record<string, unknown> }>) => {
|
||||
// Try to get the raw date from the payload for better formatting
|
||||
const rawDate = payload[0]?.payload?.__rawDate as Date | null | undefined;
|
||||
const granularity = payload[0]?.payload?.__granularity as TimeGranularity | undefined;
|
||||
|
||||
if (rawDate && granularity) {
|
||||
return formatDateForTooltip(rawDate, granularity);
|
||||
}
|
||||
return label;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Label formatter for the legend (formats x-axis values)
|
||||
const legendLabelFormatter = useMemo(() => {
|
||||
if (!isDateBased || !timeGranularity) return undefined;
|
||||
return (value: string) => {
|
||||
// For date-based axes, the value is a timestamp
|
||||
const timestamp = Number(value);
|
||||
if (!isNaN(timestamp)) {
|
||||
const date = new Date(timestamp);
|
||||
return formatDateForTooltip(date, timeGranularity);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
}, [isDateBased, timeGranularity]);
|
||||
|
||||
// Y-axis domain calculation - must be before early returns to maintain consistent hook order
|
||||
const yAxisDomain = useMemo(() => {
|
||||
let min = 0;
|
||||
for (const point of data) {
|
||||
for (const s of series) {
|
||||
const val = point[s];
|
||||
if (typeof val === "number" && isFinite(val)) {
|
||||
min = Math.min(min, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [min, "auto"] as [number, string];
|
||||
}, [data, series]);
|
||||
|
||||
// Validation
|
||||
if (!xAxisColumn) {
|
||||
return <EmptyState message="Select an X-axis column to display the chart" />;
|
||||
}
|
||||
|
||||
if (yAxisColumns.length === 0) {
|
||||
return <EmptyState message="Select a Y-axis column to display the chart" />;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <EmptyState message="No data to display" />;
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return <EmptyState message="Unable to transform data for chart" />;
|
||||
}
|
||||
|
||||
// Determine appropriate angle for X-axis labels based on granularity
|
||||
const xAxisAngle = timeGranularity === "hours" || timeGranularity === "seconds" ? -45 : 0;
|
||||
const xAxisHeight = xAxisAngle !== 0 ? 60 : undefined;
|
||||
|
||||
// Base x-axis props shared by all chart types
|
||||
const baseXAxisProps = {
|
||||
tickFormatter: xAxisTickFormatter,
|
||||
angle: xAxisAngle,
|
||||
textAnchor: xAxisAngle !== 0 ? ("end" as const) : ("middle" as const),
|
||||
height: xAxisHeight,
|
||||
};
|
||||
|
||||
// Line charts use continuous time scale for date-based data
|
||||
// This properly represents time gaps between data points
|
||||
const xAxisPropsForLine = isDateBased
|
||||
? {
|
||||
type: "number" as const,
|
||||
domain: timeDomain ?? (["auto", "auto"] as [string, string]),
|
||||
scale: "time" as const,
|
||||
// Explicitly specify tick positions so labels appear across the entire range
|
||||
ticks: timeTicks ?? undefined,
|
||||
...baseXAxisProps,
|
||||
}
|
||||
: baseXAxisProps;
|
||||
|
||||
// Bar charts always use categorical axis positioning
|
||||
// This ensures bars are evenly distributed regardless of data point count
|
||||
// (prevents massive bars when there are only a few data points)
|
||||
const xAxisPropsForBar = baseXAxisProps;
|
||||
|
||||
const yAxisProps = {
|
||||
tickFormatter: yAxisFormatter,
|
||||
domain: yAxisDomain,
|
||||
};
|
||||
|
||||
const showLegend = series.length > 0;
|
||||
|
||||
if (chartType === "bar") {
|
||||
return (
|
||||
<Chart.Root
|
||||
config={chartConfig}
|
||||
data={data}
|
||||
dataKey={xDataKey}
|
||||
series={series}
|
||||
labelFormatter={legendLabelFormatter}
|
||||
showLegend={showLegend}
|
||||
maxLegendItems={fullLegend ? Infinity : 5}
|
||||
minHeight="300px"
|
||||
fillContainer
|
||||
onViewAllLegendItems={onViewAllLegendItems}
|
||||
>
|
||||
<Chart.Bar
|
||||
xAxisProps={xAxisPropsForBar}
|
||||
yAxisProps={yAxisProps}
|
||||
stackId={stacked ? "stack" : undefined}
|
||||
tooltipLabelFormatter={tooltipLabelFormatter}
|
||||
/>
|
||||
</Chart.Root>
|
||||
);
|
||||
}
|
||||
|
||||
// Line or stacked area chart
|
||||
return (
|
||||
<Chart.Root
|
||||
config={chartConfig}
|
||||
data={data}
|
||||
dataKey={xDataKey}
|
||||
series={series}
|
||||
labelFormatter={legendLabelFormatter}
|
||||
showLegend={showLegend}
|
||||
maxLegendItems={fullLegend ? Infinity : 5}
|
||||
minHeight="300px"
|
||||
fillContainer
|
||||
onViewAllLegendItems={onViewAllLegendItems}
|
||||
>
|
||||
<Chart.Line
|
||||
xAxisProps={xAxisPropsForLine}
|
||||
yAxisProps={yAxisProps}
|
||||
stacked={stacked && series.length > 1}
|
||||
tooltipLabelFormatter={tooltipLabelFormatter}
|
||||
lineType="linear"
|
||||
/>
|
||||
</Chart.Root>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a Y-axis value formatter based on the data range
|
||||
*/
|
||||
function createYAxisFormatter(data: Record<string, unknown>[], series: string[]) {
|
||||
// Find min and max values across all series
|
||||
let minVal = Infinity;
|
||||
let maxVal = -Infinity;
|
||||
|
||||
for (const point of data) {
|
||||
for (const s of series) {
|
||||
const val = point[s];
|
||||
if (typeof val === "number" && isFinite(val)) {
|
||||
minVal = Math.min(minVal, val);
|
||||
maxVal = Math.max(maxVal, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const range = maxVal - minVal;
|
||||
|
||||
return (value: number): string => {
|
||||
// Use abbreviations for large numbers
|
||||
if (Math.abs(value) >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
if (Math.abs(value) >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(1)}K`;
|
||||
}
|
||||
|
||||
// Determine decimal places based on range
|
||||
if (range === 0 || !isFinite(range)) {
|
||||
return Number.isInteger(value) ? value.toString() : value.toFixed(2);
|
||||
}
|
||||
|
||||
// For small ranges, show more precision
|
||||
if (range < 0.01) {
|
||||
return value.toFixed(4);
|
||||
}
|
||||
if (range < 0.1) {
|
||||
return value.toFixed(3);
|
||||
}
|
||||
if (range < 10) {
|
||||
return value.toFixed(2);
|
||||
}
|
||||
if (range < 100) {
|
||||
return value.toFixed(1);
|
||||
}
|
||||
|
||||
// For large ranges, no decimals
|
||||
return Math.round(value).toString();
|
||||
};
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[300px] items-center justify-center">
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
{message}
|
||||
</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { sql, StandardSQL } from "@codemirror/lang-sql";
|
||||
import { autocompletion, startCompletion } from "@codemirror/autocomplete";
|
||||
import { linter, lintGutter } from "@codemirror/lint";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import type { ViewUpdate } from "@codemirror/view";
|
||||
import { CheckIcon, ClipboardIcon, SparklesIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import {
|
||||
type ReactCodeMirrorProps,
|
||||
type UseCodeMirror,
|
||||
useCodeMirror,
|
||||
} from "@uiw/react-codemirror";
|
||||
import { useCallback, useEffect, useRef, useState, useMemo } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { getEditorSetup } from "./codeMirrorSetup";
|
||||
import { darkTheme } from "./codeMirrorTheme";
|
||||
import { createTSQLCompletion } from "./tsql/tsqlCompletion";
|
||||
import { createTSQLLinter } from "./tsql/tsqlLinter";
|
||||
import type { TableSchema } from "@internal/tsql";
|
||||
import { format as formatSQL } from "sql-formatter";
|
||||
|
||||
export interface TSQLEditorProps extends Omit<ReactCodeMirrorProps, "onBlur"> {
|
||||
/** Initial value for the editor */
|
||||
defaultValue?: string;
|
||||
/** Whether the editor is read-only */
|
||||
readOnly?: boolean;
|
||||
/** Called when the editor content changes */
|
||||
onChange?: (value: string) => void;
|
||||
/** Called when the editor state updates */
|
||||
onUpdate?: (update: ViewUpdate) => void;
|
||||
/** Called when the editor loses focus */
|
||||
onBlur?: (code: string) => void;
|
||||
/** Schema for table/column autocompletion */
|
||||
schema?: TableSchema[];
|
||||
/** Show copy button */
|
||||
showCopyButton?: boolean;
|
||||
/** Show clear button */
|
||||
showClearButton?: boolean;
|
||||
/** Show format button */
|
||||
showFormatButton?: boolean;
|
||||
/** Enable linting (syntax checking) */
|
||||
linterEnabled?: boolean;
|
||||
/** Placeholder text when empty */
|
||||
placeholder?: string;
|
||||
/** Additional actions to show in the toolbar */
|
||||
additionalActions?: React.ReactNode;
|
||||
/** Minimum height of the editor */
|
||||
minHeight?: string;
|
||||
}
|
||||
|
||||
type TSQLEditorDefaultProps = Partial<TSQLEditorProps>;
|
||||
|
||||
const defaultProps: TSQLEditorDefaultProps = {
|
||||
readOnly: false,
|
||||
basicSetup: false,
|
||||
linterEnabled: true,
|
||||
showCopyButton: true,
|
||||
showClearButton: false,
|
||||
showFormatButton: true,
|
||||
schema: [],
|
||||
};
|
||||
|
||||
export function TSQLEditor(opts: TSQLEditorProps) {
|
||||
const {
|
||||
defaultValue = "",
|
||||
readOnly = false,
|
||||
onChange,
|
||||
onUpdate,
|
||||
onBlur,
|
||||
basicSetup = false,
|
||||
autoFocus,
|
||||
showCopyButton = true,
|
||||
showClearButton = false,
|
||||
showFormatButton = true,
|
||||
linterEnabled = true,
|
||||
schema = [],
|
||||
placeholder = "",
|
||||
additionalActions,
|
||||
minHeight = undefined,
|
||||
} = {
|
||||
...defaultProps,
|
||||
...opts,
|
||||
};
|
||||
|
||||
// Create extensions - memoize to avoid recreating on every render
|
||||
const extensions = useMemo(() => {
|
||||
const exts = getEditorSetup();
|
||||
|
||||
// Add SQL language support with StandardSQL dialect
|
||||
// This provides syntax highlighting
|
||||
exts.push(
|
||||
sql({
|
||||
dialect: StandardSQL,
|
||||
upperCaseKeywords: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Add custom TSQL completion
|
||||
if (schema && schema.length > 0) {
|
||||
exts.push(
|
||||
autocompletion({
|
||||
override: [createTSQLCompletion(schema)],
|
||||
activateOnTyping: true,
|
||||
maxRenderedOptions: 50,
|
||||
})
|
||||
);
|
||||
|
||||
// Trigger autocomplete when ' is typed in value context
|
||||
// CodeMirror's activateOnTyping only triggers on alphanumeric characters,
|
||||
// so we manually trigger for quotes after comparison operators
|
||||
exts.push(
|
||||
EditorView.domEventHandlers({
|
||||
keyup: (event, view) => {
|
||||
// Trigger on quote key (both ' and shift+' on some keyboards)
|
||||
if (event.key === "'" || event.key === '"' || event.code === "Quote") {
|
||||
setTimeout(() => {
|
||||
startCompletion(view);
|
||||
}, 50);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Add TSQL linter
|
||||
if (linterEnabled) {
|
||||
exts.push(lintGutter());
|
||||
exts.push(
|
||||
linter(createTSQLLinter({ schema }), {
|
||||
delay: 300, // Debounce linting for better performance
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return exts;
|
||||
}, [schema, linterEnabled]);
|
||||
|
||||
const editor = useRef<HTMLDivElement>(null);
|
||||
|
||||
const settings: Omit<UseCodeMirror, "onBlur"> = {
|
||||
...opts,
|
||||
container: editor.current,
|
||||
extensions,
|
||||
editable: !readOnly,
|
||||
contentEditable: !readOnly,
|
||||
value: defaultValue,
|
||||
autoFocus,
|
||||
theme: darkTheme(),
|
||||
indentWithTab: false,
|
||||
basicSetup,
|
||||
onChange,
|
||||
onUpdate,
|
||||
placeholder,
|
||||
};
|
||||
|
||||
const { setContainer, view } = useCodeMirror(settings);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor.current) {
|
||||
setContainer(editor.current);
|
||||
}
|
||||
}, [setContainer]);
|
||||
|
||||
// Update editor when defaultValue changes
|
||||
useEffect(() => {
|
||||
if (view !== undefined) {
|
||||
if (view.state.doc.toString() === defaultValue) return;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: defaultValue },
|
||||
});
|
||||
}
|
||||
}, [defaultValue, view]);
|
||||
|
||||
const clear = () => {
|
||||
if (view === undefined) return;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: undefined },
|
||||
});
|
||||
onChange?.("");
|
||||
};
|
||||
|
||||
const copy = useCallback(() => {
|
||||
if (view === undefined) return;
|
||||
navigator.clipboard.writeText(view.state.doc.toString());
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 1500);
|
||||
}, [view]);
|
||||
|
||||
const format = useCallback(() => {
|
||||
if (view === undefined) return;
|
||||
const currentContent = view.state.doc.toString();
|
||||
if (!currentContent.trim()) return;
|
||||
|
||||
try {
|
||||
const formatted = autoFormatSQL(currentContent);
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: formatted },
|
||||
});
|
||||
onChange?.(formatted);
|
||||
} catch {
|
||||
// If formatting fails (e.g., invalid SQL), silently ignore
|
||||
}
|
||||
}, [view, onChange]);
|
||||
|
||||
const showButtons = showClearButton || showCopyButton || showFormatButton || additionalActions;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative flex h-full flex-col", opts.className)}
|
||||
style={minHeight ? { minHeight } : undefined}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
)}
|
||||
ref={editor}
|
||||
onBlur={() => {
|
||||
if (!onBlur) return;
|
||||
if (!view) return;
|
||||
onBlur(view.state.doc.toString());
|
||||
}}
|
||||
/>
|
||||
{showButtons && (
|
||||
<div className="absolute right-0 top-0 z-10 flex items-center justify-end bg-charcoal-900/80 p-0.5">
|
||||
{additionalActions && additionalActions}
|
||||
{showFormatButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal/small"
|
||||
className="flex-none"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
format();
|
||||
}}
|
||||
shortcut={{ key: "f", modifiers: ["shift", "alt"], enabledOnInputElements: true }}
|
||||
>
|
||||
Format
|
||||
</Button>
|
||||
)}
|
||||
{showClearButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal/small"
|
||||
TrailingIcon={TrashIcon}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clear();
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
{showCopyButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal/small"
|
||||
TrailingIcon={copied ? CheckIcon : ClipboardIcon}
|
||||
trailingIconClassName={
|
||||
copied ? "text-green-500 group-hover:text-green-500" : undefined
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
copy();
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function autoFormatSQL(sql: string) {
|
||||
return formatSQL(sql, {
|
||||
language: "sql",
|
||||
keywordCase: "upper",
|
||||
indentStyle: "standard",
|
||||
linesBetweenQueries: 2,
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
// TSQL CodeMirror support
|
||||
// Provides syntax highlighting, autocompletion, and linting for TSQL queries
|
||||
|
||||
export { createTSQLCompletion } from "./tsqlCompletion";
|
||||
export { createTSQLLinter, isValidTSQLQuery, getTSQLError, type TSQLLinterConfig } from "./tsqlLinter";
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
import type { CompletionContext, CompletionResult, Completion } from "@codemirror/autocomplete";
|
||||
import {
|
||||
type TableSchema,
|
||||
type ColumnSchema,
|
||||
TSQL_CLICKHOUSE_FUNCTIONS,
|
||||
TSQL_AGGREGATIONS,
|
||||
} from "@internal/tsql";
|
||||
|
||||
/**
|
||||
* SQL keywords for autocomplete
|
||||
*/
|
||||
const SQL_KEYWORDS = [
|
||||
"SELECT",
|
||||
"FROM",
|
||||
"WHERE",
|
||||
"AND",
|
||||
"OR",
|
||||
"NOT",
|
||||
"IN",
|
||||
"LIKE",
|
||||
"ILIKE",
|
||||
"BETWEEN",
|
||||
"IS",
|
||||
"NULL",
|
||||
"TRUE",
|
||||
"FALSE",
|
||||
"AS",
|
||||
"ORDER",
|
||||
"BY",
|
||||
"ASC",
|
||||
"DESC",
|
||||
"LIMIT",
|
||||
"OFFSET",
|
||||
"GROUP",
|
||||
"HAVING",
|
||||
"DISTINCT",
|
||||
"JOIN",
|
||||
"LEFT",
|
||||
"RIGHT",
|
||||
"INNER",
|
||||
"OUTER",
|
||||
"FULL",
|
||||
"CROSS",
|
||||
"ON",
|
||||
"UNION",
|
||||
"INTERSECT",
|
||||
"EXCEPT",
|
||||
"ALL",
|
||||
"WITH",
|
||||
"CASE",
|
||||
"WHEN",
|
||||
"THEN",
|
||||
"ELSE",
|
||||
"END",
|
||||
"OVER",
|
||||
"PARTITION",
|
||||
"ROWS",
|
||||
"RANGE",
|
||||
"UNBOUNDED",
|
||||
"PRECEDING",
|
||||
"FOLLOWING",
|
||||
"CURRENT",
|
||||
"ROW",
|
||||
"NULLS",
|
||||
"FIRST",
|
||||
"LAST",
|
||||
];
|
||||
|
||||
/**
|
||||
* Create keyword completions from the SQL keywords list
|
||||
*/
|
||||
function createKeywordCompletions(): Completion[] {
|
||||
return SQL_KEYWORDS.map((keyword) => ({
|
||||
label: keyword,
|
||||
type: "keyword",
|
||||
boost: -1, // Keywords should have lower priority than schema items
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create function completions from TSQL function definitions
|
||||
*/
|
||||
function createFunctionCompletions(): Completion[] {
|
||||
const functions: Completion[] = [];
|
||||
|
||||
// Add regular functions
|
||||
for (const [name, meta] of Object.entries(TSQL_CLICKHOUSE_FUNCTIONS)) {
|
||||
// Skip internal functions starting with _
|
||||
if (name.startsWith("_")) continue;
|
||||
|
||||
const argsHint =
|
||||
meta.maxArgs === 0
|
||||
? "()"
|
||||
: meta.minArgs === meta.maxArgs
|
||||
? `(${meta.minArgs} args)`
|
||||
: `(${meta.minArgs}${meta.maxArgs ? `-${meta.maxArgs}` : "+"} args)`;
|
||||
|
||||
functions.push({
|
||||
label: name,
|
||||
type: "function",
|
||||
detail: argsHint,
|
||||
apply: `${name}()`,
|
||||
});
|
||||
}
|
||||
|
||||
// Add aggregate functions with slightly higher boost
|
||||
for (const [name, meta] of Object.entries(TSQL_AGGREGATIONS)) {
|
||||
if (name.startsWith("_")) continue;
|
||||
|
||||
const argsHint =
|
||||
meta.maxArgs === 0
|
||||
? "()"
|
||||
: meta.minArgs === meta.maxArgs
|
||||
? `(${meta.minArgs} args)`
|
||||
: `(${meta.minArgs}${meta.maxArgs ? `-${meta.maxArgs}` : "+"} args)`;
|
||||
|
||||
functions.push({
|
||||
label: name,
|
||||
type: "function",
|
||||
detail: `aggregate ${argsHint}`,
|
||||
apply: `${name}()`,
|
||||
boost: 0.5,
|
||||
});
|
||||
}
|
||||
|
||||
return functions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create table completions from schema
|
||||
*/
|
||||
function createTableCompletions(schema: TableSchema[]): Completion[] {
|
||||
return schema.map((table) => ({
|
||||
label: table.name,
|
||||
type: "class", // Using "class" type for tables gives them a nice icon
|
||||
detail: table.description || "table",
|
||||
boost: 1, // Tables should have higher priority
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create column completions for a specific table
|
||||
*/
|
||||
function createColumnCompletions(table: TableSchema, prefix?: string): Completion[] {
|
||||
const columns: Completion[] = [];
|
||||
|
||||
for (const [name, column] of Object.entries(table.columns)) {
|
||||
columns.push({
|
||||
label: prefix ? `${prefix}.${name}` : name,
|
||||
type: "property", // Using "property" type for columns
|
||||
detail: `${column.type}${column.description ? ` - ${column.description}` : ""}`,
|
||||
boost: 2, // Columns should have highest priority
|
||||
});
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract table names/aliases from the current query context
|
||||
* This is a simplified parser that looks for FROM and JOIN clauses
|
||||
*/
|
||||
function extractTablesFromQuery(doc: string, schema: TableSchema[]): Map<string, TableSchema> {
|
||||
const tableMap = new Map<string, TableSchema>();
|
||||
const tableNames = schema.map((t) => t.name);
|
||||
|
||||
// Simple regex to find table references in FROM and JOIN clauses
|
||||
// Handles: FROM table_name, FROM table_name AS alias, FROM table_name alias
|
||||
const tablePattern = /(?:FROM|JOIN)\s+(\w+)(?:\s+(?:AS\s+)?(\w+))?/gi;
|
||||
|
||||
let match;
|
||||
while ((match = tablePattern.exec(doc)) !== null) {
|
||||
const tableName = match[1];
|
||||
const alias = match[2] || tableName;
|
||||
|
||||
// Find the table schema if it exists
|
||||
const tableSchema = schema.find((t) => t.name.toLowerCase() === tableName.toLowerCase());
|
||||
|
||||
if (tableSchema) {
|
||||
tableMap.set(alias.toLowerCase(), tableSchema);
|
||||
}
|
||||
}
|
||||
|
||||
return tableMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine what context we're in based on cursor position
|
||||
*/
|
||||
type CompletionContextType =
|
||||
| "table" // After FROM or JOIN
|
||||
| "column" // After SELECT, WHERE, ORDER BY, GROUP BY, etc.
|
||||
| "alias" // After table_name.
|
||||
| "value" // After comparison operator (=, !=, IN, etc.)
|
||||
| "general"; // Anywhere else
|
||||
|
||||
/**
|
||||
* Result of context detection
|
||||
*/
|
||||
interface ContextResult {
|
||||
type: CompletionContextType;
|
||||
tablePrefix?: string;
|
||||
/** Column being compared (for value context) */
|
||||
columnName?: string;
|
||||
/** Table alias for the column (for value context) */
|
||||
columnTableAlias?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract column name from text before a comparison operator
|
||||
* Handles: "column =", "table.column =", "column IN", "column = 'partial", etc.
|
||||
*/
|
||||
function extractColumnBeforeOperator(
|
||||
textBefore: string
|
||||
): { columnName: string; tableAlias?: string } | null {
|
||||
// Match patterns like: column =, column !=, column IN, table.column =, etc.
|
||||
// We need to capture the column (and optional table prefix) before the operator
|
||||
// Also match when user is typing a partial string value like: column = 'val
|
||||
const patterns = [
|
||||
// column = or column != or column <> (with optional whitespace and optional partial string value)
|
||||
/(\w+)\.(\w+)\s*(?:=|!=|<>)\s*(?:'[^']*)?$/i,
|
||||
/(\w+)\s*(?:=|!=|<>)\s*(?:'[^']*)?$/i,
|
||||
// column IN ( or column NOT IN ( (with optional partial string value)
|
||||
/(\w+)\.(\w+)\s+(?:NOT\s+)?IN\s*\(\s*(?:'[^']*)?$/i,
|
||||
/(\w+)\s+(?:NOT\s+)?IN\s*\(\s*(?:'[^']*)?$/i,
|
||||
// After a comma in IN clause (with optional partial string value)
|
||||
/(\w+)\.(\w+)\s+(?:NOT\s+)?IN\s*\([^)]*,\s*(?:'[^']*)?$/i,
|
||||
/(\w+)\s+(?:NOT\s+)?IN\s*\([^)]*,\s*(?:'[^']*)?$/i,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = textBefore.match(pattern);
|
||||
if (match) {
|
||||
if (match.length === 3) {
|
||||
// table.column pattern
|
||||
return { tableAlias: match[1], columnName: match[2] };
|
||||
} else {
|
||||
// just column pattern
|
||||
return { columnName: match[1] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function determineContext(doc: string, pos: number): ContextResult {
|
||||
// Get text before cursor
|
||||
const textBefore = doc.slice(0, pos);
|
||||
|
||||
// Check if we're in a value context (after comparison operator)
|
||||
// This should be checked before other contexts
|
||||
const columnInfo = extractColumnBeforeOperator(textBefore);
|
||||
if (columnInfo) {
|
||||
return {
|
||||
type: "value",
|
||||
columnName: columnInfo.columnName,
|
||||
columnTableAlias: columnInfo.tableAlias,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if we're completing after a dot (table.column)
|
||||
const dotMatch = textBefore.match(/(\w+)\.\s*$/);
|
||||
if (dotMatch) {
|
||||
return { type: "alias", tablePrefix: dotMatch[1] };
|
||||
}
|
||||
|
||||
// Find the LAST significant keyword before cursor
|
||||
// We match all keywords and take the last one
|
||||
const keywordPattern = /\b(SELECT|FROM|JOIN|WHERE|AND|OR|ORDER\s+BY|GROUP\s+BY|HAVING|ON)\b/gi;
|
||||
let lastMatch: RegExpExecArray | null = null;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = keywordPattern.exec(textBefore)) !== null) {
|
||||
lastMatch = match;
|
||||
}
|
||||
|
||||
if (lastMatch) {
|
||||
const keyword = lastMatch[1].toUpperCase().replace(/\s+/g, " ");
|
||||
|
||||
if (keyword === "FROM" || keyword === "JOIN") {
|
||||
return { type: "table" };
|
||||
}
|
||||
|
||||
if (
|
||||
keyword === "SELECT" ||
|
||||
keyword === "WHERE" ||
|
||||
keyword === "AND" ||
|
||||
keyword === "OR" ||
|
||||
keyword === "ORDER BY" ||
|
||||
keyword === "GROUP BY" ||
|
||||
keyword === "HAVING" ||
|
||||
keyword === "ON"
|
||||
) {
|
||||
return { type: "column" };
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "general" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a column schema by name in the tables map
|
||||
*/
|
||||
function findColumnSchema(
|
||||
columnName: string,
|
||||
tableAlias: string | undefined,
|
||||
tables: Map<string, TableSchema>
|
||||
): ColumnSchema | null {
|
||||
if (tableAlias) {
|
||||
// Look in specific table
|
||||
const tableSchema = tables.get(tableAlias.toLowerCase());
|
||||
if (tableSchema) {
|
||||
return tableSchema.columns[columnName] || null;
|
||||
}
|
||||
} else {
|
||||
// Look in all tables
|
||||
for (const tableSchema of tables.values()) {
|
||||
const col = tableSchema.columns[columnName];
|
||||
if (col) {
|
||||
return col;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create completions for enum values from allowedValues
|
||||
*/
|
||||
function createEnumValueCompletions(columnSchema: ColumnSchema): Completion[] {
|
||||
if (!columnSchema.allowedValues || columnSchema.allowedValues.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return columnSchema.allowedValues.map((value) => ({
|
||||
label: `'${value}'`,
|
||||
type: "enum",
|
||||
detail: columnSchema.description || "allowed value",
|
||||
boost: 3, // Highest priority for enum values in value context
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a TSQL-aware autocompletion source
|
||||
*
|
||||
* @param schema - Array of table schemas to use for completions
|
||||
* @returns A CodeMirror completion source function
|
||||
*/
|
||||
export function createTSQLCompletion(
|
||||
schema: TableSchema[]
|
||||
): (context: CompletionContext) => CompletionResult | null {
|
||||
// Pre-compute static completions
|
||||
const keywordCompletions = createKeywordCompletions();
|
||||
const functionCompletions = createFunctionCompletions();
|
||||
const tableCompletions = createTableCompletions(schema);
|
||||
|
||||
return (context: CompletionContext): CompletionResult | null => {
|
||||
// Get the word being typed - include single quotes for value completion
|
||||
const word = context.matchBefore(/[\w.']+/);
|
||||
|
||||
// Don't show completions if no word is being typed and not explicitly triggered
|
||||
if (!word && !context.explicit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const from = word ? word.from : context.pos;
|
||||
const doc = context.state.doc.toString();
|
||||
const queryContext = determineContext(doc, context.pos);
|
||||
|
||||
let options: Completion[] = [];
|
||||
// Track if we need to extend replacement range (e.g., to consume auto-paired closing quote)
|
||||
let to: number | undefined = undefined;
|
||||
|
||||
switch (queryContext.type) {
|
||||
case "table":
|
||||
// After FROM or JOIN, show only tables
|
||||
options = tableCompletions;
|
||||
break;
|
||||
|
||||
case "alias":
|
||||
// After table., show columns for that table
|
||||
if (queryContext.tablePrefix) {
|
||||
const tables = extractTablesFromQuery(doc, schema);
|
||||
const tableSchema = tables.get(queryContext.tablePrefix.toLowerCase());
|
||||
|
||||
if (tableSchema) {
|
||||
options = createColumnCompletions(tableSchema);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "value":
|
||||
// After comparison operator, show enum values if available
|
||||
if (queryContext.columnName) {
|
||||
const tables = extractTablesFromQuery(doc, schema);
|
||||
const columnSchema = findColumnSchema(
|
||||
queryContext.columnName,
|
||||
queryContext.columnTableAlias,
|
||||
tables
|
||||
);
|
||||
|
||||
if (columnSchema) {
|
||||
options = createEnumValueCompletions(columnSchema);
|
||||
// Check if there's a closing quote right after cursor (from auto-pairing)
|
||||
// If so, extend replacement range to include it to avoid 'Completed''
|
||||
const charAfterCursor = context.state.doc.sliceString(context.pos, context.pos + 1);
|
||||
if (charAfterCursor === "'") {
|
||||
to = context.pos + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "column":
|
||||
// After SELECT, WHERE, etc., show columns, functions, and some keywords
|
||||
{
|
||||
const tables = extractTablesFromQuery(doc, schema);
|
||||
|
||||
// Add columns from all tables in the query
|
||||
tables.forEach((tableSchema, alias) => {
|
||||
// If multiple tables, prefix with alias
|
||||
const prefix = tables.size > 1 ? alias : undefined;
|
||||
options.push(...createColumnCompletions(tableSchema, prefix));
|
||||
});
|
||||
|
||||
// Also add functions and relevant keywords
|
||||
options.push(...functionCompletions);
|
||||
options.push(
|
||||
...keywordCompletions.filter((k) =>
|
||||
[
|
||||
"AND",
|
||||
"OR",
|
||||
"NOT",
|
||||
"IN",
|
||||
"LIKE",
|
||||
"ILIKE",
|
||||
"BETWEEN",
|
||||
"IS",
|
||||
"NULL",
|
||||
"AS",
|
||||
"CASE",
|
||||
"WHEN",
|
||||
"THEN",
|
||||
"ELSE",
|
||||
"END",
|
||||
].includes(k.label as string)
|
||||
)
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case "general":
|
||||
default:
|
||||
// Show everything
|
||||
options = [...tableCompletions, ...functionCompletions, ...keywordCompletions];
|
||||
|
||||
// Also add columns from tables in query
|
||||
{
|
||||
const tables = extractTablesFromQuery(doc, schema);
|
||||
tables.forEach((tableSchema, alias) => {
|
||||
const prefix = tables.size > 1 ? alias : undefined;
|
||||
options.push(...createColumnCompletions(tableSchema, prefix));
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const result: CompletionResult = {
|
||||
from,
|
||||
options,
|
||||
validFor: /^[\w.']*$/,
|
||||
};
|
||||
// Only set 'to' if we need to extend the replacement range
|
||||
if (to !== undefined) {
|
||||
result.to = to;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isValidTSQLQuery, getTSQLError } from "./tsqlLinter";
|
||||
|
||||
describe("tsqlLinter", () => {
|
||||
describe("isValidTSQLQuery", () => {
|
||||
it("should return true for empty queries", () => {
|
||||
expect(isValidTSQLQuery("")).toBe(true);
|
||||
expect(isValidTSQLQuery(" ")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for valid SELECT queries", () => {
|
||||
expect(isValidTSQLQuery("SELECT * FROM users")).toBe(true);
|
||||
expect(isValidTSQLQuery("SELECT id, name FROM users WHERE status = 'active'")).toBe(true);
|
||||
expect(isValidTSQLQuery("SELECT count(*) FROM users GROUP BY status")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for queries with ORDER BY", () => {
|
||||
expect(isValidTSQLQuery("SELECT * FROM users ORDER BY created_at DESC")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for queries with LIMIT", () => {
|
||||
expect(isValidTSQLQuery("SELECT * FROM users LIMIT 10")).toBe(true);
|
||||
expect(isValidTSQLQuery("SELECT * FROM users LIMIT 10 OFFSET 20")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for queries with JOINs", () => {
|
||||
expect(isValidTSQLQuery("SELECT * FROM users JOIN orders ON users.id = orders.user_id")).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
isValidTSQLQuery(
|
||||
"SELECT * FROM users LEFT JOIN orders ON users.id = orders.user_id"
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for invalid syntax", () => {
|
||||
expect(isValidTSQLQuery("SELEC * FROM users")).toBe(false);
|
||||
expect(isValidTSQLQuery("SELECT * FORM users")).toBe(false);
|
||||
expect(isValidTSQLQuery("SELECT FROM users")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for incomplete queries", () => {
|
||||
expect(isValidTSQLQuery("SELECT * FROM")).toBe(false);
|
||||
expect(isValidTSQLQuery("SELECT")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTSQLError", () => {
|
||||
it("should return null for empty queries", () => {
|
||||
expect(getTSQLError("")).toBeNull();
|
||||
expect(getTSQLError(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for valid queries", () => {
|
||||
expect(getTSQLError("SELECT * FROM users")).toBeNull();
|
||||
expect(getTSQLError("SELECT id, name FROM users WHERE id = 1")).toBeNull();
|
||||
});
|
||||
|
||||
it("should return error message for invalid queries", () => {
|
||||
const error = getTSQLError("SELEC * FROM users");
|
||||
expect(error).not.toBeNull();
|
||||
expect(typeof error).toBe("string");
|
||||
});
|
||||
|
||||
it("should include position information in error", () => {
|
||||
const error = getTSQLError("SELECT * FORM users");
|
||||
expect(error).not.toBeNull();
|
||||
// Error message should contain line/column info
|
||||
expect(error).toContain("line");
|
||||
});
|
||||
|
||||
it("should handle missing FROM clause", () => {
|
||||
const error = getTSQLError("SELECT * WHERE id = 1");
|
||||
expect(error).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import type { Diagnostic } from "@codemirror/lint";
|
||||
import type { TableSchema } from "@internal/tsql";
|
||||
import { parseTSQLSelect, SyntaxError, QueryError, validateQuery } from "@internal/tsql";
|
||||
|
||||
/**
|
||||
* Configuration for the TSQL linter
|
||||
*/
|
||||
export interface TSQLLinterConfig {
|
||||
/** Optional schema for validating table/column names */
|
||||
schema?: TableSchema[];
|
||||
/** Delay in milliseconds before running the linter (debouncing) */
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract line and column from a TSQL error message
|
||||
* Error format: "Syntax error at line X:Y: message"
|
||||
*/
|
||||
function parseErrorPosition(message: string): { line: number; column: number } | null {
|
||||
const match = message.match(/at line (\d+):(\d+)/);
|
||||
if (match) {
|
||||
return {
|
||||
line: parseInt(match[1], 10),
|
||||
column: parseInt(match[2], 10),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert line/column to a document position
|
||||
*/
|
||||
function positionToOffset(
|
||||
doc: string,
|
||||
line: number,
|
||||
column: number
|
||||
): number {
|
||||
const lines = doc.split("\n");
|
||||
|
||||
// line is 1-indexed
|
||||
let offset = 0;
|
||||
for (let i = 0; i < line - 1 && i < lines.length; i++) {
|
||||
offset += lines[i].length + 1; // +1 for newline
|
||||
}
|
||||
|
||||
return offset + column;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the end of a word/token at the given position
|
||||
*/
|
||||
function findTokenEnd(doc: string, start: number): number {
|
||||
let end = start;
|
||||
|
||||
// Scan forward until we hit whitespace or end of string
|
||||
while (end < doc.length && /\S/.test(doc[end])) {
|
||||
end++;
|
||||
}
|
||||
|
||||
// If we didn't move, include at least one character
|
||||
if (end === start) {
|
||||
end = Math.min(start + 1, doc.length);
|
||||
}
|
||||
|
||||
return end;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a TSQL linter function for CodeMirror
|
||||
*
|
||||
* This linter uses the TSQL ANTLR parser to detect syntax errors
|
||||
* and optionally validates against a schema.
|
||||
*
|
||||
* @param config - Linter configuration
|
||||
* @returns A linter function for use with CodeMirror's linter extension
|
||||
*/
|
||||
export function createTSQLLinter(
|
||||
config: TSQLLinterConfig = {}
|
||||
): (view: EditorView) => Diagnostic[] {
|
||||
const { schema = [] } = config;
|
||||
|
||||
return (view: EditorView): Diagnostic[] => {
|
||||
const content = view.state.doc.toString().trim();
|
||||
|
||||
// Return no errors for empty content
|
||||
if (!content) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
|
||||
try {
|
||||
// Try to parse the query
|
||||
const ast = parseTSQLSelect(content);
|
||||
|
||||
// If parsing succeeds and we have a schema, run schema validation
|
||||
if (schema.length > 0) {
|
||||
const validationResult = validateQuery(ast, schema);
|
||||
|
||||
for (const issue of validationResult.issues) {
|
||||
// Map validation severity to CodeMirror diagnostic severity
|
||||
const severity: "error" | "warning" | "info" =
|
||||
issue.severity === "error"
|
||||
? "error"
|
||||
: issue.severity === "warning"
|
||||
? "warning"
|
||||
: "info";
|
||||
|
||||
diagnostics.push({
|
||||
from: 0,
|
||||
to: content.length,
|
||||
severity,
|
||||
message: issue.message,
|
||||
source: "tsql",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
const position = parseErrorPosition(error.message);
|
||||
|
||||
let from: number;
|
||||
let to: number;
|
||||
|
||||
if (position) {
|
||||
from = positionToOffset(content, position.line, position.column);
|
||||
to = findTokenEnd(content, from);
|
||||
} else {
|
||||
// If we can't parse the position, highlight the whole query
|
||||
from = 0;
|
||||
to = content.length;
|
||||
}
|
||||
|
||||
// Clean up the error message
|
||||
let message = error.message;
|
||||
// Remove the "Syntax error at line X:Y: " prefix if present
|
||||
message = message.replace(/^Syntax error at line \d+:\d+:\s*/, "");
|
||||
|
||||
diagnostics.push({
|
||||
from,
|
||||
to,
|
||||
severity: "error",
|
||||
message: message,
|
||||
source: "tsql",
|
||||
});
|
||||
} else if (error instanceof QueryError) {
|
||||
// Schema validation errors don't have position info,
|
||||
// so highlight the whole query
|
||||
diagnostics.push({
|
||||
from: 0,
|
||||
to: content.length,
|
||||
severity: "warning",
|
||||
message: error.message,
|
||||
source: "tsql",
|
||||
});
|
||||
} else if (error instanceof Error) {
|
||||
// Unknown error
|
||||
diagnostics.push({
|
||||
from: 0,
|
||||
to: content.length,
|
||||
severity: "error",
|
||||
message: error.message,
|
||||
source: "tsql",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a TSQL query is valid
|
||||
*
|
||||
* @param query - The query to validate
|
||||
* @returns true if the query is valid, false otherwise
|
||||
*/
|
||||
export function isValidTSQLQuery(query: string): boolean {
|
||||
if (!query.trim()) {
|
||||
return true; // Empty queries are considered valid
|
||||
}
|
||||
|
||||
try {
|
||||
parseTSQLSelect(query);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error message for a TSQL query, if any
|
||||
*
|
||||
* @param query - The query to validate
|
||||
* @returns Error message if invalid, null if valid
|
||||
*/
|
||||
export function getTSQLError(query: string): string | null {
|
||||
if (!query.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
parseTSQLSelect(query);
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return "Unknown error";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,10 +51,14 @@ export function EnvironmentCombo({
|
||||
environment,
|
||||
className,
|
||||
iconClassName,
|
||||
tooltipSideOffset,
|
||||
tooltipSide,
|
||||
}: {
|
||||
environment: Environment;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
tooltipSideOffset?: number;
|
||||
tooltipSide?: "top" | "right" | "bottom" | "left";
|
||||
}) {
|
||||
return (
|
||||
<span className={cn("flex items-center gap-1.5 text-sm text-text-bright", className)}>
|
||||
@@ -62,7 +66,11 @@ export function EnvironmentCombo({
|
||||
environment={environment}
|
||||
className={cn("size-4.5 shrink-0", iconClassName)}
|
||||
/>
|
||||
<EnvironmentLabel environment={environment} />
|
||||
<EnvironmentLabel
|
||||
environment={environment}
|
||||
tooltipSideOffset={tooltipSideOffset}
|
||||
tooltipSide={tooltipSide}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -70,9 +78,13 @@ export function EnvironmentCombo({
|
||||
export function EnvironmentLabel({
|
||||
environment,
|
||||
className,
|
||||
tooltipSideOffset = 34,
|
||||
tooltipSide = "right",
|
||||
}: {
|
||||
environment: Environment;
|
||||
className?: string;
|
||||
tooltipSideOffset?: number;
|
||||
tooltipSide?: "top" | "right" | "bottom" | "left";
|
||||
}) {
|
||||
const spanRef = useRef<HTMLSpanElement>(null);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
@@ -115,9 +127,10 @@ export function EnvironmentLabel({
|
||||
{text}
|
||||
</span>
|
||||
}
|
||||
side="right"
|
||||
side={tooltipSide}
|
||||
variant="dark"
|
||||
sideOffset={34}
|
||||
sideOffset={tooltipSideOffset}
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -125,6 +138,10 @@ export function EnvironmentLabel({
|
||||
return content;
|
||||
}
|
||||
|
||||
export function EnvironmentSlug({ environment }: { environment: Environment & { slug: string } }) {
|
||||
return <span className={environmentTextClassName(environment)}>{environment.slug}</span>;
|
||||
}
|
||||
|
||||
export function environmentTitle(environment: Environment, username?: string) {
|
||||
if (environment.branchName) {
|
||||
return environment.branchName;
|
||||
|
||||
@@ -94,7 +94,11 @@ const RegenerateApiKeyModalContent = ({ id, randomWord, title, closeModal }: Mod
|
||||
Regenerate
|
||||
</Button>
|
||||
}
|
||||
cancelButton={<Button variant={"tertiary/medium"}>Cancel</Button>}
|
||||
cancelButton={
|
||||
<Button variant={"tertiary/medium"} type="button" onClick={closeModal}>
|
||||
Cancel
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</fetcher.Form>
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
import { XMarkIcon, ArrowTopRightOnSquareIcon, CheckIcon } from "@heroicons/react/20/solid";
|
||||
import { Link } from "@remix-run/react";
|
||||
import {
|
||||
type MachinePresetName,
|
||||
formatDurationMilliseconds,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTypedFetcher } from "remix-typedjson";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import { SimpleTooltip, InfoIconTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
|
||||
import { getLevelColor, getKindColor, getKindLabel } from "~/utils/logUtils";
|
||||
import { v3RunSpanPath, v3RunsPath, v3DeploymentVersionPath } from "~/utils/pathBuilder";
|
||||
import type { loader as logDetailLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId";
|
||||
import { TaskRunStatusCombo, descriptionForTaskRunStatus } from "~/components/runs/v3/TaskRunStatus";
|
||||
import { MachineLabelCombo } from "~/components/MachineLabelCombo";
|
||||
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||||
import { RunTag } from "~/components/runs/v3/RunTag";
|
||||
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
|
||||
import type { TaskRunStatus } from "@trigger.dev/database";
|
||||
import { PacketDisplay } from "~/components/runs/v3/PacketDisplay";
|
||||
import type { RunContext } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.run";
|
||||
|
||||
type RunContextData = {
|
||||
run: RunContext | null;
|
||||
};
|
||||
|
||||
|
||||
type LogDetailViewProps = {
|
||||
logId: string;
|
||||
// If we have the log entry from the list, we can display it immediately
|
||||
initialLog?: LogEntry;
|
||||
onClose: () => void;
|
||||
searchTerm?: string;
|
||||
};
|
||||
|
||||
type TabType = "details" | "run";
|
||||
|
||||
type LogAttributes = Record<string, unknown> & {
|
||||
error?: {
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function formatStringJSON(str: string): string {
|
||||
return str
|
||||
.replace(/\\n/g, "\n") // Converts literal "\n" to newline
|
||||
.replace(/\\t/g, "\t"); // Converts literal "\t" to tab
|
||||
}
|
||||
|
||||
|
||||
export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDetailViewProps) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const fetcher = useTypedFetcher<typeof logDetailLoader>();
|
||||
const [activeTab, setActiveTab] = useState<TabType>("details");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch full log details when logId changes
|
||||
useEffect(() => {
|
||||
if (!logId) return;
|
||||
|
||||
setError(null);
|
||||
fetcher.load(
|
||||
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/logs/${encodeURIComponent(logId)}`
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [organization.slug, project.slug, environment.slug, logId]);
|
||||
|
||||
// Handle fetch errors
|
||||
useEffect(() => {
|
||||
if (fetcher.data && typeof fetcher.data === "object" && "error" in fetcher.data) {
|
||||
setError(fetcher.data.error as string);
|
||||
} else if (fetcher.state === "idle" && fetcher.data === null && !initialLog) {
|
||||
setError("Failed to load log details");
|
||||
} else {
|
||||
setError(null);
|
||||
}
|
||||
}, [fetcher.data, initialLog, fetcher.state]);
|
||||
|
||||
const isLoading = fetcher.state === "loading";
|
||||
const log = fetcher.data ?? initialLog;
|
||||
|
||||
// Handle Escape key to close panel
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
if (isLoading && !log) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!log) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b border-grid-dimmed p-4">
|
||||
<Header2>Log Details</Header2>
|
||||
<Button variant="minimal/small" onClick={onClose}>
|
||||
<XMarkIcon className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Paragraph className="text-text-dimmed">{error ?? "Log not found"}</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const runPath = v3RunSpanPath(
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
{ friendlyId: log.runId },
|
||||
{ spanId: log.spanId }
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-grid-dimmed px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium",
|
||||
getKindColor(log.kind)
|
||||
)}
|
||||
>
|
||||
{getKindLabel(log.kind)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium uppercase",
|
||||
getLevelColor(log.level)
|
||||
)}
|
||||
>
|
||||
{log.level}
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="minimal/small" onClick={onClose} shortcut={{ key: "esc" }}>
|
||||
<XMarkIcon className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center justify-between border-b border-grid-dimmed px-4">
|
||||
<TabContainer>
|
||||
<TabButton
|
||||
isActive={activeTab === "details"}
|
||||
layoutId="log-detail-tabs"
|
||||
onClick={() => setActiveTab("details")}
|
||||
shortcut={{ key: "d" }}
|
||||
>
|
||||
Details
|
||||
</TabButton>
|
||||
<TabButton
|
||||
isActive={activeTab === "run"}
|
||||
layoutId="log-detail-tabs"
|
||||
onClick={() => setActiveTab("run")}
|
||||
shortcut={{ key: "r" }}
|
||||
>
|
||||
Run
|
||||
</TabButton>
|
||||
</TabContainer>
|
||||
<Link to={runPath} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="secondary/small" LeadingIcon={ArrowTopRightOnSquareIcon}>
|
||||
View Full Run
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{activeTab === "details" && (
|
||||
<DetailsTab log={log} runPath={runPath} searchTerm={searchTerm} />
|
||||
)}
|
||||
{activeTab === "run" && (
|
||||
<RunTab log={log} runPath={runPath} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailsTab({ log, runPath, searchTerm }: { log: LogEntry; runPath: string; searchTerm?: string }) {
|
||||
const logWithExtras = log as LogEntry & {
|
||||
attributes?: LogAttributes;
|
||||
};
|
||||
|
||||
|
||||
let beautifiedAttributes: string | null = null;
|
||||
|
||||
if (logWithExtras.attributes) {
|
||||
beautifiedAttributes = JSON.stringify(logWithExtras.attributes, null, 2);
|
||||
beautifiedAttributes = formatStringJSON(beautifiedAttributes);
|
||||
}
|
||||
|
||||
const showAttributes = beautifiedAttributes && beautifiedAttributes !== "{}";
|
||||
|
||||
// Determine message to show
|
||||
let message = log.message ?? "";
|
||||
if (log.level === "ERROR") {
|
||||
const maybeErrorMessage = logWithExtras.attributes?.error?.message;
|
||||
if (typeof maybeErrorMessage === "string" && maybeErrorMessage.length > 0) {
|
||||
message = maybeErrorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Time */}
|
||||
<div className="mb-6">
|
||||
<Header3 className="mb-2">Timestamp</Header3>
|
||||
<div className="text-sm text-text-dimmed">
|
||||
<DateTime date={log.startTime} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="mb-6">
|
||||
<PacketDisplay
|
||||
data={message}
|
||||
dataType="application/json"
|
||||
title="Message"
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Attributes - only available in full log detail */}
|
||||
{showAttributes && beautifiedAttributes && (
|
||||
<div className="mb-6">
|
||||
<PacketDisplay
|
||||
data={beautifiedAttributes}
|
||||
dataType="application/json"
|
||||
title="Attributes"
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RunTab({ log, runPath }: { log: LogEntry; runPath: string }) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const fetcher = useTypedFetcher<RunContextData>();
|
||||
|
||||
// Fetch run details when tab is active
|
||||
useEffect(() => {
|
||||
if (!log.runId) return;
|
||||
|
||||
fetcher.load(
|
||||
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/logs/${encodeURIComponent(log.id)}/run?runId=${encodeURIComponent(log.runId)}`
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [organization.slug, project.slug, environment.slug, log.id, log.runId]);
|
||||
|
||||
const isLoading = fetcher.state === "loading";
|
||||
const runData = fetcher.data?.run;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!runData) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<Paragraph className="text-text-dimmed">Run not found in database.</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>Run ID</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={runData.friendlyId} copyValue={runData.friendlyId} asChild />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Status</Property.Label>
|
||||
<Property.Value>
|
||||
<SimpleTooltip
|
||||
button={<TaskRunStatusCombo status={runData.status as TaskRunStatus} />}
|
||||
content={descriptionForTaskRunStatus(runData.status as TaskRunStatus)}
|
||||
disableHoverableContent
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Task</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText
|
||||
value={runData.taskIdentifier}
|
||||
copyValue={runData.taskIdentifier}
|
||||
asChild
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
{runData.rootRun && (
|
||||
<Property.Item>
|
||||
<Property.Label>Root and parent run</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText
|
||||
value={runData.rootRun.taskIdentifier}
|
||||
copyValue={runData.rootRun.taskIdentifier}
|
||||
asChild
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
|
||||
{runData.batch && (
|
||||
<Property.Item>
|
||||
<Property.Label>Batch</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText
|
||||
value={runData.batch.friendlyId}
|
||||
copyValue={runData.batch.friendlyId}
|
||||
asChild
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Version</Property.Label>
|
||||
<Property.Value>
|
||||
{runData.version ? (
|
||||
environment.type === "DEVELOPMENT" ? (
|
||||
<CopyableText value={runData.version} copyValue={runData.version} asChild />
|
||||
) : (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<TextLink
|
||||
to={v3DeploymentVersionPath(
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
runData.version
|
||||
)}
|
||||
className="group flex flex-wrap items-center gap-x-1 gap-y-0"
|
||||
>
|
||||
<CopyableText value={runData.version} copyValue={runData.version} asChild />
|
||||
</TextLink>
|
||||
}
|
||||
content={"Jump to deployment"}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<span>Never started</span>
|
||||
<InfoIconTooltip
|
||||
content={"Runs get locked to the latest version when they start."}
|
||||
contentClassName="normal-case tracking-normal"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Test run</Property.Label>
|
||||
<Property.Value>
|
||||
{runData.isTest ? <CheckIcon className="size-4 text-text-dimmed" /> : "–"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Environment</Property.Label>
|
||||
<Property.Value>
|
||||
<EnvironmentCombo environment={environment} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Queue</Property.Label>
|
||||
<Property.Value>
|
||||
<div>Name: {runData.queue}</div>
|
||||
<div>Concurrency key: {runData.concurrencyKey ? runData.concurrencyKey : "–"}</div>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
{runData.tags && runData.tags.length > 0 && (
|
||||
<Property.Item>
|
||||
<Property.Label>Tags</Property.Label>
|
||||
<Property.Value>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1 text-xs">
|
||||
{runData.tags.map((tag: string) => (
|
||||
<RunTag
|
||||
key={tag}
|
||||
tag={tag}
|
||||
to={v3RunsPath(organization, project, environment, { tags: [tag] })}
|
||||
tooltip={`Filter runs by ${tag}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Machine</Property.Label>
|
||||
<Property.Value className="-ml-0.5">
|
||||
{runData.machinePreset ? (
|
||||
<MachineLabelCombo preset={runData.machinePreset as MachinePresetName} />
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Run invocation cost</Property.Label>
|
||||
<Property.Value>
|
||||
{runData.baseCostInCents > 0
|
||||
? formatCurrencyAccurate(runData.baseCostInCents / 100)
|
||||
: "–"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Compute cost</Property.Label>
|
||||
<Property.Value>
|
||||
{runData.costInCents > 0 ? formatCurrencyAccurate(runData.costInCents / 100) : "–"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Total cost</Property.Label>
|
||||
<Property.Value>
|
||||
{runData.costInCents > 0 || runData.baseCostInCents > 0
|
||||
? formatCurrencyAccurate((runData.baseCostInCents + runData.costInCents) / 100)
|
||||
: "–"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Usage duration</Property.Label>
|
||||
<Property.Value>
|
||||
{runData.usageDurationMs > 0
|
||||
? formatDurationMilliseconds(runData.usageDurationMs, { style: "short" })
|
||||
: "–"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
import {
|
||||
ComboBox,
|
||||
SelectItem,
|
||||
SelectList,
|
||||
SelectPopover,
|
||||
SelectProvider,
|
||||
SelectTrigger,
|
||||
shortcutFromIndex,
|
||||
} from "~/components/primitives/Select";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { FilterMenuProvider, appliedSummary } from "~/components/runs/v3/SharedFilters";
|
||||
import type { LogLevel } from "~/presenters/v3/LogsListPresenter.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const allLogLevels: { level: LogLevel; label: string; color: string }[] = [
|
||||
{ level: "ERROR", label: "Error", color: "text-error" },
|
||||
{ level: "WARN", label: "Warning", color: "text-warning" },
|
||||
{ level: "INFO", label: "Info", color: "text-blue-400" },
|
||||
{ level: "CANCELLED", label: "Cancelled", color: "text-charcoal-400" },
|
||||
{ level: "DEBUG", label: "Debug", color: "text-charcoal-400" },
|
||||
{ level: "TRACE", label: "Trace", color: "text-charcoal-500" },
|
||||
];
|
||||
|
||||
function getAvailableLevels(showDebug: boolean): typeof allLogLevels {
|
||||
if (showDebug) {
|
||||
return allLogLevels;
|
||||
}
|
||||
return allLogLevels.filter((level) => level.level !== "DEBUG");
|
||||
}
|
||||
|
||||
function getLevelBadgeColor(level: LogLevel): string {
|
||||
switch (level) {
|
||||
case "ERROR":
|
||||
return "text-error bg-error/10 border-error/20";
|
||||
case "WARN":
|
||||
return "text-warning bg-warning/10 border-warning/20";
|
||||
case "DEBUG":
|
||||
return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
|
||||
case "INFO":
|
||||
return "text-blue-400 bg-blue-500/10 border-blue-500/20";
|
||||
case "TRACE":
|
||||
return "text-charcoal-500 bg-charcoal-800 border-charcoal-700";
|
||||
case "CANCELLED":
|
||||
return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
|
||||
default:
|
||||
return "text-text-dimmed bg-charcoal-750 border-charcoal-700";
|
||||
}
|
||||
}
|
||||
|
||||
const shortcut = { key: "l" };
|
||||
|
||||
export function LogsLevelFilter({ showDebug = false }: { showDebug?: boolean }) {
|
||||
const { values } = useSearchParams();
|
||||
const selectedLevels = values("levels");
|
||||
const hasLevels = selectedLevels.length > 0 && selectedLevels.some((v) => v !== "");
|
||||
|
||||
if (hasLevels) {
|
||||
return <AppliedLevelFilter showDebug={showDebug} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<LevelDropdown
|
||||
trigger={
|
||||
<SelectTrigger
|
||||
icon={<ExclamationTriangleIcon className="size-4" />}
|
||||
variant="secondary/small"
|
||||
shortcut={shortcut}
|
||||
tooltipTitle="Filter by level"
|
||||
>
|
||||
Level
|
||||
</SelectTrigger>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
showDebug={showDebug}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function LevelDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
showDebug = false,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
showDebug?: boolean;
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ levels: values, cursor: undefined, direction: undefined });
|
||||
};
|
||||
|
||||
const availableLevels = getAvailableLevels(showDebug);
|
||||
const filtered = useMemo(() => {
|
||||
return availableLevels.filter((item) =>
|
||||
item.label.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
}, [searchValue, availableLevels]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("levels")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder="Filter by level..." value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((item, index) => (
|
||||
<SelectItem
|
||||
key={item.level}
|
||||
value={item.level}
|
||||
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium uppercase",
|
||||
getLevelBadgeColor(item.level)
|
||||
)}
|
||||
>
|
||||
{item.level}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedLevelFilter({ showDebug = false }: { showDebug?: boolean }) {
|
||||
const { values, del } = useSearchParams();
|
||||
const levels = values("levels");
|
||||
|
||||
if (levels.length === 0 || levels.every((v) => v === "")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<LevelDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Level"
|
||||
icon={<ExclamationTriangleIcon className="size-4" />}
|
||||
value={appliedSummary(levels)}
|
||||
onRemove={() => del(["levels", "cursor", "direction"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
showDebug={showDebug}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import { FingerPrintIcon } from "@heroicons/react/20/solid";
|
||||
import { useCallback, useState } from "react";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import {
|
||||
SelectPopover,
|
||||
SelectProvider,
|
||||
SelectTrigger,
|
||||
} from "~/components/primitives/Select";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
|
||||
|
||||
const shortcut = { key: "r" };
|
||||
|
||||
export function LogsRunIdFilter() {
|
||||
const { value } = useSearchParams();
|
||||
const runIdValue = value("runId");
|
||||
|
||||
if (runIdValue) {
|
||||
return <AppliedRunIdFilter />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<RunIdDropdown
|
||||
trigger={
|
||||
<SelectTrigger
|
||||
icon={<FingerPrintIcon className="size-4" />}
|
||||
variant="secondary/small"
|
||||
shortcut={shortcut}
|
||||
tooltipTitle="Filter by run ID"
|
||||
>
|
||||
Run ID
|
||||
</SelectTrigger>
|
||||
}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function RunIdDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
onClose,
|
||||
}: {
|
||||
trigger: React.ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState<boolean | undefined>();
|
||||
const { value, replace } = useSearchParams();
|
||||
const runIdValue = value("runId");
|
||||
|
||||
const [runId, setRunId] = useState(runIdValue);
|
||||
|
||||
const apply = useCallback(() => {
|
||||
clearSearchValue();
|
||||
replace({
|
||||
cursor: undefined,
|
||||
direction: undefined,
|
||||
runId: runId === "" ? undefined : runId?.toString(),
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
}, [runId, replace, clearSearchValue]);
|
||||
|
||||
let error: string | undefined = undefined;
|
||||
if (runId) {
|
||||
if (!runId.startsWith("run_")) {
|
||||
error = "Run IDs start with 'run_'";
|
||||
} else if (runId.length !== 25 && runId.length !== 29) {
|
||||
error = "Run IDs are 25 or 29 characters long";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
hideOnEnter={false}
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
className="max-w-[min(32ch,var(--popover-available-width))]"
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>Run ID</Label>
|
||||
<Input
|
||||
placeholder="run_"
|
||||
value={runId ?? ""}
|
||||
onChange={(e) => setRunId(e.target.value)}
|
||||
variant="small"
|
||||
className="w-[27ch] font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{error ? <FormError>{error}</FormError> : null}
|
||||
</div>
|
||||
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
|
||||
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={error !== undefined || !runId}
|
||||
variant="secondary/small"
|
||||
shortcut={{
|
||||
modifiers: ["mod"],
|
||||
key: "Enter",
|
||||
enabledOnInputElements: true,
|
||||
}}
|
||||
onClick={() => apply()}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedRunIdFilter() {
|
||||
const { value, del } = useSearchParams();
|
||||
|
||||
const runId = value("runId");
|
||||
if (!runId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<RunIdDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Run ID"
|
||||
icon={<FingerPrintIcon className="size-4" />}
|
||||
value={runId}
|
||||
onRemove={() => del(["runId", "cursor", "direction"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
|
||||
export function LogsSearchInput() {
|
||||
const location = useOptimisticLocation();
|
||||
const navigate = useNavigate();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Get initial search value from URL
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const initialSearch = searchParams.get("search") ?? "";
|
||||
|
||||
const [text, setText] = useState(initialSearch);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
// Update text when URL search param changes (only when not focused to avoid overwriting user input)
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const urlSearch = params.get("search") ?? "";
|
||||
if (urlSearch !== text && !isFocused) {
|
||||
setText(urlSearch);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.search]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (text.trim()) {
|
||||
params.set("search", text.trim());
|
||||
} else {
|
||||
params.delete("search");
|
||||
}
|
||||
// Reset cursor when searching
|
||||
params.delete("cursor");
|
||||
params.delete("direction");
|
||||
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
|
||||
}, [text, location.pathname, location.search, navigate]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setText("");
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete("search");
|
||||
params.delete("cursor");
|
||||
params.delete("direction");
|
||||
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
|
||||
}, [location.pathname, location.search, navigate]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="relative h-6 min-w-52">
|
||||
<Input
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
variant="secondary-small"
|
||||
placeholder="Search logs…"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
fullWidth
|
||||
className={cn(isFocused && "placeholder:text-text-dimmed/70")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
icon={<MagnifyingGlassIcon className="size-4" />}
|
||||
accessory={
|
||||
text.length > 0 ? (
|
||||
<ShortcutKey shortcut={{ key: "enter" }} variant="small" />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{text.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="flex size-6 items-center justify-center rounded text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright"
|
||||
title="Clear search"
|
||||
>
|
||||
<XMarkIcon className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { ArrowPathIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
|
||||
import { getLevelColor, highlightSearchText } from "~/utils/logUtils";
|
||||
import { v3RunSpanPath } from "~/utils/pathBuilder";
|
||||
import { DateTime } from "../primitives/DateTime";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
import { TruncatedCopyableValue } from "../primitives/TruncatedCopyableValue";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellMenu,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
type TableVariant,
|
||||
} from "../primitives/Table";
|
||||
import { PopoverMenuItem } from "~/components/primitives/Popover";
|
||||
|
||||
type LogsTableProps = {
|
||||
logs: LogEntry[];
|
||||
searchTerm?: string;
|
||||
isLoading?: boolean;
|
||||
isLoadingMore?: boolean;
|
||||
hasMore?: boolean;
|
||||
onLoadMore?: () => void;
|
||||
variant?: TableVariant;
|
||||
selectedLogId?: string;
|
||||
onLogSelect?: (logId: string) => void;
|
||||
};
|
||||
|
||||
// Left border color for error highlighting
|
||||
function getLevelBorderColor(level: LogEntry["level"]): string {
|
||||
switch (level) {
|
||||
case "ERROR":
|
||||
return "border-l-error";
|
||||
case "WARN":
|
||||
return "border-l-warning";
|
||||
case "INFO":
|
||||
return "border-l-blue-500";
|
||||
case "CANCELLED":
|
||||
return "border-l-charcoal-600";
|
||||
case "DEBUG":
|
||||
case "TRACE":
|
||||
default:
|
||||
return "border-l-transparent hover:border-l-charcoal-800";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function LogsTable({
|
||||
logs,
|
||||
searchTerm,
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
hasMore = false,
|
||||
onLoadMore,
|
||||
selectedLogId,
|
||||
onLogSelect,
|
||||
}: LogsTableProps) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
const [showLoadMoreSpinner, setShowLoadMoreSpinner] = useState(false);
|
||||
|
||||
// Show load more spinner only after 0.2 seconds of loading time
|
||||
useEffect(() => {
|
||||
if (!isLoadingMore) {
|
||||
setShowLoadMoreSpinner(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setShowLoadMoreSpinner(true);
|
||||
}, 200);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [isLoadingMore]);
|
||||
|
||||
// Intersection observer for infinite scroll
|
||||
useEffect(() => {
|
||||
if (!hasMore || isLoadingMore || !onLoadMore) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
onLoadMore();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
const currentRef = loadMoreRef.current;
|
||||
if (currentRef) {
|
||||
observer.observe(currentRef);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (currentRef) {
|
||||
observer.unobserve(currentRef);
|
||||
}
|
||||
};
|
||||
}, [hasMore, isLoadingMore, onLoadMore]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<Table variant="compact/mono" containerClassName="overflow-visible">
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow>
|
||||
<TableHeaderCell className="min-w-48 whitespace-nowrap">Time</TableHeaderCell>
|
||||
<TableHeaderCell className="min-w-24 whitespace-nowrap">Run</TableHeaderCell>
|
||||
<TableHeaderCell className="min-w-32 whitespace-nowrap">Task</TableHeaderCell>
|
||||
<TableHeaderCell className="min-w-24 whitespace-nowrap">Level</TableHeaderCell>
|
||||
<TableHeaderCell className="w-full min-w-0">Message</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.length === 0 ? (
|
||||
<BlankState isLoading={isLoading} onRefresh={() => window.location.reload()} />
|
||||
) : (
|
||||
logs.map((log) => {
|
||||
const isSelected = selectedLogId === log.id;
|
||||
const runPath = v3RunSpanPath(
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
{ friendlyId: log.runId },
|
||||
{ spanId: log.spanId }
|
||||
);
|
||||
|
||||
const handleRowClick = () => onLogSelect?.(log.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={log.id}
|
||||
className={cn(
|
||||
"cursor-pointer border-l-2 transition-colors",
|
||||
getLevelBorderColor(log.level),
|
||||
isSelected ? "bg-charcoal-750" : "hover:bg-charcoal-850"
|
||||
)}
|
||||
isSelected={isSelected}
|
||||
>
|
||||
<TableCell
|
||||
className="whitespace-nowrap tabular-nums"
|
||||
onClick={handleRowClick}
|
||||
hasAction
|
||||
>
|
||||
<DateTime date={log.startTime} />
|
||||
</TableCell>
|
||||
<TableCell className="min-w-24">
|
||||
<TruncatedCopyableValue value={log.runId} />
|
||||
</TableCell>
|
||||
<TableCell className="min-w-32" onClick={handleRowClick} hasAction>
|
||||
<span className="font-mono text-xs">{log.taskIdentifier}</span>
|
||||
</TableCell>
|
||||
<TableCell onClick={handleRowClick} hasAction>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded border px-1 py-0.5 text-xxs font-medium uppercase tracking-wider",
|
||||
getLevelColor(log.level)
|
||||
)}
|
||||
>
|
||||
{log.level}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-0 truncate" onClick={handleRowClick} hasAction>
|
||||
<span className="block truncate font-mono text-xs" title={log.message}>
|
||||
{highlightSearchText(log.message, searchTerm)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCellMenu
|
||||
className="pl-32"
|
||||
hiddenButtons={
|
||||
<PopoverMenuItem
|
||||
openInNewTab={true}
|
||||
to={runPath}
|
||||
icon={ArrowTopRightOnSquareIcon}
|
||||
title="View Run"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{/* Infinite scroll trigger */}
|
||||
{hasMore && logs.length > 0 && (
|
||||
<div ref={loadMoreRef} className="flex items-center justify-center py-4">
|
||||
{showLoadMoreSpinner && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner /> <span className="text-text-dimmed">Loading more…</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlankState({ isLoading, onRefresh }: { isLoading?: boolean; onRefresh?: () => void }) {
|
||||
if (isLoading) return <TableBlankRow colSpan={6}></TableBlankRow>;
|
||||
|
||||
const handleRefresh = onRefresh ?? (() => window.location.reload());
|
||||
|
||||
return (
|
||||
<TableBlankRow colSpan={6}>
|
||||
<div className="flex flex-col items-center justify-center gap-6">
|
||||
<Paragraph className="w-auto" variant="base/bright">
|
||||
No logs match your filters. Try refreshing or modifying your filters.
|
||||
</Paragraph>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
LeadingIcon={ArrowPathIcon}
|
||||
variant="tertiary/medium"
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TableBlankRow>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
AdjustmentsHorizontalIcon,
|
||||
ArrowPathRoundedSquareIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
BeakerIcon,
|
||||
BellAlertIcon,
|
||||
ChartBarIcon,
|
||||
ChevronRightIcon,
|
||||
CircleStackIcon,
|
||||
ClockIcon,
|
||||
Cog8ToothIcon,
|
||||
CogIcon,
|
||||
@@ -13,24 +15,28 @@ import {
|
||||
GlobeAmericasIcon,
|
||||
IdentificationIcon,
|
||||
KeyIcon,
|
||||
MagnifyingGlassCircleIcon,
|
||||
PencilSquareIcon,
|
||||
PlusIcon,
|
||||
RectangleStackIcon,
|
||||
ServerStackIcon,
|
||||
Squares2X2Icon,
|
||||
TableCellsIcon,
|
||||
UsersIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Link, useNavigation } from "@remix-run/react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import simplur from "simplur";
|
||||
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
|
||||
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
|
||||
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
|
||||
import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon";
|
||||
import { LogsIcon } from "~/assets/icons/LogsIcon";
|
||||
import { RunsIconExtraSmall } from "~/assets/icons/RunsIcon";
|
||||
import { TaskIconSmall } from "~/assets/icons/TaskIcon";
|
||||
import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon";
|
||||
import { Avatar } from "~/components/primitives/Avatar";
|
||||
import { type MatchedEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useFeatureFlags } from "~/hooks/useFeatureFlags";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { type MatchedOrganization } from "~/hooks/useOrganizations";
|
||||
import { type MatchedProject } from "~/hooks/useProject";
|
||||
@@ -45,12 +51,14 @@ import {
|
||||
adminPath,
|
||||
branchesPath,
|
||||
concurrencyPath,
|
||||
limitsPath,
|
||||
logoutPath,
|
||||
newOrganizationPath,
|
||||
newProjectPath,
|
||||
organizationPath,
|
||||
organizationSettingsPath,
|
||||
organizationTeamPath,
|
||||
queryPath,
|
||||
regionsPath,
|
||||
v3ApiKeysPath,
|
||||
v3BatchesPath,
|
||||
@@ -59,6 +67,7 @@ import {
|
||||
v3DeploymentsPath,
|
||||
v3EnvironmentPath,
|
||||
v3EnvironmentVariablesPath,
|
||||
v3LogsPath,
|
||||
v3ProjectAlertsPath,
|
||||
v3ProjectPath,
|
||||
v3ProjectSettingsPath,
|
||||
@@ -93,6 +102,7 @@ import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
|
||||
import { SideMenuHeader } from "./SideMenuHeader";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
import { SideMenuSection } from "./SideMenuSection";
|
||||
import { AlphaBadge } from "../AlphaBadge";
|
||||
|
||||
type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
|
||||
export type SideMenuProject = Pick<
|
||||
@@ -125,6 +135,7 @@ export function SideMenu({
|
||||
const isFreeUser = currentPlan?.v3Subscription?.isPaying === false;
|
||||
const isAdmin = useHasAdminAccess();
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const featureFlags = useFeatureFlags();
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
@@ -260,6 +271,16 @@ export function SideMenu({
|
||||
to={v3DeploymentsPath(organization, project, environment)}
|
||||
data-action="deployments"
|
||||
/>
|
||||
{(user.admin || user.isImpersonating || featureFlags.hasLogsPageAccess) && (
|
||||
<SideMenuItem
|
||||
name="Logs"
|
||||
icon={LogsIcon}
|
||||
activeIconColor="text-logs"
|
||||
to={v3LogsPath(organization, project, environment)}
|
||||
data-action="logs"
|
||||
badge={<AlphaBadge />}
|
||||
/>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Test"
|
||||
icon={BeakerIcon}
|
||||
@@ -267,6 +288,16 @@ export function SideMenu({
|
||||
to={v3TestPath(organization, project, environment)}
|
||||
data-action="test"
|
||||
/>
|
||||
{(user.admin || user.isImpersonating || featureFlags.hasQueryAccess) && (
|
||||
<SideMenuItem
|
||||
name="Query"
|
||||
icon={TableCellsIcon}
|
||||
activeIconColor="text-purple-500"
|
||||
to={queryPath(organization, project, environment)}
|
||||
data-action="query"
|
||||
badge={<AlphaBadge />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SideMenuSection title="Waitpoints">
|
||||
@@ -320,7 +351,7 @@ export function SideMenu({
|
||||
<SideMenuItem
|
||||
name="Concurrency"
|
||||
icon={ConcurrencyIcon}
|
||||
activeIconColor="text-amber-500"
|
||||
activeIconColor="text-concurrency"
|
||||
to={concurrencyPath(organization, project, environment)}
|
||||
data-action="concurrency"
|
||||
/>
|
||||
@@ -328,11 +359,18 @@ export function SideMenu({
|
||||
<SideMenuItem
|
||||
name="Regions"
|
||||
icon={GlobeAmericasIcon}
|
||||
activeIconColor="text-green-500"
|
||||
activeIconColor="text-regions"
|
||||
to={regionsPath(organization, project, environment)}
|
||||
data-action="regions"
|
||||
badge={<V4Badge />}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Limits"
|
||||
icon={AdjustmentsHorizontalIcon}
|
||||
activeIconColor="text-limits"
|
||||
to={limitsPath(organization, project, environment)}
|
||||
data-action="limits"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Project settings"
|
||||
icon={Cog8ToothIcon}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { animate, motion, useMotionValue, useTransform } from "framer-motion";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function AnimatedNumber({ value }: { value: number }) {
|
||||
export function AnimatedNumber({ value, duration = 0.5 }: { value: number; duration?: number }) {
|
||||
const motionValue = useMotionValue(value);
|
||||
let display = useTransform(motionValue, (current) => Math.round(current).toLocaleString());
|
||||
|
||||
useEffect(() => {
|
||||
animate(motionValue, value, {
|
||||
duration: 0.5,
|
||||
duration,
|
||||
ease: "easeInOut",
|
||||
});
|
||||
}, [value]);
|
||||
}, [value, duration]);
|
||||
|
||||
return <motion.span>{display}</motion.span>;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ type Variant = keyof typeof variants;
|
||||
|
||||
type AppliedFilterProps = {
|
||||
icon?: ReactNode;
|
||||
label: ReactNode;
|
||||
label?: ReactNode;
|
||||
value: ReactNode;
|
||||
removable?: boolean;
|
||||
onRemove?: () => void;
|
||||
@@ -48,12 +48,12 @@ export function AppliedFilter({
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-0.5 leading-4">
|
||||
<div className={cn("flex items-start leading-4", label === undefined ? "gap-1.5" : "gap-0.5")}>
|
||||
<div className="-mt-[0.5px] flex items-center gap-1">
|
||||
{icon}
|
||||
<div className="text-text-bright">
|
||||
{label && <div className="text-text-bright">
|
||||
<span>{label}</span>:
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
<div className="text-text-dimmed">
|
||||
<div>{value}</div>
|
||||
|
||||
@@ -331,7 +331,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
|
||||
type LinkPropsType = Pick<
|
||||
LinkProps,
|
||||
"to" | "target" | "onClick" | "onMouseDown" | "onMouseEnter" | "onMouseLeave" | "download"
|
||||
> & { disabled?: boolean } & React.ComponentProps<typeof ButtonContent>;
|
||||
> & { disabled?: boolean; replace?: boolean } & React.ComponentProps<typeof ButtonContent>;
|
||||
export const LinkButton = ({
|
||||
to,
|
||||
onClick,
|
||||
@@ -340,6 +340,7 @@ export const LinkButton = ({
|
||||
onMouseLeave,
|
||||
download,
|
||||
disabled = false,
|
||||
replace,
|
||||
...props
|
||||
}: LinkPropsType) => {
|
||||
const innerRef = useRef<HTMLAnchorElement>(null);
|
||||
@@ -372,7 +373,7 @@ export const LinkButton = ({
|
||||
<ExtLink
|
||||
href={to.toString()}
|
||||
ref={innerRef}
|
||||
className={cn("group/button focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
className={cn("group/button block focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseEnter={onMouseEnter}
|
||||
@@ -387,7 +388,8 @@ export const LinkButton = ({
|
||||
<Link
|
||||
to={to}
|
||||
ref={innerRef}
|
||||
className={cn("group/button focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
replace={replace}
|
||||
className={cn("group/button block focus-custom", props.fullWidth ? "w-full" : "w-fit")}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseEnter={onMouseEnter}
|
||||
@@ -408,7 +410,7 @@ export const NavLinkButton = ({ to, className, target, ...props }: NavLinkPropsT
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={cn("group/button outline-none", props.fullWidth ? "w-full" : "")}
|
||||
className={cn("group/button outline-none block", props.fullWidth ? "w-full" : "")}
|
||||
target={target}
|
||||
>
|
||||
{({ isActive, isPending }) => (
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||
import { format } from "date-fns";
|
||||
import { DayPicker, useDayPicker } from "react-day-picker";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
||||
|
||||
const navButtonClass =
|
||||
"size-7 rounded-[3px] bg-secondary border border-charcoal-600 text-text-bright hover:bg-charcoal-600 hover:border-charcoal-550 transition inline-flex items-center justify-center";
|
||||
|
||||
function CustomMonthCaption({ calendarMonth }: { calendarMonth: { date: Date } }) {
|
||||
const { goToMonth, nextMonth, previousMonth } = useDayPicker();
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between px-1">
|
||||
<button
|
||||
type="button"
|
||||
className={navButtonClass}
|
||||
disabled={!previousMonth}
|
||||
onClick={() => previousMonth && goToMonth(previousMonth)}
|
||||
aria-label="Go to previous month"
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
className="rounded border border-charcoal-600 bg-charcoal-750 px-2 py-1 text-sm text-text-bright focus:border-charcoal-500 focus:outline-none"
|
||||
value={calendarMonth.date.getMonth()}
|
||||
onChange={(e) => {
|
||||
const newDate = new Date(calendarMonth.date);
|
||||
newDate.setMonth(parseInt(e.target.value));
|
||||
goToMonth(newDate);
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<option key={i} value={i}>
|
||||
{format(new Date(2000, i), "MMM")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="rounded border border-charcoal-600 bg-charcoal-750 px-2 py-1 text-sm text-text-bright focus:border-charcoal-500 focus:outline-none"
|
||||
value={calendarMonth.date.getFullYear()}
|
||||
onChange={(e) => {
|
||||
const newDate = new Date(calendarMonth.date);
|
||||
newDate.setFullYear(parseInt(e.target.value));
|
||||
goToMonth(newDate);
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 100 }, (_, i) => {
|
||||
const year = new Date().getFullYear() - 50 + i;
|
||||
return (
|
||||
<option key={year} value={year}>
|
||||
{year}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={navButtonClass}
|
||||
disabled={!nextMonth}
|
||||
onClick={() => nextMonth && goToMonth(nextMonth)}
|
||||
aria-label="Go to next month"
|
||||
>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
weekStartsOn={1}
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row gap-2",
|
||||
month: "flex flex-col gap-4",
|
||||
month_caption: "flex justify-center pt-1 relative items-center w-full",
|
||||
caption_label: "sr-only",
|
||||
nav: "hidden",
|
||||
month_grid: "w-full border-collapse",
|
||||
weekdays: "flex",
|
||||
weekday: "text-text-dimmed rounded-md w-8 font-normal text-[0.8rem]",
|
||||
week: "flex w-full mt-2",
|
||||
day: "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-charcoal-700 [&:has([aria-selected].day-outside)]:bg-charcoal-700/50 [&:has([aria-selected].day-range-end)]:rounded-r-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md",
|
||||
day_button: cn(
|
||||
"size-8 p-0 font-normal text-text-bright rounded-md",
|
||||
"hover:bg-charcoal-700 hover:text-text-bright",
|
||||
"focus:bg-charcoal-700 focus:text-text-bright focus:outline-none",
|
||||
"aria-selected:opacity-100"
|
||||
),
|
||||
range_start: "day-range-start rounded-l-md",
|
||||
range_end: "day-range-end rounded-r-md",
|
||||
selected:
|
||||
"bg-indigo-600 text-text-bright hover:bg-indigo-600 hover:text-text-bright focus:bg-indigo-600 focus:text-text-bright rounded-md",
|
||||
today: "bg-charcoal-700 text-text-bright rounded-md",
|
||||
outside:
|
||||
"day-outside text-text-dimmed opacity-50 aria-selected:bg-charcoal-700/50 aria-selected:text-text-dimmed aria-selected:opacity-30",
|
||||
disabled: "text-text-dimmed opacity-50",
|
||||
range_middle: "aria-selected:bg-charcoal-700 aria-selected:text-text-bright",
|
||||
hidden: "invisible",
|
||||
dropdowns: "flex gap-2 items-center justify-center",
|
||||
dropdown:
|
||||
"bg-charcoal-750 border border-charcoal-600 rounded px-2 py-1 text-sm text-text-bright focus:outline-none focus:border-charcoal-500",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
MonthCaption: CustomMonthCaption,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Calendar.displayName = "Calendar";
|
||||
@@ -1,41 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { motion } from "framer-motion";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import * as React from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { type Variants } from "./Tabs";
|
||||
|
||||
type ClientTabsContextValue = {
|
||||
value?: string;
|
||||
};
|
||||
|
||||
const ClientTabsContext = React.createContext<ClientTabsContextValue | undefined>(undefined);
|
||||
|
||||
function useClientTabsContext() {
|
||||
return React.useContext(ClientTabsContext);
|
||||
}
|
||||
|
||||
const ClientTabs = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>
|
||||
>((props, ref) => <TabsPrimitive.Root ref={ref} {...props} />);
|
||||
>(({ onValueChange, value: valueProp, defaultValue, ...props }, ref) => {
|
||||
const [value, setValue] = React.useState<string | undefined>(valueProp ?? defaultValue);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (valueProp !== undefined) {
|
||||
setValue(valueProp);
|
||||
}
|
||||
}, [valueProp]);
|
||||
|
||||
const handleValueChange = React.useCallback(
|
||||
(nextValue: string) => {
|
||||
if (valueProp === undefined) {
|
||||
setValue(nextValue);
|
||||
}
|
||||
onValueChange?.(nextValue);
|
||||
},
|
||||
[onValueChange, valueProp]
|
||||
);
|
||||
|
||||
const controlledProps =
|
||||
valueProp !== undefined
|
||||
? { value: valueProp }
|
||||
: defaultValue !== undefined
|
||||
? { defaultValue }
|
||||
: {};
|
||||
|
||||
const contextValue = React.useMemo<ClientTabsContextValue>(() => ({ value }), [value]);
|
||||
|
||||
return (
|
||||
<ClientTabsContext.Provider value={contextValue}>
|
||||
<TabsPrimitive.Root
|
||||
ref={ref}
|
||||
onValueChange={handleValueChange}
|
||||
{...controlledProps}
|
||||
{...props}
|
||||
/>
|
||||
</ClientTabsContext.Provider>
|
||||
);
|
||||
});
|
||||
ClientTabs.displayName = TabsPrimitive.Root.displayName;
|
||||
|
||||
const ClientTabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("inline-flex items-center justify-center transition duration-100", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & {
|
||||
variant?: Variants;
|
||||
}
|
||||
>(({ className, variant = "pipe-divider", ...props }, ref) => {
|
||||
const variantClassName = (() => {
|
||||
switch (variant) {
|
||||
case "segmented":
|
||||
return "relative flex h-10 w-full items-center rounded bg-charcoal-700/50 p-1";
|
||||
case "underline":
|
||||
return "flex gap-x-6 border-b border-grid-bright";
|
||||
default:
|
||||
return "inline-flex items-center justify-center transition duration-100";
|
||||
}
|
||||
})();
|
||||
|
||||
return <TabsPrimitive.List ref={ref} className={cn(variantClassName, className)} {...props} />;
|
||||
});
|
||||
ClientTabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const ClientTabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> & {
|
||||
variant?: Variants;
|
||||
layoutId?: string;
|
||||
}
|
||||
>(({ className, variant = "pipe-divider", layoutId, children, ...props }, ref) => {
|
||||
const context = useClientTabsContext();
|
||||
const activeValue = context?.value;
|
||||
const isActive = activeValue === props.value;
|
||||
|
||||
if (variant === "segmented") {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group relative flex h-full grow items-center justify-center focus-custom disabled:pointer-events-none disabled:opacity-50",
|
||||
"flex-1 basis-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative z-10 flex h-full w-full items-center justify-center px-3 py-[0.13rem]">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive
|
||||
? "text-text-bright"
|
||||
: "text-text-dimmed transition group-hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
{isActive ? (
|
||||
layoutId ? (
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ duration: 0.4, type: "spring" }}
|
||||
className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600" />
|
||||
)
|
||||
) : null}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "underline") {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group flex flex-col items-center pt-1 focus-custom disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive ? "text-text-bright" : "text-text-dimmed hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{layoutId ? (
|
||||
isActive ? (
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className="mt-1 h-0.5 w-full bg-indigo-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
)
|
||||
) : isActive ? (
|
||||
<div className="mt-1 h-0.5 w-full bg-indigo-500" />
|
||||
) : (
|
||||
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
)}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
});
|
||||
ClientTabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const ClientTabsContent = React.forwardRef<
|
||||
@@ -61,39 +205,7 @@ export type TabsProps = {
|
||||
currentValue: string;
|
||||
className?: string;
|
||||
layoutId: string;
|
||||
variant?: Variants;
|
||||
};
|
||||
|
||||
export function ClientTabsWithUnderline({ className, tabs, currentValue, layoutId }: TabsProps) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
className={cn(`flex flex-row gap-x-6 border-b border-charcoal-700`, className)}
|
||||
>
|
||||
{tabs.map((tab, index) => {
|
||||
const isActive = currentValue === tab.value;
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className={cn(`group flex flex-col items-center`, className)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive ? "text-indigo-500" : "text-charcoal-200"
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
|
||||
) : (
|
||||
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
)}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
})}
|
||||
</TabsPrimitive.List>
|
||||
);
|
||||
}
|
||||
|
||||
export { ClientTabs, ClientTabsList, ClientTabsTrigger, ClientTabsContent };
|
||||
export { ClientTabs, ClientTabsContent, ClientTabsList, ClientTabsTrigger };
|
||||
|
||||
@@ -3,59 +3,96 @@ import { useState } from "react";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useCopy } from "~/hooks/useCopy";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "./Buttons";
|
||||
|
||||
export function CopyableText({
|
||||
value,
|
||||
copyValue,
|
||||
className,
|
||||
asChild,
|
||||
variant,
|
||||
}: {
|
||||
value: string;
|
||||
copyValue?: string;
|
||||
className?: string;
|
||||
asChild?: boolean;
|
||||
variant?: "icon-right" | "text-below";
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const { copy, copied } = useCopy(copyValue ?? value);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("group relative inline-flex h-6 items-center", className)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<span onMouseEnter={() => setIsHovered(true)}>{value}</span>
|
||||
const resolvedVariant = variant ?? "icon-right";
|
||||
|
||||
if (resolvedVariant === "icon-right") {
|
||||
return (
|
||||
<span
|
||||
onClick={copy}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"absolute -right-6 top-0 z-10 size-6 font-sans",
|
||||
isHovered ? "flex" : "hidden"
|
||||
)}
|
||||
className={cn("group relative inline-flex h-6 items-center", className)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<span
|
||||
className={cn(
|
||||
"ml-1 flex size-6 items-center justify-center rounded border border-charcoal-650 bg-charcoal-750",
|
||||
asChild && "p-1",
|
||||
copied
|
||||
? "text-green-500"
|
||||
: "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<ClipboardCheckIcon className="size-3.5" />
|
||||
) : (
|
||||
<ClipboardIcon className="size-3.5" />
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
content={copied ? "Copied!" : "Copy"}
|
||||
className="font-sans"
|
||||
disableHoverableContent
|
||||
asChild={asChild}
|
||||
/>
|
||||
<span onMouseEnter={() => setIsHovered(true)}>{value}</span>
|
||||
<span
|
||||
onClick={copy}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"absolute -right-6 top-0 z-10 size-6 font-sans",
|
||||
isHovered ? "flex" : "hidden"
|
||||
)}
|
||||
>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<span
|
||||
className={cn(
|
||||
"ml-1 flex size-6 items-center justify-center rounded border border-charcoal-650 bg-charcoal-750",
|
||||
asChild && "p-1",
|
||||
copied
|
||||
? "text-green-500"
|
||||
: "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<ClipboardCheckIcon className="size-3.5" />
|
||||
) : (
|
||||
<ClipboardIcon className="size-3.5" />
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
content={copied ? "Copied!" : "Copy"}
|
||||
className="font-sans"
|
||||
disableHoverableContent
|
||||
asChild={asChild}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
if (resolvedVariant === "text-below") {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copy();
|
||||
}}
|
||||
className={cn(
|
||||
"cursor-pointer bg-transparent px-1 py-0 text-left text-text-dimmed transition-colors hover:bg-transparent",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="transition-colors group-hover/button:text-text-bright">{value}</span>
|
||||
</Button>
|
||||
}
|
||||
content={copied ? "Copied" : "Copy"}
|
||||
className="px-2 py-1 font-sans"
|
||||
disableHoverableContent
|
||||
open={isHovered || copied}
|
||||
onOpenChange={setIsHovered}
|
||||
asChild
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { BellAlertIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { CalendarDateTime, createCalendar } from "@internationalized/date";
|
||||
import { useDateField, useDateSegment } from "@react-aria/datepicker";
|
||||
import type { DateFieldState, DateSegment } from "@react-stately/datepicker";
|
||||
import { useDateFieldState } from "@react-stately/datepicker";
|
||||
import { Granularity } from "@react-types/datepicker";
|
||||
import {
|
||||
useDateFieldState,
|
||||
type DateFieldState,
|
||||
type DateSegment,
|
||||
} from "@react-stately/datepicker";
|
||||
import { type Granularity } from "@react-types/datepicker";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "./Buttons";
|
||||
|
||||
@@ -1,16 +1,50 @@
|
||||
import { GlobeAltIcon, GlobeAmericasIcon } from "@heroicons/react/20/solid";
|
||||
import { Laptop } from "lucide-react";
|
||||
import { Fragment, type ReactNode, useEffect, useState } from "react";
|
||||
import { memo, type ReactNode, useMemo, useSyncExternalStore } from "react";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { useLocales } from "./LocaleProvider";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
import { SimpleTooltip } from "./Tooltip";
|
||||
|
||||
// Cache the browser's local timezone - resolved once and reused
|
||||
let cachedLocalTimeZone: string | null = null;
|
||||
|
||||
function getLocalTimeZone(): string {
|
||||
if (cachedLocalTimeZone === null) {
|
||||
cachedLocalTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
}
|
||||
return cachedLocalTimeZone;
|
||||
}
|
||||
|
||||
// For SSR compatibility: returns "UTC" on server, actual timezone on client
|
||||
function subscribeToTimeZone() {
|
||||
// No-op - timezone doesn't change
|
||||
return () => { };
|
||||
}
|
||||
|
||||
function getTimeZoneSnapshot(): string {
|
||||
return getLocalTimeZone();
|
||||
}
|
||||
|
||||
function getServerTimeZoneSnapshot(): string {
|
||||
return "UTC";
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the browser's local timezone.
|
||||
* Uses useSyncExternalStore for SSR compatibility - returns "UTC" on server,
|
||||
* actual timezone on client. The timezone is cached and only resolved once.
|
||||
*/
|
||||
export function useLocalTimeZone(): string {
|
||||
return useSyncExternalStore(subscribeToTimeZone, getTimeZoneSnapshot, getServerTimeZoneSnapshot);
|
||||
}
|
||||
|
||||
type DateTimeProps = {
|
||||
date: Date | string;
|
||||
timeZone?: string;
|
||||
includeSeconds?: boolean;
|
||||
includeTime?: boolean;
|
||||
includeDate?: boolean;
|
||||
showTimezone?: boolean;
|
||||
showTooltip?: boolean;
|
||||
hideDate?: boolean;
|
||||
@@ -23,46 +57,48 @@ export const DateTime = ({
|
||||
timeZone,
|
||||
includeSeconds = true,
|
||||
includeTime = true,
|
||||
includeDate = true,
|
||||
showTimezone = false,
|
||||
showTooltip = true,
|
||||
hour12 = true,
|
||||
}: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
const [localTimeZone, setLocalTimeZone] = useState<string>("UTC");
|
||||
const localTimeZone = useLocalTimeZone();
|
||||
|
||||
const realDate = typeof date === "string" ? new Date(date) : date;
|
||||
|
||||
useEffect(() => {
|
||||
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
|
||||
setLocalTimeZone(resolvedOptions.timeZone);
|
||||
}, []);
|
||||
|
||||
const tooltipContent = (
|
||||
<TooltipContent
|
||||
realDate={realDate}
|
||||
timeZone={timeZone}
|
||||
localTimeZone={localTimeZone}
|
||||
locales={locales}
|
||||
/>
|
||||
);
|
||||
const realDate = useMemo(() => (typeof date === "string" ? new Date(date) : date), [date]);
|
||||
|
||||
const formattedDateTime = (
|
||||
<Fragment>
|
||||
<span suppressHydrationWarning>
|
||||
{formatDateTime(
|
||||
realDate,
|
||||
timeZone ?? localTimeZone,
|
||||
locales,
|
||||
includeSeconds,
|
||||
includeTime,
|
||||
includeDate,
|
||||
hour12
|
||||
).replace(/\s/g, String.fromCharCode(32))}
|
||||
{showTimezone ? ` (${timeZone ?? "UTC"})` : null}
|
||||
</Fragment>
|
||||
</span>
|
||||
);
|
||||
|
||||
if (!showTooltip) return formattedDateTime;
|
||||
|
||||
return <SimpleTooltip button={formattedDateTime} content={tooltipContent} side="right" />;
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={formattedDateTime}
|
||||
content={
|
||||
<TooltipContent
|
||||
realDate={realDate}
|
||||
timeZone={timeZone}
|
||||
localTimeZone={localTimeZone}
|
||||
locales={locales}
|
||||
/>
|
||||
}
|
||||
side="right"
|
||||
asChild={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export function formatDateTime(
|
||||
@@ -71,12 +107,13 @@ export function formatDateTime(
|
||||
locales: string[],
|
||||
includeSeconds: boolean,
|
||||
includeTime: boolean,
|
||||
includeDate: boolean = true,
|
||||
hour12: boolean = true
|
||||
): string {
|
||||
return new Intl.DateTimeFormat(locales, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: includeDate ? "numeric" : undefined,
|
||||
month: includeDate ? "short" : undefined,
|
||||
day: includeDate ? "numeric" : undefined,
|
||||
hour: includeTime ? "numeric" : undefined,
|
||||
minute: includeTime ? "numeric" : undefined,
|
||||
second: includeTime && includeSeconds ? "numeric" : undefined,
|
||||
@@ -128,8 +165,9 @@ export function formatDateTimeISO(date: Date, timeZone: string): string {
|
||||
}
|
||||
|
||||
// New component that only shows date when it changes
|
||||
export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC", hour12 = true }: DateTimeProps) => {
|
||||
export const SmartDateTime = ({ date, previousDate = null, hour12 = true }: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
const localTimeZone = useLocalTimeZone();
|
||||
const realDate = typeof date === "string" ? new Date(date) : date;
|
||||
const realPrevDate = previousDate
|
||||
? typeof previousDate === "string"
|
||||
@@ -137,31 +175,15 @@ export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC", hou
|
||||
: previousDate
|
||||
: null;
|
||||
|
||||
// Initial formatted values
|
||||
const initialTimeOnly = formatTimeOnly(realDate, timeZone, locales, hour12);
|
||||
const initialWithDate = formatSmartDateTime(realDate, timeZone, locales, hour12);
|
||||
// Check if we should show the date
|
||||
const showDatePart = !realPrevDate || !isSameDay(realDate, realPrevDate);
|
||||
|
||||
// State for the formatted time
|
||||
const [formattedDateTime, setFormattedDateTime] = useState<string>(
|
||||
realPrevDate && isSameDay(realDate, realPrevDate) ? initialTimeOnly : initialWithDate
|
||||
);
|
||||
// Format with appropriate function
|
||||
const formattedDateTime = showDatePart
|
||||
? formatSmartDateTime(realDate, localTimeZone, locales, hour12)
|
||||
: formatTimeOnly(realDate, localTimeZone, locales, hour12);
|
||||
|
||||
useEffect(() => {
|
||||
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
|
||||
const userTimeZone = resolvedOptions.timeZone;
|
||||
|
||||
// Check if we should show the date
|
||||
const showDatePart = !realPrevDate || !isSameDay(realDate, realPrevDate);
|
||||
|
||||
// Format with appropriate function
|
||||
setFormattedDateTime(
|
||||
showDatePart
|
||||
? formatSmartDateTime(realDate, userTimeZone, locales, hour12)
|
||||
: formatTimeOnly(realDate, userTimeZone, locales, hour12)
|
||||
);
|
||||
}, [locales, realDate, realPrevDate, hour12]);
|
||||
|
||||
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
|
||||
return <span suppressHydrationWarning>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</span>;
|
||||
};
|
||||
|
||||
// Helper function to check if two dates are on the same day
|
||||
@@ -174,7 +196,12 @@ function isSameDay(date1: Date, date2: Date): boolean {
|
||||
}
|
||||
|
||||
// Format with date and time
|
||||
function formatSmartDateTime(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
|
||||
function formatSmartDateTime(
|
||||
date: Date,
|
||||
timeZone: string,
|
||||
locales: string[],
|
||||
hour12: boolean = true
|
||||
): string {
|
||||
return new Intl.DateTimeFormat(locales, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
@@ -189,7 +216,12 @@ function formatSmartDateTime(date: Date, timeZone: string, locales: string[], ho
|
||||
}
|
||||
|
||||
// Format time only
|
||||
function formatTimeOnly(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
|
||||
function formatTimeOnly(
|
||||
date: Date,
|
||||
timeZone: string,
|
||||
locales: string[],
|
||||
hour12: boolean = true
|
||||
): string {
|
||||
return new Intl.DateTimeFormat(locales, {
|
||||
hour: "2-digit",
|
||||
minute: "numeric",
|
||||
@@ -201,7 +233,7 @@ function formatTimeOnly(date: Date, timeZone: string, locales: string[], hour12:
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export const DateTimeAccurate = ({
|
||||
const DateTimeAccurateInner = ({
|
||||
date,
|
||||
timeZone = "UTC",
|
||||
previousDate = null,
|
||||
@@ -210,7 +242,7 @@ export const DateTimeAccurate = ({
|
||||
hour12 = true,
|
||||
}: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
const [localTimeZone, setLocalTimeZone] = useState<string>("UTC");
|
||||
const localTimeZone = useLocalTimeZone();
|
||||
const realDate = typeof date === "string" ? new Date(date) : date;
|
||||
const realPrevDate = previousDate
|
||||
? typeof previousDate === "string"
|
||||
@@ -218,22 +250,19 @@ export const DateTimeAccurate = ({
|
||||
: previousDate
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
|
||||
setLocalTimeZone(resolvedOptions.timeZone);
|
||||
}, []);
|
||||
|
||||
// Smart formatting based on whether date changed
|
||||
const formattedDateTime = hideDate
|
||||
? formatTimeOnly(realDate, localTimeZone, locales, hour12)
|
||||
: realPrevDate
|
||||
? isSameDay(realDate, realPrevDate)
|
||||
const formattedDateTime = useMemo(() => {
|
||||
return hideDate
|
||||
? formatTimeOnly(realDate, localTimeZone, locales, hour12)
|
||||
: formatDateTimeAccurate(realDate, localTimeZone, locales, hour12)
|
||||
: formatDateTimeAccurate(realDate, localTimeZone, locales, hour12);
|
||||
: realPrevDate
|
||||
? isSameDay(realDate, realPrevDate)
|
||||
? formatTimeOnly(realDate, localTimeZone, locales, hour12)
|
||||
: formatDateTimeAccurate(realDate, localTimeZone, locales, hour12)
|
||||
: formatDateTimeAccurate(realDate, localTimeZone, locales, hour12);
|
||||
}, [realDate, localTimeZone, locales, hour12, hideDate, previousDate]);
|
||||
|
||||
if (!showTooltip)
|
||||
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
|
||||
return <span suppressHydrationWarning>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</span>;
|
||||
|
||||
const tooltipContent = (
|
||||
<TooltipContent
|
||||
@@ -246,14 +275,42 @@ export const DateTimeAccurate = ({
|
||||
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={<Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>}
|
||||
button={<span suppressHydrationWarning>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</span>}
|
||||
content={tooltipContent}
|
||||
side="right"
|
||||
asChild={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function formatDateTimeAccurate(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
|
||||
function areDateTimePropsEqual(prev: DateTimeProps, next: DateTimeProps): boolean {
|
||||
// Compare Date objects by timestamp value, not reference
|
||||
const prevTime = prev.date instanceof Date ? prev.date.getTime() : prev.date;
|
||||
const nextTime = next.date instanceof Date ? next.date.getTime() : next.date;
|
||||
if (prevTime !== nextTime) return false;
|
||||
|
||||
const prevPrevTime =
|
||||
prev.previousDate instanceof Date ? prev.previousDate.getTime() : prev.previousDate;
|
||||
const nextPrevTime =
|
||||
next.previousDate instanceof Date ? next.previousDate.getTime() : next.previousDate;
|
||||
if (prevPrevTime !== nextPrevTime) return false;
|
||||
|
||||
return (
|
||||
prev.timeZone === next.timeZone &&
|
||||
prev.showTooltip === next.showTooltip &&
|
||||
prev.hideDate === next.hideDate &&
|
||||
prev.hour12 === next.hour12
|
||||
);
|
||||
}
|
||||
|
||||
export const DateTimeAccurate = memo(DateTimeAccurateInner, areDateTimePropsEqual);
|
||||
|
||||
function formatDateTimeAccurate(
|
||||
date: Date,
|
||||
timeZone: string,
|
||||
locales: string[],
|
||||
hour12: boolean = true
|
||||
): string {
|
||||
const formattedDateTime = new Intl.DateTimeFormat(locales, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
@@ -269,21 +326,21 @@ function formatDateTimeAccurate(date: Date, timeZone: string, locales: string[],
|
||||
return formattedDateTime;
|
||||
}
|
||||
|
||||
export const DateTimeShort = ({ date, timeZone = "UTC", hour12 = true }: DateTimeProps) => {
|
||||
export const DateTimeShort = ({ date, hour12 = true }: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
const localTimeZone = useLocalTimeZone();
|
||||
const realDate = typeof date === "string" ? new Date(date) : date;
|
||||
const initialFormattedDateTime = formatDateTimeShort(realDate, timeZone, locales, hour12);
|
||||
const [formattedDateTime, setFormattedDateTime] = useState<string>(initialFormattedDateTime);
|
||||
const formattedDateTime = formatDateTimeShort(realDate, localTimeZone, locales, hour12);
|
||||
|
||||
useEffect(() => {
|
||||
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
|
||||
setFormattedDateTime(formatDateTimeShort(realDate, resolvedOptions.timeZone, locales, hour12));
|
||||
}, [locales, realDate, hour12]);
|
||||
|
||||
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
|
||||
return <span suppressHydrationWarning>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</span>;
|
||||
};
|
||||
|
||||
function formatDateTimeShort(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
|
||||
function formatDateTimeShort(
|
||||
date: Date,
|
||||
timeZone: string,
|
||||
locales: string[],
|
||||
hour12: boolean = true
|
||||
): string {
|
||||
const formattedDateTime = new Intl.DateTimeFormat(locales, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
@@ -310,14 +367,17 @@ function DateTimeTooltipContent({
|
||||
isoDateTime,
|
||||
icon,
|
||||
}: DateTimeTooltipContentProps) {
|
||||
const getUtcOffset = () => {
|
||||
if (title !== "Local") return "";
|
||||
const offset = -new Date().getTimezoneOffset();
|
||||
const sign = offset >= 0 ? "+" : "-";
|
||||
const hours = Math.abs(Math.floor(offset / 60));
|
||||
const minutes = Math.abs(offset % 60);
|
||||
return `(UTC ${sign}${hours}${minutes ? `:${minutes.toString().padStart(2, "0")}` : ""})`;
|
||||
};
|
||||
const getUtcOffset = useMemo(
|
||||
() => () => {
|
||||
if (title !== "Local") return "";
|
||||
const offset = -new Date().getTimezoneOffset();
|
||||
const sign = offset >= 0 ? "+" : "-";
|
||||
const hours = Math.abs(Math.floor(offset / 60));
|
||||
const minutes = Math.abs(offset % 60);
|
||||
return `(UTC ${sign}${hours}${minutes ? `:${minutes.toString().padStart(2, "0")}` : ""})`;
|
||||
},
|
||||
[title]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -353,20 +413,20 @@ function TooltipContent({
|
||||
{timeZone && timeZone !== "UTC" && (
|
||||
<DateTimeTooltipContent
|
||||
title={timeZone}
|
||||
dateTime={formatDateTime(realDate, timeZone, locales, true, true)}
|
||||
dateTime={formatDateTime(realDate, timeZone, locales, true, true, true)}
|
||||
isoDateTime={formatDateTimeISO(realDate, timeZone)}
|
||||
icon={<GlobeAmericasIcon className="size-4 text-purple-500" />}
|
||||
/>
|
||||
)}
|
||||
<DateTimeTooltipContent
|
||||
title="UTC"
|
||||
dateTime={formatDateTime(realDate, "UTC", locales, true, true)}
|
||||
dateTime={formatDateTime(realDate, "UTC", locales, true, true, true)}
|
||||
isoDateTime={formatDateTimeISO(realDate, "UTC")}
|
||||
icon={<GlobeAltIcon className="size-4 text-blue-500" />}
|
||||
/>
|
||||
<DateTimeTooltipContent
|
||||
title="Local"
|
||||
dateTime={formatDateTime(realDate, localTimeZone, locales, true, true)}
|
||||
dateTime={formatDateTime(realDate, localTimeZone, locales, true, true, true)}
|
||||
isoDateTime={formatDateTimeISO(realDate, localTimeZone)}
|
||||
icon={<Laptop className="size-4 text-green-500" />}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ChevronUpDownIcon } from "@heroicons/react/20/solid";
|
||||
import { format } from "date-fns";
|
||||
import { Calendar } from "./Calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./Popover";
|
||||
import { Button } from "./Buttons";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { SimpleTooltip } from "./Tooltip";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
type DateTimePickerProps = {
|
||||
label: string;
|
||||
value?: Date;
|
||||
onChange?: (date: Date | undefined) => void;
|
||||
showSeconds?: boolean;
|
||||
showNowButton?: boolean;
|
||||
showClearButton?: boolean;
|
||||
showInlineLabel?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function DateTimePicker({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
showSeconds = true,
|
||||
showNowButton = false,
|
||||
showClearButton = false,
|
||||
showInlineLabel = false,
|
||||
className,
|
||||
}: DateTimePickerProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
// Extract time parts from value
|
||||
const hours = value ? value.getHours().toString().padStart(2, "0") : "";
|
||||
const minutes = value ? value.getMinutes().toString().padStart(2, "0") : "";
|
||||
const seconds = value ? value.getSeconds().toString().padStart(2, "0") : "";
|
||||
const timeValue = showSeconds ? `${hours}:${minutes}:${seconds}` : `${hours}:${minutes}`;
|
||||
|
||||
const handleDateSelect = (date: Date | undefined) => {
|
||||
if (date) {
|
||||
// Preserve the time from the current value if it exists
|
||||
if (value) {
|
||||
date.setHours(value.getHours());
|
||||
date.setMinutes(value.getMinutes());
|
||||
date.setSeconds(value.getSeconds());
|
||||
}
|
||||
onChange?.(date);
|
||||
} else {
|
||||
onChange?.(undefined);
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const timeString = e.target.value;
|
||||
if (!timeString) return;
|
||||
|
||||
const [h, m, s] = timeString.split(":").map(Number);
|
||||
const newDate = value ? new Date(value) : new Date();
|
||||
newDate.setHours(h || 0);
|
||||
newDate.setMinutes(m || 0);
|
||||
newDate.setSeconds(s || 0);
|
||||
onChange?.(newDate);
|
||||
};
|
||||
|
||||
const handleNowClick = () => {
|
||||
onChange?.(new Date());
|
||||
};
|
||||
|
||||
const handleClearClick = () => {
|
||||
onChange?.(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
{showInlineLabel && (
|
||||
<span className="w-6 shrink-0 text-right text-xxs text-charcoal-500">{label}</span>
|
||||
)}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-[1.8rem] w-full items-center justify-between gap-2 whitespace-nowrap rounded border border-charcoal-650 bg-charcoal-750 px-2 text-xs tabular-nums transition hover:border-charcoal-600",
|
||||
value ? "text-text-bright" : "text-text-dimmed"
|
||||
)}
|
||||
>
|
||||
{value ? format(value, "yyyy/MM/dd") : "Select date"}
|
||||
<ChevronUpDownIcon className="size-3.5 text-text-dimmed" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={value}
|
||||
onSelect={handleDateSelect}
|
||||
captionLayout="dropdown"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<input
|
||||
type="time"
|
||||
step={showSeconds ? "1" : "60"}
|
||||
value={value ? timeValue : ""}
|
||||
onChange={handleTimeChange}
|
||||
className={cn(
|
||||
"h-[1.8rem] rounded border border-charcoal-650 bg-charcoal-750 px-2 text-xs tabular-nums transition hover:border-charcoal-600",
|
||||
value ? "text-text-bright" : "text-text-dimmed",
|
||||
"focus:border-charcoal-500 focus:outline-none",
|
||||
"[&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
|
||||
)}
|
||||
aria-label={`${label} time`}
|
||||
/>
|
||||
{showNowButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary/small"
|
||||
className="h-[1.8rem]"
|
||||
onClick={handleNowClick}
|
||||
>
|
||||
Now
|
||||
</Button>
|
||||
)}
|
||||
{showClearButton && (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-[1.8rem] items-center justify-center px-1 text-text-dimmed transition hover:text-text-bright"
|
||||
onClick={handleClearClick}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
}
|
||||
content="Clear"
|
||||
disableHoverableContent
|
||||
asChild
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,14 +38,18 @@ const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
fullscreen?: boolean;
|
||||
}
|
||||
>(({ className, children, showCloseButton = true, ...props }, ref) => (
|
||||
>(({ className, children, showCloseButton = true, fullscreen = false, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed z-50 grid w-full gap-4 rounded-b-lg border bg-background-dimmed px-4 pb-4 pt-2.5 shadow-lg animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0",
|
||||
"fixed z-50 grid gap-4 border bg-background-dimmed shadow-lg animate-in data-[state=open]:fade-in-90",
|
||||
fullscreen
|
||||
? "inset-6 rounded-lg pt-2.5 px-4 pb-4"
|
||||
: "w-full rounded-b-lg px-4 pb-4 pt-2.5 data-[state=open]:slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -117,4 +121,6 @@ export {
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogPortal,
|
||||
DialogOverlay
|
||||
};
|
||||
|
||||
@@ -3,10 +3,12 @@ import { cn } from "~/utils/cn";
|
||||
export function FormButtons({
|
||||
cancelButton,
|
||||
confirmButton,
|
||||
defaultAction,
|
||||
className,
|
||||
}: {
|
||||
cancelButton?: React.ReactNode;
|
||||
confirmButton: React.ReactNode;
|
||||
defaultAction?: { name: string; value: string; disabled?: boolean };
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
@@ -16,6 +18,17 @@ export function FormButtons({
|
||||
className
|
||||
)}
|
||||
>
|
||||
{defaultAction && (
|
||||
<button
|
||||
type="submit"
|
||||
name={defaultAction.name}
|
||||
value={defaultAction.value}
|
||||
disabled={defaultAction.disabled}
|
||||
className="hidden"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{cancelButton ? cancelButton : <div />} {confirmButton}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -65,6 +65,7 @@ const PopoverMenuItem = React.forwardRef<
|
||||
className?: string;
|
||||
onClick?: React.MouseEventHandler;
|
||||
disabled?: boolean;
|
||||
openInNewTab?: boolean;
|
||||
}
|
||||
>(
|
||||
(
|
||||
@@ -78,6 +79,7 @@ const PopoverMenuItem = React.forwardRef<
|
||||
className,
|
||||
onClick,
|
||||
disabled,
|
||||
openInNewTab = false,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -102,6 +104,8 @@ const PopoverMenuItem = React.forwardRef<
|
||||
ref={ref as React.Ref<HTMLAnchorElement>}
|
||||
className={cn("group/button focus-custom", contentProps.fullWidth ? "w-full" : "")}
|
||||
onClick={onClick as any}
|
||||
target={openInNewTab ? "_blank" : undefined}
|
||||
rel={openInNewTab ? "noopener noreferrer" : undefined}
|
||||
>
|
||||
<ButtonContent {...contentProps}>{title}</ButtonContent>
|
||||
</Link>
|
||||
@@ -185,37 +189,54 @@ function PopoverSideMenuTrigger({
|
||||
);
|
||||
}
|
||||
|
||||
const popoverArrowTriggerVariants = {
|
||||
minimal: {
|
||||
trigger: "text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright",
|
||||
text: "group-hover:text-text-bright",
|
||||
icon: "text-text-dimmed group-hover:text-text-bright",
|
||||
},
|
||||
tertiary: {
|
||||
trigger: "bg-tertiary text-text-bright hover:bg-charcoal-600",
|
||||
text: "text-text-bright",
|
||||
icon: "text-text-bright",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type PopoverArrowTriggerVariant = keyof typeof popoverArrowTriggerVariants;
|
||||
|
||||
function PopoverArrowTrigger({
|
||||
isOpen,
|
||||
children,
|
||||
fullWidth = false,
|
||||
overflowHidden = false,
|
||||
variant = "minimal",
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
isOpen?: boolean;
|
||||
fullWidth?: boolean;
|
||||
overflowHidden?: boolean;
|
||||
variant?: PopoverArrowTriggerVariant;
|
||||
} & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
|
||||
const variantStyles = popoverArrowTriggerVariants[variant];
|
||||
|
||||
return (
|
||||
<PopoverTrigger
|
||||
{...props}
|
||||
className={cn(
|
||||
"group flex h-6 items-center gap-1 rounded pl-2 pr-1 text-text-dimmed transition focus-custom hover:bg-charcoal-700 hover:text-text-bright",
|
||||
"group flex h-6 items-center gap-1 rounded pl-2 pr-1 transition focus-custom",
|
||||
variantStyles.trigger,
|
||||
fullWidth && "w-full justify-between",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Paragraph
|
||||
variant="extra-small"
|
||||
className={cn(
|
||||
"flex transition group-hover:text-text-bright",
|
||||
overflowHidden && "overflow-hidden"
|
||||
)}
|
||||
className={cn("flex transition", variantStyles.text, overflowHidden && "overflow-hidden")}
|
||||
>
|
||||
{children}
|
||||
</Paragraph>
|
||||
<DropdownIcon className="size-4 min-w-4 text-text-dimmed transition group-hover:text-text-bright" />
|
||||
<DropdownIcon className={cn("size-4 min-w-4 transition", variantStyles.icon)} />
|
||||
</PopoverTrigger>
|
||||
);
|
||||
}
|
||||
@@ -249,3 +270,5 @@ export {
|
||||
PopoverTrigger,
|
||||
PopoverVerticalEllipseTrigger,
|
||||
};
|
||||
|
||||
export type { PopoverArrowTriggerVariant };
|
||||
|
||||
@@ -2,18 +2,65 @@ import { RadioGroup } from "@headlessui/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const variants = {
|
||||
primary: {
|
||||
base: "bg-charcoal-700",
|
||||
active: "text-text-bright hover:bg-charcoal-750/50",
|
||||
const sizes = {
|
||||
small: {
|
||||
control: "h-6",
|
||||
option: "px-2 text-xs",
|
||||
container: "gap-x-0.5",
|
||||
},
|
||||
secondary: {
|
||||
base: "bg-charcoal-700/50",
|
||||
active: "text-text-bright bg-charcoal-700 rounded-[2px] border border-charcoal-600/50",
|
||||
medium: {
|
||||
control: "h-10",
|
||||
option: "px-3 py-[0.13rem] text-sm",
|
||||
container: "p-1 gap-x-0.5",
|
||||
},
|
||||
};
|
||||
|
||||
type Variants = keyof typeof variants;
|
||||
const theme = {
|
||||
primary: {
|
||||
base: "bg-charcoal-700",
|
||||
active: "text-text-bright hover:bg-charcoal-750/50",
|
||||
inactive: "text-text-dimmed transition hover:text-text-bright",
|
||||
selected: "absolute inset-0 rounded-[2px] outline outline-3 outline-primary",
|
||||
},
|
||||
secondary: {
|
||||
base: "bg-charcoal-700/50",
|
||||
active: "text-text-bright",
|
||||
inactive: "text-text-dimmed transition hover:text-text-bright",
|
||||
selected: "absolute inset-0 rounded bg-charcoal-700 border border-charcoal-600",
|
||||
},
|
||||
};
|
||||
|
||||
type Size = keyof typeof sizes;
|
||||
type Theme = keyof typeof theme;
|
||||
|
||||
type VariantStyle = {
|
||||
base: string;
|
||||
active: string;
|
||||
inactive: string;
|
||||
option: string;
|
||||
container: string;
|
||||
selected: string;
|
||||
};
|
||||
|
||||
function createVariant(sizeName: Size, themeName: Theme): VariantStyle {
|
||||
return {
|
||||
base: cn(sizes[sizeName].control, theme[themeName].base),
|
||||
active: theme[themeName].active,
|
||||
inactive: theme[themeName].inactive,
|
||||
option: sizes[sizeName].option,
|
||||
container: sizes[sizeName].container,
|
||||
selected: theme[themeName].selected,
|
||||
};
|
||||
}
|
||||
|
||||
const variants = {
|
||||
"primary/small": createVariant("small", "primary"),
|
||||
"primary/medium": createVariant("medium", "primary"),
|
||||
"secondary/small": createVariant("small", "secondary"),
|
||||
"secondary/medium": createVariant("medium", "secondary"),
|
||||
} as const;
|
||||
|
||||
type VariantType = keyof typeof variants;
|
||||
|
||||
type Options = {
|
||||
label: string;
|
||||
@@ -25,7 +72,7 @@ type SegmentedControlProps = {
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
options: Options[];
|
||||
variant?: Variants;
|
||||
variant?: VariantType;
|
||||
fullWidth?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
};
|
||||
@@ -35,15 +82,18 @@ export default function SegmentedControl({
|
||||
value,
|
||||
defaultValue,
|
||||
options,
|
||||
variant = "secondary",
|
||||
variant = "secondary/medium",
|
||||
fullWidth,
|
||||
onChange,
|
||||
}: SegmentedControlProps) {
|
||||
const variantStyle = variants[variant];
|
||||
const isPrimary = variant.startsWith("primary");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 rounded text-text-bright",
|
||||
variants[variant].base,
|
||||
"flex rounded text-text-bright",
|
||||
variantStyle.base,
|
||||
fullWidth ? "w-full" : "w-fit"
|
||||
)}
|
||||
>
|
||||
@@ -58,31 +108,36 @@ export default function SegmentedControl({
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex h-full w-full items-center justify-between gap-x-1 p-1">
|
||||
<div
|
||||
className={cn("flex h-full w-full items-center justify-between", variantStyle.container)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<RadioGroup.Option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ active, checked }) =>
|
||||
className={({ checked }) =>
|
||||
cn(
|
||||
"relative flex h-full grow cursor-pointer text-center font-normal focus-custom",
|
||||
checked
|
||||
? variants[variant].active
|
||||
: "text-text-dimmed transition hover:text-text-bright"
|
||||
checked ? variantStyle.active : variantStyle.inactive
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ checked }) => (
|
||||
<>
|
||||
<div className="relative flex h-full w-full items-center justify-between px-3 py-[0.13rem]">
|
||||
<div className="z-10 flex h-full w-full items-center justify-center text-sm">
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex h-full w-full items-center justify-between",
|
||||
variantStyle.option
|
||||
)}
|
||||
>
|
||||
<div className="z-10 flex h-full w-full items-center justify-center">
|
||||
<RadioGroup.Label as="p">{option.label}</RadioGroup.Label>
|
||||
</div>
|
||||
{checked && variant === "primary" && (
|
||||
{checked && (
|
||||
<motion.div
|
||||
layoutId={`segmented-control-${name}`}
|
||||
transition={{ duration: 0.4, type: "spring" }}
|
||||
className="absolute inset-0 rounded-[2px] shadow-md outline outline-3 outline-primary"
|
||||
className={variantStyle.selected}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,11 +9,11 @@ import { useOperatingSystem } from "./OperatingSystemProvider";
|
||||
import { KeyboardEnterIcon } from "~/assets/icons/KeyboardEnterIcon";
|
||||
|
||||
const medium =
|
||||
"text-[0.75rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
|
||||
"justify-center min-w-[1.25rem] min-h-[1.25rem] text-[0.65rem] font-mono font-medium rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
|
||||
|
||||
export const variants = {
|
||||
small:
|
||||
"text-[0.6rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase",
|
||||
"justify-center text-[0.6rem] font-mono font-medium min-w-[1rem] min-h-[1rem] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase",
|
||||
medium: cn(medium, "group-hover:border-charcoal-550"),
|
||||
"medium/bright": cn(medium, "bg-charcoal-750 text-text-bright border-charcoal-650"),
|
||||
};
|
||||
@@ -57,7 +57,7 @@ export function ShortcutKey({ shortcut, variant, className }: ShortcutKeyProps)
|
||||
function keyString(key: string, isMac: boolean, variant: "small" | "medium" | "medium/bright") {
|
||||
key = key.toLowerCase();
|
||||
|
||||
const className = variant === "small" ? "w-2.5 h-4" : "w-3 h-5";
|
||||
const className = variant === "small" ? "w-2.5 h-4" : "w-2.5 h-4.5";
|
||||
|
||||
switch (key) {
|
||||
case "enter":
|
||||
|
||||
@@ -1,23 +1,54 @@
|
||||
import { ChevronRightIcon } from "@heroicons/react/24/solid";
|
||||
import { Link } from "@remix-run/react";
|
||||
import React, { type ReactNode, forwardRef, useState, useContext, createContext } from "react";
|
||||
import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react";
|
||||
import React, { type ReactNode, createContext, forwardRef, useContext, useState } from "react";
|
||||
import { useCopy } from "~/hooks/useCopy";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Popover, PopoverContent, PopoverVerticalEllipseTrigger } from "./Popover";
|
||||
import { InfoIconTooltip } from "./Tooltip";
|
||||
import { InfoIconTooltip, SimpleTooltip } from "./Tooltip";
|
||||
|
||||
const variants = {
|
||||
bright: {
|
||||
header: "bg-background-bright",
|
||||
headerCell: "px-3 py-2.5 pb-3 text-sm",
|
||||
cell: "group-hover/table-row:bg-charcoal-750 group-has-[[tabindex='0']:focus]/table-row:bg-charcoal-750",
|
||||
cellSize: "px-3 py-3",
|
||||
cellText: "text-xs group-hover/table-row:text-text-bright",
|
||||
stickyCell: "bg-background-bright group-hover/table-row:bg-charcoal-750",
|
||||
menuButton:
|
||||
"bg-background-bright group-hover/table-row:bg-charcoal-750 group-hover/table-row:ring-charcoal-600/70 group-has-[[tabindex='0']:focus]/table-row:bg-charcoal-750",
|
||||
menuButtonDivider: "group-hover/table-row:border-charcoal-600/70",
|
||||
rowSelected: "bg-charcoal-750 group-hover/table-row:bg-charcoal-750",
|
||||
},
|
||||
"bright/no-hover": {
|
||||
header: "bg-transparent",
|
||||
headerCell: "px-3 py-2.5 pb-3 text-sm",
|
||||
cell: "group-hover/table-row:bg-transparent",
|
||||
cellSize: "px-3 py-3",
|
||||
cellText: "text-xs",
|
||||
stickyCell: "bg-background-bright",
|
||||
menuButton: "bg-background-bright",
|
||||
menuButtonDivider: "",
|
||||
rowSelected: "bg-charcoal-750",
|
||||
},
|
||||
dimmed: {
|
||||
header: "bg-background-dimmed",
|
||||
headerCell: "px-3 py-2.5 pb-3 text-sm",
|
||||
cell: "group-hover/table-row:bg-charcoal-800 group-has-[[tabindex='0']:focus]/table-row:bg-background-bright",
|
||||
cellSize: "px-3 py-3",
|
||||
cellText: "text-xs group-hover/table-row:text-text-bright",
|
||||
stickyCell: "group-hover/table-row:bg-charcoal-800",
|
||||
menuButton:
|
||||
"bg-background-dimmed group-hover/table-row:bg-charcoal-800 group-hover/table-row:ring-grid-bright group-has-[[tabindex='0']:focus]/table-row:bg-background-bright",
|
||||
menuButtonDivider: "group-hover/table-row:border-grid-bright",
|
||||
rowSelected: "bg-charcoal-750 group-hover/table-row:bg-charcoal-750",
|
||||
},
|
||||
"compact/mono": {
|
||||
header: "bg-background-dimmed",
|
||||
headerCell: "px-2 py-1.5 text-sm",
|
||||
cell: "group-hover/table-row:bg-charcoal-800 group-has-[[tabindex='0']:focus]/table-row:bg-background-bright",
|
||||
cellSize: "px-2 py-1.5",
|
||||
cellText: "text-xs font-mono group-hover/table-row:text-text-bright",
|
||||
stickyCell: "group-hover/table-row:bg-charcoal-800",
|
||||
menuButton:
|
||||
"bg-background-dimmed group-hover/table-row:bg-charcoal-800 group-hover/table-row:ring-grid-bright group-has-[[tabindex='0']:focus]/table-row:bg-background-bright",
|
||||
@@ -70,7 +101,7 @@ export const TableHeader = forwardRef<HTMLTableSectionElement, TableHeaderProps>
|
||||
<thead
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"sticky top-0 z-10 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright",
|
||||
"safari-only sticky top-0 z-10 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright supports-[(-webkit-hyphens:none)]:after:content-none",
|
||||
variants[variant].header,
|
||||
className
|
||||
)}
|
||||
@@ -96,7 +127,7 @@ export const TableBody = forwardRef<HTMLTableSectionElement, TableBodyProps>(
|
||||
}
|
||||
);
|
||||
|
||||
type TableRowProps = {
|
||||
type TableRowProps = JSX.IntrinsicElements["tr"] & {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
@@ -104,11 +135,12 @@ type TableRowProps = {
|
||||
};
|
||||
|
||||
export const TableRow = forwardRef<HTMLTableRowElement, TableRowProps>(
|
||||
({ className, disabled, isSelected, children }, ref) => {
|
||||
({ className, disabled, isSelected, children, ...props }, ref) => {
|
||||
const { variant } = useContext(TableContext);
|
||||
return (
|
||||
<tr
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cn(
|
||||
"group/table-row relative w-full outline-none after:absolute after:bottom-0 after:left-3 after:right-0 after:h-px after:bg-grid-dimmed",
|
||||
isSelected && variants[variant].rowSelected,
|
||||
@@ -136,6 +168,7 @@ type TableHeaderCellProps = TableCellBasicProps & {
|
||||
|
||||
export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellProps>(
|
||||
({ className, alignment = "left", children, colSpan, hiddenLabel = false, tooltip }, ref) => {
|
||||
const { variant } = useContext(TableContext);
|
||||
let alignmentClassName = "text-left";
|
||||
switch (alignment) {
|
||||
case "center":
|
||||
@@ -146,17 +179,22 @@ export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellP
|
||||
break;
|
||||
}
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<th
|
||||
ref={ref}
|
||||
scope="col"
|
||||
className={cn(
|
||||
"px-3 py-2.5 pb-3 align-middle text-sm font-medium text-text-bright",
|
||||
"align-middle font-medium text-text-bright",
|
||||
variants[variant].headerCell,
|
||||
alignmentClassName,
|
||||
className
|
||||
)}
|
||||
colSpan={colSpan}
|
||||
tabIndex={-1}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{hiddenLabel ? (
|
||||
<span className="sr-only">{children}</span>
|
||||
@@ -168,7 +206,11 @@ export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellP
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
<InfoIconTooltip content={tooltip} contentClassName="normal-case tracking-normal" />
|
||||
<InfoIconTooltip
|
||||
content={tooltip}
|
||||
contentClassName="normal-case tracking-normal"
|
||||
enabled={isHovered}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
@@ -217,23 +259,28 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
break;
|
||||
}
|
||||
|
||||
const { variant } = useContext(TableContext);
|
||||
const flexClasses = cn(
|
||||
"flex w-full whitespace-nowrap px-3 py-3 items-center text-xs text-text-dimmed",
|
||||
"flex w-full whitespace-nowrap items-center text-text-dimmed",
|
||||
variants[variant].cellSize,
|
||||
variants[variant].cellText,
|
||||
alignment === "left"
|
||||
? "justify-start text-left"
|
||||
: alignment === "center"
|
||||
? "justify-center text-center"
|
||||
: "justify-end text-right"
|
||||
);
|
||||
const { variant } = useContext(TableContext);
|
||||
|
||||
return (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-xs text-charcoal-400 has-[[tabindex='0']:focus]:before:absolute has-[[tabindex='0']:focus]:before:-top-px has-[[tabindex='0']:focus]:before:left-0 has-[[tabindex='0']:focus]:before:h-px has-[[tabindex='0']:focus]:before:w-3 has-[[tabindex='0']:focus]:before:bg-grid-dimmed has-[[tabindex='0']:focus]:after:absolute has-[[tabindex='0']:focus]:after:bottom-0 has-[[tabindex='0']:focus]:after:left-0 has-[[tabindex='0']:focus]:after:right-0 has-[[tabindex='0']:focus]:after:h-px has-[[tabindex='0']:focus]:after:bg-grid-dimmed",
|
||||
"safari-only text-xs text-charcoal-400 has-[[tabindex='0']:focus]:before:absolute has-[[tabindex='0']:focus]:before:-top-px has-[[tabindex='0']:focus]:before:left-0 has-[[tabindex='0']:focus]:before:h-px has-[[tabindex='0']:focus]:before:w-3 has-[[tabindex='0']:focus]:before:bg-grid-dimmed has-[[tabindex='0']:focus]:after:absolute has-[[tabindex='0']:focus]:after:bottom-0 has-[[tabindex='0']:focus]:after:left-0 has-[[tabindex='0']:focus]:after:right-0 has-[[tabindex='0']:focus]:after:h-px has-[[tabindex='0']:focus]:after:bg-grid-dimmed",
|
||||
variants[variant].cellText,
|
||||
variants[variant].cell,
|
||||
to || onClick || hasAction ? "cursor-pointer" : "cursor-default px-3 py-3 align-middle",
|
||||
to || onClick || hasAction
|
||||
? "cursor-pointer"
|
||||
: cn("cursor-default align-middle", variants[variant].cellSize),
|
||||
!to && !onClick && alignmentClassName,
|
||||
isSticky &&
|
||||
"[&:has(.group-hover/table-row:block)]:w-auto sticky right-0 bg-background-dimmed",
|
||||
@@ -269,6 +316,60 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
}
|
||||
);
|
||||
|
||||
type CopyableTableCellProps = TableCellProps & {
|
||||
value: string;
|
||||
};
|
||||
|
||||
export const CopyableTableCell = forwardRef<HTMLTableCellElement, CopyableTableCellProps>(
|
||||
({ value, children, className, ...props }, ref) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const { copy, copied } = useCopy(value);
|
||||
|
||||
return (
|
||||
<TableCell ref={ref} className={className} {...props}>
|
||||
<div
|
||||
className="relative flex items-center"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{children}
|
||||
{isHovered && (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
copy();
|
||||
}}
|
||||
className="absolute -right-2 top-1/2 z-10 flex -translate-y-1/2 cursor-pointer"
|
||||
>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-6 items-center justify-center rounded border border-charcoal-650 bg-charcoal-750",
|
||||
copied
|
||||
? "text-green-500"
|
||||
: "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<ClipboardCheckIcon className="size-3.5" />
|
||||
) : (
|
||||
<ClipboardIcon className="size-3.5" />
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
content={copied ? "Copied!" : "Copy"}
|
||||
disableHoverableContent
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const TableCellChevron = forwardRef<
|
||||
HTMLTableCellElement,
|
||||
{
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { NavLink } from "@remix-run/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ReactNode, useRef } from "react";
|
||||
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { type ReactNode, useRef } from "react";
|
||||
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ShortcutKey } from "./ShortcutKey";
|
||||
|
||||
export type Variants = "underline" | "pipe-divider" | "segmented";
|
||||
|
||||
export type TabsProps = {
|
||||
tabs: {
|
||||
label: string;
|
||||
@@ -12,13 +14,14 @@ export type TabsProps = {
|
||||
}[];
|
||||
className?: string;
|
||||
layoutId: string;
|
||||
variant?: Variants;
|
||||
};
|
||||
|
||||
export function Tabs({ tabs, className, layoutId }: TabsProps) {
|
||||
export function Tabs({ tabs, className, layoutId, variant = "underline" }: TabsProps) {
|
||||
return (
|
||||
<TabContainer className={className}>
|
||||
<TabContainer className={className} variant={variant}>
|
||||
{tabs.map((tab, index) => (
|
||||
<TabLink key={index} to={tab.to} layoutId={layoutId}>
|
||||
<TabLink key={index} to={tab.to} layoutId={layoutId} variant={variant}>
|
||||
{tab.label}
|
||||
</TabLink>
|
||||
))}
|
||||
@@ -26,23 +29,107 @@ export function Tabs({ tabs, className, layoutId }: TabsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function TabContainer({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn(`flex flex-row gap-x-6 border-b border-grid-bright`, className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
export function TabContainer({
|
||||
children,
|
||||
className,
|
||||
variant = "underline",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
variant?: Variants;
|
||||
}) {
|
||||
if (variant === "segmented") {
|
||||
return (
|
||||
<div
|
||||
className={cn("relative flex h-10 items-center rounded bg-charcoal-700/50 p-1", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "underline") {
|
||||
return (
|
||||
<div className={cn(`flex gap-x-6 border-b border-grid-bright`, className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={cn(`flex`, className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function TabLink({
|
||||
to,
|
||||
children,
|
||||
layoutId,
|
||||
variant = "underline",
|
||||
}: {
|
||||
to: string;
|
||||
children: ReactNode;
|
||||
layoutId: string;
|
||||
variant?: Variants;
|
||||
}) {
|
||||
if (variant === "segmented") {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className="group relative flex h-full grow items-center justify-center focus-custom"
|
||||
end
|
||||
>
|
||||
{({ isActive, isPending }) => {
|
||||
const active = isActive || isPending;
|
||||
return (
|
||||
<>
|
||||
<div className="relative z-10 flex h-full w-full items-center justify-center px-3 py-[0.13rem]">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
active
|
||||
? "text-text-bright"
|
||||
: "text-text-dimmed transition group-hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
{active && (
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ duration: 0.4, type: "spring" }}
|
||||
className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "pipe-divider") {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className="group flex flex-col items-center border-r border-charcoal-700 px-2 pt-1 focus-custom first:pl-0 last:border-none"
|
||||
end
|
||||
>
|
||||
{({ isActive, isPending }) => {
|
||||
const active = isActive || isPending;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
active ? "text-text-link" : "text-text-dimmed transition hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
// underline variant (default)
|
||||
return (
|
||||
<NavLink to={to} className="group flex flex-col items-center pt-1 focus-custom" end>
|
||||
{({ isActive, isPending }) => {
|
||||
@@ -51,13 +138,19 @@ export function TabLink({
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive || isPending ? "text-text-bright" : "text-text-bright"
|
||||
isActive || isPending
|
||||
? "text-text-bright"
|
||||
: "text-text-dimmed hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{isActive || isPending ? (
|
||||
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className="mt-1 h-0.5 w-full bg-indigo-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
)}
|
||||
@@ -106,17 +199,18 @@ export function TabButton({
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive ? "text-text-bright" : "text-text-bright"
|
||||
)}
|
||||
className={"text-sm transition duration-200 text-text-bright"}
|
||||
>
|
||||
{props.children}
|
||||
</span>
|
||||
{shortcut && <ShortcutKey className={cn("")} shortcut={shortcut} variant={"small"} />}
|
||||
</div>
|
||||
{isActive ? (
|
||||
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className="mt-1 h-0.5 w-full bg-indigo-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
)}
|
||||
|
||||
@@ -64,6 +64,8 @@ function SimpleTooltip({
|
||||
buttonStyle,
|
||||
asChild = false,
|
||||
sideOffset,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
button: React.ReactNode;
|
||||
content: React.ReactNode;
|
||||
@@ -76,11 +78,14 @@ function SimpleTooltip({
|
||||
buttonStyle?: React.CSSProperties;
|
||||
asChild?: boolean;
|
||||
sideOffset?: number;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<TooltipProvider disableHoverableContent={disableHoverableContent}>
|
||||
<Tooltip>
|
||||
<Tooltip open={open} onOpenChange={onOpenChange}>
|
||||
<TooltipTrigger
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className={cn("h-fit", buttonClassName)}
|
||||
style={buttonStyle}
|
||||
@@ -107,20 +112,28 @@ export function InfoIconTooltip({
|
||||
buttonClassName,
|
||||
contentClassName,
|
||||
variant = "basic",
|
||||
disableHoverableContent = false,
|
||||
enabled = true,
|
||||
}: {
|
||||
content: React.ReactNode;
|
||||
buttonClassName?: string;
|
||||
contentClassName?: string;
|
||||
variant?: Variant;
|
||||
disableHoverableContent?: boolean;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const icon = (
|
||||
<InformationCircleIcon className={cn("size-3.5 text-text-dimmed flex-0", buttonClassName)} />
|
||||
);
|
||||
|
||||
if (!enabled) return icon;
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<InformationCircleIcon className={cn("size-3.5 text-text-dimmed", buttonClassName)} />
|
||||
}
|
||||
button={icon}
|
||||
content={content}
|
||||
variant={variant}
|
||||
className={contentClassName}
|
||||
disableHoverableContent={disableHoverableContent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -423,6 +423,10 @@ export function useTree<TData, TFilterValue>({
|
||||
}
|
||||
case "Left":
|
||||
case "ArrowLeft": {
|
||||
if (e.metaKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const selected = selectedIdFromState(state.nodes);
|
||||
@@ -523,6 +527,7 @@ export function useTree<TData, TFilterValue>({
|
||||
/** An actual tree structure with custom data */
|
||||
export type Tree<TData> = {
|
||||
id: string;
|
||||
runId?: string;
|
||||
children?: Tree<TData>[];
|
||||
data: TData;
|
||||
};
|
||||
@@ -531,6 +536,7 @@ export type Tree<TData> = {
|
||||
export type FlatTreeItem<TData> = {
|
||||
id: string;
|
||||
parentId?: string | undefined;
|
||||
runId?: string;
|
||||
children: string[];
|
||||
hasChildren: boolean;
|
||||
/** The indentation level, the root is 0 */
|
||||
@@ -548,6 +554,7 @@ export function flattenTree<TData>(tree: Tree<TData>): FlatTree<TData> {
|
||||
flatTree.push({
|
||||
id: node.id,
|
||||
parentId,
|
||||
runId: node.runId,
|
||||
children,
|
||||
hasChildren: children.length > 0,
|
||||
level,
|
||||
@@ -567,6 +574,7 @@ export function flattenTree<TData>(tree: Tree<TData>): FlatTree<TData> {
|
||||
type FlatTreeWithoutChildren<TData> = {
|
||||
id: string;
|
||||
parentId: string | undefined;
|
||||
runId?: string;
|
||||
data: TData;
|
||||
};
|
||||
|
||||
@@ -576,7 +584,7 @@ export function createTreeFromFlatItems<TData>(
|
||||
): Tree<TData> | undefined {
|
||||
// Index items by id
|
||||
const indexedItems: { [id: string]: Tree<TData> } = withoutChildren.reduce((acc, item) => {
|
||||
acc[item.id] = { id: item.id, data: item.data, children: [] };
|
||||
acc[item.id] = { id: item.id, runId: item.runId, data: item.data, children: [] };
|
||||
return acc;
|
||||
}, {} as { [id: string]: Tree<TData> });
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
import { AnimatedNumber } from "../AnimatedNumber";
|
||||
import { Spinner } from "../Spinner";
|
||||
|
||||
interface BigNumberProps {
|
||||
animate?: boolean;
|
||||
loading?: boolean;
|
||||
value?: number;
|
||||
valueClassName?: string;
|
||||
defaultValue?: number;
|
||||
suffix?: string;
|
||||
suffixClassName?: string;
|
||||
}
|
||||
|
||||
export function BigNumber({
|
||||
value,
|
||||
defaultValue,
|
||||
valueClassName,
|
||||
suffix,
|
||||
suffixClassName,
|
||||
animate = false,
|
||||
loading = false,
|
||||
}: BigNumberProps) {
|
||||
const v = value ?? defaultValue;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-full text-[3.75rem] font-normal tabular-nums leading-none text-text-bright",
|
||||
valueClassName
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="grid h-full place-items-center">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
) : v !== undefined ? (
|
||||
<div className="flex items-baseline gap-1">
|
||||
{animate ? <AnimatedNumber value={v} /> : v}
|
||||
{suffix && <div className={cn("text-xs", suffixClassName)}>{suffix}</div>}
|
||||
</div>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Header3 } from "../Headers";
|
||||
|
||||
export const Card = ({ children, className }: { children: ReactNode; className?: string }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col rounded-lg border border-grid-bright bg-background-bright pb-2 pt-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CardHeader = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<Header3 className="mb-4 flex items-center justify-between gap-2 px-4">{children}</Header3>
|
||||
);
|
||||
};
|
||||
|
||||
const CardContent = ({ children, className }: { children: ReactNode; className?: string }) => {
|
||||
return <div className={cn("px-2", className)}>{children}</div>;
|
||||
};
|
||||
|
||||
const CardAccessory = ({ children }: { children: ReactNode }) => {
|
||||
return <div className="flex items-center gap-2">{children}</div>;
|
||||
};
|
||||
|
||||
Card.Header = CardHeader;
|
||||
Card.Content = CardContent;
|
||||
Card.Accessory = CardAccessory;
|
||||
+148
-18
@@ -1,16 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { AnimatedNumber } from "../AnimatedNumber";
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
export type ChartState = "loading" | "noData" | "invalid" | "loaded" | undefined;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
value?: number;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
@@ -49,15 +51,13 @@ const ChartContainer = React.forwardRef<
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-text-dimmed/50 [&_.recharts-cartesian-grid_line]:stroke-grid-bright/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-grid-dimmed [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-grid-dimmed [&_.recharts-radial-bar-background-sector]:fill-grid-dimmed [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-grid-dimmed [&_.recharts-reference-line-line]:stroke-grid-dimmed [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer className="!h-auto">
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
@@ -65,7 +65,7 @@ const ChartContainer = React.forwardRef<
|
||||
ChartContainer.displayName = "Chart";
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color);
|
||||
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
@@ -74,8 +74,9 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES).map(
|
||||
([theme, prefix]) => `
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
@@ -85,7 +86,8 @@ ${colorConfig
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
),
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -130,7 +132,7 @@ const ChartTooltipContent = React.forwardRef<
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || item.dataKey || item.name || "value"}`;
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
@@ -160,7 +162,7 @@ const ChartTooltipContent = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-grid-bright/50 bg-background-dimmed px-2.5 py-1.5 text-xs shadow-xl",
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-grid-bright bg-background-bright px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -175,11 +177,11 @@ const ChartTooltipContent = React.forwardRef<
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"flex w-full items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-text-dimmed",
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item.value && item.name ? (
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
@@ -209,16 +211,18 @@ const ChartTooltipContent = React.forwardRef<
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between gap-1.5 leading-none",
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-text-dimmed">{itemConfig?.label || item.name}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="font-mono font-medium tabular-nums text-text-bright">
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
@@ -237,6 +241,12 @@ ChartTooltipContent.displayName = "ChartTooltip";
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
type ExtendedLegendPayload = Parameters<
|
||||
NonNullable<RechartsPrimitive.LegendProps["formatter"]>
|
||||
>[0] & {
|
||||
payload?: { remainingCount?: number };
|
||||
};
|
||||
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> &
|
||||
@@ -268,7 +278,7 @@ const ChartLegendContent = React.forwardRef<
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-text-dimmed"
|
||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
@@ -290,6 +300,125 @@ const ChartLegendContent = React.forwardRef<
|
||||
});
|
||||
ChartLegendContent.displayName = "ChartLegend";
|
||||
|
||||
const ChartLegendContentRows = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> &
|
||||
Omit<Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign">, "payload"> & {
|
||||
payload?: ExtendedLegendPayload[];
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
onMouseEnter?: (e: any) => void;
|
||||
onMouseLeave?: (e: any) => void;
|
||||
data?: Record<string, number>;
|
||||
activeKey?: string | null;
|
||||
renderViewMore?: (remainingCount: number) => React.ReactNode;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
data,
|
||||
activeKey,
|
||||
renderViewMore,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-col items-start justify-between",
|
||||
verticalAlign === "top" ? "pb-4" : "pt-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
// Handle view more button
|
||||
if (item.dataKey === "view-more" && renderViewMore && item.payload?.remainingCount) {
|
||||
return renderViewMore(item.payload.remainingCount);
|
||||
}
|
||||
|
||||
const total = item.dataKey && data ? data[item.dataKey as string] : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
"relative flex w-full items-center justify-between gap-2 rounded px-2 py-1 transition",
|
||||
total === 0 && "opacity-50"
|
||||
)}
|
||||
onMouseEnter={() => onMouseEnter?.(item)}
|
||||
onMouseLeave={() => onMouseLeave?.(item)}
|
||||
>
|
||||
{activeKey === item.dataKey && item.color && (
|
||||
<div
|
||||
className="absolute inset-0 rounded opacity-10"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
)}
|
||||
<div className="relative flex w-full items-center justify-between gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-text-dimmed"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
item.color && (
|
||||
<div
|
||||
className="h-3 w-1 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{itemConfig?.label && (
|
||||
<span
|
||||
className={
|
||||
activeKey === item.dataKey ? "text-text-bright" : "text-text-dimmed"
|
||||
}
|
||||
>
|
||||
{itemConfig.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{total !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
activeKey === item.dataKey ? "text-text-bright" : "text-text-dimmed"
|
||||
)}
|
||||
>
|
||||
<AnimatedNumber value={total} duration={0.25} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
ChartLegendContentRows.displayName = "ChartLegendRows";
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
@@ -322,5 +451,6 @@ export {
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartLegendContentRows,
|
||||
ChartStyle,
|
||||
};
|
||||
@@ -0,0 +1,274 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ReferenceArea,
|
||||
ReferenceLine,
|
||||
XAxis,
|
||||
YAxis,
|
||||
type XAxisProps,
|
||||
type YAxisProps,
|
||||
} from "recharts";
|
||||
import {
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
type ChartState,
|
||||
} from "~/components/primitives/charts/Chart";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ChartBarLoading, ChartBarInvalid, ChartBarNoData } from "./ChartLoading";
|
||||
import { useChartContext } from "./ChartContext";
|
||||
import { ChartRoot, useHasNoData } from "./ChartRoot";
|
||||
// Legend is now rendered by ChartRoot outside the chart container
|
||||
import { ZoomTooltip, useZoomHandlers } from "./ChartZoom";
|
||||
import { getBarOpacity } from "./hooks/useHighlightState";
|
||||
import type { ZoomRange } from "./hooks/useZoomSelection";
|
||||
|
||||
//TODO: fix the first and last bars in a stack not having rounded corners
|
||||
|
||||
type ReferenceLineProps = {
|
||||
value: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// COMPOUND COMPONENT API
|
||||
// ============================================================================
|
||||
|
||||
export type ChartBarRendererProps = {
|
||||
/** Stack ID for stacked bar charts */
|
||||
stackId?: string;
|
||||
/** Custom X-axis props to merge with defaults */
|
||||
xAxisProps?: Partial<XAxisProps>;
|
||||
/** Custom Y-axis props to merge with defaults */
|
||||
yAxisProps?: Partial<YAxisProps>;
|
||||
/** Reference line (horizontal) */
|
||||
referenceLine?: ReferenceLineProps;
|
||||
/** Custom tooltip label formatter */
|
||||
tooltipLabelFormatter?: (label: string, payload: any[]) => string;
|
||||
/** Width injected by ResponsiveContainer */
|
||||
width?: number;
|
||||
/** Height injected by ResponsiveContainer */
|
||||
height?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Bar chart renderer for the compound component system.
|
||||
* Must be used within a Chart.Root.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Chart.Root config={config} data={data} dataKey="day">
|
||||
* <Chart.Bar stackId="a" />
|
||||
* <Chart.Legend />
|
||||
* </Chart.Root>
|
||||
* ```
|
||||
*/
|
||||
export function ChartBarRenderer({
|
||||
stackId,
|
||||
xAxisProps: xAxisPropsProp,
|
||||
yAxisProps: yAxisPropsProp,
|
||||
referenceLine,
|
||||
tooltipLabelFormatter,
|
||||
width,
|
||||
height,
|
||||
}: ChartBarRendererProps) {
|
||||
const { config, data, dataKey, dataKeys, state, highlight, zoom, showLegend } = useChartContext();
|
||||
const hasNoData = useHasNoData();
|
||||
const zoomHandlers = useZoomHandlers();
|
||||
const enableZoom = zoom !== null;
|
||||
|
||||
const handleBarClick = useCallback(
|
||||
(barData: any, e: React.MouseEvent) => {
|
||||
if (!enableZoom || !zoom) return;
|
||||
e.stopPropagation();
|
||||
|
||||
if (!zoom.isSelecting) {
|
||||
zoom.toggleInspectionLine(barData[dataKey]);
|
||||
}
|
||||
},
|
||||
[enableZoom, zoom, dataKey]
|
||||
);
|
||||
|
||||
// Handle mouse leave to also reset highlight
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
zoomHandlers.onMouseLeave?.();
|
||||
highlight.reset();
|
||||
}, [zoomHandlers, highlight]);
|
||||
|
||||
// Render loading/error states
|
||||
if (state === "loading") {
|
||||
return <ChartBarLoading />;
|
||||
} else if (state === "noData" || hasNoData) {
|
||||
return <ChartBarNoData />;
|
||||
} else if (state === "invalid") {
|
||||
return <ChartBarInvalid />;
|
||||
}
|
||||
|
||||
// Get the x-axis ticks based on tooltip state
|
||||
// Only hide middle ticks when zoom is enabled (to make room for zoom instructions)
|
||||
const xAxisTicks =
|
||||
enableZoom && highlight.tooltipActive && data.length > 2
|
||||
? [data[0]?.[dataKey], data[data.length - 1]?.[dataKey]]
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<BarChart
|
||||
data={data}
|
||||
width={width}
|
||||
height={height}
|
||||
barCategoryGap={1}
|
||||
onMouseDown={zoomHandlers.onMouseDown}
|
||||
onMouseMove={(e: any) => {
|
||||
zoomHandlers.onMouseMove?.(e);
|
||||
// Update active payload for legend
|
||||
if (e?.activePayload?.length) {
|
||||
highlight.setActivePayload(e.activePayload);
|
||||
highlight.setTooltipActive(true);
|
||||
} else {
|
||||
highlight.setTooltipActive(false);
|
||||
}
|
||||
}}
|
||||
onMouseUp={zoomHandlers.onMouseUp}
|
||||
onClick={zoomHandlers.onClick}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<CartesianGrid vertical={false} stroke="#272A2E" />
|
||||
<XAxis
|
||||
dataKey={dataKey}
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
ticks={xAxisTicks}
|
||||
interval="preserveStartEnd"
|
||||
tick={{
|
||||
fill: "#878C99",
|
||||
fontSize: 11,
|
||||
style: { fontVariantNumeric: "tabular-nums" },
|
||||
}}
|
||||
{...xAxisPropsProp}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickMargin={8}
|
||||
tick={{
|
||||
fill: "#878C99",
|
||||
fontSize: 11,
|
||||
style: { fontVariantNumeric: "tabular-nums" },
|
||||
}}
|
||||
domain={["auto", (dataMax: number) => dataMax * 1.15]}
|
||||
{...yAxisPropsProp}
|
||||
/>
|
||||
{/* Hide tooltip when legend is shown - legend displays hover data instead */}
|
||||
{!showLegend && (
|
||||
<ChartTooltip
|
||||
cursor={{ fill: "#2C3034" }}
|
||||
content={
|
||||
tooltipLabelFormatter ? (
|
||||
<ChartTooltipContent />
|
||||
) : (
|
||||
<ZoomTooltip
|
||||
isSelecting={zoom?.isSelecting}
|
||||
refAreaLeft={zoom?.refAreaLeft}
|
||||
refAreaRight={zoom?.refAreaRight}
|
||||
invalidSelection={zoom?.invalidSelection}
|
||||
/>
|
||||
)
|
||||
}
|
||||
labelFormatter={tooltipLabelFormatter}
|
||||
allowEscapeViewBox={{ x: false, y: true }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Zoom selection area - rendered before bars to appear behind them */}
|
||||
{enableZoom && zoom?.refAreaLeft !== null && zoom?.refAreaRight !== null && (
|
||||
<ReferenceArea
|
||||
x1={zoom.refAreaLeft}
|
||||
x2={zoom.refAreaRight}
|
||||
strokeOpacity={0.4}
|
||||
fill="#3B82F6"
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
)}
|
||||
|
||||
{dataKeys.map((key, index, array) => {
|
||||
return (
|
||||
<Bar
|
||||
key={key}
|
||||
dataKey={key}
|
||||
stackId={stackId}
|
||||
fill={config[key]?.color}
|
||||
radius={
|
||||
[
|
||||
index === array.length - 1 ? 2 : 0,
|
||||
index === array.length - 1 ? 2 : 0,
|
||||
index === 0 ? 2 : 0,
|
||||
index === 0 ? 2 : 0,
|
||||
] as [number, number, number, number]
|
||||
}
|
||||
activeBar={false}
|
||||
fillOpacity={1}
|
||||
onClick={(data, index, e) => handleBarClick(data, e)}
|
||||
onMouseEnter={(entry, index) => {
|
||||
if (entry.tooltipPayload?.[0]) {
|
||||
const { dataKey: hoveredKey } = entry.tooltipPayload[0];
|
||||
highlight.setHoveredBar(hoveredKey, index);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={highlight.reset}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{data.map((_, dataIndex) => {
|
||||
// Don't dim bars during zoom selection
|
||||
const opacity = zoom?.isSelecting ? 1 : getBarOpacity(key, dataIndex, highlight);
|
||||
|
||||
return (
|
||||
<Cell
|
||||
key={`cell-${key}-${dataIndex}`}
|
||||
fill={config[key]?.color}
|
||||
fillOpacity={opacity}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Bar>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Horizontal reference line */}
|
||||
{referenceLine && (
|
||||
<ReferenceLine
|
||||
y={referenceLine.value}
|
||||
label={{
|
||||
position: "top",
|
||||
value: referenceLine.label,
|
||||
fill: "#878C99",
|
||||
fontSize: 11,
|
||||
}}
|
||||
isFront={true}
|
||||
stroke="#3B3E45"
|
||||
strokeDasharray="4 4"
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Zoom inspection line - rendered after bars to appear on top */}
|
||||
{enableZoom && zoom?.inspectionLine && (
|
||||
<ReferenceLine
|
||||
x={zoom.inspectionLine}
|
||||
stroke="#D7D9DD"
|
||||
strokeWidth={2}
|
||||
isFront={true}
|
||||
onClick={(e: any) => {
|
||||
e?.stopPropagation?.();
|
||||
zoom.clearInspectionLine();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Note: Legend is now rendered by ChartRoot outside the chart container */}
|
||||
</BarChart>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Compound Chart Component System
|
||||
*
|
||||
* This module exports a unified Chart compound component that can render
|
||||
* different chart types (bar, line, etc.) with optional features like
|
||||
* zoom and legends.
|
||||
*
|
||||
* Note: Due to recharts' component hierarchy requirements, the Legend must be
|
||||
* rendered inside the chart component (BarChart, LineChart). Use the showLegend
|
||||
* prop on Chart.Bar or Chart.Line instead of Chart.Legend as a sibling.
|
||||
*
|
||||
* @example Simple bar chart with legend
|
||||
* ```tsx
|
||||
* import { Chart } from "~/components/primitives/charts/ChartCompound";
|
||||
*
|
||||
* <Chart.Root config={config} data={data} dataKey="day">
|
||||
* <Chart.Bar stackId="a" showLegend maxLegendItems={5} />
|
||||
* </Chart.Root>
|
||||
* ```
|
||||
*
|
||||
* @example Line chart
|
||||
* ```tsx
|
||||
* <Chart.Root config={config} data={data} dataKey="day">
|
||||
* <Chart.Line lineType="step" showLegend />
|
||||
* </Chart.Root>
|
||||
* ```
|
||||
*
|
||||
* @example Bar chart with zoom (callback-based)
|
||||
* ```tsx
|
||||
* function MyChart() {
|
||||
* const handleZoomChange = (range: ZoomRange) => {
|
||||
* // Fetch new data based on range, update state
|
||||
* };
|
||||
*
|
||||
* return (
|
||||
* <Chart.Root
|
||||
* config={config}
|
||||
* data={data}
|
||||
* dataKey="day"
|
||||
* enableZoom
|
||||
* onZoomChange={handleZoomChange}
|
||||
* >
|
||||
* <Chart.Bar stackId="a" showLegend />
|
||||
* </Chart.Root>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example Dashboard with synced charts (using DateRangeContext)
|
||||
* ```tsx
|
||||
* <DateRangeProvider defaultStartDate={start} defaultEndDate={end}>
|
||||
* <DashboardControls />
|
||||
*
|
||||
* <Chart.Root config={config1} data={data1} dataKey="day" enableZoom onZoomChange={handleZoom1}>
|
||||
* <Chart.Bar showLegend />
|
||||
* </Chart.Root>
|
||||
*
|
||||
* <Chart.Root config={config2} data={data2} dataKey="day" enableZoom onZoomChange={handleZoom2}>
|
||||
* <Chart.Line showLegend />
|
||||
* </Chart.Root>
|
||||
* </DateRangeProvider>
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { ChartRoot } from "./ChartRoot";
|
||||
import { ChartBarRenderer } from "./ChartBar";
|
||||
import { ChartLineRenderer } from "./ChartLine";
|
||||
import { ChartLegendCompound } from "./ChartLegendCompound";
|
||||
import { ChartZoom } from "./ChartZoom";
|
||||
|
||||
// Re-export types
|
||||
export type { ChartConfig, ChartState } from "./Chart";
|
||||
export type { ZoomRange } from "./hooks/useZoomSelection";
|
||||
export type { ChartRootProps } from "./ChartRoot";
|
||||
export type { ChartBarRendererProps } from "./ChartBar";
|
||||
export type { ChartLineRendererProps } from "./ChartLine";
|
||||
export type { ChartLegendCompoundProps } from "./ChartLegendCompound";
|
||||
export type { ChartZoomProps } from "./ChartZoom";
|
||||
|
||||
/**
|
||||
* Chart compound component for building flexible, composable charts.
|
||||
*
|
||||
* Components:
|
||||
* - `Chart.Root` - Main wrapper that provides context
|
||||
* - `Chart.Bar` - Bar chart renderer (use showLegend prop for legend)
|
||||
* - `Chart.Line` - Line/area chart renderer (use showLegend prop for legend)
|
||||
* - `Chart.Zoom` - Optional zoom overlay (rendered internally when enableZoom is set)
|
||||
*
|
||||
* Note: Chart.Legend is exported for advanced use cases but should typically
|
||||
* be enabled via the showLegend prop on Chart.Bar or Chart.Line.
|
||||
*/
|
||||
export const Chart = {
|
||||
Root: ChartRoot,
|
||||
Bar: ChartBarRenderer,
|
||||
Line: ChartLineRenderer,
|
||||
Legend: ChartLegendCompound,
|
||||
Zoom: ChartZoom,
|
||||
};
|
||||
|
||||
// Also export individual components for direct imports
|
||||
export { ChartRoot, ChartBarRenderer, ChartLineRenderer, ChartLegendCompound, ChartZoom };
|
||||
|
||||
// Re-export context hook for advanced usage
|
||||
export { useChartContext } from "./ChartContext";
|
||||
export { useHasNoData, useSeriesTotal } from "./ChartRoot";
|
||||
export { useZoomHandlers, ZoomTooltip } from "./ChartZoom";
|
||||
@@ -0,0 +1,107 @@
|
||||
import React, { createContext, useContext, useMemo } from "react";
|
||||
import type { ChartConfig, ChartState } from "./Chart";
|
||||
import { useHighlightState, type UseHighlightStateReturn } from "./hooks/useHighlightState";
|
||||
import {
|
||||
useZoomSelection,
|
||||
type UseZoomSelectionReturn,
|
||||
type ZoomRange,
|
||||
} from "./hooks/useZoomSelection";
|
||||
|
||||
/** Function to format the x-axis label for display */
|
||||
export type LabelFormatter = (value: string) => string;
|
||||
|
||||
export type ChartContextValue = {
|
||||
// Core data
|
||||
config: ChartConfig;
|
||||
data: any[];
|
||||
dataKey: string;
|
||||
/** Computed series keys (all config keys except dataKey) */
|
||||
dataKeys: string[];
|
||||
|
||||
// Display state
|
||||
state?: ChartState;
|
||||
|
||||
// Formatters
|
||||
/** Function to format the x-axis label (used in legend, tooltips, etc.) */
|
||||
labelFormatter?: LabelFormatter;
|
||||
|
||||
// Highlight state
|
||||
highlight: UseHighlightStateReturn;
|
||||
|
||||
// Zoom state (only present when zoom is enabled)
|
||||
zoom: UseZoomSelectionReturn | null;
|
||||
|
||||
// Zoom callback (only present when zoom is enabled)
|
||||
onZoomChange?: (range: ZoomRange) => void;
|
||||
|
||||
// Whether the compound legend is shown (disables tooltip when true)
|
||||
showLegend: boolean;
|
||||
};
|
||||
|
||||
const ChartCompoundContext = createContext<ChartContextValue | null>(null);
|
||||
|
||||
export function useChartContext(): ChartContextValue {
|
||||
const context = useContext(ChartCompoundContext);
|
||||
if (!context) {
|
||||
throw new Error("useChartContext must be used within a Chart.Root component");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export type ChartProviderProps = {
|
||||
config: ChartConfig;
|
||||
data: any[];
|
||||
dataKey: string;
|
||||
/** Series keys to render (if not provided, derived from config keys) */
|
||||
series?: string[];
|
||||
state?: ChartState;
|
||||
/** Function to format the x-axis label (used in legend, tooltips, etc.) */
|
||||
labelFormatter?: LabelFormatter;
|
||||
/** Enable zoom functionality */
|
||||
enableZoom?: boolean;
|
||||
/** Callback when zoom range changes */
|
||||
onZoomChange?: (range: ZoomRange) => void;
|
||||
/** Whether the compound legend is shown (disables tooltip when true) */
|
||||
showLegend?: boolean;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function ChartProvider({
|
||||
config,
|
||||
data,
|
||||
dataKey,
|
||||
series,
|
||||
state,
|
||||
labelFormatter,
|
||||
enableZoom = false,
|
||||
onZoomChange,
|
||||
showLegend = false,
|
||||
children,
|
||||
}: ChartProviderProps) {
|
||||
const highlight = useHighlightState();
|
||||
const zoomState = useZoomSelection();
|
||||
|
||||
// Compute series keys (use provided series or derive from config)
|
||||
const dataKeys = useMemo(
|
||||
() => series ?? Object.keys(config).filter((k) => k !== dataKey),
|
||||
[series, config, dataKey]
|
||||
);
|
||||
|
||||
const value = useMemo<ChartContextValue>(
|
||||
() => ({
|
||||
config,
|
||||
data,
|
||||
dataKey,
|
||||
dataKeys,
|
||||
state,
|
||||
labelFormatter,
|
||||
highlight,
|
||||
zoom: enableZoom ? zoomState : null,
|
||||
onZoomChange: enableZoom ? onZoomChange : undefined,
|
||||
showLegend,
|
||||
}),
|
||||
[config, data, dataKey, dataKeys, state, labelFormatter, highlight, zoomState, enableZoom, onZoomChange, showLegend]
|
||||
);
|
||||
|
||||
return <ChartCompoundContext.Provider value={value}>{children}</ChartCompoundContext.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { useChartContext } from "./ChartContext";
|
||||
import { useSeriesTotal } from "./ChartRoot";
|
||||
import { Button } from "../Buttons";
|
||||
import { Paragraph } from "../Paragraph";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { AnimatedNumber } from "../AnimatedNumber";
|
||||
|
||||
export type ChartLegendCompoundProps = {
|
||||
/** Maximum number of legend items to show before collapsing */
|
||||
maxItems?: number;
|
||||
/** Hide the legend entirely (useful for conditional rendering) */
|
||||
hidden?: boolean;
|
||||
/** Additional className */
|
||||
className?: string;
|
||||
/** Label for the total row */
|
||||
totalLabel?: string;
|
||||
/** Callback when "View all" button is clicked */
|
||||
onViewAllLegendItems?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Legend component for the chart compound system.
|
||||
* Renders as a permanent element below the chart (not inside recharts).
|
||||
* Automatically connects to chart context for highlighting.
|
||||
*
|
||||
* @example Using via Chart.Root showLegend prop (recommended)
|
||||
* ```tsx
|
||||
* <Chart.Root config={config} data={data} dataKey="day" showLegend maxLegendItems={5}>
|
||||
* <Chart.Bar />
|
||||
* </Chart.Root>
|
||||
* ```
|
||||
*/
|
||||
export function ChartLegendCompound({
|
||||
maxItems = Infinity,
|
||||
hidden = false,
|
||||
className,
|
||||
totalLabel = "Total",
|
||||
onViewAllLegendItems,
|
||||
}: ChartLegendCompoundProps) {
|
||||
const { config, dataKey, dataKeys, highlight, labelFormatter } = useChartContext();
|
||||
const totals = useSeriesTotal();
|
||||
|
||||
// Calculate grand total (sum of all series totals)
|
||||
const grandTotal = useMemo(() => {
|
||||
return dataKeys.reduce((sum, key) => sum + (totals[key] || 0), 0);
|
||||
}, [totals, dataKeys]);
|
||||
|
||||
// Calculate current total based on hover state
|
||||
const currentTotal = useMemo(() => {
|
||||
if (!highlight.activePayload?.length) return grandTotal;
|
||||
|
||||
// Sum all values from the hovered data point
|
||||
return highlight.activePayload.reduce((sum, item) => {
|
||||
if (item.value !== undefined && dataKeys.includes(item.dataKey as string)) {
|
||||
return sum + (Number(item.value) || 0);
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
}, [highlight.activePayload, grandTotal, dataKeys]);
|
||||
|
||||
// Get the label for the total row - x-axis value when hovering, totalLabel otherwise
|
||||
const currentTotalLabel = useMemo(() => {
|
||||
if (!highlight.activePayload?.length) return totalLabel;
|
||||
|
||||
// Get the x-axis label from the payload's original data
|
||||
const firstPayloadItem = highlight.activePayload[0];
|
||||
const xAxisValue = firstPayloadItem?.payload?.[dataKey];
|
||||
|
||||
if (xAxisValue === undefined) return totalLabel;
|
||||
|
||||
// Apply the formatter if provided, otherwise just stringify the value
|
||||
const stringValue = String(xAxisValue);
|
||||
return labelFormatter ? labelFormatter(stringValue) : stringValue;
|
||||
}, [highlight.activePayload, dataKey, totalLabel, labelFormatter]);
|
||||
|
||||
// Get current data for the legend based on hover state
|
||||
const currentData = useMemo(() => {
|
||||
if (!highlight.activePayload?.length) return totals;
|
||||
|
||||
// If we have activePayload data from hovering over a bar
|
||||
const hoverData = highlight.activePayload.reduce((acc, item) => {
|
||||
if (item.dataKey && item.value !== undefined) {
|
||||
acc[item.dataKey] = Number(item.value) || 0;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
// Return a merged object - totals for keys not in the hover data
|
||||
return {
|
||||
...totals,
|
||||
...hoverData,
|
||||
};
|
||||
}, [highlight.activePayload, totals]);
|
||||
|
||||
// Prepare legend items with capped display
|
||||
const legendItems = useMemo(() => {
|
||||
const allItems = dataKeys.map((key) => ({
|
||||
dataKey: key,
|
||||
color: config[key]?.color,
|
||||
label: config[key]?.label ?? key,
|
||||
}));
|
||||
|
||||
if (allItems.length <= maxItems) {
|
||||
return { visible: allItems, remaining: 0, hoveredHiddenItem: undefined };
|
||||
}
|
||||
|
||||
const visibleItems = allItems.slice(0, maxItems);
|
||||
const remainingCount = allItems.length - maxItems;
|
||||
|
||||
// If we're hovering over an item that's not visible in the legend,
|
||||
// pass it separately to replace the "view more" row
|
||||
let hoveredHiddenItem: (typeof allItems)[0] | undefined;
|
||||
if (
|
||||
highlight.activeBarKey &&
|
||||
!visibleItems.some((item) => item.dataKey === highlight.activeBarKey)
|
||||
) {
|
||||
hoveredHiddenItem = allItems.find((item) => item.dataKey === highlight.activeBarKey);
|
||||
}
|
||||
|
||||
return { visible: visibleItems, remaining: remainingCount, hoveredHiddenItem };
|
||||
}, [config, dataKeys, maxItems, highlight.activeBarKey]);
|
||||
|
||||
if (hidden || dataKeys.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isHovering = (highlight.activePayload?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col pt-4 text-sm", className)}>
|
||||
{/* Total row */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 rounded px-2 py-1 transition",
|
||||
isHovering ? "text-text-bright" : "text-text-dimmed"
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{currentTotalLabel}</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
<AnimatedNumber value={currentTotal} duration={0.25} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="mx-2 my-1 border-t border-charcoal-750" />
|
||||
|
||||
{legendItems.visible.map((item) => {
|
||||
const total = currentData[item.dataKey] ?? 0;
|
||||
const isActive = highlight.activeBarKey === item.dataKey;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-pointer items-center justify-between gap-2 rounded px-2 py-1 transition",
|
||||
total === 0 && "opacity-50"
|
||||
)}
|
||||
onMouseEnter={() => highlight.setHoveredLegendItem(item.dataKey)}
|
||||
onMouseLeave={() => highlight.reset()}
|
||||
>
|
||||
{/* Active highlight background */}
|
||||
{isActive && item.color && (
|
||||
<div
|
||||
className="absolute inset-0 rounded opacity-10"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
)}
|
||||
<div className="relative flex w-full items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{item.color && (
|
||||
<div
|
||||
className="h-3 w-1 shrink-0 rounded-[2px]"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
)}
|
||||
<span className={isActive ? "text-text-bright" : "text-text-dimmed"}>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn("tabular-nums", isActive ? "text-text-bright" : "text-text-dimmed")}
|
||||
>
|
||||
<AnimatedNumber value={total} duration={0.25} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* View more row - replaced by hovered hidden item when applicable */}
|
||||
{legendItems.remaining > 0 &&
|
||||
(legendItems.hoveredHiddenItem ? (
|
||||
<HoveredHiddenItemRow
|
||||
item={legendItems.hoveredHiddenItem}
|
||||
value={currentData[legendItems.hoveredHiddenItem.dataKey] ?? 0}
|
||||
remainingCount={legendItems.remaining - 1}
|
||||
/>
|
||||
) : (
|
||||
<ViewAllDataRow remainingCount={legendItems.remaining} onViewAll={onViewAllLegendItems} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ViewAllDataRowProps = {
|
||||
remainingCount: number;
|
||||
onViewAll?: () => void;
|
||||
};
|
||||
|
||||
function ViewAllDataRow({ remainingCount, onViewAll }: ViewAllDataRowProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
fullWidth
|
||||
iconSpacing="justify-between"
|
||||
className="px-2 py-1"
|
||||
onClick={onViewAll}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-text-dimmed">
|
||||
<div className="h-3 w-1 rounded-[2px] border border-charcoal-600" />
|
||||
<Paragraph variant="extra-small" className="tabular-nums">
|
||||
{remainingCount} more…
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Paragraph variant="extra-small" className="text-indigo-500">
|
||||
View all
|
||||
</Paragraph>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
type HoveredHiddenItemRowProps = {
|
||||
item: { dataKey: string; color?: string; label: React.ReactNode };
|
||||
value: number;
|
||||
remainingCount: number;
|
||||
};
|
||||
|
||||
function HoveredHiddenItemRow({ item, value, remainingCount }: HoveredHiddenItemRowProps) {
|
||||
return (
|
||||
<div className="relative flex w-full items-center justify-between gap-2 rounded px-2 py-1">
|
||||
{/* Active highlight background */}
|
||||
{item.color && (
|
||||
<div
|
||||
className="absolute inset-0 rounded opacity-10"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
)}
|
||||
<div className="relative flex w-full items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{item.color && (
|
||||
<div
|
||||
className="h-3 w-1 shrink-0 rounded-[2px]"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-text-bright">{item.label}</span>
|
||||
{remainingCount > 0 && <span className="text-text-dimmed">+{remainingCount} more</span>}
|
||||
</div>
|
||||
<span className="tabular-nums text-text-bright">
|
||||
<AnimatedNumber value={value} duration={0.25} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
XAxis,
|
||||
YAxis,
|
||||
type XAxisProps,
|
||||
type YAxisProps,
|
||||
} from "recharts";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartState,
|
||||
} from "~/components/primitives/charts/Chart";
|
||||
import { ChartLineLoading, ChartLineNoData, ChartLineInvalid } from "./ChartLoading";
|
||||
import { useChartContext } from "./ChartContext";
|
||||
import { ChartRoot, useHasNoData } from "./ChartRoot";
|
||||
// Legend is now rendered by ChartRoot outside the chart container
|
||||
import type { ZoomRange } from "./hooks/useZoomSelection";
|
||||
|
||||
type CurveType =
|
||||
| "basis"
|
||||
| "basisClosed"
|
||||
| "basisOpen"
|
||||
| "linear"
|
||||
| "linearClosed"
|
||||
| "natural"
|
||||
| "monotoneX"
|
||||
| "monotoneY"
|
||||
| "monotone"
|
||||
| "step"
|
||||
| "stepBefore"
|
||||
| "stepAfter";
|
||||
|
||||
// ============================================================================
|
||||
// COMPOUND COMPONENT API
|
||||
// ============================================================================
|
||||
|
||||
export type ChartLineRendererProps = {
|
||||
/** Line curve type */
|
||||
lineType?: CurveType;
|
||||
/** Custom X-axis props to merge with defaults */
|
||||
xAxisProps?: Partial<XAxisProps>;
|
||||
/** Custom Y-axis props to merge with defaults */
|
||||
yAxisProps?: Partial<YAxisProps>;
|
||||
/** Render as stacked area chart instead of line chart */
|
||||
stacked?: boolean;
|
||||
/** Custom tooltip label formatter */
|
||||
tooltipLabelFormatter?: (label: string, payload: any[]) => string;
|
||||
/** Width injected by ResponsiveContainer */
|
||||
width?: number;
|
||||
/** Height injected by ResponsiveContainer */
|
||||
height?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Line chart renderer for the compound component system.
|
||||
* Must be used within a Chart.Root.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Chart.Root config={config} data={data} dataKey="day">
|
||||
* <Chart.Line type="step" />
|
||||
* <Chart.Legend simple />
|
||||
* </Chart.Root>
|
||||
* ```
|
||||
*/
|
||||
export function ChartLineRenderer({
|
||||
lineType = "step",
|
||||
xAxisProps: xAxisPropsProp,
|
||||
yAxisProps: yAxisPropsProp,
|
||||
stacked = false,
|
||||
tooltipLabelFormatter,
|
||||
width,
|
||||
height,
|
||||
}: ChartLineRendererProps) {
|
||||
const { config, data, dataKey, dataKeys, state, highlight, showLegend } = useChartContext();
|
||||
const hasNoData = useHasNoData();
|
||||
|
||||
// Render loading/error states
|
||||
if (state === "loading") {
|
||||
return <ChartLineLoading />;
|
||||
} else if (state === "noData" || hasNoData) {
|
||||
return <ChartLineNoData />;
|
||||
} else if (state === "invalid") {
|
||||
return <ChartLineInvalid />;
|
||||
}
|
||||
|
||||
// Get the x-axis ticks based on tooltip state
|
||||
const xAxisTicks =
|
||||
highlight.tooltipActive && data.length > 2
|
||||
? [data[0]?.[dataKey], data[data.length - 1]?.[dataKey]]
|
||||
: undefined;
|
||||
|
||||
const xAxisConfig = {
|
||||
dataKey,
|
||||
tickLine: false,
|
||||
axisLine: false,
|
||||
tickMargin: 10,
|
||||
ticks: xAxisTicks,
|
||||
interval: "preserveStartEnd" as const,
|
||||
tick: {
|
||||
fill: "#878C99",
|
||||
fontSize: 11,
|
||||
style: { fontVariantNumeric: "tabular-nums" },
|
||||
},
|
||||
...xAxisPropsProp,
|
||||
};
|
||||
|
||||
const yAxisConfig = {
|
||||
axisLine: false,
|
||||
tickLine: false,
|
||||
tickMargin: 8,
|
||||
tick: {
|
||||
fill: "#878C99",
|
||||
fontSize: 11,
|
||||
style: { fontVariantNumeric: "tabular-nums" },
|
||||
},
|
||||
...yAxisPropsProp,
|
||||
};
|
||||
|
||||
// Handle mouse leave to also reset highlight
|
||||
const handleMouseLeave = () => {
|
||||
highlight.setTooltipActive(false);
|
||||
highlight.reset();
|
||||
};
|
||||
|
||||
// Render stacked area chart if stacked prop is true
|
||||
if (stacked && dataKeys.length > 1) {
|
||||
return (
|
||||
<AreaChart
|
||||
data={data}
|
||||
width={width}
|
||||
height={height}
|
||||
stackOffset="none"
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
onMouseMove={(e: any) => {
|
||||
// Update active payload for legend
|
||||
if (e?.activePayload?.length) {
|
||||
highlight.setActivePayload(e.activePayload);
|
||||
highlight.setTooltipActive(true);
|
||||
} else {
|
||||
highlight.setTooltipActive(false);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<CartesianGrid vertical={false} stroke="#272A2E" strokeDasharray="3 3" />
|
||||
<XAxis {...xAxisConfig} />
|
||||
<YAxis {...yAxisConfig} />
|
||||
{/* Hide tooltip when legend is shown - legend displays hover data instead */}
|
||||
{!showLegend && (
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator="line" />}
|
||||
labelFormatter={tooltipLabelFormatter}
|
||||
/>
|
||||
)}
|
||||
{/* Note: Legend is now rendered by ChartRoot outside the chart container */}
|
||||
{dataKeys.map((key) => (
|
||||
<Area
|
||||
key={key}
|
||||
type={lineType}
|
||||
dataKey={key}
|
||||
stroke={config[key]?.color}
|
||||
fill={config[key]?.color}
|
||||
fillOpacity={0.6}
|
||||
strokeWidth={2}
|
||||
stackId="stack"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LineChart
|
||||
accessibilityLayer
|
||||
data={data}
|
||||
width={width}
|
||||
height={height}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
onMouseMove={(e: any) => {
|
||||
// Update active payload for legend
|
||||
if (e?.activePayload?.length) {
|
||||
highlight.setActivePayload(e.activePayload);
|
||||
highlight.setTooltipActive(true);
|
||||
} else {
|
||||
highlight.setTooltipActive(false);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<CartesianGrid vertical={false} stroke="#272A2E" strokeDasharray="3 3" />
|
||||
<XAxis {...xAxisConfig} />
|
||||
<YAxis {...yAxisConfig} />
|
||||
{/* Hide tooltip when legend is shown - legend displays hover data instead */}
|
||||
{!showLegend && (
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent />}
|
||||
labelFormatter={tooltipLabelFormatter}
|
||||
/>
|
||||
)}
|
||||
{/* Note: Legend is now rendered by ChartRoot outside the chart container */}
|
||||
{dataKeys.map((key) => (
|
||||
<Line
|
||||
key={key}
|
||||
dataKey={key}
|
||||
type={lineType}
|
||||
stroke={config[key]?.color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Button } from "../Buttons";
|
||||
import { Paragraph } from "../Paragraph";
|
||||
import { Spinner } from "../Spinner";
|
||||
import { useDateRange } from "./DateRangeContext";
|
||||
import { useMemo } from "react";
|
||||
import { ClientOnly } from "remix-utils/client-only";
|
||||
|
||||
export function ChartBarLoading() {
|
||||
return (
|
||||
<div className="relative grid h-full place-items-center p-2 pt-0">
|
||||
<ChartBarLoadingBackground />
|
||||
<div className="absolute z-10 mb-16 flex items-center gap-2">
|
||||
<Spinner className="size-6" />
|
||||
<Paragraph variant="base/bright">Loading data…</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartBarNoData() {
|
||||
const dateRange = useDateRange();
|
||||
|
||||
return (
|
||||
<div className="relative grid h-full place-items-center p-2 pt-0">
|
||||
<ChartBarLoadingBackground />
|
||||
<div className="absolute z-10 mb-16 flex flex-col items-center gap-2">
|
||||
<Paragraph variant="small/bright">No data</Paragraph>
|
||||
<Paragraph variant="small" spacing>
|
||||
There's no data available for the filters you've set.
|
||||
</Paragraph>
|
||||
{dateRange && (
|
||||
<Button variant="secondary/small" onClick={dateRange.resetDateRange}>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartBarInvalid() {
|
||||
const dateRange = useDateRange();
|
||||
|
||||
return (
|
||||
<div className="relative grid h-full place-items-center p-2 pt-0">
|
||||
<ChartBarLoadingBackground />
|
||||
<div className="absolute z-10 mb-16 flex flex-col items-center gap-2">
|
||||
<Paragraph variant="small/bright">Chart not applicable</Paragraph>
|
||||
<Paragraph variant="small" spacing>
|
||||
Your current filter settings don't apply to this chart's data type.
|
||||
</Paragraph>
|
||||
{dateRange && (
|
||||
<Button variant="secondary/small" onClick={dateRange.resetDateRange}>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartLineLoading() {
|
||||
return (
|
||||
<div className="relative mb-16 grid h-full place-items-center p-2 pt-0">
|
||||
<ChartLineLoadingBackground />
|
||||
<div className="absolute z-10 flex items-center gap-2">
|
||||
<Spinner className="size-6" />
|
||||
<Paragraph variant="base/bright">Loading data…</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartLineNoData() {
|
||||
const dateRange = useDateRange();
|
||||
|
||||
return (
|
||||
<div className="relative grid h-full place-items-center p-2 pt-0">
|
||||
<ChartLineLoadingBackground />
|
||||
<div className="absolute z-10 mb-16 flex flex-col items-center gap-2">
|
||||
<Paragraph variant="small/bright">No data</Paragraph>
|
||||
<Paragraph variant="small" spacing>
|
||||
There's no data available for the filters you've set.
|
||||
</Paragraph>
|
||||
{dateRange && (
|
||||
<Button variant="secondary/small" onClick={dateRange.resetDateRange}>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartLineInvalid() {
|
||||
const dateRange = useDateRange();
|
||||
|
||||
return (
|
||||
<div className="relative grid h-full place-items-center p-2 pt-0">
|
||||
<ChartLineLoadingBackground />
|
||||
<div className="absolute z-10 mb-16 flex flex-col items-center gap-2">
|
||||
<Paragraph variant="small/bright">Chart not applicable</Paragraph>
|
||||
<Paragraph variant="small" spacing>
|
||||
Your current filter settings don't apply to this chart's data type.
|
||||
</Paragraph>
|
||||
{dateRange && (
|
||||
<Button variant="secondary/small" onClick={dateRange.resetDateRange}>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartBarLoadingBackground() {
|
||||
return (
|
||||
<motion.div
|
||||
className="flex h-full w-full flex-col"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<ClientOnly fallback={<div />}>
|
||||
{() => (<>
|
||||
<motion.div
|
||||
className="flex flex-1 items-end gap-1"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
>
|
||||
{Array.from({ length: 30 }).map((_, i) => {
|
||||
const height = Math.max(15, Math.floor(Math.random() * 100));
|
||||
return (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="flex-1 rounded-sm bg-charcoal-750/50"
|
||||
style={{ height: `${height}%` }}
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: `${height}%`, opacity: 1 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 120,
|
||||
damping: 14,
|
||||
mass: 1,
|
||||
delay: 0.1 + i * 0.02,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="mt-2 flex flex-col justify-center gap-1"
|
||||
initial={{ y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 100,
|
||||
damping: 15,
|
||||
delay: 0.2,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<motion.div
|
||||
className="flex items-center gap-1"
|
||||
key={i}
|
||||
initial={{ x: -10, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 100,
|
||||
damping: 12,
|
||||
delay: 0.3 + i * 0.08,
|
||||
}}
|
||||
>
|
||||
<div className="h-5 w-2 rounded-sm bg-charcoal-750/50" />
|
||||
<div className="h-5 w-full rounded-sm bg-charcoal-750/50" />
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</>)}
|
||||
</ClientOnly>
|
||||
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartLineLoadingBackground() {
|
||||
// Generate line points with configurable starting position and constraints
|
||||
const generateLinePoints = (startY: number, minY: number, maxY: number) => {
|
||||
const numPoints = 10;
|
||||
const points = [];
|
||||
let lastY = startY;
|
||||
|
||||
for (let i = 0; i < numPoints; i++) {
|
||||
// Calculate x value that spreads points across the full width
|
||||
const x = i * (9 / (numPoints - 1));
|
||||
|
||||
// Create less extreme variations that move smoothly
|
||||
const change = Math.random() * 6 - 3; // Range from -3 to +3
|
||||
const y = Math.max(minY, Math.min(maxY, lastY + change)); // Apply constraints
|
||||
|
||||
points.push({ x, y });
|
||||
lastY = y;
|
||||
}
|
||||
|
||||
return points;
|
||||
};
|
||||
|
||||
// Generate points for both lines
|
||||
const points = useMemo(() => generateLinePoints(30, 10, 90), []);
|
||||
const secondPoints = useMemo(() => generateLinePoints(40, 30, 90), []);
|
||||
|
||||
const generateSmoothPath = (points: Array<{ x: number; y: number }>) => {
|
||||
if (points.length < 2) return "";
|
||||
|
||||
let path = `M0,${50 - points[0].y}`;
|
||||
|
||||
// Use curve command for smooth lines
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const x1 = points[i].x;
|
||||
const y1 = 50 - points[i].y;
|
||||
const x2 = points[i + 1].x;
|
||||
const y2 = 50 - points[i + 1].y;
|
||||
|
||||
// Bezier control points (create smooth curve)
|
||||
const cx1 = (x1 + x2) / 2;
|
||||
const cy1 = y1;
|
||||
const cx2 = (x1 + x2) / 2;
|
||||
const cy2 = y2;
|
||||
|
||||
path += ` C${cx1},${cy1} ${cx2},${cy2} ${x2},${y2}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
};
|
||||
|
||||
const generateAreaPath = (points: Array<{ x: number; y: number }>) => {
|
||||
const curvePath = generateSmoothPath(points);
|
||||
const lastX = 9;
|
||||
return `${curvePath} L${lastX},50 L0,50 Z`;
|
||||
};
|
||||
|
||||
// Component to render a line with area fill and animation
|
||||
const AnimatedLine = ({
|
||||
points,
|
||||
gradientId,
|
||||
delay = 0,
|
||||
}: {
|
||||
points: Array<{ x: number; y: number }>;
|
||||
gradientId: string;
|
||||
delay?: number;
|
||||
}) => (
|
||||
<>
|
||||
<motion.path
|
||||
d={generateAreaPath(points)}
|
||||
fill={`url(#${gradientId})`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 1, ease: "easeInOut", delay }}
|
||||
/>
|
||||
<motion.path
|
||||
d={generateSmoothPath(points)}
|
||||
stroke="#212327"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.8 }}
|
||||
transition={{ duration: 1.5, ease: "easeInOut", delay }}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex h-full w-full flex-col"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="relative flex-1">
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full"
|
||||
viewBox="0 0 9 50"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="areaGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#212327" stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor="#212327" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="secondAreaGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#212327" stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor="#212327" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g>
|
||||
<AnimatedLine points={points} gradientId="areaGradient" />
|
||||
<AnimatedLine points={secondPoints} gradientId="secondAreaGradient" delay={0.2} />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="mt-2 flex flex-col justify-center gap-1"
|
||||
initial={{ y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 100,
|
||||
damping: 15,
|
||||
delay: 0.2,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<motion.div
|
||||
className="flex items-center gap-1"
|
||||
key={i}
|
||||
initial={{ x: -10, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 100,
|
||||
damping: 12,
|
||||
delay: 0.3 + i * 0.08,
|
||||
}}
|
||||
>
|
||||
<div className="h-5 w-2 rounded-sm bg-charcoal-750/50" />
|
||||
<div className="h-5 w-full rounded-sm bg-charcoal-750/50" />
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import React, { useMemo } from "react";
|
||||
import type * as RechartsPrimitive from "recharts";
|
||||
import { ChartContainer, type ChartConfig, type ChartState } from "./Chart";
|
||||
import { ChartProvider, useChartContext, type LabelFormatter } from "./ChartContext";
|
||||
import { ChartLegendCompound } from "./ChartLegendCompound";
|
||||
import type { ZoomRange } from "./hooks/useZoomSelection";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export type ChartRootProps = {
|
||||
config: ChartConfig;
|
||||
data: any[];
|
||||
dataKey: string;
|
||||
/** Series keys to render (if not provided, derived from config keys) */
|
||||
series?: string[];
|
||||
state?: ChartState;
|
||||
/** Function to format the x-axis label (used in legend, tooltips, etc.) */
|
||||
labelFormatter?: LabelFormatter;
|
||||
/** Enable zoom functionality */
|
||||
enableZoom?: boolean;
|
||||
/** Callback when zoom range changes */
|
||||
onZoomChange?: (range: ZoomRange) => void;
|
||||
/** Minimum height for the chart */
|
||||
minHeight?: string;
|
||||
/** Additional className for the container */
|
||||
className?: string;
|
||||
/** Show the compound legend below the chart */
|
||||
showLegend?: boolean;
|
||||
/** Maximum items in the legend before showing "view more" */
|
||||
maxLegendItems?: number;
|
||||
/** Label for the total row in the legend */
|
||||
legendTotalLabel?: string;
|
||||
/** Callback when "View all" legend button is clicked */
|
||||
onViewAllLegendItems?: () => void;
|
||||
/** When true, chart fills its parent container height and distributes space between chart and legend */
|
||||
fillContainer?: boolean;
|
||||
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
|
||||
};
|
||||
|
||||
/**
|
||||
* Root component for the chart compound component system.
|
||||
* Provides shared context for all child chart components.
|
||||
*
|
||||
* @example Simple bar chart
|
||||
* ```tsx
|
||||
* <Chart.Root config={config} data={data} dataKey="day">
|
||||
* <Chart.Bar stackId="a" />
|
||||
* <Chart.Legend />
|
||||
* </Chart.Root>
|
||||
* ```
|
||||
*
|
||||
* @example Chart with zoom
|
||||
* ```tsx
|
||||
* <Chart.Root config={config} data={data} dataKey="day" enableZoom onZoomChange={handleZoom}>
|
||||
* <Chart.Bar stackId="a" />
|
||||
* <Chart.Zoom />
|
||||
* <Chart.Legend />
|
||||
* </Chart.Root>
|
||||
* ```
|
||||
*/
|
||||
export function ChartRoot({
|
||||
config,
|
||||
data,
|
||||
dataKey,
|
||||
series,
|
||||
state,
|
||||
labelFormatter,
|
||||
enableZoom = false,
|
||||
onZoomChange,
|
||||
minHeight,
|
||||
className,
|
||||
showLegend = false,
|
||||
maxLegendItems = 5,
|
||||
legendTotalLabel,
|
||||
onViewAllLegendItems,
|
||||
fillContainer = false,
|
||||
children,
|
||||
}: ChartRootProps) {
|
||||
return (
|
||||
<ChartProvider
|
||||
config={config}
|
||||
data={data}
|
||||
dataKey={dataKey}
|
||||
series={series}
|
||||
state={state}
|
||||
labelFormatter={labelFormatter}
|
||||
enableZoom={enableZoom}
|
||||
onZoomChange={onZoomChange}
|
||||
showLegend={showLegend}
|
||||
>
|
||||
<ChartRootInner
|
||||
minHeight={minHeight}
|
||||
className={className}
|
||||
showLegend={showLegend}
|
||||
maxLegendItems={maxLegendItems}
|
||||
legendTotalLabel={legendTotalLabel}
|
||||
onViewAllLegendItems={onViewAllLegendItems}
|
||||
fillContainer={fillContainer}
|
||||
>
|
||||
{children}
|
||||
</ChartRootInner>
|
||||
</ChartProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type ChartRootInnerProps = {
|
||||
minHeight?: string;
|
||||
className?: string;
|
||||
showLegend?: boolean;
|
||||
maxLegendItems?: number;
|
||||
legendTotalLabel?: string;
|
||||
onViewAllLegendItems?: () => void;
|
||||
fillContainer?: boolean;
|
||||
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
|
||||
};
|
||||
|
||||
function ChartRootInner({
|
||||
minHeight,
|
||||
className,
|
||||
showLegend = false,
|
||||
maxLegendItems = 5,
|
||||
legendTotalLabel,
|
||||
onViewAllLegendItems,
|
||||
fillContainer = false,
|
||||
children,
|
||||
}: ChartRootInnerProps) {
|
||||
const { config, zoom } = useChartContext();
|
||||
const enableZoom = zoom !== null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex w-full flex-col",
|
||||
fillContainer && "h-full",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
fillContainer ? "min-h-0 flex-1" : "h-full w-full",
|
||||
enableZoom && "mt-8 cursor-crosshair"
|
||||
)}
|
||||
style={{ touchAction: "none", userSelect: "none" }}
|
||||
>
|
||||
<ChartContainer
|
||||
config={config}
|
||||
className={cn(
|
||||
"h-full w-full",
|
||||
fillContainer && "!aspect-auto",
|
||||
enableZoom &&
|
||||
"[&_.recharts-surface]:cursor-crosshair [&_.recharts-wrapper]:cursor-crosshair"
|
||||
)}
|
||||
style={fillContainer ? undefined : minHeight ? { minHeight } : undefined}
|
||||
>
|
||||
{children}
|
||||
</ChartContainer>
|
||||
</div>
|
||||
{/* Legend rendered outside the chart container */}
|
||||
{showLegend && (
|
||||
<ChartLegendCompound
|
||||
maxItems={maxLegendItems}
|
||||
totalLabel={legendTotalLabel}
|
||||
onViewAllLegendItems={onViewAllLegendItems}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check if all data in the visible range is empty (null or undefined).
|
||||
* Zero values are considered valid data and will render.
|
||||
* Useful for rendering "no data" states.
|
||||
*/
|
||||
export function useHasNoData(): boolean {
|
||||
const { data, dataKey, dataKeys } = useChartContext();
|
||||
|
||||
return useMemo(() => {
|
||||
if (data.length === 0) return true;
|
||||
|
||||
return data.every((item) => {
|
||||
return dataKeys.every((key) => {
|
||||
const value = item[key];
|
||||
return value === null || value === undefined;
|
||||
});
|
||||
});
|
||||
}, [data, dataKeys]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to calculate totals for each series across all data points.
|
||||
* Useful for legend displays.
|
||||
*/
|
||||
export function useSeriesTotal(): Record<string, number> {
|
||||
const { data, dataKeys } = useChartContext();
|
||||
|
||||
return useMemo(() => {
|
||||
return data.reduce((acc, item) => {
|
||||
for (const seriesKey of dataKeys) {
|
||||
acc[seriesKey] = (acc[seriesKey] || 0) + Number(item[seriesKey] || 0);
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
}, [data, dataKeys]);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { ReferenceArea, ReferenceLine } from "recharts";
|
||||
import { useChartContext } from "./ChartContext";
|
||||
import { useDateRange } from "./DateRangeContext";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export type ChartZoomProps = {
|
||||
/** Sync zoom with DateRangeContext (for dashboard-level syncing) */
|
||||
syncWithDateRange?: boolean;
|
||||
/** Minimum number of data points required for a valid zoom selection */
|
||||
minDataPoints?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Zoom overlay component for charts.
|
||||
* Renders the zoom selection area and inspection line.
|
||||
*
|
||||
* Must be used within a Chart.Root with enableZoom={true}.
|
||||
*
|
||||
* @example Basic zoom
|
||||
* ```tsx
|
||||
* <Chart.Root config={config} data={data} dataKey="day" enableZoom onZoomChange={handleZoom}>
|
||||
* <Chart.Bar />
|
||||
* <Chart.Zoom />
|
||||
* </Chart.Root>
|
||||
* ```
|
||||
*
|
||||
* @example Synced with DateRangeContext
|
||||
* ```tsx
|
||||
* <DateRangeProvider>
|
||||
* <Chart.Root config={config} data={data} dataKey="day" enableZoom onZoomChange={handleZoom}>
|
||||
* <Chart.Bar />
|
||||
* <Chart.Zoom syncWithDateRange />
|
||||
* </Chart.Root>
|
||||
* </DateRangeProvider>
|
||||
* ```
|
||||
*/
|
||||
export function ChartZoom({ syncWithDateRange = false, minDataPoints = 3 }: ChartZoomProps) {
|
||||
const { zoom, data, dataKey, onZoomChange } = useChartContext();
|
||||
const globalDateRange = useDateRange();
|
||||
|
||||
if (!zoom) {
|
||||
console.warn("ChartZoom: zoom is not enabled. Add enableZoom to Chart.Root.");
|
||||
return null;
|
||||
}
|
||||
|
||||
const { inspectionLine, refAreaLeft, refAreaRight } = zoom;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Inspection line (click to inspect) */}
|
||||
{inspectionLine && (
|
||||
<ReferenceLine
|
||||
x={inspectionLine}
|
||||
stroke="#D7D9DD"
|
||||
strokeWidth={2}
|
||||
isFront={true}
|
||||
onClick={(e: any) => {
|
||||
e?.stopPropagation?.();
|
||||
zoom.clearInspectionLine();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Zoom selection area */}
|
||||
{refAreaLeft && refAreaRight && (
|
||||
<ReferenceArea
|
||||
x1={refAreaLeft}
|
||||
x2={refAreaRight}
|
||||
strokeOpacity={0.4}
|
||||
fill="#3B82F6"
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltip component for showing zoom selection feedback.
|
||||
* Can be used inside ChartTooltip content prop.
|
||||
*
|
||||
* Note: This component receives zoom state as props because recharts
|
||||
* may render tooltips outside the normal React tree where context isn't available.
|
||||
*/
|
||||
export type ZoomTooltipProps = {
|
||||
active?: boolean;
|
||||
payload?: any[];
|
||||
label?: string;
|
||||
viewBox?: {
|
||||
height: number;
|
||||
width: number;
|
||||
};
|
||||
coordinate?: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
// Zoom state passed as props (since context might not be available in tooltip)
|
||||
isSelecting?: boolean;
|
||||
refAreaLeft?: string | null;
|
||||
refAreaRight?: string | null;
|
||||
invalidSelection?: boolean;
|
||||
};
|
||||
|
||||
export function ZoomTooltip({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
viewBox,
|
||||
coordinate,
|
||||
isSelecting,
|
||||
refAreaLeft,
|
||||
refAreaRight,
|
||||
invalidSelection,
|
||||
}: ZoomTooltipProps) {
|
||||
if (!active) return null;
|
||||
|
||||
// Show zoom range when selecting
|
||||
if (isSelecting && refAreaLeft && refAreaRight) {
|
||||
const message = invalidSelection
|
||||
? "Zoom: Drag a wider range"
|
||||
: `Zoom: ${refAreaLeft} to ${refAreaRight}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute whitespace-nowrap rounded border px-2 py-1 text-xxs tabular-nums",
|
||||
invalidSelection
|
||||
? "border-amber-800 bg-amber-950 text-amber-400"
|
||||
: "border-blue-800 bg-[#1B2334] text-blue-400"
|
||||
)}
|
||||
style={{
|
||||
left: coordinate?.x,
|
||||
top: viewBox?.height ? viewBox.height + 14 : 0,
|
||||
transform: "translateX(-50%)",
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -top-[5px] left-1/2 h-2 w-2 -translate-x-1/2 rotate-45",
|
||||
invalidSelection
|
||||
? "border-l border-t border-amber-800 bg-amber-950"
|
||||
: "border-l border-t border-blue-800 bg-[#1B2334]"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default tooltip behavior (show label)
|
||||
if (!payload?.length) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute whitespace-nowrap rounded border border-charcoal-600 bg-charcoal-700 px-2 py-1 text-xxs tabular-nums text-text-bright"
|
||||
style={{
|
||||
left: coordinate?.x,
|
||||
top: viewBox?.height ? viewBox.height + 13 : 0,
|
||||
transform: "translateX(-50%)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
<div className="absolute -top-[5px] left-1/2 h-2 w-2 -translate-x-1/2 rotate-45 border-l border-t border-charcoal-600 bg-charcoal-700" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that returns event handlers for zoom interactions.
|
||||
* Use these on your chart component (BarChart, LineChart, etc.).
|
||||
*/
|
||||
export function useZoomHandlers(
|
||||
options: { minDataPoints?: number; syncWithDateRange?: boolean } = {}
|
||||
) {
|
||||
const { minDataPoints = 3, syncWithDateRange = false } = options;
|
||||
const { zoom, data, dataKey, onZoomChange } = useChartContext();
|
||||
const globalDateRange = useDateRange();
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: any) => {
|
||||
if (!zoom || !e?.activeLabel) return;
|
||||
zoom.startSelection(e.activeLabel);
|
||||
},
|
||||
[zoom]
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: any) => {
|
||||
if (!zoom || !e?.activeLabel) return;
|
||||
if (zoom.isSelecting) {
|
||||
zoom.updateSelection(e.activeLabel, data, dataKey, minDataPoints);
|
||||
}
|
||||
},
|
||||
[zoom, data, dataKey, minDataPoints]
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (!zoom) return;
|
||||
|
||||
const range = zoom.finishSelection(data, dataKey, minDataPoints);
|
||||
|
||||
if (range) {
|
||||
// If syncing with DateRangeContext, update it
|
||||
if (syncWithDateRange && globalDateRange) {
|
||||
globalDateRange.setDateRange(range.start, range.end);
|
||||
}
|
||||
|
||||
// Call the onZoomChange callback
|
||||
onZoomChange?.(range);
|
||||
}
|
||||
}, [zoom, data, dataKey, minDataPoints, syncWithDateRange, globalDateRange, onZoomChange]);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: any) => {
|
||||
if (!zoom || zoom.isSelecting || !e?.activeLabel) return;
|
||||
zoom.toggleInspectionLine(e.activeLabel);
|
||||
},
|
||||
[zoom]
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (!zoom) return;
|
||||
zoom.cancelSelection();
|
||||
}, [zoom]);
|
||||
|
||||
if (!zoom) {
|
||||
return {
|
||||
onMouseDown: undefined,
|
||||
onMouseMove: undefined,
|
||||
onMouseUp: undefined,
|
||||
onClick: undefined,
|
||||
onMouseLeave: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
onMouseDown: handleMouseDown,
|
||||
onMouseMove: handleMouseMove,
|
||||
onMouseUp: handleMouseUp,
|
||||
onClick: handleClick,
|
||||
onMouseLeave: handleMouseLeave,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import React, { createContext, useState, useContext, type ReactNode } from "react";
|
||||
|
||||
type DateRangeContextType = {
|
||||
/** Start date as ISO string (YYYY-MM-DD) or custom format */
|
||||
startDate: string;
|
||||
/** End date as ISO string (YYYY-MM-DD) or custom format */
|
||||
endDate: string;
|
||||
setDateRange: (startDate: string, endDate: string) => void;
|
||||
resetDateRange: () => void;
|
||||
};
|
||||
|
||||
// Formatters for displaying dates
|
||||
const shortDateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const longDateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
/**
|
||||
* Format a Date object as a short date string (e.g., "Nov 1")
|
||||
*/
|
||||
export function formatChartDate(date: Date): string {
|
||||
return shortDateFormatter.format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Date object as a long date string (e.g., "Nov 1, 2023")
|
||||
*/
|
||||
export function formatChartDateLong(date: Date): string {
|
||||
return longDateFormatter.format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Date to ISO date string (YYYY-MM-DD) using local date components
|
||||
*/
|
||||
export function toISODateString(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an ISO date string (YYYY-MM-DD) to a local Date object
|
||||
*/
|
||||
export function parseISODateString(isoString: string): Date {
|
||||
const [year, month, day] = isoString.split("-").map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO date string for display (e.g., "2023-11-01" -> "Nov 1")
|
||||
*/
|
||||
export function formatISODate(isoString: string): string {
|
||||
const date = parseISODateString(isoString);
|
||||
return formatChartDate(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO date string for display with year (e.g., "2023-11-01" -> "Nov 1, 2023")
|
||||
*/
|
||||
export function formatISODateLong(isoString: string): string {
|
||||
const date = parseISODateString(isoString);
|
||||
return formatChartDateLong(date);
|
||||
}
|
||||
|
||||
const DateRangeContext = createContext<DateRangeContextType | null>(null);
|
||||
|
||||
export function DateRangeProvider({
|
||||
children,
|
||||
defaultStartDate,
|
||||
defaultEndDate,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
defaultStartDate: Date;
|
||||
defaultEndDate: Date;
|
||||
}) {
|
||||
// Store dates as ISO strings for consistent data matching
|
||||
const defaultStartISO = toISODateString(defaultStartDate);
|
||||
const defaultEndISO = toISODateString(defaultEndDate);
|
||||
|
||||
const [startDate, setStartDate] = useState<string>(defaultStartISO);
|
||||
const [endDate, setEndDate] = useState<string>(defaultEndISO);
|
||||
|
||||
const setDateRange = (start: string, end: string) => {
|
||||
setStartDate(start);
|
||||
setEndDate(end);
|
||||
};
|
||||
|
||||
const resetDateRange = () => {
|
||||
setStartDate(defaultStartISO);
|
||||
setEndDate(defaultEndISO);
|
||||
};
|
||||
|
||||
return (
|
||||
<DateRangeContext.Provider
|
||||
value={{
|
||||
startDate,
|
||||
endDate,
|
||||
setDateRange,
|
||||
resetDateRange,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DateRangeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDateRange(): DateRangeContextType | null {
|
||||
const context = useContext(DateRangeContext);
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export type HighlightState = {
|
||||
/** The currently highlighted series key (e.g., "completed", "failed") */
|
||||
activeBarKey: string | null;
|
||||
/** The index of the specific data point being hovered (null when hovering legend) */
|
||||
activeDataPointIndex: number | null;
|
||||
/** The payload data from the hovered element */
|
||||
activePayload: any[] | null;
|
||||
/** Whether the tooltip is currently active */
|
||||
tooltipActive: boolean;
|
||||
};
|
||||
|
||||
export type HighlightActions = {
|
||||
/** Set the hovered bar (specific data point) */
|
||||
setHoveredBar: (key: string, index: number, payload?: any[]) => void;
|
||||
/** Set the hovered legend item (highlights all bars of that type) */
|
||||
setHoveredLegendItem: (key: string) => void;
|
||||
/** Set the active payload (for tooltip data) */
|
||||
setActivePayload: (payload: any[] | null) => void;
|
||||
/** Set tooltip active state */
|
||||
setTooltipActive: (active: boolean) => void;
|
||||
/** Reset all highlight state */
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
export type UseHighlightStateReturn = HighlightState & HighlightActions;
|
||||
|
||||
const initialState: HighlightState = {
|
||||
activeBarKey: null,
|
||||
activeDataPointIndex: null,
|
||||
activePayload: null,
|
||||
tooltipActive: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to manage highlight state for chart elements.
|
||||
* Handles both bar hover (specific data point) and legend hover (all bars of a type).
|
||||
*/
|
||||
export function useHighlightState(): UseHighlightStateReturn {
|
||||
const [state, setState] = useState<HighlightState>(initialState);
|
||||
|
||||
const setHoveredBar = useCallback((key: string, index: number, payload?: any[]) => {
|
||||
setState({
|
||||
activeBarKey: key,
|
||||
activeDataPointIndex: index,
|
||||
activePayload: payload ?? null,
|
||||
tooltipActive: true,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setHoveredLegendItem = useCallback((key: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
activeBarKey: key,
|
||||
activeDataPointIndex: null, // null indicates legend hover (all bars of this type)
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setActivePayload = useCallback((payload: any[] | null) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
activePayload: payload,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setTooltipActive = useCallback((active: boolean) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
tooltipActive: active,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState(initialState);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
setHoveredBar,
|
||||
setHoveredLegendItem,
|
||||
setActivePayload,
|
||||
setTooltipActive,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the opacity for a bar based on highlight state.
|
||||
* @param key - The series key of this bar
|
||||
* @param dataIndex - The data point index of this bar
|
||||
* @param highlight - The current highlight state
|
||||
* @param dimmedOpacity - The opacity to use for dimmed bars (default 0.2)
|
||||
*/
|
||||
export function getBarOpacity(
|
||||
key: string,
|
||||
dataIndex: number,
|
||||
highlight: HighlightState,
|
||||
dimmedOpacity = 0.2
|
||||
): number {
|
||||
const { activeBarKey, activeDataPointIndex } = highlight;
|
||||
|
||||
// No highlight active - full opacity
|
||||
if (activeBarKey === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Hovering a specific bar (from chart)
|
||||
if (activeDataPointIndex !== null) {
|
||||
return key === activeBarKey && dataIndex === activeDataPointIndex ? 1 : dimmedOpacity;
|
||||
}
|
||||
|
||||
// Hovering a legend item (all bars of this type)
|
||||
return key === activeBarKey ? 1 : dimmedOpacity;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
export type ZoomRange = {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
|
||||
export type ZoomSelectionState = {
|
||||
/** Starting point of drag selection (x-axis value) */
|
||||
refAreaLeft: string | null;
|
||||
/** Ending point of drag selection (x-axis value) */
|
||||
refAreaRight: string | null;
|
||||
/** Whether user is currently dragging to select */
|
||||
isSelecting: boolean;
|
||||
/** Whether the current selection is too small to be valid */
|
||||
invalidSelection: boolean;
|
||||
/** X-axis value for the inspection line (click to inspect) */
|
||||
inspectionLine: string | null;
|
||||
};
|
||||
|
||||
export type ZoomSelectionActions = {
|
||||
/** Start a new selection at the given x-axis value */
|
||||
startSelection: (label: string) => void;
|
||||
/** Update the selection as the user drags */
|
||||
updateSelection: (label: string, data: any[], dataKey: string, minDataPoints?: number) => void;
|
||||
/** Finish the selection and return the range if valid */
|
||||
finishSelection: (data: any[], dataKey: string, minDataPoints?: number) => ZoomRange | null;
|
||||
/** Cancel the current selection */
|
||||
cancelSelection: () => void;
|
||||
/** Toggle the inspection line at the given x-axis value */
|
||||
toggleInspectionLine: (label: string) => void;
|
||||
/** Clear the inspection line */
|
||||
clearInspectionLine: () => void;
|
||||
/** Reset all zoom state */
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
export type UseZoomSelectionReturn = ZoomSelectionState & ZoomSelectionActions;
|
||||
|
||||
const initialState: ZoomSelectionState = {
|
||||
refAreaLeft: null,
|
||||
refAreaRight: null,
|
||||
isSelecting: false,
|
||||
invalidSelection: false,
|
||||
inspectionLine: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to manage zoom selection state for charts.
|
||||
* Handles drag-to-zoom and click-to-inspect functionality.
|
||||
*/
|
||||
export function useZoomSelection(): UseZoomSelectionReturn {
|
||||
const [state, setState] = useState<ZoomSelectionState>(initialState);
|
||||
// Ref to track current state synchronously (needed for finishSelection)
|
||||
const stateRef = useRef<ZoomSelectionState>(state);
|
||||
|
||||
// Keep ref in sync with state
|
||||
stateRef.current = state;
|
||||
|
||||
const startSelection = useCallback((label: string) => {
|
||||
const next = {
|
||||
...stateRef.current,
|
||||
refAreaLeft: label,
|
||||
refAreaRight: null,
|
||||
isSelecting: true,
|
||||
invalidSelection: false,
|
||||
};
|
||||
// Update ref synchronously first
|
||||
stateRef.current = next;
|
||||
setState(next);
|
||||
}, []);
|
||||
|
||||
const updateSelection = useCallback(
|
||||
(label: string, data: any[], dataKey: string, minDataPoints = 3) => {
|
||||
const prev = stateRef.current;
|
||||
if (!prev.isSelecting || !prev.refAreaLeft) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if selection is valid (has enough data points)
|
||||
const allLabels = data.map((item) => item[dataKey] as string).filter(Boolean);
|
||||
const leftIndex = allLabels.indexOf(prev.refAreaLeft);
|
||||
const rightIndex = allLabels.indexOf(label);
|
||||
|
||||
let invalidSelection = false;
|
||||
if (leftIndex !== -1 && rightIndex !== -1) {
|
||||
const [start, end] = [leftIndex, rightIndex].sort((a, b) => a - b);
|
||||
invalidSelection = end - start < minDataPoints - 1;
|
||||
} else {
|
||||
invalidSelection = true;
|
||||
}
|
||||
|
||||
const next = {
|
||||
...prev,
|
||||
refAreaRight: label,
|
||||
invalidSelection,
|
||||
};
|
||||
// Update ref synchronously first
|
||||
stateRef.current = next;
|
||||
setState(next);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const finishSelection = useCallback(
|
||||
(data: any[], dataKey: string, minDataPoints = 3): ZoomRange | null => {
|
||||
// Get current state synchronously to calculate result
|
||||
const currentState = stateRef.current;
|
||||
|
||||
if (!currentState.refAreaLeft || !currentState.refAreaRight) {
|
||||
const next = { ...initialState, inspectionLine: currentState.inspectionLine };
|
||||
stateRef.current = next;
|
||||
setState(next);
|
||||
return null;
|
||||
}
|
||||
|
||||
const allLabels = data.map((item) => item[dataKey] as string).filter(Boolean);
|
||||
const leftIndex = allLabels.indexOf(currentState.refAreaLeft);
|
||||
const rightIndex = allLabels.indexOf(currentState.refAreaRight);
|
||||
|
||||
let result: ZoomRange | null = null;
|
||||
|
||||
if (leftIndex !== -1 && rightIndex !== -1) {
|
||||
const [startIdx, endIdx] = [leftIndex, rightIndex].sort((a, b) => a - b);
|
||||
|
||||
// Only create a valid range if we have enough data points
|
||||
if (endIdx - startIdx >= minDataPoints - 1) {
|
||||
result = {
|
||||
start: allLabels[startIdx],
|
||||
end: allLabels[endIdx],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the state (preserve inspection line)
|
||||
const next = { ...initialState, inspectionLine: currentState.inspectionLine };
|
||||
stateRef.current = next;
|
||||
setState(next);
|
||||
|
||||
return result;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const cancelSelection = useCallback(() => {
|
||||
const next = {
|
||||
...initialState,
|
||||
inspectionLine: stateRef.current.inspectionLine,
|
||||
};
|
||||
stateRef.current = next;
|
||||
setState(next);
|
||||
}, []);
|
||||
|
||||
const toggleInspectionLine = useCallback((label: string) => {
|
||||
const prev = stateRef.current;
|
||||
const next = {
|
||||
...prev,
|
||||
inspectionLine: prev.inspectionLine === label ? null : label,
|
||||
};
|
||||
stateRef.current = next;
|
||||
setState(next);
|
||||
}, []);
|
||||
|
||||
const clearInspectionLine = useCallback(() => {
|
||||
const next = {
|
||||
...stateRef.current,
|
||||
inspectionLine: null,
|
||||
};
|
||||
stateRef.current = next;
|
||||
setState(next);
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
stateRef.current = initialState;
|
||||
setState(initialState);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
startSelection,
|
||||
updateSelection,
|
||||
finishSelection,
|
||||
cancelSelection,
|
||||
toggleInspectionLine,
|
||||
clearInspectionLine,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -1,17 +1,27 @@
|
||||
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/20/solid";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import type { BatchTaskRunStatus } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export const allBatchStatuses = ["PENDING", "COMPLETED", "ABORTED"] as const satisfies Readonly<
|
||||
Array<BatchTaskRunStatus>
|
||||
>;
|
||||
export const allBatchStatuses = [
|
||||
"PROCESSING",
|
||||
"PENDING",
|
||||
"COMPLETED",
|
||||
"PARTIAL_FAILED",
|
||||
"ABORTED",
|
||||
] as const satisfies Readonly<Array<BatchTaskRunStatus>>;
|
||||
|
||||
const descriptions: Record<BatchTaskRunStatus, string> = {
|
||||
PROCESSING: "The batch is being processed and runs are being created.",
|
||||
PENDING: "The batch has child runs that have not yet completed.",
|
||||
COMPLETED: "All the batch child runs have finished.",
|
||||
ABORTED: "The batch was aborted because some child tasks could not be triggered.",
|
||||
PARTIAL_FAILED: "Some runs failed to be created. Successfully created runs are still executing.",
|
||||
ABORTED: "The batch was aborted because child tasks could not be triggered.",
|
||||
};
|
||||
|
||||
export function descriptionForBatchStatus(status: BatchTaskRunStatus): string {
|
||||
@@ -47,10 +57,14 @@ export function BatchStatusIcon({
|
||||
className: string;
|
||||
}) {
|
||||
switch (status) {
|
||||
case "PROCESSING":
|
||||
return <Spinner className={cn(batchStatusColor(status), className)} />;
|
||||
case "PENDING":
|
||||
return <Spinner className={cn(batchStatusColor(status), className)} />;
|
||||
case "COMPLETED":
|
||||
return <CheckCircleIcon className={cn(batchStatusColor(status), className)} />;
|
||||
case "PARTIAL_FAILED":
|
||||
return <ExclamationTriangleIcon className={cn(batchStatusColor(status), className)} />;
|
||||
case "ABORTED":
|
||||
return <XCircleIcon className={cn(batchStatusColor(status), className)} />;
|
||||
default: {
|
||||
@@ -61,10 +75,14 @@ export function BatchStatusIcon({
|
||||
|
||||
export function batchStatusColor(status: BatchTaskRunStatus): string {
|
||||
switch (status) {
|
||||
case "PROCESSING":
|
||||
return "text-blue-500";
|
||||
case "PENDING":
|
||||
return "text-pending";
|
||||
case "COMPLETED":
|
||||
return "text-success";
|
||||
case "PARTIAL_FAILED":
|
||||
return "text-warning";
|
||||
case "ABORTED":
|
||||
return "text-error";
|
||||
default: {
|
||||
@@ -75,10 +93,14 @@ export function batchStatusColor(status: BatchTaskRunStatus): string {
|
||||
|
||||
export function batchStatusTitle(status: BatchTaskRunStatus): string {
|
||||
switch (status) {
|
||||
case "PROCESSING":
|
||||
return "Processing";
|
||||
case "PENDING":
|
||||
return "In progress";
|
||||
case "COMPLETED":
|
||||
return "Completed";
|
||||
case "PARTIAL_FAILED":
|
||||
return "Partial failure";
|
||||
case "ABORTED":
|
||||
return "Aborted";
|
||||
default: {
|
||||
|
||||
@@ -11,10 +11,12 @@ export function PacketDisplay({
|
||||
data,
|
||||
dataType,
|
||||
title,
|
||||
searchTerm,
|
||||
}: {
|
||||
data: string;
|
||||
dataType: string;
|
||||
title: string;
|
||||
searchTerm?: string;
|
||||
}) {
|
||||
switch (dataType) {
|
||||
case "application/store": {
|
||||
@@ -51,6 +53,7 @@ export function PacketDisplay({
|
||||
maxLines={20}
|
||||
showLineNumbers={false}
|
||||
showTextWrapping
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -63,6 +66,7 @@ export function PacketDisplay({
|
||||
maxLines={20}
|
||||
showLineNumbers={false}
|
||||
showTextWrapping
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { TaskIconSmall } from "~/assets/icons/TaskIcon";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { RectangleStackIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
export function QueueName({
|
||||
name,
|
||||
type,
|
||||
paused,
|
||||
className,
|
||||
}: {
|
||||
name: string;
|
||||
type: "task" | "custom";
|
||||
paused?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span className={cn("flex items-center gap-1", className)}>
|
||||
{type === "task" ? (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<TaskIconSmall
|
||||
className={cn("size-[1.125rem] text-blue-500", paused && "opacity-50")}
|
||||
/>
|
||||
}
|
||||
content={`This queue was automatically created from your "${name}" task`}
|
||||
/>
|
||||
) : (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<RectangleStackIcon
|
||||
className={cn("size-[1.125rem] text-purple-500", paused && "opacity-50")}
|
||||
/>
|
||||
}
|
||||
content={`This is a custom queue you added in your code.`}
|
||||
/>
|
||||
)}
|
||||
<span className={paused ? "opacity-50" : undefined}>{name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user