Compare commits
125 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84a00b6eb6 | |||
| 506b161751 | |||
| 1ec7722690 | |||
| 2825a200e6 | |||
| eb0f963393 | |||
| 59b6eb9a3e | |||
| b793f33e59 | |||
| 6e0b54b081 | |||
| 4c986ad1eb | |||
| 7af789bc3e | |||
| 72ce6a8300 | |||
| 921285ca32 | |||
| 667a93ca3c | |||
| 4dfa65809a | |||
| d4cd34094e | |||
| 796ad29386 | |||
| b4e08bddec | |||
| 5d6085dcff | |||
| 1d744fa3c6 | |||
| a3d3b17df4 | |||
| 35e11e09a3 | |||
| 9030e94362 | |||
| 2462c80c8a | |||
| a8f9a90280 | |||
| 4b7f67604a | |||
| bfe417f679 | |||
| ba320a4bdb | |||
| bc0d1ff59a | |||
| 062bcaece8 | |||
| c2085e6cc6 | |||
| d7bc37fdc0 | |||
| 6e3ac8bd91 | |||
| 170fde3498 | |||
| ddeb9c415e | |||
| 2feecece88 | |||
| 48a96efbdc | |||
| ebffa1039c | |||
| bc63edd6bf | |||
| eaed7d0ba4 | |||
| 9b21f8d322 | |||
| e536d35b17 | |||
| b96a0b70d4 | |||
| 3bb9aac014 | |||
| 283f88b203 | |||
| c55af7bead | |||
| db4fb9eeef | |||
| c0595700f8 | |||
| 6a45f5623b | |||
| 104f720f6f | |||
| e017913021 | |||
| 7781e2aad1 | |||
| 8e0034484c | |||
| b72cacc671 | |||
| 1ccb8c186f | |||
| 279102c17c | |||
| b221719c09 | |||
| e6861f4fe4 | |||
| bc7ce78103 | |||
| 9937823a7f | |||
| 3925f8cc49 | |||
| 01208fde27 | |||
| 5e049cde3a | |||
| 72c357125b | |||
| 0674d74bbb | |||
| 9e08712749 | |||
| f53db6fd16 | |||
| c0b86efbd3 | |||
| e29e1c86d9 | |||
| 34203d6a6f | |||
| d4e4fbd7fc | |||
| 49de105862 | |||
| 5fb9cc36bc | |||
| 70c8d6d14b | |||
| eeab6bdeac | |||
| 825219a2f4 | |||
| b143027d95 | |||
| 409388365e | |||
| fe5178f3e8 | |||
| a3f1eb2361 | |||
| ab4b50b95a | |||
| 1859fd0283 | |||
| 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 |
@@ -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,28 @@
|
||||
name: Vouch Request
|
||||
description: Request to be vouched as a contributor
|
||||
labels: ["vouch-request"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Vouch Request
|
||||
|
||||
We use [vouch](https://github.com/mitchellh/vouch) to manage contributor trust. PRs from unvouched users are automatically closed.
|
||||
|
||||
To get vouched, fill out this form. A maintainer will review your request and vouch for you by commenting on this issue.
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Why do you want to contribute?
|
||||
description: Tell us a bit about yourself and what you'd like to work on.
|
||||
placeholder: "I'd like to fix a bug I found in..."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: prior-work
|
||||
attributes:
|
||||
label: Prior contributions or relevant experience
|
||||
description: Links to previous open source work, relevant projects, or anything that helps us understand your background.
|
||||
placeholder: "https://github.com/..."
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,16 @@
|
||||
# Vouched contributors for Trigger.dev
|
||||
# See: https://github.com/mitchellh/vouch
|
||||
#
|
||||
# Org members
|
||||
0ski
|
||||
D-K-P
|
||||
ericallam
|
||||
matt-aitken
|
||||
mpcgrid
|
||||
myftija
|
||||
nicktrn
|
||||
samejr
|
||||
isshaddad
|
||||
# Outside contributors
|
||||
gautamsi
|
||||
capaj
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
- name: Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
node-version: 20.20.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
- name: Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
node-version: 20.20.0
|
||||
|
||||
- name: Install and update lockfile
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: |
|
||||
REPO: ${{ github.repository }}
|
||||
PR NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
Please review this pull request and provide feedback on:
|
||||
- Code quality and best practices
|
||||
- Potential bugs or issues
|
||||
- Performance considerations
|
||||
- Security concerns
|
||||
- Test coverage
|
||||
|
||||
Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
|
||||
|
||||
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
|
||||
|
||||
# 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 issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"
|
||||
--model claude-opus-4-5-20251101
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
node-version: 20.20.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
- 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
|
||||
@@ -29,3 +29,7 @@ jobs:
|
||||
with:
|
||||
package: cli-v3
|
||||
secrets: inherit
|
||||
|
||||
sdk-compat:
|
||||
uses: ./.github/workflows/sdk-compat.yml
|
||||
secrets: inherit
|
||||
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
- name: Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
node-version: 20.20.0
|
||||
cache: "pnpm"
|
||||
|
||||
# npm v11.5.1 or newer is required for OIDC support
|
||||
@@ -122,7 +122,6 @@ jobs:
|
||||
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: |
|
||||
@@ -130,6 +129,17 @@ jobs:
|
||||
git tag "v.docker.${{ steps.get_version.outputs.package_version }}"
|
||||
git push origin "v.docker.${{ steps.get_version.outputs.package_version }}"
|
||||
|
||||
# Trigger Docker builds directly via workflow_call since tags pushed with
|
||||
# GITHUB_TOKEN don't trigger other workflows (GitHub Actions limitation).
|
||||
publish-docker:
|
||||
name: 🐳 Publish Docker images
|
||||
needs: release
|
||||
if: needs.release.outputs.published == 'true'
|
||||
uses: ./.github/workflows/publish.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
image_tag: v${{ needs.release.outputs.published_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
|
||||
@@ -154,7 +164,7 @@ jobs:
|
||||
- name: Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
node-version: 20.20.0
|
||||
cache: "pnpm"
|
||||
|
||||
# npm v11.5.1 or newer is required for OIDC support
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
name: "🔌 SDK Compatibility Tests"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
node-compat:
|
||||
name: "Node.js ${{ matrix.node }} (${{ matrix.os }})"
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
node: ["20.20", "22.12"]
|
||||
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 📀 Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: 🔨 Build SDK dependencies
|
||||
shell: bash
|
||||
run: pnpm run build --filter '@trigger.dev/sdk^...'
|
||||
|
||||
- name: 🔨 Build SDK
|
||||
shell: bash
|
||||
run: pnpm run build --filter '@trigger.dev/sdk'
|
||||
|
||||
- name: 🧪 Run SDK Compatibility Tests
|
||||
shell: bash
|
||||
run: pnpm --filter @internal/sdk-compat-tests test
|
||||
|
||||
bun-compat:
|
||||
name: "Bun Runtime"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- 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: 🥟 Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 📀 Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: 🔨 Build SDK dependencies
|
||||
run: pnpm run build --filter @trigger.dev/sdk^...
|
||||
|
||||
- name: 🔨 Build SDK
|
||||
run: pnpm run build --filter @trigger.dev/sdk
|
||||
|
||||
- name: 🧪 Run Bun Compatibility Test
|
||||
working-directory: internal-packages/sdk-compat-tests/src/fixtures/bun
|
||||
run: bun run test.ts
|
||||
|
||||
deno-compat:
|
||||
name: "Deno Runtime"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- 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: 🦕 Setup Deno
|
||||
uses: denoland/setup-deno@v2
|
||||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 📀 Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: 🔨 Build SDK dependencies
|
||||
run: pnpm run build --filter @trigger.dev/sdk^...
|
||||
|
||||
- name: 🔨 Build SDK
|
||||
run: pnpm run build --filter @trigger.dev/sdk
|
||||
|
||||
- name: 🔗 Link node_modules for Deno fixture
|
||||
working-directory: internal-packages/sdk-compat-tests/src/fixtures/deno
|
||||
run: ln -s ../../../../../node_modules node_modules
|
||||
|
||||
- name: 🧪 Run Deno Compatibility Test
|
||||
working-directory: internal-packages/sdk-compat-tests/src/fixtures/deno
|
||||
run: deno run --allow-read --allow-env --allow-sys test.ts
|
||||
|
||||
cloudflare-compat:
|
||||
name: "Cloudflare Workers"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- 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: 🔨 Build SDK dependencies
|
||||
run: pnpm run build --filter @trigger.dev/sdk^...
|
||||
|
||||
- name: 🔨 Build SDK
|
||||
run: pnpm run build --filter @trigger.dev/sdk
|
||||
|
||||
- name: 📥 Install Cloudflare fixture deps
|
||||
working-directory: internal-packages/sdk-compat-tests/src/fixtures/cloudflare-worker
|
||||
run: pnpm install
|
||||
|
||||
- name: 🧪 Run Cloudflare Workers Compatibility Test (dry-run)
|
||||
working-directory: internal-packages/sdk-compat-tests/src/fixtures/cloudflare-worker
|
||||
run: npx wrangler deploy --dry-run --outdir dist
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
node-version: 20.20.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
- 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
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
- 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
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
- 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
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
- 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
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
- 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
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
- 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
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Vouch - Check PR
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: read
|
||||
|
||||
jobs:
|
||||
check-pr:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: mitchellh/vouch/action/check-pr@main
|
||||
with:
|
||||
pr-number: ${{ github.event.pull_request.number }}
|
||||
auto-close: true
|
||||
require-vouch: true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Vouch - Manage by Issue
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
manage:
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
contains(github.event.comment.body, 'vouch') ||
|
||||
contains(github.event.comment.body, 'denounce') ||
|
||||
contains(github.event.comment.body, 'unvouch')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: mitchellh/vouch/action/manage-by-issue@main
|
||||
with:
|
||||
comment-id: ${{ github.event.comment.id }}
|
||||
issue-id: ${{ github.event.issue.number }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
+5
-1
@@ -15,6 +15,9 @@ out/
|
||||
dist
|
||||
packages/**/dist
|
||||
|
||||
# vendored bundles (generated during build)
|
||||
packages/**/src/**/vendor
|
||||
|
||||
# Tailwind
|
||||
apps/**/styles/tailwind.css
|
||||
packages/**/styles/tailwind.css
|
||||
@@ -61,6 +64,7 @@ apps/**/public/build
|
||||
/packages/core/src/package.json
|
||||
/packages/trigger-sdk/src/package.json
|
||||
/packages/python/src/package.json
|
||||
.claude
|
||||
**/.claude/settings.local.json
|
||||
.mcp.log
|
||||
.mcp.json
|
||||
.cursor/debug.log
|
||||
Vendored
+9
@@ -31,6 +31,15 @@
|
||||
"cwd": "${workspaceFolder}/apps/webapp",
|
||||
"sourceMaps": true
|
||||
},
|
||||
{
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"name": "Debug opened test file",
|
||||
"command": "pnpm run test -- ./${relativeFile}",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"sourceMaps": true
|
||||
},
|
||||
{
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
|
||||
Vendored
+1
-1
@@ -7,5 +7,5 @@
|
||||
"packages/cli-v3/e2e": true
|
||||
},
|
||||
"vitest.disableWorkspaceWarning": true,
|
||||
"typescript.experimental.useTsgo": false
|
||||
"chat.agent.maxRequests": 10000
|
||||
}
|
||||
|
||||
@@ -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 `10.23.0` 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`
|
||||
+20
-5
@@ -2,10 +2,25 @@
|
||||
|
||||
Thank you for taking the time to contribute to Trigger.dev. Your involvement is not just welcomed, but we encourage it! 🚀
|
||||
|
||||
Please take some time to read this guide to understand contributing best practices for Trigger.dev.
|
||||
Please take some time to read this guide to understand contributing best practices for Trigger.dev. Note that we use [vouch](https://github.com/mitchellh/vouch) to manage contributor trust, so you'll need to be vouched before opening a PR.
|
||||
|
||||
Thank you for helping us make Trigger.dev even better! 🤩
|
||||
|
||||
> **Important:** We only accept PRs that address a single issue. Please do not submit PRs containing multiple unrelated fixes or features. If you have multiple contributions, open a separate PR for each one.
|
||||
|
||||
## Getting vouched (required before opening a PR)
|
||||
|
||||
We use [vouch](https://github.com/mitchellh/vouch) to manage contributor trust. **PRs from unvouched users are automatically closed.**
|
||||
|
||||
Before you open your first pull request, you need to be vouched by a maintainer. Here's how:
|
||||
|
||||
1. Open a [Vouch Request](https://github.com/triggerdotdev/trigger.dev/issues/new?template=vouch-request.yml) issue.
|
||||
2. Tell us what you'd like to work on and share any relevant background.
|
||||
3. A maintainer will review your request and vouch for you by commenting on the issue.
|
||||
4. Once vouched, your PRs will be accepted normally.
|
||||
|
||||
If you're unsure whether you're already vouched, go ahead and open a PR — the check will tell you.
|
||||
|
||||
## Developing
|
||||
|
||||
The development branch is `main`. This is the branch that all pull
|
||||
@@ -14,7 +29,7 @@ branch are tagged into a release periodically.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/en) version 20.11.1
|
||||
- [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,7 +49,7 @@ 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 (`10.23.0`) as specified in the root `package.json` file.
|
||||
|
||||
@@ -92,7 +107,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 +142,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
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ COPY --from=pruner --chown=node:node /app/out/full/ .
|
||||
COPY --from=dev-deps --chown=node:node /app/ .
|
||||
COPY --chown=node:node turbo.json turbo.json
|
||||
|
||||
RUN pnpm run -r --filter coordinator build:bundle
|
||||
RUN pnpm run -r --filter @trigger.dev/core bundle-vendor && pnpm run -r --filter coordinator build:bundle
|
||||
|
||||
FROM alpine AS cri-tools
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ COPY --from=pruner --chown=node:node /app/out/full/ .
|
||||
COPY --from=dev-deps --chown=node:node /app/ .
|
||||
COPY --chown=node:node turbo.json turbo.json
|
||||
|
||||
RUN pnpm run -r --filter docker-provider build:bundle
|
||||
RUN pnpm run -r --filter @trigger.dev/core bundle-vendor && pnpm run -r --filter docker-provider build:bundle
|
||||
|
||||
FROM base AS runner
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ COPY --from=pruner --chown=node:node /app/out/full/ .
|
||||
COPY --from=dev-deps --chown=node:node /app/ .
|
||||
COPY --chown=node:node turbo.json turbo.json
|
||||
|
||||
RUN pnpm run -r --filter kubernetes-provider build:bundle
|
||||
RUN pnpm run -r --filter @trigger.dev/core bundle-vendor && pnpm run -r --filter kubernetes-provider build:bundle
|
||||
|
||||
FROM base AS runner
|
||||
|
||||
|
||||
@@ -89,8 +89,33 @@ 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>
|
||||
|
||||
// Project affinity settings - pods from the same project prefer the same node
|
||||
KUBERNETES_PROJECT_AFFINITY_ENABLED: BoolEnv.default(false),
|
||||
KUBERNETES_PROJECT_AFFINITY_WEIGHT: z.coerce.number().int().min(1).max(100).default(50),
|
||||
KUBERNETES_PROJECT_AFFINITY_TOPOLOGY_KEY: z.string().trim().min(1).default("kubernetes.io/hostname"),
|
||||
|
||||
// Placement tags settings
|
||||
PLACEMENT_TAGS_ENABLED: BoolEnv.default(false),
|
||||
|
||||
@@ -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.#getAffinity(opts.machine, opts.projectId),
|
||||
terminationGracePeriodSeconds: 60 * 60,
|
||||
containers: [
|
||||
{
|
||||
@@ -320,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);
|
||||
@@ -356,4 +385,91 @@ export class KubernetesWorkloadManager implements WorkloadManager {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#isLargeMachine(preset: MachinePreset): boolean {
|
||||
return preset.name.startsWith("large-");
|
||||
}
|
||||
|
||||
#getAffinity(preset: MachinePreset, projectId: string): k8s.V1Affinity | undefined {
|
||||
const nodeAffinity = this.#getNodeAffinityRules(preset);
|
||||
const podAffinity = this.#getProjectPodAffinity(projectId);
|
||||
|
||||
if (!nodeAffinity && !podAffinity) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...(nodeAffinity && { nodeAffinity }),
|
||||
...(podAffinity && { podAffinity }),
|
||||
};
|
||||
}
|
||||
|
||||
#getNodeAffinityRules(preset: MachinePreset): k8s.V1NodeAffinity | 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 {
|
||||
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 {
|
||||
requiredDuringSchedulingIgnoredDuringExecution: {
|
||||
nodeSelectorTerms: [
|
||||
{
|
||||
matchExpressions: [
|
||||
{
|
||||
key: "node.cluster.x-k8s.io/machinepool",
|
||||
operator: "NotIn",
|
||||
values: [env.KUBERNETES_LARGE_MACHINE_POOL_LABEL],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#getProjectPodAffinity(projectId: string): k8s.V1PodAffinity | undefined {
|
||||
if (!env.KUBERNETES_PROJECT_AFFINITY_ENABLED) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
preferredDuringSchedulingIgnoredDuringExecution: [
|
||||
{
|
||||
weight: env.KUBERNETES_PROJECT_AFFINITY_WEIGHT,
|
||||
podAffinityTerm: {
|
||||
labelSelector: {
|
||||
matchExpressions: [
|
||||
{
|
||||
key: "project",
|
||||
operator: "In",
|
||||
values: [projectId],
|
||||
},
|
||||
],
|
||||
},
|
||||
topologyKey: env.KUBERNETES_PROJECT_AFFINITY_TOPOLOGY_KEY,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -30,3 +30,32 @@ export function AlphaTitle({ children }: { children: React.ReactNode }) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BetaBadge({
|
||||
inline = false,
|
||||
className,
|
||||
}: {
|
||||
inline?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<Badge variant="extra-small" className={cn(inline ? "inline-grid" : "", className)}>
|
||||
Beta
|
||||
</Badge>
|
||||
}
|
||||
content="This feature is in Beta."
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BetaTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<span>{children}</span>
|
||||
<BetaBadge />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
HandThumbUpIcon,
|
||||
StopIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { type FeedbackComment, KapaProvider, type QA, useChat } from "@kapaai/react-sdk";
|
||||
import { useSearchParams } from "@remix-run/react";
|
||||
import DOMPurify from "dompurify";
|
||||
@@ -37,7 +38,7 @@ function useKapaWebsiteId() {
|
||||
return routeMatch?.kapa.websiteId;
|
||||
}
|
||||
|
||||
export function AskAI() {
|
||||
export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) {
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const websiteId = useKapaWebsiteId();
|
||||
|
||||
@@ -54,21 +55,23 @@ export function AskAI() {
|
||||
hideShortcutKey
|
||||
data-modal-override-open-class-ask-ai="true"
|
||||
disabled
|
||||
className={isCollapsed ? "w-full justify-center" : ""}
|
||||
>
|
||||
<AISparkleIcon className="size-5" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{() => <AskAIProvider websiteId={websiteId} />}
|
||||
{() => <AskAIProvider websiteId={websiteId} isCollapsed={isCollapsed} />}
|
||||
</ClientOnly>
|
||||
);
|
||||
}
|
||||
|
||||
type AskAIProviderProps = {
|
||||
websiteId: string;
|
||||
isCollapsed?: boolean;
|
||||
};
|
||||
|
||||
function AskAIProvider({ websiteId }: AskAIProviderProps) {
|
||||
function AskAIProvider({ websiteId, isCollapsed = false }: AskAIProviderProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [initialQuery, setInitialQuery] = useState<string | undefined>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -112,28 +115,39 @@ function AskAIProvider({ websiteId }: AskAIProviderProps) {
|
||||
}}
|
||||
botProtectionMechanism="hcaptcha"
|
||||
>
|
||||
<TooltipProvider disableHoverableContent>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="inline-flex">
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
data-action="ask-ai"
|
||||
shortcut={{ modifiers: ["mod"], key: "/", enabledOnInputElements: true }}
|
||||
hideShortcutKey
|
||||
data-modal-override-open-class-ask-ai="true"
|
||||
onClick={() => openAskAI()}
|
||||
>
|
||||
<AISparkleIcon className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="flex items-center gap-1 py-1.5 pl-2.5 pr-2 text-xs">
|
||||
Ask AI
|
||||
<ShortcutKey shortcut={{ modifiers: ["mod"], key: "/" }} variant="medium/bright" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<motion.div layout="position" transition={{ duration: 0.2, ease: "easeInOut" }}>
|
||||
<TooltipProvider disableHoverableContent>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn("inline-flex h-8", isCollapsed && "w-full")}>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
data-action="ask-ai"
|
||||
shortcut={{ modifiers: ["mod"], key: "i", enabledOnInputElements: true }}
|
||||
hideShortcutKey
|
||||
data-modal-override-open-class-ask-ai="true"
|
||||
onClick={() => openAskAI()}
|
||||
fullWidth={isCollapsed}
|
||||
className={cn("h-full", isCollapsed && "justify-center")}
|
||||
>
|
||||
<AISparkleIcon className="size-5" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
Ask AI
|
||||
<span className="flex items-center">
|
||||
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "i" }} variant="medium/bright" />
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</motion.div>
|
||||
<AskAIDialog
|
||||
initialQuery={initialQuery}
|
||||
isOpen={isOpen}
|
||||
|
||||
@@ -599,9 +599,9 @@ function DeploymentOnboardingSteps() {
|
||||
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 className="mb-2 flex min-w-0 items-center gap-2">
|
||||
<EnvironmentIcon environment={environment} className="-ml-1 size-8 shrink-0" />
|
||||
<Header1 className="truncate">Deploy your tasks to {environmentFullTitle(environment)}</Header1>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<SimpleTooltip
|
||||
|
||||
@@ -32,8 +32,6 @@ export function OctoKitty({ className }: { className?: string }) {
|
||||
baseProfile="tiny"
|
||||
id="Layer_1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 2350 2314.8"
|
||||
xmlSpace="preserve"
|
||||
fill="currentColor"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Header3 } from "./primitives/Headers";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import { LogLevel } from "./logs/LogLevel";
|
||||
|
||||
export function LogLevelTooltipInfo() {
|
||||
return (
|
||||
<div className="flex max-w-xs flex-col gap-4 p-1 pb-2">
|
||||
<div>
|
||||
<Header3>Log Levels</Header3>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Structured logging helps you debug and monitor your tasks.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1">
|
||||
<LogLevel level="TRACE" />
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Traces and spans representing the execution flow of your tasks.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1">
|
||||
<LogLevel level="INFO" />
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
General informational messages about task execution.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1">
|
||||
<LogLevel level="WARN" />
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Warning messages indicating potential issues that don't prevent execution.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1">
|
||||
<LogLevel level="ERROR" />
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Error messages for failures and exceptions during task execution.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1">
|
||||
<LogLevel level="DEBUG" />
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Detailed diagnostic information for development and debugging.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
import { Keyboard } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { Button } from "./primitives/Buttons";
|
||||
import { Header3 } from "./primitives/Headers";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
SheetTrigger
|
||||
} from "./primitives/SheetV3";
|
||||
import { ShortcutKey } from "./primitives/ShortcutKey";
|
||||
import { Button } from "./primitives/Buttons";
|
||||
import { useState } from "react";
|
||||
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
|
||||
export function Shortcuts() {
|
||||
return (
|
||||
@@ -26,8 +25,8 @@ export function Shortcuts() {
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
shortcut={{ modifiers: ["shift"], key: "?", enabled: false }}
|
||||
className="gap-x-0 pl-0.5"
|
||||
iconSpacing="gap-x-0.5"
|
||||
className="gap-x-0 pl-1.5"
|
||||
iconSpacing="gap-x-1.5"
|
||||
>
|
||||
Shortcuts
|
||||
</Button>
|
||||
@@ -77,11 +76,16 @@ function ShortcutContent() {
|
||||
<ShortcutKey shortcut={{ key: "enter" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Ask AI">
|
||||
<ShortcutKey shortcut={{ modifiers: ["mod"], key: "/" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "i" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Filter">
|
||||
<ShortcutKey shortcut={{ key: "f" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Toggle side menu">
|
||||
<ShortcutKey shortcut={{ modifiers: ["mod"]}} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "b" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Select filter">
|
||||
<ShortcutKey shortcut={{ key: "1" }} variant="medium/bright" />
|
||||
<Paragraph variant="small" className="ml-1.5">
|
||||
@@ -135,8 +139,8 @@ function ShortcutContent() {
|
||||
<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" />
|
||||
<ShortcutKey shortcut={{ key: "j" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "k" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Expand all">
|
||||
<ShortcutKey shortcut={{ key: "e" }} variant="medium/bright" />
|
||||
@@ -158,6 +162,43 @@ function ShortcutContent() {
|
||||
<ShortcutKey shortcut={{ key: "p" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Header3>Logs page</Header3>
|
||||
<Shortcut name="Filter by task">
|
||||
<ShortcutKey shortcut={{ key: "t" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Filter by run ID">
|
||||
<ShortcutKey shortcut={{ key: "i" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Filter by level">
|
||||
<ShortcutKey shortcut={{ key: "l" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Select log level">
|
||||
<ShortcutKey shortcut={{ key: "1" }} variant="medium/bright" />
|
||||
<Paragraph variant="small" className="ml-1.5">
|
||||
to
|
||||
</Paragraph>
|
||||
<ShortcutKey shortcut={{ key: "4" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Close detail panel">
|
||||
<ShortcutKey shortcut={{ key: "esc" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Details tab">
|
||||
<ShortcutKey shortcut={{ key: "d" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Run tab">
|
||||
<ShortcutKey shortcut={{ key: "r" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="View full run">
|
||||
<ShortcutKey shortcut={{ key: "v" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Header3>Metrics page</Header3>
|
||||
<Shortcut name="Toggle fullscreen chart">
|
||||
<ShortcutKey shortcut={{ key: "v" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Header3>Schedules page</Header3>
|
||||
<Shortcut name="New schedule">
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTypedLoaderData } from "remix-typedjson";
|
||||
import type { loader } from "~/root";
|
||||
|
||||
export function TimezoneSetter() {
|
||||
const { timezone: storedTimezone } = useTypedLoaderData<typeof loader>();
|
||||
const fetcher = useFetcher();
|
||||
const hasSetTimezone = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasSetTimezone.current) return;
|
||||
|
||||
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
if (browserTimezone && browserTimezone !== storedTimezone) {
|
||||
hasSetTimezone.current = true;
|
||||
fetcher.submit(
|
||||
{ timezone: browserTimezone },
|
||||
{
|
||||
method: "POST",
|
||||
action: "/resources/timezone",
|
||||
encType: "application/json",
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [storedTimezone, fetcher]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import { PencilSquareIcon, PlusIcon, SparklesIcon } from "@heroicons/react/20/solid";
|
||||
import { CheckIcon, PencilSquareIcon, PlusIcon, XMarkIcon } 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";
|
||||
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";
|
||||
|
||||
// Lazy load streamdown components to avoid SSR issues
|
||||
const StreamdownRenderer = lazy(() =>
|
||||
@@ -13,32 +19,33 @@ const StreamdownRenderer = lazy(() =>
|
||||
),
|
||||
}))
|
||||
);
|
||||
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 { cn } from "~/utils/cn";
|
||||
|
||||
type StreamEventType =
|
||||
| { type: "thinking"; content: string }
|
||||
| { type: "tool_call"; tool: string; args: unknown }
|
||||
| { type: "result"; success: true; query: string }
|
||||
| { 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("");
|
||||
@@ -50,7 +57,7 @@ export function AIQueryInput({
|
||||
const [lastResult, setLastResult] = useState<"success" | "error" | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const lastAutoSubmitRef = useRef<string | null>(null);
|
||||
const lastAutoSubmitRef = useRef<{ prompt: string; key?: number } | null>(null);
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
@@ -171,10 +178,18 @@ export function AIQueryInput({
|
||||
setThinking((prev) => prev + event.content);
|
||||
break;
|
||||
case "tool_call":
|
||||
setThinking((prev) => prev + `\nValidating query...\n`);
|
||||
// Tool calls are handled silently — no UI text needed
|
||||
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");
|
||||
@@ -186,7 +201,7 @@ export function AIQueryInput({
|
||||
break;
|
||||
}
|
||||
},
|
||||
[onQueryGenerated]
|
||||
[onQueryGenerated, onTimeFilterChange]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
@@ -197,19 +212,22 @@ export function AIQueryInput({
|
||||
[prompt, submitQuery]
|
||||
);
|
||||
|
||||
// Auto-submit when autoSubmitPrompt changes
|
||||
// Auto-submit when autoSubmitPrompt or autoSubmitKey changes
|
||||
useEffect(() => {
|
||||
if (
|
||||
autoSubmitPrompt &&
|
||||
autoSubmitPrompt.trim() &&
|
||||
autoSubmitPrompt !== lastAutoSubmitRef.current &&
|
||||
!isLoading
|
||||
) {
|
||||
lastAutoSubmitRef.current = autoSubmitPrompt;
|
||||
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, isLoading, submitQuery]);
|
||||
}, [autoSubmitPrompt, autoSubmitKey, isLoading, submitQuery]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
@@ -229,13 +247,13 @@ export function AIQueryInput({
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col">
|
||||
{/* Gradient border wrapper like the schedules AI input */}
|
||||
<div
|
||||
className="rounded-md p-px"
|
||||
className="overflow-hidden rounded-md p-px"
|
||||
style={{ background: "linear-gradient(to bottom right, #E543FF, #286399)" }}
|
||||
>
|
||||
<div className="overflow-hidden rounded-[5px] bg-background-bright">
|
||||
<div className="overflow-hidden rounded-md bg-background-bright">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
@@ -264,10 +282,10 @@ export function AIQueryInput({
|
||||
variant="tertiary/small"
|
||||
disabled={true}
|
||||
LeadingIcon={Spinner}
|
||||
className="pl-1.5"
|
||||
className="pl-2"
|
||||
iconSpacing="gap-1.5"
|
||||
>
|
||||
{mode === "edit" ? "Editing..." : "Generating..."}
|
||||
{mode === "edit" ? "Editing…" : "Generating…"}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
@@ -333,64 +351,60 @@ export function AIQueryInput({
|
||||
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">
|
||||
<div className="px-1">
|
||||
<div className="rounded-b-lg border-x border-b border-grid-dimmed bg-charcoal-850 p-3 pb-1">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
{isLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : lastResult === "success" ? (
|
||||
<CheckIcon className="size-4 text-success" />
|
||||
) : lastResult === "error" ? (
|
||||
<XMarkIcon className="size-4 text-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 ? (
|
||||
<Spinner
|
||||
color={{
|
||||
background: "rgba(99, 102, 241, 0.3)",
|
||||
foreground: "rgba(99, 102, 241, 1)",
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
setIsLoading(false);
|
||||
setShowThinking(false);
|
||||
setThinking("");
|
||||
}}
|
||||
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>
|
||||
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>
|
||||
{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>
|
||||
|
||||
@@ -1,27 +1,20 @@
|
||||
import type { OutputColumnMetadata } from "@internal/clickhouse";
|
||||
import { BarChart, LineChart } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { IconSortAscending, IconSortDescending } from "@tabler/icons-react";
|
||||
import { BarChart, CheckIcon, LineChart, Plus, XIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Header3 } from "../primitives/Headers";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
|
||||
import { Select, SelectItem } from "../primitives/Select";
|
||||
import { Switch } from "../primitives/Switch";
|
||||
import SegmentedControl from "../primitives/SegmentedControl";
|
||||
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;
|
||||
}
|
||||
import {
|
||||
type AggregationType,
|
||||
type ChartConfiguration,
|
||||
type SortDirection,
|
||||
} from "../metrics/QueryWidget";
|
||||
import { CHART_COLORS_BY_HUE, getSeriesColor } from "./chartColors";
|
||||
|
||||
export const defaultChartConfig: ChartConfiguration = {
|
||||
chartType: "bar",
|
||||
@@ -32,6 +25,7 @@ export const defaultChartConfig: ChartConfiguration = {
|
||||
sortByColumn: null,
|
||||
sortDirection: "asc",
|
||||
aggregation: "sum",
|
||||
seriesColors: {},
|
||||
};
|
||||
|
||||
interface ChartConfigPanelProps {
|
||||
@@ -155,8 +149,11 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
|
||||
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]);
|
||||
// Only re-run when the actual column structure changes, not on every config change.
|
||||
// columnsKey (a string) is stable when columns match, so this won't re-fire
|
||||
// unnecessarily when the same query is re-run with identical columns.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [columnsKey]);
|
||||
|
||||
const updateConfig = useCallback(
|
||||
(updates: Partial<ChartConfiguration>) => {
|
||||
@@ -239,54 +236,38 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2 px-3 py-2", className)}>
|
||||
<div className={cn("flex flex-col gap-3 p-2", className)}>
|
||||
{/* Chart Type */}
|
||||
<div className="flex items-center gap-1">
|
||||
<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>
|
||||
<SegmentedControl
|
||||
name="chartType"
|
||||
value={config.chartType}
|
||||
variant="secondary/small"
|
||||
options={[
|
||||
{
|
||||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
<BarChart className="size-3" /> Bar
|
||||
</span>
|
||||
),
|
||||
value: "bar",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
<LineChart className="size-3" /> Line
|
||||
</span>
|
||||
),
|
||||
value: "line",
|
||||
},
|
||||
]}
|
||||
onChange={(value) => updateConfig({ chartType: value as "bar" | "line" })}
|
||||
/>
|
||||
</ConfigField>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* X-Axis */}
|
||||
<ConfigField label="X-Axis">
|
||||
<Select
|
||||
@@ -322,31 +303,123 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
|
||||
</Select>
|
||||
</ConfigField>
|
||||
|
||||
{/* Y-Axis */}
|
||||
<ConfigField label="Y-Axis">
|
||||
{/* 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>
|
||||
) : (
|
||||
<Select
|
||||
value={config.yAxisColumns[0] ?? ""}
|
||||
setValue={(value) => updateConfig({ yAxisColumns: value ? [value] : [] })}
|
||||
variant="tertiary/small"
|
||||
placeholder="Select column"
|
||||
items={yAxisOptions}
|
||||
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>
|
||||
<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">
|
||||
{col && !config.groupByColumn && (
|
||||
<SeriesColorPicker
|
||||
color={config.seriesColors?.[col] ?? getSeriesColor(index)}
|
||||
onColorChange={(color) => {
|
||||
updateConfig({
|
||||
seriesColors: { ...config.seriesColors, [col]: color },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
value={col}
|
||||
setValue={(value) => {
|
||||
const newColumns = [...config.yAxisColumns];
|
||||
const updates: Partial<ChartConfiguration> = {};
|
||||
if (value) {
|
||||
// If this is a new slot (empty string), add it
|
||||
if (index >= config.yAxisColumns.length) {
|
||||
newColumns.push(value);
|
||||
} else {
|
||||
// If the column name changed, migrate the color
|
||||
const oldCol = newColumns[index];
|
||||
if (oldCol && oldCol !== value && config.seriesColors?.[oldCol]) {
|
||||
const newSeriesColors = { ...config.seriesColors };
|
||||
newSeriesColors[value] = newSeriesColors[oldCol];
|
||||
delete newSeriesColors[oldCol];
|
||||
updates.seriesColors = newSeriesColors;
|
||||
}
|
||||
newColumns[index] = value;
|
||||
}
|
||||
} else if (index < config.yAxisColumns.length) {
|
||||
newColumns.splice(index, 1);
|
||||
}
|
||||
updateConfig({ ...updates, 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 removedCol = config.yAxisColumns[index];
|
||||
const newColumns = config.yAxisColumns.filter((_, i) => i !== index);
|
||||
const updates: Partial<ChartConfiguration> = { yAxisColumns: newColumns };
|
||||
// Clean up the color entry for the removed series
|
||||
if (removedCol && config.seriesColors?.[removedCol]) {
|
||||
const newSeriesColors = { ...config.seriesColors };
|
||||
delete newSeriesColors[removedCol];
|
||||
updates.seriesColors = newSeriesColors;
|
||||
}
|
||||
updateConfig(updates);
|
||||
}}
|
||||
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>
|
||||
|
||||
@@ -370,39 +443,42 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
|
||||
</Select>
|
||||
</ConfigField>
|
||||
|
||||
{/* Group By */}
|
||||
{/* Group By - disabled when multiple series are selected */}
|
||||
<ConfigField label="Group by">
|
||||
<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>
|
||||
{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 (only when grouped) */}
|
||||
{config.groupByColumn && (
|
||||
<ConfigField label="">
|
||||
{/* Stacked toggle (when grouped or multiple series) */}
|
||||
{(config.groupByColumn || config.yAxisColumns.length > 1) && (
|
||||
<ConfigField label={config.groupByColumn ? "Stack groups" : "Stack series"}>
|
||||
<Switch
|
||||
variant="small"
|
||||
label="Stacked"
|
||||
variant="medium"
|
||||
checked={config.stacked}
|
||||
onCheckedChange={(checked) => updateConfig({ stacked: checked })}
|
||||
/>
|
||||
@@ -438,10 +514,30 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
|
||||
|
||||
{/* Sort Direction (only when sorting) */}
|
||||
{config.sortByColumn && (
|
||||
<ConfigField label="">
|
||||
<SortDirectionToggle
|
||||
direction={config.sortDirection}
|
||||
onChange={(direction) => updateConfig({ sortDirection: direction })}
|
||||
<ConfigField label="Sort direction">
|
||||
<SegmentedControl
|
||||
name="sortDirection"
|
||||
value={config.sortDirection}
|
||||
variant="secondary/small"
|
||||
options={[
|
||||
{
|
||||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
<IconSortAscending className="size-3" /> Asc
|
||||
</span>
|
||||
),
|
||||
value: "asc",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
<IconSortDescending className="size-3" /> Desc
|
||||
</span>
|
||||
),
|
||||
value: "desc",
|
||||
},
|
||||
]}
|
||||
onChange={(value) => updateConfig({ sortDirection: value as SortDirection })}
|
||||
/>
|
||||
</ConfigField>
|
||||
)}
|
||||
@@ -452,49 +548,56 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
|
||||
|
||||
function ConfigField({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{label && <span className="text-xs text-text-dimmed">{label}</span>}
|
||||
<div className="flex flex-col gap-1">
|
||||
{label && <span className="text-xs text-text-bright">{label}</span>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortDirectionToggle({
|
||||
direction,
|
||||
onChange,
|
||||
function SeriesColorPicker({
|
||||
color,
|
||||
onColorChange,
|
||||
}: {
|
||||
direction: SortDirection;
|
||||
onChange: (direction: SortDirection) => void;
|
||||
color: string;
|
||||
onColorChange: (color: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
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>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 rounded p-0.5 hover:bg-charcoal-700"
|
||||
title="Change series color"
|
||||
>
|
||||
<span
|
||||
className="block h-4 w-4 rounded-full border border-white/30"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-auto p-2">
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{CHART_COLORS_BY_HUE.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onColorChange(c);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="group/swatch flex h-6 w-6 items-center justify-center rounded-full border border-white/30"
|
||||
style={{ backgroundColor: c }}
|
||||
title={c}
|
||||
>
|
||||
{c === color && <CheckIcon className="h-3.5 w-3.5 text-white drop-shadow-md" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -64,6 +65,12 @@ type CodeBlockProps = {
|
||||
|
||||
/** Whether to show the open in modal button */
|
||||
showOpenInModal?: boolean;
|
||||
|
||||
/** Search term to highlight in the code */
|
||||
searchTerm?: string;
|
||||
|
||||
/** Whether to wrap the code */
|
||||
wrap?: boolean;
|
||||
};
|
||||
|
||||
const dimAmount = 0.5;
|
||||
@@ -202,6 +209,8 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
showChrome = false,
|
||||
fileName,
|
||||
rowTitle,
|
||||
searchTerm,
|
||||
wrap = false,
|
||||
...props
|
||||
}: CodeBlockProps,
|
||||
ref
|
||||
@@ -210,7 +219,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [modalCopied, setModalCopied] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isWrapped, setIsWrapped] = useState(false);
|
||||
const [isWrapped, setIsWrapped] = useState(wrap);
|
||||
|
||||
const onCopied = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
@@ -238,7 +247,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;
|
||||
@@ -340,6 +349,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
className="px-2 py-3"
|
||||
preClassName="text-xs"
|
||||
isWrapped={isWrapped}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
@@ -360,7 +370,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
)}
|
||||
dir="ltr"
|
||||
>
|
||||
{code}
|
||||
{highlightSearchText(code, searchTerm)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
@@ -402,7 +412,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>
|
||||
)}
|
||||
@@ -451,6 +461,7 @@ type HighlightCodeProps = {
|
||||
className?: string;
|
||||
preClassName?: string;
|
||||
isWrapped: boolean;
|
||||
searchTerm?: string;
|
||||
};
|
||||
|
||||
function HighlightCode({
|
||||
@@ -463,6 +474,7 @@ function HighlightCode({
|
||||
className,
|
||||
preClassName,
|
||||
isWrapped,
|
||||
searchTerm,
|
||||
}: HighlightCodeProps) {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
@@ -556,6 +568,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}
|
||||
@@ -564,7 +580,9 @@ function HighlightCode({
|
||||
color: tokenProps?.style?.color as string,
|
||||
...tokenProps.style,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,54 +1,39 @@
|
||||
import type { OutputColumnMetadata } from "@internal/clickhouse";
|
||||
import { BarChart3, LineChart } from "lucide-react";
|
||||
import { memo, useMemo } from "react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "~/components/primitives/Chart";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import type { AggregationType, ChartConfiguration } from "./ChartConfigPanel";
|
||||
import type { ChartConfig } from "~/components/primitives/charts/Chart";
|
||||
import { Chart } from "~/components/primitives/charts/ChartCompound";
|
||||
import { ChartBlankState } from "../primitives/charts/ChartBlankState";
|
||||
import { Callout } from "../primitives/Callout";
|
||||
import type { AggregationType, ChartConfiguration } from "../metrics/QueryWidget";
|
||||
import { aggregateValues } from "../primitives/charts/aggregation";
|
||||
import { getRunStatusHexColor } from "~/components/runs/v3/TaskRunStatus";
|
||||
import { getSeriesColor } from "./chartColors";
|
||||
|
||||
// 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];
|
||||
}
|
||||
const MAX_SERIES = 50;
|
||||
const MAX_SVG_ELEMENT_BUDGET = 6_000;
|
||||
const MIN_DATA_POINTS = 100;
|
||||
const MAX_DATA_POINTS = 500;
|
||||
|
||||
interface QueryResultsChartProps {
|
||||
rows: Record<string, unknown>[];
|
||||
columns: OutputColumnMetadata[];
|
||||
config: ChartConfiguration;
|
||||
/** The effective time range from the query filter (used to show the full x-axis period) */
|
||||
timeRange?: { from: string; to: string };
|
||||
fullLegend?: boolean;
|
||||
/** Callback when "View all" legend button is clicked */
|
||||
onViewAllLegendItems?: () => void;
|
||||
/** When true, constrains legend to max 50% height with scrolling */
|
||||
legendScrollable?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
interface TransformedData {
|
||||
data: Record<string, unknown>[];
|
||||
series: string[];
|
||||
/** Total number of series before any truncation (equals series.length when no truncation) */
|
||||
totalSeriesCount: number;
|
||||
/** Raw date values for determining formatting granularity */
|
||||
dateValues: Date[];
|
||||
/** Whether the x-axis is date-based (continuous time scale) */
|
||||
@@ -142,12 +127,41 @@ function formatDateByGranularity(date: Date, granularity: TimeGranularity): stri
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap a millisecond value up to the nearest "nice" interval
|
||||
*/
|
||||
function snapToNiceInterval(ms: number): number {
|
||||
const SECOND = 1000;
|
||||
const MINUTE = 60 * SECOND;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
if (ms <= SECOND) return SECOND;
|
||||
if (ms <= 5 * SECOND) return 5 * SECOND;
|
||||
if (ms <= 10 * SECOND) return 10 * SECOND;
|
||||
if (ms <= 15 * SECOND) return 15 * SECOND;
|
||||
if (ms <= 30 * SECOND) return 30 * SECOND;
|
||||
if (ms <= MINUTE) return MINUTE;
|
||||
if (ms <= 5 * MINUTE) return 5 * MINUTE;
|
||||
if (ms <= 10 * MINUTE) return 10 * MINUTE;
|
||||
if (ms <= 15 * MINUTE) return 15 * MINUTE;
|
||||
if (ms <= 30 * MINUTE) return 30 * MINUTE;
|
||||
if (ms <= HOUR) return HOUR;
|
||||
if (ms <= 2 * HOUR) return 2 * HOUR;
|
||||
if (ms <= 4 * HOUR) return 4 * HOUR;
|
||||
if (ms <= 6 * HOUR) return 6 * HOUR;
|
||||
if (ms <= 12 * HOUR) return 12 * HOUR;
|
||||
if (ms <= DAY) return DAY;
|
||||
|
||||
return ms;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
if (timestamps.length < 2) return 24 * 60 * 60 * 1000; // Default to 1 day
|
||||
|
||||
const sorted = [...timestamps].sort((a, b) => a - b);
|
||||
const gaps: number[] = [];
|
||||
@@ -165,25 +179,7 @@ function detectDataInterval(timestamps: number[]): number {
|
||||
// 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;
|
||||
return snapToNiceInterval(minGap);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -207,20 +203,7 @@ function fillTimeGaps(
|
||||
// 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;
|
||||
effectiveInterval = snapToNiceInterval(Math.ceil(range / maxPoints));
|
||||
}
|
||||
|
||||
// Create a map to collect values for each bucket (for aggregation)
|
||||
@@ -270,17 +253,18 @@ function fillTimeGaps(
|
||||
}
|
||||
filledData.push(point);
|
||||
} else {
|
||||
// Create a zero-filled data point
|
||||
const zeroPoint: Record<string, unknown> = {
|
||||
// Create a null-filled data point so gaps appear in line/bar charts
|
||||
// and legend aggregations (avg/min/max) skip these slots
|
||||
const gapPoint: Record<string, unknown> = {
|
||||
[xDataKey]: t,
|
||||
__rawDate: new Date(t),
|
||||
__granularity: granularity,
|
||||
__originalX: new Date(t).toISOString(),
|
||||
};
|
||||
for (const s of series) {
|
||||
zeroPoint[s] = 0;
|
||||
gapPoint[s] = null;
|
||||
}
|
||||
filledData.push(zeroPoint);
|
||||
filledData.push(gapPoint);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,22 +363,32 @@ function generateTimeTicks(minTime: number, maxTime: number, maxTicks = 8): numb
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date for tooltips (always shows full precision)
|
||||
* Formats a date for tooltips and legend headers.
|
||||
* Always includes time when the data point has a non-midnight time,
|
||||
* so hovering a specific bar at e.g. 14:00 shows the full timestamp
|
||||
* even when the axis labels only show the day.
|
||||
* Seconds are shown whenever the granularity is "seconds" or the
|
||||
* specific data point has non-zero seconds.
|
||||
*/
|
||||
function formatDateForTooltip(date: Date, granularity: TimeGranularity): string {
|
||||
// For shorter time ranges, include time
|
||||
if (granularity === "seconds" || granularity === "minutes" || granularity === "hours") {
|
||||
const hasTime = date.getHours() !== 0 || date.getMinutes() !== 0 || date.getSeconds() !== 0;
|
||||
const hasSeconds = date.getSeconds() !== 0;
|
||||
|
||||
if (
|
||||
granularity === "seconds" ||
|
||||
(hasTime && granularity !== "months" && granularity !== "years")
|
||||
) {
|
||||
return date.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: granularity === "seconds" ? "2-digit" : undefined,
|
||||
second: granularity === "seconds" || hasSeconds ? "2-digit" : undefined,
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
// For longer ranges, just show date
|
||||
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
@@ -452,7 +446,8 @@ function tryParseDate(value: unknown): Date | null {
|
||||
*/
|
||||
function transformDataForChart(
|
||||
rows: Record<string, unknown>[],
|
||||
config: ChartConfiguration
|
||||
config: ChartConfiguration,
|
||||
timeRange?: { from: string; to: string }
|
||||
): TransformedData {
|
||||
const { xAxisColumn, yAxisColumns, groupByColumn, aggregation } = config;
|
||||
|
||||
@@ -460,6 +455,7 @@ function transformDataForChart(
|
||||
return {
|
||||
data: [],
|
||||
series: [],
|
||||
totalSeriesCount: 0,
|
||||
dateValues: [],
|
||||
isDateBased: false,
|
||||
xDataKey: xAxisColumn || "",
|
||||
@@ -478,24 +474,37 @@ function transformDataForChart(
|
||||
}
|
||||
|
||||
// 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";
|
||||
// When there are no results but a timeRange is provided, treat as date-based
|
||||
const isDateBased =
|
||||
rows.length === 0 && timeRange ? true : dateValues.length >= rows.length * 0.8; // At least 80% are dates
|
||||
|
||||
// Detect granularity from the full time range when available, otherwise from data
|
||||
const granularity = isDateBased
|
||||
? timeRange
|
||||
? detectTimeGranularity([new Date(timeRange.from), new Date(timeRange.to)])
|
||||
: 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
|
||||
// When a timeRange is provided (from the query filter), use it so the chart
|
||||
// shows the full requested period rather than just the range of returned data.
|
||||
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);
|
||||
// Raw min/max used for gap filling (without padding)
|
||||
let rawMinTime = 0;
|
||||
let rawMaxTime = 0;
|
||||
if (isDateBased && (dateValues.length > 0 || timeRange)) {
|
||||
const dataTimestamps = dateValues.map((d) => d.getTime());
|
||||
rawMinTime = timeRange ? new Date(timeRange.from).getTime() : Math.min(...dataTimestamps);
|
||||
rawMaxTime = timeRange ? new Date(timeRange.to).getTime() : Math.max(...dataTimestamps);
|
||||
// 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];
|
||||
const padding = (rawMaxTime - rawMinTime) * 0.02;
|
||||
timeDomain = [rawMinTime - padding, rawMaxTime + padding];
|
||||
// Generate evenly-spaced ticks across the entire range using nice intervals
|
||||
timeTicks = generateTimeTicks(minTime, maxTime);
|
||||
timeTicks = generateTimeTicks(rawMinTime, rawMaxTime);
|
||||
}
|
||||
|
||||
// Helper to format X value for categorical axes (non-date)
|
||||
@@ -550,30 +559,57 @@ function transformDataForChart(
|
||||
});
|
||||
|
||||
// Fill in gaps with zeros for date-based data
|
||||
const seriesForBudget = Math.min(yAxisColumns.length, MAX_SERIES);
|
||||
const effectiveMaxPoints = Math.max(
|
||||
MIN_DATA_POINTS,
|
||||
Math.min(MAX_DATA_POINTS, Math.floor(MAX_SVG_ELEMENT_BUDGET / seriesForBudget))
|
||||
);
|
||||
|
||||
if (isDateBased && timeDomain) {
|
||||
const timestamps = dateValues.map((d) => d.getTime());
|
||||
const dataInterval = detectDataInterval(timestamps);
|
||||
const rangeMs = rawMaxTime - rawMinTime;
|
||||
const minRangeInterval = timeRange ? snapToNiceInterval(rangeMs / effectiveMaxPoints) : 0;
|
||||
const maxRangeInterval =
|
||||
timeRange && rangeMs > 0 ? snapToNiceInterval(rangeMs / 8) : Infinity;
|
||||
const effectiveInterval = Math.min(
|
||||
Math.max(dataInterval, minRangeInterval),
|
||||
maxRangeInterval
|
||||
);
|
||||
data = fillTimeGaps(
|
||||
data,
|
||||
xDataKey,
|
||||
yAxisColumns,
|
||||
timeDomain[0],
|
||||
timeDomain[1],
|
||||
dataInterval,
|
||||
rawMinTime,
|
||||
rawMaxTime,
|
||||
effectiveInterval,
|
||||
granularity,
|
||||
aggregation
|
||||
aggregation,
|
||||
effectiveMaxPoints
|
||||
);
|
||||
} else if (data.length > effectiveMaxPoints) {
|
||||
data = data.slice(0, effectiveMaxPoints);
|
||||
}
|
||||
|
||||
return { data, series: yAxisColumns, dateValues, isDateBased, xDataKey, timeDomain, timeTicks };
|
||||
return {
|
||||
data,
|
||||
series: yAxisColumns,
|
||||
totalSeriesCount: yAxisColumns.length,
|
||||
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
|
||||
// First pass: collect all values grouped by (xKey, groupValue) and accumulate
|
||||
// per-group totals so we can pick the top-N groups before building heavy data
|
||||
// objects with thousands of keys.
|
||||
const groupTotals = new Map<string, number>();
|
||||
const groupedByX = new Map<
|
||||
string | number,
|
||||
{ values: Record<string, number[]>; rawDate: Date | null; originalX: unknown }
|
||||
@@ -582,29 +618,39 @@ function transformDataForChart(
|
||||
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);
|
||||
groupTotals.set(groupValue, (groupTotals.get(groupValue) ?? 0) + Math.abs(yValue));
|
||||
|
||||
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();
|
||||
// Keep only the top MAX_SERIES groups by absolute total to avoid O(n) processing
|
||||
// downstream (data objects, gap filling, legend totals, SVG rendering).
|
||||
const totalSeriesCount = groupTotals.size;
|
||||
let series: string[];
|
||||
if (groupTotals.size <= MAX_SERIES) {
|
||||
series = Array.from(groupTotals.keys()).sort();
|
||||
} else {
|
||||
series = Array.from(groupTotals.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, MAX_SERIES)
|
||||
.map(([key]) => key)
|
||||
.sort();
|
||||
}
|
||||
// Convert to array format with aggregation applied (only for kept series)
|
||||
let data = Array.from(groupedByX.entries()).map(([xKey, { values, rawDate, originalX }]) => {
|
||||
const point: Record<string, unknown> = {
|
||||
[xDataKey]: xKey,
|
||||
@@ -618,23 +664,44 @@ function transformDataForChart(
|
||||
return point;
|
||||
});
|
||||
|
||||
// Fill in gaps with zeros for date-based data
|
||||
// Dynamic data-point budget based on the (already capped) series count
|
||||
const effectiveMaxPoints = Math.max(
|
||||
MIN_DATA_POINTS,
|
||||
Math.min(MAX_DATA_POINTS, Math.floor(MAX_SVG_ELEMENT_BUDGET / series.length))
|
||||
);
|
||||
|
||||
if (isDateBased && timeDomain) {
|
||||
const timestamps = dateValues.map((d) => d.getTime());
|
||||
const dataInterval = detectDataInterval(timestamps);
|
||||
const rangeMs = rawMaxTime - rawMinTime;
|
||||
const minRangeInterval = timeRange ? snapToNiceInterval(rangeMs / effectiveMaxPoints) : 0;
|
||||
const maxRangeInterval = timeRange && rangeMs > 0 ? snapToNiceInterval(rangeMs / 8) : Infinity;
|
||||
const effectiveInterval = Math.min(Math.max(dataInterval, minRangeInterval), maxRangeInterval);
|
||||
data = fillTimeGaps(
|
||||
data,
|
||||
xDataKey,
|
||||
series,
|
||||
timeDomain[0],
|
||||
timeDomain[1],
|
||||
dataInterval,
|
||||
rawMinTime,
|
||||
rawMaxTime,
|
||||
effectiveInterval,
|
||||
granularity,
|
||||
aggregation
|
||||
aggregation,
|
||||
effectiveMaxPoints
|
||||
);
|
||||
} else if (data.length > effectiveMaxPoints) {
|
||||
data = data.slice(0, effectiveMaxPoints);
|
||||
}
|
||||
|
||||
return { data, series, dateValues, isDateBased, xDataKey, timeDomain, timeTicks };
|
||||
return {
|
||||
data,
|
||||
series,
|
||||
totalSeriesCount,
|
||||
dateValues,
|
||||
isDateBased,
|
||||
xDataKey,
|
||||
timeDomain,
|
||||
timeTicks,
|
||||
};
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number {
|
||||
@@ -646,25 +713,6 @@ function toNumber(value: unknown): number {
|
||||
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
|
||||
*/
|
||||
@@ -714,6 +762,11 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
rows,
|
||||
columns,
|
||||
config,
|
||||
timeRange,
|
||||
fullLegend = false,
|
||||
onViewAllLegendItems,
|
||||
isLoading = false,
|
||||
legendScrollable = false,
|
||||
}: QueryResultsChartProps) {
|
||||
const {
|
||||
xAxisColumn,
|
||||
@@ -729,12 +782,13 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
const {
|
||||
data: unsortedData,
|
||||
series,
|
||||
totalSeriesCount,
|
||||
dateValues,
|
||||
isDateBased,
|
||||
xDataKey,
|
||||
timeDomain,
|
||||
timeTicks,
|
||||
} = useMemo(() => transformDataForChart(rows, config), [rows, config]);
|
||||
} = useMemo(() => transformDataForChart(rows, config, timeRange), [rows, config, timeRange]);
|
||||
|
||||
// Apply sorting (for date-based, sort by timestamp to ensure correct order)
|
||||
const data = useMemo(() => {
|
||||
@@ -745,13 +799,54 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
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]
|
||||
// Sort series by descending total sum so largest appears at bottom of
|
||||
// stacked charts and first in the legend
|
||||
const sortedSeries = useMemo(() => {
|
||||
if (series.length <= 1) return series;
|
||||
const totals = new Map<string, number>();
|
||||
for (const s of series) {
|
||||
let total = 0;
|
||||
for (const point of data) {
|
||||
const val = point[s];
|
||||
if (typeof val === "number" && isFinite(val)) {
|
||||
total += Math.abs(val);
|
||||
}
|
||||
}
|
||||
totals.set(s, total);
|
||||
}
|
||||
return [...series].sort((a, b) => (totals.get(b) ?? 0) - (totals.get(a) ?? 0));
|
||||
}, [series, data]);
|
||||
|
||||
// Limit SVG-rendered series to MAX_SERIES (top N by total value)
|
||||
const visibleSeries = useMemo(
|
||||
() => (sortedSeries.length > MAX_SERIES ? sortedSeries.slice(0, MAX_SERIES) : sortedSeries),
|
||||
[sortedSeries]
|
||||
);
|
||||
|
||||
// X-axis tick formatter for date-based axes
|
||||
const seriesLimitCallout =
|
||||
totalSeriesCount > series.length ? (
|
||||
<div className="mt-1 px-2">
|
||||
<Callout variant="warning">
|
||||
{`Limited to the top ${
|
||||
series.length
|
||||
} of ${totalSeriesCount.toLocaleString()} series for performance reasons.`}
|
||||
</Callout>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
// Detect time granularity — use the full time range when available so tick
|
||||
// labels are appropriate for the period (e.g. "Jan 5" for a 7-day range
|
||||
// instead of just "16:00:00" when data is sparse)
|
||||
const timeGranularity = useMemo(() => {
|
||||
if (timeRange) {
|
||||
return detectTimeGranularity([new Date(timeRange.from), new Date(timeRange.to)]);
|
||||
}
|
||||
return dateValues.length > 0 ? detectTimeGranularity(dateValues) : null;
|
||||
}, [dateValues, timeRange]);
|
||||
|
||||
// X-axis tick formatter for date-based axes (pure – no deduplication).
|
||||
// Label deduplication is handled inside dateAxisTick below so that the
|
||||
// mutable "lastLabel" state is correctly reset on each Recharts render pass.
|
||||
const xAxisTickFormatter = useMemo(() => {
|
||||
if (!isDateBased || !timeGranularity) return undefined;
|
||||
return (value: number) => {
|
||||
@@ -763,17 +858,25 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
// Create dynamic Y-axis formatter based on data range
|
||||
const yAxisFormatter = useMemo(() => createYAxisFormatter(data, series), [data, series]);
|
||||
|
||||
// Check if the group-by column has a runStatus customRenderType
|
||||
const groupByIsRunStatus = useMemo(() => {
|
||||
if (!groupByColumn) return false;
|
||||
const col = columns.find((c) => c.name === groupByColumn);
|
||||
return col?.customRenderType === "runStatus";
|
||||
}, [groupByColumn, columns]);
|
||||
|
||||
// Build chart config for colors/labels
|
||||
const chartConfig = useMemo(() => {
|
||||
const cfg: ChartConfig = {};
|
||||
series.forEach((s, i) => {
|
||||
sortedSeries.forEach((s, i) => {
|
||||
const statusColor = groupByIsRunStatus ? getRunStatusHexColor(s) : undefined;
|
||||
cfg[s] = {
|
||||
label: s,
|
||||
color: getSeriesColor(i),
|
||||
color: statusColor ?? config.seriesColors?.[s] ?? getSeriesColor(i),
|
||||
};
|
||||
});
|
||||
return cfg;
|
||||
}, [series]);
|
||||
}, [sortedSeries, groupByIsRunStatus, config.seriesColors]);
|
||||
|
||||
// Custom tooltip label formatter for better date display
|
||||
const tooltipLabelFormatter = useMemo(() => {
|
||||
@@ -789,139 +892,239 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Validation
|
||||
// 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]);
|
||||
|
||||
// Angle all date-based labels for consistent appearance and to avoid overlap
|
||||
const xAxisAngle = isDateBased ? -45 : 0;
|
||||
const xAxisHeight = xAxisAngle !== 0 ? 65 : undefined;
|
||||
|
||||
// Check if the data would produce duplicate labels at the current granularity.
|
||||
// Only use the custom tick renderer (with interval:0) when duplicates exist,
|
||||
// otherwise let Recharts handle label spacing to avoid collisions.
|
||||
const hasDuplicateLabels = useMemo(() => {
|
||||
if (!isDateBased || !timeGranularity || data.length === 0) return false;
|
||||
const labels = new Set<string>();
|
||||
for (const point of data) {
|
||||
const ts = point.__timestamp ?? point[xDataKey];
|
||||
if (typeof ts === "number") {
|
||||
labels.add(formatDateByGranularity(new Date(ts), timeGranularity));
|
||||
}
|
||||
}
|
||||
return labels.size < data.length;
|
||||
}, [isDateBased, timeGranularity, data, xDataKey]);
|
||||
|
||||
// Custom tick renderer for date-based axes: renders a tick mark alongside
|
||||
// each label, and for unlabelled points (de-duplicated) just a subtle tick mark.
|
||||
// De-duplication lives here (not in xAxisTickFormatter) so that the mutable
|
||||
// lastLabel is reset when Recharts starts a new render pass (index === 0).
|
||||
const dateAxisTick = useMemo(() => {
|
||||
if (!isDateBased || !xAxisTickFormatter) return undefined;
|
||||
let lastLabel = "";
|
||||
return (props: Record<string, unknown>) => {
|
||||
const { x, y, payload, index } = props as {
|
||||
x: number;
|
||||
y: number;
|
||||
payload: { value: number };
|
||||
index: number;
|
||||
};
|
||||
|
||||
// Reset dedup state at the start of each Recharts render pass
|
||||
if (index === 0) lastLabel = "";
|
||||
|
||||
const formatted = xAxisTickFormatter(payload.value);
|
||||
const label = formatted === lastLabel ? "" : formatted;
|
||||
lastLabel = formatted;
|
||||
// y is the tick text position, offset from the axis by tickMargin + internal padding
|
||||
const axisY = (y as number) - 12;
|
||||
if (label) {
|
||||
return (
|
||||
<g>
|
||||
<line
|
||||
x1={x as number}
|
||||
y1={axisY}
|
||||
x2={x as number}
|
||||
y2={axisY - 3}
|
||||
stroke="#878C99"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={x}
|
||||
y={axisY}
|
||||
dy={10}
|
||||
fill="#878C99"
|
||||
fontSize={11}
|
||||
textAnchor={xAxisAngle !== 0 ? "end" : "middle"}
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
transform={
|
||||
xAxisAngle !== 0 ? `rotate(${xAxisAngle}, ${x}, ${axisY + 10})` : undefined
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
// Small tick mark sitting on the axis baseline, pointing upward
|
||||
return (
|
||||
<line
|
||||
x1={x as number}
|
||||
y1={axisY}
|
||||
x2={x as number}
|
||||
y2={axisY - 3}
|
||||
stroke="#272A2E"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}, [isDateBased, xAxisTickFormatter, xAxisAngle]);
|
||||
|
||||
// Validation — all hooks must be above this point
|
||||
const chartIcon = chartType === "bar" ? BarChart3 : LineChart;
|
||||
|
||||
if (!xAxisColumn) {
|
||||
return <EmptyState message="Select an X-axis column to display the chart" />;
|
||||
return (
|
||||
<ChartBlankState icon={chartIcon} 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" />;
|
||||
return (
|
||||
<ChartBlankState icon={chartIcon} message="Select a Y-axis column to display the chart" />
|
||||
);
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <EmptyState message="No data to display" />;
|
||||
return <ChartBlankState icon={chartIcon} message="No data to display" />;
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return <EmptyState message="Unable to transform data for chart" />;
|
||||
return <ChartBlankState icon={chartIcon} message="Unable to transform data for chart" />;
|
||||
}
|
||||
|
||||
const commonProps = {
|
||||
data,
|
||||
margin: { top: 10, right: 10, left: 10, bottom: 10 },
|
||||
// Base x-axis props shared by all chart types
|
||||
const baseXAxisProps = {
|
||||
...(dateAxisTick
|
||||
? {
|
||||
tick: dateAxisTick,
|
||||
tickLine: false,
|
||||
tickFormatter: undefined,
|
||||
// Only force every tick to render when there are duplicates to de-duplicate;
|
||||
// otherwise let Recharts auto-space to avoid label collisions
|
||||
...(hasDuplicateLabels ? { interval: 0 } : {}),
|
||||
}
|
||||
: { tickFormatter: xAxisTickFormatter }),
|
||||
angle: xAxisAngle,
|
||||
textAnchor: xAxisAngle !== 0 ? ("end" as const) : ("middle" as const),
|
||||
height: xAxisHeight,
|
||||
};
|
||||
|
||||
// Determine appropriate angle for X-axis labels based on granularity
|
||||
const xAxisAngle = timeGranularity === "hours" || timeGranularity === "seconds" ? -45 : 0;
|
||||
const xAxisHeight = xAxisAngle !== 0 ? 60 : undefined;
|
||||
|
||||
// Build xAxisProps - different config for date-based (continuous) vs categorical axes
|
||||
const xAxisProps = isDateBased
|
||||
// Line charts use continuous time scale for date-based data
|
||||
// This properly represents time gaps between data points
|
||||
const xAxisPropsForLine = isDateBased
|
||||
? {
|
||||
dataKey: xDataKey,
|
||||
type: "number" as const,
|
||||
domain: timeDomain ?? ["auto", "auto"],
|
||||
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,
|
||||
fontSize: 12,
|
||||
tickLine: false,
|
||||
tickMargin: 8,
|
||||
axisLine: false,
|
||||
tick: { fill: "var(--color-text-dimmed)" },
|
||||
tickFormatter: xAxisTickFormatter,
|
||||
angle: xAxisAngle,
|
||||
textAnchor: xAxisAngle !== 0 ? ("end" as const) : ("middle" as const),
|
||||
height: xAxisHeight,
|
||||
...baseXAxisProps,
|
||||
}
|
||||
: {
|
||||
dataKey: xDataKey,
|
||||
fontSize: 12,
|
||||
tickLine: false,
|
||||
tickMargin: 8,
|
||||
axisLine: false,
|
||||
tick: { fill: "var(--color-text-dimmed)" },
|
||||
angle: xAxisAngle,
|
||||
textAnchor: xAxisAngle !== 0 ? ("end" as const) : ("middle" as const),
|
||||
height: xAxisHeight,
|
||||
};
|
||||
: 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 = {
|
||||
fontSize: 12,
|
||||
tickLine: false,
|
||||
tickMargin: 8,
|
||||
axisLine: false,
|
||||
tick: { fill: "var(--color-text-dimmed)" },
|
||||
tickFormatter: yAxisFormatter,
|
||||
domain: yAxisDomain,
|
||||
};
|
||||
|
||||
const showLegend = sortedSeries.length > 0;
|
||||
|
||||
if (chartType === "bar") {
|
||||
return (
|
||||
<Chart.Root
|
||||
config={chartConfig}
|
||||
data={data}
|
||||
dataKey={xDataKey}
|
||||
series={sortedSeries}
|
||||
visibleSeries={visibleSeries}
|
||||
labelFormatter={legendLabelFormatter}
|
||||
showLegend={showLegend}
|
||||
maxLegendItems={fullLegend ? Infinity : 5}
|
||||
legendAggregation={config.aggregation}
|
||||
minHeight="300px"
|
||||
fillContainer
|
||||
onViewAllLegendItems={onViewAllLegendItems}
|
||||
legendScrollable={legendScrollable}
|
||||
state={isLoading ? "loading" : "loaded"}
|
||||
beforeLegend={seriesLimitCallout}
|
||||
>
|
||||
<Chart.Bar
|
||||
xAxisProps={xAxisPropsForBar}
|
||||
yAxisProps={yAxisProps}
|
||||
stackId={stacked ? "stack" : undefined}
|
||||
tooltipLabelFormatter={tooltipLabelFormatter}
|
||||
/>
|
||||
</Chart.Root>
|
||||
);
|
||||
}
|
||||
|
||||
// Line or stacked area chart
|
||||
return (
|
||||
<ChartContainer config={chartConfig} className="h-full min-h-[300px] w-full">
|
||||
{chartType === "bar" ? (
|
||||
<BarChart {...commonProps}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis {...xAxisProps} />
|
||||
<YAxis {...yAxisProps} />
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent />}
|
||||
labelFormatter={tooltipLabelFormatter}
|
||||
cursor={{ fill: "var(--color-charcoal-800)", opacity: 0.5 }}
|
||||
/>
|
||||
{series.length > 1 && <ChartLegend content={<ChartLegendContent />} />}
|
||||
{series.map((s, i) => (
|
||||
<Bar
|
||||
key={s}
|
||||
dataKey={s}
|
||||
fill={getSeriesColor(i)}
|
||||
stackId={stacked ? "stack" : undefined}
|
||||
radius={stacked ? [0, 0, 0, 0] : [4, 4, 0, 0]}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
) : stacked && series.length > 1 ? (
|
||||
<AreaChart {...commonProps} stackOffset="none">
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis {...xAxisProps} />
|
||||
<YAxis {...yAxisProps} />
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent indicator="line" />}
|
||||
labelFormatter={tooltipLabelFormatter}
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
{series.map((s, i) => (
|
||||
<Area
|
||||
key={s}
|
||||
type="linear"
|
||||
dataKey={s}
|
||||
stroke={getSeriesColor(i)}
|
||||
fill={getSeriesColor(i)}
|
||||
fillOpacity={0.6}
|
||||
strokeWidth={2}
|
||||
stackId="stack"
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
) : (
|
||||
<LineChart {...commonProps}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis {...xAxisProps} />
|
||||
<YAxis {...yAxisProps} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} labelFormatter={tooltipLabelFormatter} />
|
||||
{series.length > 1 && <ChartLegend content={<ChartLegendContent />} />}
|
||||
{series.map((s, i) => (
|
||||
<Line
|
||||
key={s}
|
||||
type="linear"
|
||||
dataKey={s}
|
||||
stroke={getSeriesColor(i)}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
)}
|
||||
</ChartContainer>
|
||||
<Chart.Root
|
||||
config={chartConfig}
|
||||
data={data}
|
||||
dataKey={xDataKey}
|
||||
series={sortedSeries}
|
||||
visibleSeries={visibleSeries}
|
||||
labelFormatter={legendLabelFormatter}
|
||||
showLegend={showLegend}
|
||||
maxLegendItems={fullLegend ? Infinity : 5}
|
||||
legendAggregation={config.aggregation}
|
||||
minHeight="300px"
|
||||
fillContainer
|
||||
onViewAllLegendItems={onViewAllLegendItems}
|
||||
legendScrollable={legendScrollable}
|
||||
state={isLoading ? "loading" : "loaded"}
|
||||
beforeLegend={seriesLimitCallout}
|
||||
>
|
||||
<Chart.Line
|
||||
xAxisProps={xAxisPropsForLine}
|
||||
yAxisProps={yAxisProps}
|
||||
stacked={stacked && visibleSeries.length > 1}
|
||||
tooltipLabelFormatter={tooltipLabelFormatter}
|
||||
lineType="linear"
|
||||
/>
|
||||
</Chart.Root>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -977,13 +1180,3 @@ function createYAxisFormatter(data: Record<string, unknown>[], series: string[])
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { sql, StandardSQL } from "@codemirror/lang-sql";
|
||||
import { autocompletion } from "@codemirror/autocomplete";
|
||||
import { autocompletion, startCompletion } from "@codemirror/autocomplete";
|
||||
import { linter, lintGutter } from "@codemirror/lint";
|
||||
import { EditorView, keymap } from "@codemirror/view";
|
||||
import type { ViewUpdate } from "@codemirror/view";
|
||||
import { CheckIcon, ClipboardIcon, SparklesIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import {
|
||||
@@ -59,6 +60,54 @@ const defaultProps: TSQLEditorDefaultProps = {
|
||||
schema: [],
|
||||
};
|
||||
|
||||
// Toggle comment on current line or selected lines with -- comment symbol
|
||||
const toggleLineComment = (view: EditorView): boolean => {
|
||||
const { from, to } = view.state.selection.main;
|
||||
const startLine = view.state.doc.lineAt(from);
|
||||
// When `to` is exactly at the start of a line and there's an actual selection,
|
||||
// the caret sits before that line — so exclude it by stepping back one position.
|
||||
const adjustedTo = to > from && view.state.doc.lineAt(to).from === to ? to - 1 : to;
|
||||
const endLine = view.state.doc.lineAt(adjustedTo);
|
||||
|
||||
// Collect all lines in the selection
|
||||
const lines: { from: number; to: number; text: string }[] = [];
|
||||
for (let i = startLine.number; i <= endLine.number; i++) {
|
||||
const line = view.state.doc.line(i);
|
||||
lines.push({ from: line.from, to: line.to, text: line.text });
|
||||
}
|
||||
|
||||
// Determine action: if all non-empty lines are commented, uncomment; otherwise comment
|
||||
const allCommented = lines.every((line) => {
|
||||
const trimmed = line.text.trimStart();
|
||||
return trimmed.length === 0 || trimmed.startsWith("--");
|
||||
});
|
||||
|
||||
const changes = lines
|
||||
.map((line) => {
|
||||
const trimmed = line.text.trimStart();
|
||||
if (trimmed.length === 0) return null; // skip empty lines
|
||||
const indent = line.text.length - trimmed.length;
|
||||
|
||||
if (allCommented) {
|
||||
// Remove comment: strip "-- " or just "--"
|
||||
const afterComment = trimmed.slice(2);
|
||||
const newText = line.text.slice(0, indent) + afterComment.replace(/^\s/, "");
|
||||
return { from: line.from, to: line.to, insert: newText };
|
||||
} else {
|
||||
// Add comment: prepend "-- " to the line content
|
||||
const newText = line.text.slice(0, indent) + "-- " + trimmed;
|
||||
return { from: line.from, to: line.to, insert: newText };
|
||||
}
|
||||
})
|
||||
.filter((c): c is { from: number; to: number; insert: string } => c !== null);
|
||||
|
||||
if (changes.length > 0) {
|
||||
view.dispatch({ changes });
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export function TSQLEditor(opts: TSQLEditorProps) {
|
||||
const {
|
||||
defaultValue = "",
|
||||
@@ -103,6 +152,23 @@ export function TSQLEditor(opts: TSQLEditorProps) {
|
||||
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
|
||||
@@ -115,6 +181,14 @@ export function TSQLEditor(opts: TSQLEditorProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Add keyboard shortcut for toggling comments
|
||||
exts.push(
|
||||
keymap.of([
|
||||
{ key: "Cmd-/", run: toggleLineComment },
|
||||
{ key: "Ctrl-/", run: toggleLineComment },
|
||||
])
|
||||
);
|
||||
|
||||
return exts;
|
||||
}, [schema, linterEnabled]);
|
||||
|
||||
@@ -200,6 +274,9 @@ export function TSQLEditor(opts: TSQLEditorProps) {
|
||||
"min-h-0 flex-1 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
)}
|
||||
ref={editor}
|
||||
onClick={() => {
|
||||
view?.focus();
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!onBlur) return;
|
||||
if (!view) return;
|
||||
@@ -207,7 +284,7 @@ export function TSQLEditor(opts: TSQLEditorProps) {
|
||||
}}
|
||||
/>
|
||||
{showButtons && (
|
||||
<div className="absolute right-0 top-0 z-10 flex items-center justify-end bg-charcoal-900/80 p-0.5">
|
||||
<div className="absolute right-0 top-0 z-10 flex items-center justify-end bg-charcoal-900/80 p-1.5">
|
||||
{additionalActions && additionalActions}
|
||||
{showFormatButton && (
|
||||
<Button
|
||||
@@ -261,11 +338,50 @@ export function TSQLEditor(opts: TSQLEditorProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// SQL keywords that legitimately appear before parentheses with a space
|
||||
const SQL_KEYWORDS_BEFORE_PAREN = new Set([
|
||||
"IN",
|
||||
"NOT",
|
||||
"EXISTS",
|
||||
"OVER",
|
||||
"USING",
|
||||
"VALUES",
|
||||
"BETWEEN",
|
||||
"LIKE",
|
||||
"AND",
|
||||
"OR",
|
||||
"ON",
|
||||
"SET",
|
||||
"INTO",
|
||||
"TABLE",
|
||||
"CASE",
|
||||
"WHEN",
|
||||
"THEN",
|
||||
"ELSE",
|
||||
"AS",
|
||||
"FROM",
|
||||
"WHERE",
|
||||
"HAVING",
|
||||
"JOIN",
|
||||
"SELECT",
|
||||
]);
|
||||
|
||||
export function autoFormatSQL(sql: string) {
|
||||
return formatSQL(sql, {
|
||||
let formatted = formatSQL(sql, {
|
||||
language: "sql",
|
||||
keywordCase: "upper",
|
||||
indentStyle: "standard",
|
||||
linesBetweenQueries: 2,
|
||||
});
|
||||
|
||||
// sql-formatter adds a space before ( for unknown/custom functions (e.g. timeBucket ())
|
||||
// Remove that space for anything that isn't a SQL keyword
|
||||
formatted = formatted.replace(/(\b\w+)\s+\(/g, (match, name) => {
|
||||
if (SQL_KEYWORDS_BEFORE_PAREN.has(name.toUpperCase())) {
|
||||
return match;
|
||||
}
|
||||
return `${name}(`;
|
||||
});
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Chart color palette defined in HSL (Hue, Saturation, Lightness).
|
||||
*
|
||||
* HSL is a human-friendly color model:
|
||||
* h: 0–360 (hue — position on the color wheel: 0=red, 120=green, 240=blue)
|
||||
* s: 0–100 (saturation — 0 is gray, 100 is full color)
|
||||
* l: 0–100 (lightness — 0 is black, 50 is pure color, 100 is white)
|
||||
*/
|
||||
|
||||
interface HSLColor {
|
||||
h: number;
|
||||
s: number;
|
||||
l: number;
|
||||
}
|
||||
|
||||
interface ChartColorDef {
|
||||
name: string;
|
||||
hsl: HSLColor;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Palette — 30 distinct colors for chart series, defined in HSL
|
||||
// ---------------------------------------------------------------------------
|
||||
const CHART_COLOR_DEFS: ChartColorDef[] = [
|
||||
// Primary colors (high contrast, spread across hue wheel)
|
||||
{ name: "Purple", hsl: { h: 252, s: 98, l: 66 } },
|
||||
{ name: "Green", hsl: { h: 142, s: 71, l: 45 } },
|
||||
{ name: "Amber", hsl: { h: 38, s: 92, l: 50 } },
|
||||
{ name: "Red", hsl: { h: 0, s: 84, l: 60 } },
|
||||
{ name: "Cyan", hsl: { h: 189, s: 95, l: 43 } },
|
||||
{ name: "Pink", hsl: { h: 330, s: 81, l: 60 } },
|
||||
{ name: "Violet", hsl: { h: 258, s: 90, l: 66 } },
|
||||
{ name: "Teal", hsl: { h: 173, s: 80, l: 40 } },
|
||||
{ name: "Orange", hsl: { h: 25, s: 95, l: 53 } },
|
||||
{ name: "Indigo", hsl: { h: 239, s: 84, l: 67 } },
|
||||
// Extended palette
|
||||
{ name: "Lime", hsl: { h: 84, s: 81, l: 44 } },
|
||||
{ name: "Sky", hsl: { h: 199, s: 89, l: 48 } },
|
||||
{ name: "Rose", hsl: { h: 350, s: 89, l: 60 } },
|
||||
{ name: "Fuchsia", hsl: { h: 271, s: 91, l: 65 } },
|
||||
{ name: "Yellow", hsl: { h: 45, s: 93, l: 47 } },
|
||||
{ name: "Emerald", hsl: { h: 160, s: 84, l: 39 } },
|
||||
{ name: "Blue", hsl: { h: 217, s: 91, l: 60 } },
|
||||
{ name: "Magenta", hsl: { h: 292, s: 84, l: 61 } },
|
||||
{ name: "Stone", hsl: { h: 25, s: 5, l: 45 } },
|
||||
{ name: "Gold", hsl: { h: 48, s: 96, l: 53 } },
|
||||
// Additional distinct colors (lighter variants)
|
||||
{ name: "Turquoise", hsl: { h: 173, s: 66, l: 50 } },
|
||||
{ name: "Light Orange", hsl: { h: 27, s: 96, l: 61 } },
|
||||
{ name: "Yellow-Green", hsl: { h: 83, s: 78, l: 55 } },
|
||||
{ name: "Light Blue", hsl: { h: 198, s: 93, l: 60 } },
|
||||
{ name: "Light Purple", hsl: { h: 270, s: 95, l: 75 } },
|
||||
{ name: "Light Green", hsl: { h: 142, s: 69, l: 58 } },
|
||||
{ name: "Light Amber", hsl: { h: 43, s: 96, l: 56 } },
|
||||
{ name: "Light Pink", hsl: { h: 329, s: 86, l: 70 } },
|
||||
{ name: "Light Cyan", hsl: { h: 187, s: 92, l: 69 } },
|
||||
{ name: "Light Indigo", hsl: { h: 235, s: 89, l: 74 } },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HSL ↔ Hex conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Convert an HSL color (h: 0–360, s: 0–100, l: 0–100) to a hex string */
|
||||
function hslToHex({ h, s, l }: HSLColor): string {
|
||||
const sNorm = s / 100;
|
||||
const lNorm = l / 100;
|
||||
|
||||
const c = (1 - Math.abs(2 * lNorm - 1)) * sNorm;
|
||||
const hPrime = h / 60;
|
||||
const x = c * (1 - Math.abs((hPrime % 2) - 1));
|
||||
const m = lNorm - c / 2;
|
||||
|
||||
let r1: number, g1: number, b1: number;
|
||||
|
||||
if (hPrime < 1) {
|
||||
r1 = c;
|
||||
g1 = x;
|
||||
b1 = 0;
|
||||
} else if (hPrime < 2) {
|
||||
r1 = x;
|
||||
g1 = c;
|
||||
b1 = 0;
|
||||
} else if (hPrime < 3) {
|
||||
r1 = 0;
|
||||
g1 = c;
|
||||
b1 = x;
|
||||
} else if (hPrime < 4) {
|
||||
r1 = 0;
|
||||
g1 = x;
|
||||
b1 = c;
|
||||
} else if (hPrime < 5) {
|
||||
r1 = x;
|
||||
g1 = 0;
|
||||
b1 = c;
|
||||
} else {
|
||||
r1 = c;
|
||||
g1 = 0;
|
||||
b1 = x;
|
||||
}
|
||||
|
||||
const toHex = (v: number) =>
|
||||
Math.round((v + m) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
|
||||
return `#${toHex(r1)}${toHex(g1)}${toHex(b1)}`;
|
||||
}
|
||||
|
||||
/** Convert a hex string to HSL (h: 0–360, s: 0–100, l: 0–100) */
|
||||
function hexToHsl(hex: string): HSLColor {
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const delta = max - min;
|
||||
const l = (max + min) / 2;
|
||||
|
||||
if (delta === 0) {
|
||||
return { h: 0, s: 0, l: Math.round(l * 100) };
|
||||
}
|
||||
|
||||
const s = delta / (1 - Math.abs(2 * l - 1));
|
||||
|
||||
let h: number;
|
||||
if (max === r) {
|
||||
h = 60 * (((g - b) / delta + 6) % 6);
|
||||
} else if (max === g) {
|
||||
h = 60 * ((b - r) / delta + 2);
|
||||
} else {
|
||||
h = 60 * ((r - g) / delta + 4);
|
||||
}
|
||||
|
||||
return {
|
||||
h: Math.round(h),
|
||||
s: Math.round(s * 100),
|
||||
l: Math.round(l * 100),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived hex palette (for consumers that need plain hex strings)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Color palette for chart series — 30 distinct hex colors derived from HSL definitions */
|
||||
const CHART_COLORS: string[] = CHART_COLOR_DEFS.map((def) => hslToHex(def.hsl));
|
||||
|
||||
/** Get the hex color for a series by its index (wraps around) */
|
||||
export function getSeriesColor(index: number): string {
|
||||
return CHART_COLORS[index % CHART_COLORS.length];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hue-sorted palette (rainbow order for color pickers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SATURATION_THRESHOLD = 10;
|
||||
|
||||
/**
|
||||
* Chart colors sorted by perceived hue — the natural rainbow order
|
||||
* that humans expect: red -> orange -> yellow -> green -> cyan -> blue -> purple -> pink.
|
||||
*
|
||||
* Very desaturated colors (like grays) are placed at the end since they don't
|
||||
* have a strong hue.
|
||||
*/
|
||||
export const CHART_COLORS_BY_HUE: string[] = [...CHART_COLOR_DEFS]
|
||||
.sort((a, b) => {
|
||||
const aIsGray = a.hsl.s < SATURATION_THRESHOLD;
|
||||
const bIsGray = b.hsl.s < SATURATION_THRESHOLD;
|
||||
|
||||
// Push desaturated colors to the end
|
||||
if (aIsGray && !bIsGray) return 1;
|
||||
if (!aIsGray && bIsGray) return -1;
|
||||
if (aIsGray && bIsGray) return a.hsl.l - b.hsl.l;
|
||||
|
||||
// Sort by hue, then by saturation (more vivid first), then by lightness
|
||||
if (a.hsl.h !== b.hsl.h) return a.hsl.h - b.hsl.h;
|
||||
if (a.hsl.s !== b.hsl.s) return b.hsl.s - a.hsl.s;
|
||||
return a.hsl.l - b.hsl.l;
|
||||
})
|
||||
.map((def) => hslToHex(def.hsl));
|
||||
@@ -1,5 +1,5 @@
|
||||
import { closeBrackets } from "@codemirror/autocomplete";
|
||||
import { indentWithTab } from "@codemirror/commands";
|
||||
import { indentWithTab, history, historyKeymap, undo, redo } from "@codemirror/commands";
|
||||
import { bracketMatching } from "@codemirror/language";
|
||||
import { lintKeymap } from "@codemirror/lint";
|
||||
import { highlightSelectionMatches } from "@codemirror/search";
|
||||
@@ -18,6 +18,7 @@ export function getEditorSetup(showLineNumbers = true, showHighlights = true): A
|
||||
const options = [
|
||||
drawSelection(),
|
||||
dropCursor(),
|
||||
history(),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
Prec.highest(
|
||||
@@ -31,7 +32,15 @@ export function getEditorSetup(showLineNumbers = true, showHighlights = true): A
|
||||
},
|
||||
])
|
||||
),
|
||||
keymap.of([indentWithTab, ...lintKeymap]),
|
||||
// Explicit undo/redo keybindings with high precedence
|
||||
Prec.high(
|
||||
keymap.of([
|
||||
{ key: "Mod-z", run: undo },
|
||||
{ key: "Mod-Shift-z", run: redo },
|
||||
{ key: "Mod-y", run: redo },
|
||||
])
|
||||
),
|
||||
keymap.of([indentWithTab, ...historyKeymap, ...lintKeymap]),
|
||||
];
|
||||
|
||||
if (showLineNumbers) {
|
||||
|
||||
@@ -67,9 +67,10 @@ export function darkTheme(): Extension {
|
||||
},
|
||||
|
||||
".cm-cursor, .cm-dropCursor": { borderLeftColor: cursor },
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
|
||||
backgroundColor: selection,
|
||||
},
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
|
||||
{
|
||||
backgroundColor: selection,
|
||||
},
|
||||
|
||||
".cm-panels": { backgroundColor: darkBackground, color: ivory },
|
||||
".cm-panels.cm-panels-top": { borderBottom: "2px solid black" },
|
||||
@@ -87,8 +88,8 @@ export function darkTheme(): Extension {
|
||||
".cm-selectionMatch": { backgroundColor: "#aafe661a" },
|
||||
|
||||
"&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": {
|
||||
backgroundColor: "#bad0f847",
|
||||
outline: "1px solid #515a6b",
|
||||
backgroundColor: "rgba(18, 19, 23, 0.9)",
|
||||
outline: "1px solid rgba(81, 90, 107, 0.5)",
|
||||
},
|
||||
|
||||
".cm-gutters": {
|
||||
@@ -166,14 +167,20 @@ export function darkTheme(): Extension {
|
||||
backgroundColor: scrollbarBg,
|
||||
},
|
||||
},
|
||||
{ dark: true }
|
||||
{ dark: true },
|
||||
);
|
||||
|
||||
/// The highlighting style for code in the JSON Hero theme.
|
||||
const jsonHeroHighlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: violet },
|
||||
{
|
||||
tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName],
|
||||
tag: [
|
||||
tags.name,
|
||||
tags.deleted,
|
||||
tags.character,
|
||||
tags.propertyName,
|
||||
tags.macroName,
|
||||
],
|
||||
color: lilac,
|
||||
},
|
||||
{ tag: [tags.function(tags.variableName), tags.labelName], color: malibu },
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createTSQLCompletion } from "./tsqlCompletion";
|
||||
import type { TableSchema, ColumnSchema } from "@internal/tsql";
|
||||
|
||||
// Helper to create a mock completion context
|
||||
function createMockContext(doc: string, pos: number, explicit = false) {
|
||||
return {
|
||||
state: {
|
||||
doc: {
|
||||
toString: () => doc,
|
||||
},
|
||||
},
|
||||
pos,
|
||||
explicit,
|
||||
matchBefore: (regex: RegExp) => {
|
||||
const beforePos = doc.slice(0, pos);
|
||||
const match = beforePos.match(new RegExp(regex.source + "$"));
|
||||
if (match) {
|
||||
return {
|
||||
from: pos - match[0].length,
|
||||
to: pos,
|
||||
text: match[0],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
// Test schema
|
||||
const testSchema: TableSchema[] = [
|
||||
{
|
||||
name: "runs",
|
||||
clickhouseName: "trigger_dev.task_runs_v2",
|
||||
tenantColumns: {
|
||||
organizationId: "organization_id",
|
||||
projectId: "project_id",
|
||||
environmentId: "environment_id",
|
||||
},
|
||||
description: "Task runs table",
|
||||
columns: {
|
||||
id: { name: "id", type: "String", description: "Run ID" },
|
||||
status: { name: "status", type: "String", description: "Run status" },
|
||||
created_at: { name: "created_at", type: "DateTime64", description: "Creation time" },
|
||||
organization_id: { name: "organization_id", type: "String" },
|
||||
project_id: { name: "project_id", type: "String" },
|
||||
environment_id: { name: "environment_id", type: "String" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "logs",
|
||||
clickhouseName: "trigger_dev.task_events_v2",
|
||||
tenantColumns: {
|
||||
organizationId: "organization_id",
|
||||
projectId: "project_id",
|
||||
environmentId: "environment_id",
|
||||
},
|
||||
description: "Task logs table",
|
||||
columns: {
|
||||
id: { name: "id", type: "String" },
|
||||
run_id: { name: "run_id", type: "String" },
|
||||
message: { name: "message", type: "String" },
|
||||
level: { name: "level", type: "String" },
|
||||
timestamp: { name: "timestamp", type: "DateTime64" },
|
||||
organization_id: { name: "organization_id", type: "String" },
|
||||
project_id: { name: "project_id", type: "String" },
|
||||
environment_id: { name: "environment_id", type: "String" },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("createTSQLCompletion", () => {
|
||||
const completionSource = createTSQLCompletion(testSchema);
|
||||
|
||||
it("should return null for empty input without explicit trigger", () => {
|
||||
const context = createMockContext("", 0, false);
|
||||
const result = completionSource(context);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return completions when explicitly triggered", () => {
|
||||
const context = createMockContext("", 0, true);
|
||||
const result = completionSource(context);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.options.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should suggest tables after FROM keyword", () => {
|
||||
const doc = "SELECT * FROM ";
|
||||
const context = createMockContext(doc, doc.length, true);
|
||||
const result = completionSource(context);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
const tableLabels = result?.options.map((o) => o.label);
|
||||
expect(tableLabels).toContain("runs");
|
||||
expect(tableLabels).toContain("logs");
|
||||
});
|
||||
|
||||
it("should suggest columns after SELECT keyword", () => {
|
||||
const doc = "SELECT FROM runs";
|
||||
// Position cursor right after SELECT
|
||||
const pos = 7;
|
||||
const context = createMockContext(doc, pos, true);
|
||||
const result = completionSource(context);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
// Should include functions
|
||||
const labels = result?.options.map((o) => o.label) || [];
|
||||
expect(labels.some((l) => l === "count")).toBe(true);
|
||||
expect(labels.some((l) => l === "sum")).toBe(true);
|
||||
});
|
||||
|
||||
it("should suggest columns with table prefix for qualified references", () => {
|
||||
const doc = "SELECT runs. FROM runs";
|
||||
// Position cursor right after "runs."
|
||||
const pos = 12;
|
||||
const context = createMockContext(doc, pos, true);
|
||||
const result = completionSource(context);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
const columnLabels = result?.options.map((o) => o.label);
|
||||
expect(columnLabels).toContain("id");
|
||||
expect(columnLabels).toContain("status");
|
||||
expect(columnLabels).toContain("created_at");
|
||||
});
|
||||
|
||||
it("should include SQL keywords in general context", () => {
|
||||
const doc = "S";
|
||||
const context = createMockContext(doc, doc.length, true);
|
||||
const result = completionSource(context);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
const labels = result?.options.map((o) => o.label);
|
||||
expect(labels).toContain("SELECT");
|
||||
});
|
||||
|
||||
it("should include aggregate functions", () => {
|
||||
const doc = "SELECT ";
|
||||
const context = createMockContext(doc, doc.length, true);
|
||||
const result = completionSource(context);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
const labels = result?.options.map((o) => o.label);
|
||||
expect(labels).toContain("count");
|
||||
expect(labels).toContain("sum");
|
||||
expect(labels).toContain("avg");
|
||||
expect(labels).toContain("min");
|
||||
expect(labels).toContain("max");
|
||||
});
|
||||
|
||||
it("should handle WHERE clause context", () => {
|
||||
const doc = "SELECT * FROM runs WHERE ";
|
||||
const context = createMockContext(doc, doc.length, true);
|
||||
const result = completionSource(context);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
// Should suggest columns
|
||||
const labels = result?.options.map((o) => o.label) || [];
|
||||
expect(labels).toContain("status");
|
||||
|
||||
// Should include conditional keywords
|
||||
expect(labels).toContain("AND");
|
||||
expect(labels).toContain("OR");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CompletionContext, CompletionResult, Completion } from "@codemirror/autocomplete";
|
||||
import type { TableSchema, ColumnSchema } from "@internal/tsql";
|
||||
import {
|
||||
type TableSchema,
|
||||
type ColumnSchema,
|
||||
TSQL_CLICKHOUSE_FUNCTIONS,
|
||||
TSQL_AGGREGATIONS,
|
||||
} from "@internal/tsql";
|
||||
@@ -88,7 +89,11 @@ function createFunctionCompletions(): Completion[] {
|
||||
if (name.startsWith("_")) continue;
|
||||
|
||||
const argsHint =
|
||||
meta.maxArgs === 0 ? "()" : meta.minArgs === meta.maxArgs ? `(${meta.minArgs} args)` : `(${meta.minArgs}${meta.maxArgs ? `-${meta.maxArgs}` : "+"} args)`;
|
||||
meta.maxArgs === 0
|
||||
? "()"
|
||||
: meta.minArgs === meta.maxArgs
|
||||
? `(${meta.minArgs} args)`
|
||||
: `(${meta.minArgs}${meta.maxArgs ? `-${meta.maxArgs}` : "+"} args)`;
|
||||
|
||||
functions.push({
|
||||
label: name,
|
||||
@@ -103,7 +108,11 @@ function createFunctionCompletions(): Completion[] {
|
||||
if (name.startsWith("_")) continue;
|
||||
|
||||
const argsHint =
|
||||
meta.maxArgs === 0 ? "()" : meta.minArgs === meta.maxArgs ? `(${meta.minArgs} args)` : `(${meta.minArgs}${meta.maxArgs ? `-${meta.maxArgs}` : "+"} args)`;
|
||||
meta.maxArgs === 0
|
||||
? "()"
|
||||
: meta.minArgs === meta.maxArgs
|
||||
? `(${meta.minArgs} args)`
|
||||
: `(${meta.minArgs}${meta.maxArgs ? `-${meta.maxArgs}` : "+"} args)`;
|
||||
|
||||
functions.push({
|
||||
label: name,
|
||||
@@ -114,6 +123,16 @@ function createFunctionCompletions(): Completion[] {
|
||||
});
|
||||
}
|
||||
|
||||
// Add special TSQL functions not in the ClickHouse function registry
|
||||
functions.push({
|
||||
label: "timeBucket",
|
||||
type: "function",
|
||||
detail: "auto time bucket (0 args)",
|
||||
apply: "timeBucket()",
|
||||
boost: 1.5,
|
||||
info: "Automatically bucket by time using the table's time column. Interval is chosen based on the query's time range.",
|
||||
});
|
||||
|
||||
return functions;
|
||||
}
|
||||
|
||||
@@ -157,8 +176,7 @@ function extractTablesFromQuery(doc: string, schema: TableSchema[]): Map<string,
|
||||
|
||||
// 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;
|
||||
const tablePattern = /(?:FROM|JOIN)\s+(\w+)(?:\s+(?:AS\s+)?(\w+))?/gi;
|
||||
|
||||
let match;
|
||||
while ((match = tablePattern.exec(doc)) !== null) {
|
||||
@@ -166,9 +184,7 @@ function extractTablesFromQuery(doc: string, schema: TableSchema[]): Map<string,
|
||||
const alias = match[2] || tableName;
|
||||
|
||||
// Find the table schema if it exists
|
||||
const tableSchema = schema.find(
|
||||
(t) => t.name.toLowerCase() === tableName.toLowerCase()
|
||||
);
|
||||
const tableSchema = schema.find((t) => t.name.toLowerCase() === tableName.toLowerCase());
|
||||
|
||||
if (tableSchema) {
|
||||
tableMap.set(alias.toLowerCase(), tableSchema);
|
||||
@@ -202,21 +218,24 @@ interface ContextResult {
|
||||
|
||||
/**
|
||||
* Extract column name from text before a comparison operator
|
||||
* Handles: "column =", "table.column =", "column IN", etc.
|
||||
* Handles: "column =", "table.column =", "column IN", "column = 'partial", etc.
|
||||
*/
|
||||
function extractColumnBeforeOperator(textBefore: string): { columnName: string; tableAlias?: string } | null {
|
||||
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)
|
||||
/(\w+)\.(\w+)\s*(?:=|!=|<>)\s*$/i,
|
||||
/(\w+)\s*(?:=|!=|<>)\s*$/i,
|
||||
// column IN ( or column NOT IN (
|
||||
/(\w+)\.(\w+)\s+(?:NOT\s+)?IN\s*\(\s*$/i,
|
||||
/(\w+)\s+(?:NOT\s+)?IN\s*\(\s*$/i,
|
||||
// After a comma in IN clause - need to find the column before IN
|
||||
/(\w+)\.(\w+)\s+(?:NOT\s+)?IN\s*\([^)]*,\s*$/i,
|
||||
/(\w+)\s+(?:NOT\s+)?IN\s*\([^)]*,\s*$/i,
|
||||
// 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) {
|
||||
@@ -235,10 +254,7 @@ function extractColumnBeforeOperator(textBefore: string): { columnName: string;
|
||||
return null;
|
||||
}
|
||||
|
||||
function determineContext(
|
||||
doc: string,
|
||||
pos: number
|
||||
): ContextResult {
|
||||
function determineContext(doc: string, pos: number): ContextResult {
|
||||
// Get text before cursor
|
||||
const textBefore = doc.slice(0, pos);
|
||||
|
||||
@@ -320,21 +336,9 @@ function findColumnSchema(
|
||||
}
|
||||
|
||||
/**
|
||||
* Create completions for enum values
|
||||
* Uses user-friendly values from valueMap when available, showing internal value as detail
|
||||
* Create completions for enum values from allowedValues
|
||||
*/
|
||||
function createEnumValueCompletions(columnSchema: ColumnSchema): Completion[] {
|
||||
// Prefer valueMap over allowedValues if available
|
||||
if (columnSchema.valueMap && Object.keys(columnSchema.valueMap).length > 0) {
|
||||
return Object.entries(columnSchema.valueMap).map(([internalValue, userFriendlyValue]) => ({
|
||||
label: `'${userFriendlyValue}'`,
|
||||
type: "enum",
|
||||
detail: `→ ${internalValue}`,
|
||||
boost: 3, // Highest priority for enum values in value context
|
||||
}));
|
||||
}
|
||||
|
||||
// Fall back to allowedValues
|
||||
if (!columnSchema.allowedValues || columnSchema.allowedValues.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -375,6 +379,8 @@ export function createTSQLCompletion(
|
||||
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":
|
||||
@@ -406,6 +412,12 @@ export function createTSQLCompletion(
|
||||
|
||||
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;
|
||||
@@ -426,9 +438,23 @@ export function createTSQLCompletion(
|
||||
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
|
||||
)
|
||||
[
|
||||
"AND",
|
||||
"OR",
|
||||
"NOT",
|
||||
"IN",
|
||||
"LIKE",
|
||||
"ILIKE",
|
||||
"BETWEEN",
|
||||
"IS",
|
||||
"NULL",
|
||||
"AS",
|
||||
"CASE",
|
||||
"WHEN",
|
||||
"THEN",
|
||||
"ELSE",
|
||||
"END",
|
||||
].includes(k.label as string)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -437,11 +463,7 @@ export function createTSQLCompletion(
|
||||
case "general":
|
||||
default:
|
||||
// Show everything
|
||||
options = [
|
||||
...tableCompletions,
|
||||
...functionCompletions,
|
||||
...keywordCompletions,
|
||||
];
|
||||
options = [...tableCompletions, ...functionCompletions, ...keywordCompletions];
|
||||
|
||||
// Also add columns from tables in query
|
||||
{
|
||||
@@ -454,11 +476,15 @@ export function createTSQLCompletion(
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -80,11 +80,13 @@ export function EnvironmentLabel({
|
||||
className,
|
||||
tooltipSideOffset = 34,
|
||||
tooltipSide = "right",
|
||||
disableTooltip = false,
|
||||
}: {
|
||||
environment: Environment;
|
||||
className?: string;
|
||||
tooltipSideOffset?: number;
|
||||
tooltipSide?: "top" | "right" | "bottom" | "left";
|
||||
disableTooltip?: boolean;
|
||||
}) {
|
||||
const spanRef = useRef<HTMLSpanElement>(null);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
@@ -117,7 +119,7 @@ export function EnvironmentLabel({
|
||||
</span>
|
||||
);
|
||||
|
||||
if (isTruncated) {
|
||||
if (isTruncated && !disableTooltip) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
asChild
|
||||
@@ -138,6 +140,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;
|
||||
|
||||
@@ -10,11 +10,14 @@ import { FormButtons } from "../primitives/FormButtons";
|
||||
import { Input } from "../primitives/Input";
|
||||
import { InputGroup } from "../primitives/InputGroup";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { CheckboxWithLabel } from "../primitives/Checkbox";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
|
||||
type ModalProps = {
|
||||
id: string;
|
||||
title: string;
|
||||
hasVercelIntegration: boolean;
|
||||
isDevelopment: boolean;
|
||||
};
|
||||
|
||||
type ModalContentProps = ModalProps & {
|
||||
@@ -22,7 +25,12 @@ type ModalContentProps = ModalProps & {
|
||||
closeModal: () => void;
|
||||
};
|
||||
|
||||
export function RegenerateApiKeyModal({ id, title }: ModalProps) {
|
||||
export function RegenerateApiKeyModal({
|
||||
id,
|
||||
title,
|
||||
hasVercelIntegration,
|
||||
isDevelopment,
|
||||
}: ModalProps) {
|
||||
const randomWord = generateTwoRandomWords();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
@@ -37,6 +45,8 @@ export function RegenerateApiKeyModal({ id, title }: ModalProps) {
|
||||
<RegenerateApiKeyModalContent
|
||||
id={id}
|
||||
title={title}
|
||||
hasVercelIntegration={hasVercelIntegration}
|
||||
isDevelopment={isDevelopment}
|
||||
randomWord={randomWord}
|
||||
closeModal={() => setOpen(false)}
|
||||
/>
|
||||
@@ -45,7 +55,14 @@ export function RegenerateApiKeyModal({ id, title }: ModalProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const RegenerateApiKeyModalContent = ({ id, randomWord, title, closeModal }: ModalContentProps) => {
|
||||
const RegenerateApiKeyModalContent = ({
|
||||
id,
|
||||
randomWord,
|
||||
title,
|
||||
hasVercelIntegration,
|
||||
isDevelopment,
|
||||
closeModal,
|
||||
}: ModalContentProps) => {
|
||||
const [confirmationText, setConfirmationText] = useState("");
|
||||
const fetcher = useFetcher();
|
||||
const isSubmitting = fetcher.state === "submitting";
|
||||
@@ -83,6 +100,15 @@ const RegenerateApiKeyModalContent = ({ id, randomWord, title, closeModal }: Mod
|
||||
onChange={(e) => setConfirmationText(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
{hasVercelIntegration && !isDevelopment && (
|
||||
<CheckboxWithLabel
|
||||
name="syncToVercel"
|
||||
variant="simple/small"
|
||||
label="Also update TRIGGER_SECRET_KEY in Vercel"
|
||||
defaultChecked={true}
|
||||
value="on"
|
||||
/>
|
||||
)}
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
@@ -94,7 +120,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,176 @@
|
||||
import { Switch } from "~/components/primitives/Switch";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import {
|
||||
EnvironmentIcon,
|
||||
environmentFullTitle,
|
||||
environmentTextClassName,
|
||||
} from "~/components/environments/EnvironmentLabel";
|
||||
import { envSlugToType, type EnvSlug } from "~/v3/vercel/vercelProjectIntegrationSchema";
|
||||
|
||||
type BuildSettingsFieldsProps = {
|
||||
availableEnvSlugs: EnvSlug[];
|
||||
pullEnvVarsBeforeBuild: EnvSlug[];
|
||||
onPullEnvVarsChange: (slugs: EnvSlug[]) => void;
|
||||
discoverEnvVars: EnvSlug[];
|
||||
onDiscoverEnvVarsChange: (slugs: EnvSlug[]) => void;
|
||||
atomicBuilds: EnvSlug[];
|
||||
onAtomicBuildsChange: (slugs: EnvSlug[]) => void;
|
||||
envVarsConfigLink?: string;
|
||||
};
|
||||
|
||||
export function BuildSettingsFields({
|
||||
availableEnvSlugs,
|
||||
pullEnvVarsBeforeBuild,
|
||||
onPullEnvVarsChange,
|
||||
discoverEnvVars,
|
||||
onDiscoverEnvVarsChange,
|
||||
atomicBuilds,
|
||||
onAtomicBuildsChange,
|
||||
envVarsConfigLink,
|
||||
}: BuildSettingsFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Pull env vars before build */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Pull env vars before build</Label>
|
||||
<Hint>
|
||||
Select which environments should pull environment variables from Vercel before each
|
||||
build.{" "}
|
||||
{envVarsConfigLink && (
|
||||
<>
|
||||
<TextLink to={envVarsConfigLink}>Configure which variables to pull</TextLink>.
|
||||
</>
|
||||
)}
|
||||
</Hint>
|
||||
</div>
|
||||
{availableEnvSlugs.length > 1 && (
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={
|
||||
availableEnvSlugs.length > 0 &&
|
||||
availableEnvSlugs.every((s) => pullEnvVarsBeforeBuild.includes(s))
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
onPullEnvVarsChange(checked ? [...availableEnvSlugs] : []);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 rounded border bg-charcoal-800 p-3">
|
||||
{availableEnvSlugs.map((slug) => {
|
||||
const envType = envSlugToType(slug);
|
||||
return (
|
||||
<div key={slug} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: envType }} className="size-4" />
|
||||
<span className={`text-sm ${environmentTextClassName({ type: envType })}`}>
|
||||
{environmentFullTitle({ type: envType })}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={pullEnvVarsBeforeBuild.includes(slug)}
|
||||
onCheckedChange={(checked) => {
|
||||
onPullEnvVarsChange(
|
||||
checked
|
||||
? [...pullEnvVarsBeforeBuild, slug]
|
||||
: pullEnvVarsBeforeBuild.filter((s) => s !== slug)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Discover new env vars */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Discover new env vars</Label>
|
||||
<Hint>
|
||||
Select which environments should automatically discover and create new environment
|
||||
variables from Vercel during builds.
|
||||
</Hint>
|
||||
</div>
|
||||
{availableEnvSlugs.length > 1 && (
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={
|
||||
availableEnvSlugs.length > 0 &&
|
||||
availableEnvSlugs.every(
|
||||
(s) => discoverEnvVars.includes(s) || !pullEnvVarsBeforeBuild.includes(s)
|
||||
) &&
|
||||
availableEnvSlugs.some((s) => discoverEnvVars.includes(s))
|
||||
}
|
||||
disabled={!availableEnvSlugs.some((s) => pullEnvVarsBeforeBuild.includes(s))}
|
||||
onCheckedChange={(checked) => {
|
||||
onDiscoverEnvVarsChange(
|
||||
checked
|
||||
? availableEnvSlugs.filter((s) => pullEnvVarsBeforeBuild.includes(s))
|
||||
: []
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 rounded border bg-charcoal-800 p-3">
|
||||
{availableEnvSlugs.map((slug) => {
|
||||
const envType = envSlugToType(slug);
|
||||
const isPullDisabled = !pullEnvVarsBeforeBuild.includes(slug);
|
||||
return (
|
||||
<div
|
||||
key={slug}
|
||||
className={`flex items-center justify-between ${isPullDisabled ? "opacity-50" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: envType }} className="size-4" />
|
||||
<span className={`text-sm ${environmentTextClassName({ type: envType })}`}>
|
||||
{environmentFullTitle({ type: envType })}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={discoverEnvVars.includes(slug)}
|
||||
disabled={isPullDisabled}
|
||||
onCheckedChange={(checked) => {
|
||||
onDiscoverEnvVarsChange(
|
||||
checked
|
||||
? [...discoverEnvVars, slug]
|
||||
: discoverEnvVars.filter((s) => s !== slug)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Atomic deployments */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Atomic deployments</Label>
|
||||
<Hint>
|
||||
When enabled, production deployments wait for Vercel deployment to complete before
|
||||
promoting the Trigger.dev deployment.
|
||||
</Hint>
|
||||
</div>
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={atomicBuilds.includes("prod")}
|
||||
onCheckedChange={(checked) => {
|
||||
onAtomicBuildsChange(checked ? ["prod"] : []);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export function VercelLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 76 65"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M37.5274 0L75.0548 65H0L37.5274 0Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,7 @@ export function MainBody({ children }: { children: React.ReactNode }) {
|
||||
|
||||
/** This container should be placed around the content on a page */
|
||||
export function PageContainer({ children }: { children: React.ReactNode }) {
|
||||
return <div className="grid grid-rows-[auto_1fr] overflow-hidden">{children}</div>;
|
||||
return <div className="grid h-full grid-rows-[auto_1fr] overflow-hidden">{children}</div>;
|
||||
}
|
||||
|
||||
export function PageBody({
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import type { TaskRunStatus } from "@trigger.dev/database";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTypedFetcher } from "remix-typedjson";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import { DateTimeAccurate } from "~/components/primitives/DateTime";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { PacketDisplay } from "~/components/runs/v3/PacketDisplay";
|
||||
import {
|
||||
TaskRunStatusCombo,
|
||||
descriptionForTaskRunStatus,
|
||||
} from "~/components/runs/v3/TaskRunStatus";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
|
||||
import type { loader as logDetailLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { getLevelColor } from "~/utils/logUtils";
|
||||
import { v3RunSpanPath } from "~/utils/pathBuilder";
|
||||
import { LogLevel } from "./LogLevel";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
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 LogAttributes = Record<string, unknown> & {
|
||||
error?: {
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function getDisplayMessage(log: {
|
||||
message: string;
|
||||
level: string;
|
||||
attributes?: LogAttributes;
|
||||
}): string {
|
||||
let message = log.message ?? "";
|
||||
if (log.level === "ERROR") {
|
||||
const maybeErrorMessage = log.attributes?.error?.message;
|
||||
if (typeof maybeErrorMessage === "string" && maybeErrorMessage.length > 0) {
|
||||
message = maybeErrorMessage;
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
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 [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;
|
||||
const runStatus = fetcher.data?.runStatus;
|
||||
|
||||
const runPath = v3RunSpanPath(
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
{ friendlyId: log?.runId ?? "" },
|
||||
{ spanId: log?.spanId ?? "" }
|
||||
);
|
||||
|
||||
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 py-2 pl-3 pr-2">
|
||||
<Header2>Log Details</Header2>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
shortcutPosition="before-trailing-icon"
|
||||
className="pl-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Paragraph className="text-text-dimmed">{error ?? "Log not found"}</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between overflow-hidden border-b border-grid-dimmed py-2 pl-3 pr-2">
|
||||
<Header2 className="truncate">{getDisplayMessage(log)}</Header2>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
shortcutPosition="before-trailing-icon"
|
||||
className="pl-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-y-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<DetailsTab log={log} runPath={runPath} runStatus={runStatus} searchTerm={searchTerm} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailsTab({
|
||||
log,
|
||||
runPath,
|
||||
runStatus,
|
||||
searchTerm,
|
||||
}: {
|
||||
log: LogEntry & {
|
||||
attributes?: LogAttributes;
|
||||
};
|
||||
runPath: string;
|
||||
runStatus?: TaskRunStatus;
|
||||
searchTerm?: string;
|
||||
}) {
|
||||
let beautifiedAttributes: string | null = null;
|
||||
|
||||
if (log.attributes) {
|
||||
beautifiedAttributes = JSON.stringify(log.attributes, null, 2);
|
||||
beautifiedAttributes = formatStringJSON(beautifiedAttributes);
|
||||
}
|
||||
|
||||
const showAttributes = beautifiedAttributes && beautifiedAttributes !== "{}";
|
||||
|
||||
const message = getDisplayMessage(log);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>Run ID</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={log.runId} copyValue={log.runId} asChild />
|
||||
<LinkButton
|
||||
to={runPath}
|
||||
variant="tertiary/small"
|
||||
shortcut={{ key: "v" }}
|
||||
className="mt-2"
|
||||
>
|
||||
View full run
|
||||
</LinkButton>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
{runStatus && (
|
||||
<Property.Item>
|
||||
<Property.Label>Status</Property.Label>
|
||||
<Property.Value>
|
||||
<SimpleTooltip
|
||||
button={<TaskRunStatusCombo status={runStatus} />}
|
||||
content={descriptionForTaskRunStatus(runStatus)}
|
||||
disableHoverableContent
|
||||
className="mt-1"
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Task</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={log.taskIdentifier} copyValue={log.taskIdentifier} asChild />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Level</Property.Label>
|
||||
<Property.Value>
|
||||
<LogLevel level={log.level} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Timestamp</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTimeAccurate date={log.triggeredTimestamp} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
|
||||
{/* Message */}
|
||||
<div className="mb-6 mt-3">
|
||||
<PacketDisplay
|
||||
data={message}
|
||||
dataType="application/json"
|
||||
title="Message"
|
||||
searchTerm={searchTerm}
|
||||
wrap={true}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
import { getLevelColor } from "~/utils/logUtils";
|
||||
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
|
||||
|
||||
export function LogLevel({ level }: { level: LogEntry["level"] }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded border px-1 py-0.5 text-xxs font-medium uppercase tracking-wider",
|
||||
getLevelColor(level)
|
||||
)}
|
||||
>
|
||||
{level}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import { IconListTree } from "@tabler/icons-react";
|
||||
import { type ReactNode } from "react";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
import {
|
||||
SelectItem,
|
||||
SelectList,
|
||||
SelectPopover,
|
||||
SelectProvider,
|
||||
SelectTrigger,
|
||||
shortcutFromIndex,
|
||||
} from "~/components/primitives/Select";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { 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: "TRACE", label: "Trace", color: "text-purple-400" },
|
||||
{ level: "INFO", label: "Info", color: "text-blue-400" },
|
||||
{ level: "WARN", label: "Warning", color: "text-warning" },
|
||||
{ level: "ERROR", label: "Error", color: "text-error" },
|
||||
{ level: "DEBUG", label: "Debug", color: "text-charcoal-400" },
|
||||
];
|
||||
|
||||
// In the future we might add other levels or change which are available
|
||||
function getAvailableLevels(): typeof allLogLevels {
|
||||
return allLogLevels;
|
||||
}
|
||||
|
||||
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 "TRACE":
|
||||
return "text-purple-400 bg-purple-500/10 border-purple-500/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";
|
||||
default:
|
||||
return "text-text-dimmed bg-charcoal-750 border-charcoal-700";
|
||||
}
|
||||
}
|
||||
|
||||
const shortcut = { key: "l" };
|
||||
|
||||
export function LogsLevelFilter() {
|
||||
const { values } = useSearchParams();
|
||||
const selectedLevels = values("levels");
|
||||
const hasLevels = selectedLevels.length > 0 && selectedLevels.some((v) => v !== "");
|
||||
|
||||
if (hasLevels) {
|
||||
return <AppliedLevelFilter/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<LevelDropdown
|
||||
trigger={
|
||||
<SelectTrigger
|
||||
icon={<IconListTree className="size-4" />}
|
||||
variant="secondary/small"
|
||||
shortcut={shortcut}
|
||||
tooltipTitle="Filter by level"
|
||||
>
|
||||
Level
|
||||
</SelectTrigger>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LevelDropdown({
|
||||
trigger,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
replace({ levels: values, cursor: undefined, direction: undefined });
|
||||
};
|
||||
|
||||
const availableLevels = getAvailableLevels();
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("levels")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover className="min-w-0 max-w-[min(240px,var(--popover-available-width))]">
|
||||
<SelectList>
|
||||
{availableLevels.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() {
|
||||
const { values, del } = useSearchParams();
|
||||
const levels = values("levels");
|
||||
|
||||
if (levels.length === 0 || levels.every((v) => v === "")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<LevelDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Level"
|
||||
icon={<IconListTree className="size-4" />}
|
||||
value={appliedSummary(levels)}
|
||||
onRemove={() => del(["levels", "cursor", "direction"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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: "i" };
|
||||
|
||||
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,100 @@
|
||||
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { motion } from "framer-motion";
|
||||
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";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
|
||||
export function LogsSearchInput() {
|
||||
const location = useOptimisticLocation();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { value, replace, del } = useSearchParams();
|
||||
|
||||
// Get initial search value from URL
|
||||
const initialSearch = value("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 urlSearch = value("search") ?? "";
|
||||
if (urlSearch !== text && !isFocused) {
|
||||
setText(urlSearch);
|
||||
}
|
||||
}, [value, text, isFocused]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (text.trim()) {
|
||||
replace({ search: text.trim() });
|
||||
} else {
|
||||
del("search");
|
||||
}
|
||||
}, [text, replace, del]);
|
||||
|
||||
const handleClear = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setText("");
|
||||
del(["search", "cursor", "direction"]);
|
||||
},
|
||||
[del]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<motion.div
|
||||
initial={{ width: "auto" }}
|
||||
animate={{ width: isFocused && text.length > 0 ? "24rem" : "auto" }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
}}
|
||||
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 ? (
|
||||
<div className="-mr-1 flex items-center gap-1">
|
||||
<ShortcutKey shortcut={{ key: "enter" }} variant="small" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="flex size-4.5 items-center justify-center rounded-[2px] border border-text-dimmed/40 text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright"
|
||||
title="Clear search"
|
||||
>
|
||||
<XMarkIcon className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { ArrowPathIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button, LinkButton } 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 { highlightSearchText } from "~/utils/logUtils";
|
||||
import { v3RunSpanPath } from "~/utils/pathBuilder";
|
||||
import { DateTimeAccurate } from "../primitives/DateTime";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
import { LogLevel } from "./LogLevel";
|
||||
import { TruncatedCopyableValue } from "../primitives/TruncatedCopyableValue";
|
||||
import { LogLevelTooltipInfo } from "~/components/LogLevelTooltipInfo";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellMenu,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
type TableVariant,
|
||||
} from "../primitives/Table";
|
||||
|
||||
type LogsTableProps = {
|
||||
logs: LogEntry[];
|
||||
searchTerm?: string;
|
||||
isLoading?: boolean;
|
||||
isLoadingMore?: boolean;
|
||||
hasMore?: boolean;
|
||||
onLoadMore?: () => void;
|
||||
onCheckForMore?: () => void;
|
||||
variant?: TableVariant;
|
||||
selectedLogId?: string;
|
||||
onLogSelect?: (logId: string) => void;
|
||||
};
|
||||
|
||||
// Inner shadow for level highlighting (better scroll performance than border-l)
|
||||
function getLevelBoxShadow(level: LogEntry["level"]): string {
|
||||
switch (level) {
|
||||
case "ERROR":
|
||||
return "inset 2px 0 0 0 rgb(239, 68, 68)";
|
||||
case "WARN":
|
||||
return "inset 2px 0 0 0 rgb(234, 179, 8)";
|
||||
case "INFO":
|
||||
return "inset 2px 0 0 0 rgb(59, 130, 246)";
|
||||
case "TRACE":
|
||||
return "inset 2px 0 0 0 rgb(168, 85, 247)";
|
||||
case "DEBUG":
|
||||
default:
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
|
||||
export function LogsTable({
|
||||
logs,
|
||||
searchTerm,
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
hasMore = false,
|
||||
onLoadMore,
|
||||
onCheckForMore,
|
||||
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 border-t scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<Table variant="compact/mono" containerClassName="overflow-visible" showTopBorder={false}>
|
||||
<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"
|
||||
tooltip={<LogLevelTooltipInfo />}
|
||||
>
|
||||
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 transition-colors",
|
||||
isSelected ? "bg-charcoal-750" : "hover:bg-charcoal-850"
|
||||
)}
|
||||
isSelected={isSelected}
|
||||
>
|
||||
<TableCell
|
||||
className="whitespace-nowrap tabular-nums"
|
||||
onClick={handleRowClick}
|
||||
hasAction
|
||||
style={{
|
||||
boxShadow: getLevelBoxShadow(log.level),
|
||||
}}
|
||||
>
|
||||
<DateTimeAccurate date={log.triggeredTimestamp} hour12={false} />
|
||||
</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>
|
||||
<LogLevel level={log.level} />
|
||||
</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={
|
||||
<LinkButton
|
||||
to={runPath}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ArrowTopRightOnSquareIcon}
|
||||
>
|
||||
View run
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{/* Infinite scroll trigger */}
|
||||
{hasMore && logs.length > 0 && (
|
||||
<div ref={loadMoreRef} className="flex items-center justify-center py-12">
|
||||
<div className={cn("flex items-center gap-2", !showLoadMoreSpinner && "invisible")}>
|
||||
<Spinner /> <span className="text-text-dimmed">Loading more…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Show all logs message with check for more button */}
|
||||
{!hasMore && logs.length > 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<span className="text-text-dimmed">Showing all {logs.length} logs</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { TaskTriggerSource } from "@trigger.dev/database";
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import {
|
||||
ComboBox,
|
||||
SelectItem,
|
||||
SelectList,
|
||||
SelectPopover,
|
||||
SelectProvider,
|
||||
SelectTrigger,
|
||||
} from "~/components/primitives/Select";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { TaskTriggerSourceIcon } from "~/components/runs/v3/TaskTriggerSource";
|
||||
import { TaskIcon } from "~/assets/icons/TaskIcon";
|
||||
import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
|
||||
const shortcut = { key: "t" };
|
||||
|
||||
type TaskOption = {
|
||||
slug: string;
|
||||
triggerSource: TaskTriggerSource;
|
||||
};
|
||||
|
||||
interface LogsTaskFilterProps {
|
||||
possibleTasks: TaskOption[];
|
||||
}
|
||||
|
||||
export function LogsTaskFilter({ possibleTasks }: LogsTaskFilterProps) {
|
||||
const { values, replace, del } = useSearchParams();
|
||||
const selectedTasks = values("tasks");
|
||||
|
||||
if (selectedTasks.length === 0 || selectedTasks.every((v) => v === "")) {
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<TasksDropdown
|
||||
trigger={
|
||||
<SelectTrigger
|
||||
icon={<TaskIcon className="size-4" />}
|
||||
variant="secondary/small"
|
||||
shortcut={shortcut}
|
||||
tooltipTitle="Filter by task"
|
||||
>
|
||||
<span className="ml-0.5">Tasks</span>
|
||||
</SelectTrigger>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
possibleTasks={possibleTasks}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<TasksDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Task"
|
||||
icon={<TaskIcon className="size-4" />}
|
||||
value={appliedSummary(
|
||||
selectedTasks.map((v) => {
|
||||
const task = possibleTasks.find((task) => task.slug === v);
|
||||
return task ? task.slug : v;
|
||||
})
|
||||
)}
|
||||
onRemove={() => del(["tasks", "cursor", "direction"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
possibleTasks={possibleTasks}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TasksDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
possibleTasks,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
possibleTasks: TaskOption[];
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ tasks: values, cursor: undefined, direction: undefined });
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return possibleTasks.filter((item) => {
|
||||
return item.slug.toLowerCase().includes(searchValue.toLowerCase());
|
||||
});
|
||||
}, [searchValue, possibleTasks]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("tasks")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(360px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder={"Filter by task..."} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((item, index) => (
|
||||
<SelectItem
|
||||
key={`${item.triggerSource}-${item.slug}`}
|
||||
value={item.slug}
|
||||
icon={
|
||||
<TaskTriggerSourceIcon source={item.triggerSource} className="size-4 flex-none" />
|
||||
}
|
||||
>
|
||||
{item.slug}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
import { DocumentDuplicateIcon, PencilSquareIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { ClipboardIcon } from "@heroicons/react/24/outline";
|
||||
import { ChartBarIcon } from "@heroicons/react/24/solid";
|
||||
import { type OutputColumnMetadata } from "@internal/tsql";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { IconBraces, IconChartHistogram, IconFileTypeCsv } from "@tabler/icons-react";
|
||||
import { assertNever } from "assert-never";
|
||||
import { Maximize2 } from "lucide-react";
|
||||
import { useCallback, useRef, useState, type ReactNode } from "react";
|
||||
import { z } from "zod";
|
||||
import { Card } from "~/components/primitives/charts/Card";
|
||||
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { rowsToCSV, rowsToJSON } from "~/utils/dataExport";
|
||||
import { QueryResultsChart } from "../code/QueryResultsChart";
|
||||
import { TSQLResultsTable } from "../code/TSQLResultsTable";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { Callout } from "../primitives/Callout";
|
||||
import { BigNumberCard } from "../primitives/charts/BigNumberCard";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "../primitives/Dialog";
|
||||
import { Input } from "../primitives/Input";
|
||||
import { InputGroup } from "../primitives/InputGroup";
|
||||
import { Label } from "../primitives/Label";
|
||||
import { LoadingBarDivider } from "../primitives/LoadingBarDivider";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverMenuItem,
|
||||
PopoverVerticalEllipseTrigger,
|
||||
} from "../primitives/Popover";
|
||||
|
||||
const ChartType = z.union([z.literal("bar"), z.literal("line")]);
|
||||
export type ChartType = z.infer<typeof ChartType>;
|
||||
|
||||
const SortDirection = z.union([z.literal("asc"), z.literal("desc")]);
|
||||
export type SortDirection = z.infer<typeof SortDirection>;
|
||||
|
||||
const AggregationType = z.union([
|
||||
z.literal("sum"),
|
||||
z.literal("avg"),
|
||||
z.literal("count"),
|
||||
z.literal("min"),
|
||||
z.literal("max"),
|
||||
]);
|
||||
export type AggregationType = z.infer<typeof AggregationType>;
|
||||
|
||||
const chartConfigOptions = {
|
||||
chartType: ChartType,
|
||||
xAxisColumn: z.string().nullable(),
|
||||
yAxisColumns: z.string().array(),
|
||||
groupByColumn: z.string().nullable(),
|
||||
stacked: z.boolean(),
|
||||
sortByColumn: z.string().nullable(),
|
||||
sortDirection: SortDirection,
|
||||
aggregation: AggregationType,
|
||||
seriesColors: z.record(z.string()).optional(),
|
||||
};
|
||||
|
||||
const ChartConfiguration = z.object({ ...chartConfigOptions });
|
||||
export type ChartConfiguration = z.infer<typeof ChartConfiguration>;
|
||||
|
||||
const BigNumberAggregationType = z.union([
|
||||
z.literal("sum"),
|
||||
z.literal("avg"),
|
||||
z.literal("count"),
|
||||
z.literal("min"),
|
||||
z.literal("max"),
|
||||
z.literal("first"),
|
||||
z.literal("last"),
|
||||
]);
|
||||
export type BigNumberAggregationType = z.infer<typeof BigNumberAggregationType>;
|
||||
|
||||
const BigNumberSortDirection = z.union([z.literal("asc"), z.literal("desc")]);
|
||||
|
||||
const bigNumberConfigOptions = {
|
||||
column: z.string(),
|
||||
aggregation: BigNumberAggregationType,
|
||||
sortDirection: BigNumberSortDirection.optional(),
|
||||
abbreviate: z.boolean().default(false),
|
||||
prefix: z.string().optional(),
|
||||
suffix: z.string().optional(),
|
||||
};
|
||||
|
||||
const BigNumberConfiguration = z.object({ ...bigNumberConfigOptions });
|
||||
export type BigNumberConfiguration = z.infer<typeof BigNumberConfiguration>;
|
||||
|
||||
export const QueryWidgetConfig = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("table"),
|
||||
prettyFormatting: z.boolean().default(true),
|
||||
sorting: z
|
||||
.array(
|
||||
z.object({
|
||||
desc: z.boolean(),
|
||||
id: z.string(),
|
||||
})
|
||||
)
|
||||
.default([]),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("chart"),
|
||||
...chartConfigOptions,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("bignumber"),
|
||||
...bigNumberConfigOptions,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("title"),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type QueryWidgetConfig = z.infer<typeof QueryWidgetConfig>;
|
||||
|
||||
/** Result data containing rows and column metadata */
|
||||
export type QueryWidgetData = {
|
||||
rows: Record<string, unknown>[];
|
||||
columns: OutputColumnMetadata[];
|
||||
};
|
||||
|
||||
/** Widget configuration with optional result data (used for edit callbacks) */
|
||||
export type WidgetData = {
|
||||
title: string;
|
||||
query: string;
|
||||
display: QueryWidgetConfig;
|
||||
/** The current result data from the widget */
|
||||
resultData?: QueryWidgetData;
|
||||
};
|
||||
|
||||
export type QueryWidgetProps = {
|
||||
title: ReactNode;
|
||||
/** String title for rename dialog (optional - if not provided, rename won't be available) */
|
||||
titleString?: string;
|
||||
/** The TSQL query string (used for "Copy query" in the menu) */
|
||||
query?: string;
|
||||
isLoading?: boolean;
|
||||
error?: string;
|
||||
data: QueryWidgetData;
|
||||
config: QueryWidgetConfig;
|
||||
/** The effective time range for the query (used to show full x-axis on time-based charts) */
|
||||
timeRange?: { from: string; to: string };
|
||||
accessory?: ReactNode;
|
||||
isResizing?: boolean;
|
||||
isDraggable?: boolean;
|
||||
/** Additional className applied to the Card wrapper */
|
||||
className?: string;
|
||||
/** Callback when edit is clicked. Receives the current data. */
|
||||
onEdit?: (data: QueryWidgetData) => void;
|
||||
/** Callback when rename is clicked. Receives the new title. */
|
||||
onRename?: (newTitle: string) => void;
|
||||
/** Callback when delete is clicked. */
|
||||
onDelete?: () => void;
|
||||
/** Callback when duplicate is clicked. Receives the current data. */
|
||||
onDuplicate?: (data: QueryWidgetData) => void;
|
||||
/** When true, show table column headers even when there are no rows */
|
||||
showTableHeaderOnEmpty?: boolean;
|
||||
};
|
||||
|
||||
export function QueryWidget({
|
||||
title,
|
||||
titleString,
|
||||
query,
|
||||
accessory,
|
||||
isLoading,
|
||||
error,
|
||||
isResizing,
|
||||
isDraggable,
|
||||
className,
|
||||
onEdit,
|
||||
onRename,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
...props
|
||||
}: QueryWidgetProps) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState(titleString ?? "");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const hasEditActions = onEdit || onRename || onDelete || onDuplicate;
|
||||
const hasData = props.data.rows.length > 0;
|
||||
|
||||
// "v" to toggle fullscreen on hovered widget
|
||||
useShortcutKeys({
|
||||
shortcut: { key: "v" },
|
||||
action: useCallback(() => {
|
||||
const isHovered = containerRef.current?.matches(":hover");
|
||||
if (!isFullscreen && !isHovered) return;
|
||||
setIsFullscreen((prev) => !prev);
|
||||
}, [isFullscreen]),
|
||||
});
|
||||
|
||||
const copyToClipboard = useCallback((text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
}, []);
|
||||
|
||||
const copyQuery = useCallback(() => {
|
||||
if (query) {
|
||||
copyToClipboard(query);
|
||||
}
|
||||
}, [query, copyToClipboard]);
|
||||
|
||||
const copyJSON = useCallback(() => {
|
||||
copyToClipboard(rowsToJSON(props.data.rows));
|
||||
}, [props.data.rows, copyToClipboard]);
|
||||
|
||||
const copyCSV = useCallback(() => {
|
||||
copyToClipboard(rowsToCSV(props.data.rows, props.data.columns));
|
||||
}, [props.data, copyToClipboard]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="group h-full">
|
||||
<Card className={cn("h-full overflow-hidden px-0 pb-0", className)}>
|
||||
<Card.Header draggable={isDraggable}>
|
||||
<div className="flex items-center gap-1.5">{title}</div>
|
||||
<Card.Accessory>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<span className="opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
LeadingIcon={Maximize2}
|
||||
leadingIconClassName="text-text-dimmed group-hover/button:text-text-bright"
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
className="!px-1"
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
content={
|
||||
<span className="flex items-center gap-1">
|
||||
Maximize
|
||||
<ShortcutKey shortcut={{ key: "v" }} variant="small/bright" />
|
||||
</span>
|
||||
}
|
||||
asChild
|
||||
/>
|
||||
<Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
||||
<PopoverVerticalEllipseTrigger
|
||||
isOpen={isMenuOpen}
|
||||
className={cn(
|
||||
"transition-opacity",
|
||||
isMenuOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
||||
)}
|
||||
/>
|
||||
<PopoverContent align="end" className="p-0">
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{hasEditActions && (
|
||||
<>
|
||||
{onEdit && (
|
||||
<PopoverMenuItem
|
||||
icon={IconChartHistogram}
|
||||
title="Edit chart"
|
||||
onClick={() => {
|
||||
onEdit(props.data);
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
leadingIconClassName="-ml-0.5 -mr-1"
|
||||
/>
|
||||
)}
|
||||
{onRename && (
|
||||
<PopoverMenuItem
|
||||
icon={PencilSquareIcon}
|
||||
title="Rename"
|
||||
onClick={() => {
|
||||
setRenameValue(titleString ?? "");
|
||||
setIsRenameDialogOpen(true);
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{onDuplicate && (
|
||||
<PopoverMenuItem
|
||||
icon={DocumentDuplicateIcon}
|
||||
title="Duplicate chart"
|
||||
onClick={() => {
|
||||
onDuplicate(props.data);
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
className="pr-4"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{query && (
|
||||
<PopoverMenuItem
|
||||
icon={ClipboardIcon}
|
||||
title="Copy query"
|
||||
onClick={() => {
|
||||
copyQuery();
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<PopoverMenuItem
|
||||
icon={IconBraces}
|
||||
title="Copy JSON"
|
||||
disabled={!hasData}
|
||||
onClick={() => {
|
||||
copyJSON();
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
leadingIconClassName="-ml-0.5 -mr-1"
|
||||
/>
|
||||
<PopoverMenuItem
|
||||
icon={IconFileTypeCsv}
|
||||
title="Copy CSV"
|
||||
disabled={!hasData}
|
||||
onClick={() => {
|
||||
copyCSV();
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
leadingIconClassName="-ml-0.5 -mr-1"
|
||||
/>
|
||||
{onDelete && (
|
||||
<PopoverMenuItem
|
||||
icon={TrashIcon}
|
||||
title="Delete chart"
|
||||
leadingIconClassName="text-error"
|
||||
className="text-error hover:!bg-error/10"
|
||||
onClick={() => {
|
||||
onDelete();
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{accessory}
|
||||
</Card.Accessory>
|
||||
</Card.Header>
|
||||
<LoadingBarDivider isLoading={isLoading ?? false} className="bg-transparent" />
|
||||
<Card.Content className="min-h-0 flex-1 overflow-hidden p-0">
|
||||
{isResizing ? (
|
||||
<div className="flex h-full flex-1 items-center justify-center p-3">
|
||||
<div className="flex flex-col items-center gap-1 text-text-dimmed">
|
||||
<ChartBarIcon className="size-10 text-text-dimmed" />{" "}
|
||||
<span className="text-base font-medium">Resizing...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-3">
|
||||
<Callout variant="error">{error}</Callout>
|
||||
</div>
|
||||
) : (
|
||||
<QueryWidgetBody
|
||||
{...props}
|
||||
title={title}
|
||||
isFullscreen={isFullscreen}
|
||||
setIsFullscreen={setIsFullscreen}
|
||||
isLoading={isLoading ?? false}
|
||||
/>
|
||||
)}
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
{/* Rename Dialog */}
|
||||
{onRename && (
|
||||
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>Rename chart</DialogHeader>
|
||||
<form
|
||||
className="space-y-4 pt-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (renameValue.trim()) {
|
||||
onRename(renameValue.trim());
|
||||
setIsRenameDialogOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<InputGroup>
|
||||
<Label>Title</Label>
|
||||
<Input
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
placeholder="Chart title"
|
||||
autoFocus
|
||||
/>
|
||||
</InputGroup>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" variant="primary/medium" disabled={!renameValue.trim()}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type QueryWidgetBodyProps = {
|
||||
title: ReactNode;
|
||||
data: QueryWidgetData;
|
||||
config: QueryWidgetConfig;
|
||||
timeRange?: { from: string; to: string };
|
||||
isFullscreen: boolean;
|
||||
setIsFullscreen: (open: boolean) => void;
|
||||
isLoading: boolean;
|
||||
showTableHeaderOnEmpty?: boolean;
|
||||
};
|
||||
|
||||
function QueryWidgetBody({
|
||||
title,
|
||||
data,
|
||||
config,
|
||||
timeRange,
|
||||
isFullscreen,
|
||||
setIsFullscreen,
|
||||
isLoading,
|
||||
showTableHeaderOnEmpty,
|
||||
}: QueryWidgetBodyProps) {
|
||||
const type = config.type;
|
||||
|
||||
// Only show the loading state if we have no data yet (initial load).
|
||||
// During a reload with existing data, keep showing the current data
|
||||
// while the loading bar in the header indicates a refresh is in progress.
|
||||
const hasData = data.rows.length > 0;
|
||||
const showLoading = isLoading && !hasData;
|
||||
|
||||
switch (type) {
|
||||
case "table": {
|
||||
return (
|
||||
<>
|
||||
<TSQLResultsTable
|
||||
rows={data.rows}
|
||||
columns={data.columns}
|
||||
prettyFormatting={config.prettyFormatting}
|
||||
sorting={config.sorting}
|
||||
showHeaderOnEmpty={showTableHeaderOnEmpty}
|
||||
/>
|
||||
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
|
||||
<DialogContent
|
||||
fullscreen
|
||||
className="flex flex-col gap-0 bg-background-bright px-0 pb-0"
|
||||
>
|
||||
<DialogHeader className="px-4">{title}</DialogHeader>
|
||||
<div className="min-h-0 w-full flex-1 pt-2.5">
|
||||
<TSQLResultsTable
|
||||
rows={data.rows}
|
||||
columns={data.columns}
|
||||
prettyFormatting={config.prettyFormatting}
|
||||
sorting={config.sorting}
|
||||
showHeaderOnEmpty={showTableHeaderOnEmpty}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "chart": {
|
||||
return (
|
||||
<>
|
||||
<QueryResultsChart
|
||||
rows={data.rows}
|
||||
columns={data.columns}
|
||||
config={config}
|
||||
timeRange={timeRange}
|
||||
onViewAllLegendItems={() => setIsFullscreen(true)}
|
||||
isLoading={showLoading}
|
||||
/>
|
||||
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
|
||||
<DialogContent fullscreen className="flex flex-col bg-background-bright">
|
||||
<DialogHeader>{title}</DialogHeader>
|
||||
<div className="min-h-0 w-full flex-1 overflow-hidden pt-4">
|
||||
<QueryResultsChart
|
||||
rows={data.rows}
|
||||
columns={data.columns}
|
||||
config={config}
|
||||
timeRange={timeRange}
|
||||
fullLegend
|
||||
legendScrollable
|
||||
isLoading={showLoading}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "bignumber": {
|
||||
return (
|
||||
<>
|
||||
<BigNumberCard
|
||||
rows={data.rows}
|
||||
columns={data.columns}
|
||||
config={config}
|
||||
isLoading={showLoading}
|
||||
/>
|
||||
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
|
||||
<DialogContent fullscreen className="flex flex-col bg-background-bright">
|
||||
<DialogHeader>{title}</DialogHeader>
|
||||
<div className="flex min-h-0 w-full flex-1 items-center justify-center pt-4">
|
||||
<BigNumberCard
|
||||
rows={data.rows}
|
||||
columns={data.columns}
|
||||
config={config}
|
||||
isLoading={showLoading}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "title": {
|
||||
// Title widgets are rendered by TitleWidget, not QueryWidget
|
||||
return null;
|
||||
}
|
||||
default: {
|
||||
assertNever(type);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import { RectangleStackIcon } from "@heroicons/react/20/solid";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { matchSorter } from "match-sorter";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { TaskIcon } from "~/assets/icons/TaskIcon";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
import {
|
||||
ComboBox,
|
||||
SelectItem,
|
||||
SelectList,
|
||||
SelectPopover,
|
||||
SelectProvider,
|
||||
SelectTrigger,
|
||||
} from "~/components/primitives/Select";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { useDebounceEffect } from "~/hooks/useDebounce";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { type loader as queuesLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues";
|
||||
import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
|
||||
|
||||
const shortcut = { key: "q" };
|
||||
|
||||
export function QueuesFilter() {
|
||||
const { values, replace, del } = useSearchParams();
|
||||
const selectedQueues = values("queues");
|
||||
|
||||
if (selectedQueues.length === 0 || selectedQueues.every((v) => v === "")) {
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<QueuesDropdown
|
||||
trigger={
|
||||
<SelectTrigger
|
||||
icon={<RectangleStackIcon className="size-4" />}
|
||||
variant="secondary/small"
|
||||
shortcut={shortcut}
|
||||
tooltipTitle="Filter by queue"
|
||||
>
|
||||
<span className="ml-1">Queues</span>
|
||||
</SelectTrigger>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<QueuesDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Queues"
|
||||
icon={<RectangleStackIcon className="size-4" />}
|
||||
value={appliedSummary(selectedQueues.map((v) => v.replace("task/", "")))}
|
||||
onRemove={() => del(["queues"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function QueuesDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({
|
||||
queues: values.length > 0 ? values : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const queueValues = values("queues").filter((v) => v !== "");
|
||||
const selected = queueValues.length > 0 ? queueValues : undefined;
|
||||
|
||||
const fetcher = useFetcher<typeof queuesLoader>();
|
||||
|
||||
useDebounceEffect(
|
||||
searchValue,
|
||||
(s) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("per_page", "25");
|
||||
if (searchValue) {
|
||||
searchParams.set("query", s);
|
||||
}
|
||||
fetcher.load(
|
||||
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${
|
||||
environment.slug
|
||||
}/queues?${searchParams.toString()}`
|
||||
);
|
||||
},
|
||||
250
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
// Use a Map to deduplicate by value
|
||||
const itemsMap = new Map<string, { name: string; type: "custom" | "task"; value: string }>();
|
||||
|
||||
// Add selected items first (for items not yet loaded from fetcher)
|
||||
for (const queueName of selected ?? []) {
|
||||
const queueItem = fetcher.data?.queues.find((q) => q.name === queueName);
|
||||
if (!queueItem) {
|
||||
if (queueName.startsWith("task/")) {
|
||||
itemsMap.set(queueName, {
|
||||
name: queueName.replace("task/", ""),
|
||||
type: "task",
|
||||
value: queueName,
|
||||
});
|
||||
} else {
|
||||
itemsMap.set(queueName, {
|
||||
name: queueName,
|
||||
type: "custom",
|
||||
value: queueName,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add items from fetcher data
|
||||
if (fetcher.data !== undefined) {
|
||||
for (const q of fetcher.data.queues) {
|
||||
const value = q.type === "task" ? `task/${q.name}` : q.name;
|
||||
itemsMap.set(value, {
|
||||
name: q.name,
|
||||
type: q.type,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const items = Array.from(itemsMap.values());
|
||||
return matchSorter(items, searchValue, {
|
||||
keys: ["name"],
|
||||
});
|
||||
}, [searchValue, fetcher.data, selected]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={selected ?? []} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(360px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox
|
||||
value={searchValue}
|
||||
render={(props) => (
|
||||
<div className="flex items-center justify-stretch">
|
||||
<input {...props} placeholder={"Filter by queues..."} />
|
||||
{fetcher.state === "loading" && <Spinner color="muted" />}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<SelectList>
|
||||
{filtered.length > 0
|
||||
? filtered.map((queue) => (
|
||||
<SelectItem
|
||||
key={queue.value}
|
||||
value={queue.value}
|
||||
icon={
|
||||
queue.type === "task" ? (
|
||||
<TaskIcon className="size-4 shrink-0 text-blue-500" />
|
||||
) : (
|
||||
<RectangleStackIcon className="size-4 shrink-0 text-purple-500" />
|
||||
)
|
||||
}
|
||||
>
|
||||
{queue.name}
|
||||
</SelectItem>
|
||||
))
|
||||
: null}
|
||||
{filtered.length === 0 && fetcher.state !== "loading" && (
|
||||
<SelectItem disabled>No queues found</SelectItem>
|
||||
)}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { useFetcher, useNavigate } from "@remix-run/react";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import {
|
||||
useCustomDashboards,
|
||||
useOrganization,
|
||||
useWidgetLimitPerDashboard,
|
||||
} from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { v3CustomDashboardPath } from "~/utils/pathBuilder";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { Dialog, DialogContent, DialogHeader } from "../primitives/Dialog";
|
||||
import { FormButtons } from "../primitives/FormButtons";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import type { QueryWidgetConfig } from "./QueryWidget";
|
||||
|
||||
export type SaveToDashboardDialogProps = {
|
||||
title: string;
|
||||
query: string;
|
||||
config: QueryWidgetConfig;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function SaveToDashboardDialog({
|
||||
title,
|
||||
query,
|
||||
config,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
}: SaveToDashboardDialogProps) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const customDashboards = useCustomDashboards();
|
||||
const widgetLimit = useWidgetLimitPerDashboard();
|
||||
const fetcher = useFetcher<{ success: boolean }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Find the first dashboard that isn't at the widget limit
|
||||
const firstAvailableDashboard = customDashboards.find((d) => d.widgetCount < widgetLimit);
|
||||
|
||||
const [selectedDashboardId, setSelectedDashboardId] = useState<string | null>(
|
||||
firstAvailableDashboard?.friendlyId ?? customDashboards[0]?.friendlyId ?? null
|
||||
);
|
||||
|
||||
// Build the form action URL
|
||||
const formAction = selectedDashboardId
|
||||
? `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${selectedDashboardId}/widgets`
|
||||
: "";
|
||||
|
||||
const isLoading = fetcher.state === "submitting";
|
||||
|
||||
// Check if selected dashboard is at widget limit
|
||||
const selectedDashboard = customDashboards.find((d) => d.friendlyId === selectedDashboardId);
|
||||
const isSelectedAtLimit = selectedDashboard
|
||||
? selectedDashboard.widgetCount >= widgetLimit
|
||||
: false;
|
||||
|
||||
// Navigate to the dashboard when the fetcher completes successfully
|
||||
useEffect(() => {
|
||||
if (fetcher.state === "idle" && fetcher.data?.success && selectedDashboardId) {
|
||||
onOpenChange(false);
|
||||
navigate(
|
||||
v3CustomDashboardPath(
|
||||
{ slug: organization.slug },
|
||||
{ slug: project.slug },
|
||||
{ slug: environment.slug },
|
||||
{ friendlyId: selectedDashboardId }
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [fetcher.state, fetcher.data, selectedDashboardId, onOpenChange, navigate, organization.slug, project.slug, environment.slug]);
|
||||
|
||||
// Update selection if dashboards change
|
||||
useEffect(() => {
|
||||
if (customDashboards.length > 0 && !selectedDashboardId) {
|
||||
const available = customDashboards.find((d) => d.widgetCount < widgetLimit);
|
||||
setSelectedDashboardId(available?.friendlyId ?? customDashboards[0].friendlyId);
|
||||
}
|
||||
}, [customDashboards, selectedDashboardId, widgetLimit]);
|
||||
|
||||
if (customDashboards.length === 0) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>Add to dashboard</DialogHeader>
|
||||
<div className="!mt-1 space-y-4">
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
You don't have any custom dashboards yet. Create one first from the sidebar menu.
|
||||
</Paragraph>
|
||||
<FormButtons
|
||||
className="justify-end"
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary/medium">Close</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>Add to dashboard</DialogHeader>
|
||||
<fetcher.Form method="post" action={formAction} className="space-y-4">
|
||||
<input type="hidden" name="action" value="add" />
|
||||
<input type="hidden" name="title" value={title} />
|
||||
<input type="hidden" name="query" value={query} />
|
||||
<input type="hidden" name="config" value={JSON.stringify(config)} />
|
||||
|
||||
<div className="!mt-1 space-y-2">
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Select a dashboard to add this chart to:
|
||||
</Paragraph>
|
||||
<div className="max-h-64 space-y-1 overflow-y-auto">
|
||||
{customDashboards.map((dashboard) => {
|
||||
const isAtLimit = dashboard.widgetCount >= widgetLimit;
|
||||
return (
|
||||
<button
|
||||
key={dashboard.friendlyId}
|
||||
type="button"
|
||||
onClick={() => !isAtLimit && setSelectedDashboardId(dashboard.friendlyId)}
|
||||
disabled={isAtLimit}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition",
|
||||
isAtLimit
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: selectedDashboardId === dashboard.friendlyId
|
||||
? "bg-charcoal-700 text-text-bright"
|
||||
: "text-text-dimmed hover:bg-charcoal-750 hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{selectedDashboardId === dashboard.friendlyId ? (
|
||||
<IconCheck className="size-4 shrink-0 text-green-500" />
|
||||
) : (
|
||||
<span className="size-4 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 truncate">{dashboard.title}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs",
|
||||
isAtLimit ? "text-error" : "text-text-dimmed"
|
||||
)}
|
||||
>
|
||||
{dashboard.widgetCount}/{widgetLimit}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary/medium"
|
||||
disabled={isLoading || !selectedDashboardId || isSelectedAtLimit}
|
||||
>
|
||||
{isLoading ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</fetcher.Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
import { SelectItem, SelectPopover, SelectProvider } from "~/components/primitives/Select";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import type { QueryScope } from "~/services/queryService.server";
|
||||
import { CubeTransparentIcon, GlobeAltIcon } from "@heroicons/react/20/solid";
|
||||
import { IconListLetters } from "@tabler/icons-react";
|
||||
|
||||
const scopeOptions = [
|
||||
{ value: "environment", label: "Environment" },
|
||||
{ value: "project", label: "Project" },
|
||||
{ value: "organization", label: "Organization" },
|
||||
] as const;
|
||||
|
||||
export function ScopeFilter() {
|
||||
const { value, replace } = useSearchParams();
|
||||
const scope = (value("scope") as QueryScope) ?? "environment";
|
||||
|
||||
const handleChange = (newScope: string) => {
|
||||
replace({ scope: newScope === "environment" ? undefined : newScope });
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectProvider value={scope} setValue={handleChange}>
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Scope"
|
||||
icon={<CubeTransparentIcon className="size-4" />}
|
||||
value={<ScopeItem scope={scope} />}
|
||||
removable={false}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
<SelectPopover className="min-w-0 max-w-[min(240px,var(--popover-available-width))]">
|
||||
{scopeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<ScopeItem scope={option.value} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopeItem({ scope }: { scope: QueryScope }) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
switch (scope) {
|
||||
case "organization":
|
||||
return `Org: ${organization.title}`;
|
||||
case "project":
|
||||
return `Project: ${project.name}`;
|
||||
case "environment":
|
||||
return <EnvironmentLabel environment={environment} />;
|
||||
default:
|
||||
return scope;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useState } from "react";
|
||||
import { PencilIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverMenuItem,
|
||||
PopoverVerticalEllipseTrigger,
|
||||
} from "../primitives/Popover";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "../primitives/Dialog";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { Input } from "../primitives/Input";
|
||||
import { InputGroup } from "../primitives/InputGroup";
|
||||
import { Label } from "../primitives/Label";
|
||||
|
||||
export type TitleWidgetProps = {
|
||||
title: string;
|
||||
isDraggable?: boolean;
|
||||
isResizing?: boolean;
|
||||
/** Callback when rename is clicked. Receives the new title. */
|
||||
onRename?: (newTitle: string) => void;
|
||||
/** Callback when delete is clicked. */
|
||||
onDelete?: () => void;
|
||||
};
|
||||
|
||||
export function TitleWidget({
|
||||
title,
|
||||
isDraggable,
|
||||
isResizing,
|
||||
onRename,
|
||||
onDelete,
|
||||
}: TitleWidgetProps) {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState(title);
|
||||
|
||||
const hasMenu = onRename || onDelete;
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div
|
||||
className={cn(
|
||||
"group flex h-full items-center gap-2 rounded-lg border border-grid-bright bg-background-bright px-4",
|
||||
isDraggable && "drag-handle cursor-grab active:cursor-grabbing"
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-lg font-medium text-text-bright">
|
||||
{title}
|
||||
</span>
|
||||
{hasMenu && (
|
||||
<div className="flex-shrink-0 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
||||
<PopoverVerticalEllipseTrigger isOpen={isMenuOpen} />
|
||||
<PopoverContent align="end" className="p-0">
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{onRename && (
|
||||
<PopoverMenuItem
|
||||
icon={PencilIcon}
|
||||
title="Rename"
|
||||
onClick={() => {
|
||||
setRenameValue(title);
|
||||
setIsRenameDialogOpen(true);
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{onDelete && (
|
||||
<PopoverMenuItem
|
||||
icon={TrashIcon}
|
||||
title="Delete"
|
||||
leadingIconClassName="text-error"
|
||||
className="text-error hover:!bg-error/10"
|
||||
onClick={() => {
|
||||
onDelete();
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rename Dialog */}
|
||||
{onRename && (
|
||||
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>Rename title</DialogHeader>
|
||||
<form
|
||||
className="space-y-4 pt-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (renameValue.trim()) {
|
||||
onRename(renameValue.trim());
|
||||
setIsRenameDialogOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<InputGroup>
|
||||
<Label>Title</Label>
|
||||
<Input
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
placeholder="Section title"
|
||||
autoFocus
|
||||
/>
|
||||
</InputGroup>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" variant="primary/medium" disabled={!renameValue.trim()}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
personalAccessTokensPath,
|
||||
rootPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { AskAI } from "../AskAI";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
|
||||
import { SideMenuHeader } from "./SideMenuHeader";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
|
||||
|
||||
export function AccountSideMenu({ user }: { user: User }) {
|
||||
return (
|
||||
@@ -55,8 +56,9 @@ export function AccountSideMenu({ user }: { user: User }) {
|
||||
data-action="security"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
|
||||
<div className="flex w-full items-center justify-between border-t border-grid-bright p-1">
|
||||
<HelpAndFeedback />
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { Form, useNavigation } from "@remix-run/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { PlusIcon } from "@heroicons/react/20/solid";
|
||||
import { useEffect, useState } from "react";
|
||||
import { type MatchedOrganization, useDashboardLimits } from "~/hooks/useOrganizations";
|
||||
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { Button, LinkButton } from "../primitives/Buttons";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTrigger,
|
||||
} from "../primitives/Dialog";
|
||||
import { FormButtons } from "../primitives/FormButtons";
|
||||
import { Input } from "../primitives/Input";
|
||||
import { InputGroup } from "../primitives/InputGroup";
|
||||
import { Label } from "../primitives/Label";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
|
||||
import { v3BillingPath } from "~/utils/pathBuilder";
|
||||
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
|
||||
|
||||
export function CreateDashboardButton({
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
isCollapsed,
|
||||
}: {
|
||||
organization: MatchedOrganization;
|
||||
project: SideMenuProject;
|
||||
environment: SideMenuEnvironment;
|
||||
isCollapsed: boolean;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const navigation = useNavigation();
|
||||
const limits = useDashboardLimits();
|
||||
const plan = useCurrentPlan();
|
||||
|
||||
const isAtLimit = limits.used >= limits.limit;
|
||||
const planLimits = (plan?.v3Subscription?.plan?.limits as any)?.metricDashboards;
|
||||
const canExceed = typeof planLimits === "object" && planLimits.canExceed === true;
|
||||
const canUpgrade = plan?.v3Subscription?.plan && !canExceed;
|
||||
|
||||
const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/create`;
|
||||
|
||||
// Close dialog when form submission starts (redirect is happening)
|
||||
useEffect(() => {
|
||||
if (navigation.formAction === formAction && navigation.state === "loading") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [navigation.formAction, navigation.state, formAction]);
|
||||
|
||||
if (isCollapsed) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<TooltipProvider disableHoverableContent>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-full w-full items-center justify-center rounded text-text-dimmed transition focus-custom hover:bg-charcoal-600 hover:text-text-bright"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="text-xs">
|
||||
Create dashboard
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{isAtLimit ? (
|
||||
<CreateDashboardUpgradeDialog
|
||||
limits={limits}
|
||||
canUpgrade={!!canUpgrade}
|
||||
isFreePlan={plan?.v3Subscription?.isPaying === false}
|
||||
organization={organization}
|
||||
/>
|
||||
) : (
|
||||
<CreateDashboardDialog formAction={formAction} limits={limits} />
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const PROGRESS_RING_R = 27.5;
|
||||
const PROGRESS_RING_CIRCUMFERENCE = 2 * Math.PI * PROGRESS_RING_R;
|
||||
const PROGRESS_COLOR_SUCCESS = "#28BF5C"; // mint-500 / success
|
||||
const PROGRESS_COLOR_ERROR = "#E11D48"; // rose-600 / error
|
||||
|
||||
function CreateDashboardUpgradeDialog({
|
||||
limits,
|
||||
canUpgrade,
|
||||
isFreePlan,
|
||||
organization,
|
||||
}: {
|
||||
limits: { used: number; limit: number };
|
||||
canUpgrade: boolean;
|
||||
isFreePlan: boolean;
|
||||
organization: MatchedOrganization;
|
||||
}) {
|
||||
|
||||
if (isFreePlan) {
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>Upgrade to unlock dashboards</DialogHeader>
|
||||
<div className="flex items-center gap-4 pt-3">
|
||||
<ArrowUpCircleIcon className="ml-1 size-14 shrink-0 text-indigo-500" />
|
||||
<DialogDescription className="pt-0">
|
||||
Custom metric dashboards are available on paid plans. Upgrade to create dashboards and
|
||||
track your task metrics.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogFooter className="flex justify-between">
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
<LinkButton variant="primary/medium" to={v3BillingPath(organization)}>
|
||||
Upgrade plan
|
||||
</LinkButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
const percentage = Math.min(limits.used / limits.limit, 1);
|
||||
const filled = percentage * PROGRESS_RING_CIRCUMFERENCE;
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>Dashboard limit reached</DialogHeader>
|
||||
<div className="flex items-center gap-4 pt-3">
|
||||
<div className="relative ml-1 mt-2 shrink-0" style={{ width: 60, height: 60 }}>
|
||||
<svg className="h-full w-full -rotate-90 overflow-visible">
|
||||
<circle
|
||||
className="fill-none stroke-grid-bright"
|
||||
strokeWidth="5"
|
||||
r={PROGRESS_RING_R}
|
||||
cx="30"
|
||||
cy="30"
|
||||
/>
|
||||
<motion.circle
|
||||
className="fill-none"
|
||||
strokeWidth="5"
|
||||
r={PROGRESS_RING_R}
|
||||
cx="30"
|
||||
cy="30"
|
||||
strokeLinecap="round"
|
||||
initial={{
|
||||
strokeDasharray: `0 ${PROGRESS_RING_CIRCUMFERENCE}`,
|
||||
stroke: PROGRESS_COLOR_SUCCESS,
|
||||
}}
|
||||
animate={{
|
||||
strokeDasharray: `${filled} ${PROGRESS_RING_CIRCUMFERENCE}`,
|
||||
stroke: PROGRESS_COLOR_ERROR,
|
||||
}}
|
||||
transition={{ duration: 1.2, ease: "easeInOut" }}
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute inset-0 flex items-center justify-center text-lg text-text-dimmed">
|
||||
{limits.limit}
|
||||
</span>
|
||||
</div>
|
||||
<DialogDescription className="pt-0">
|
||||
{canUpgrade ? (
|
||||
<>
|
||||
{limits.limit === 1
|
||||
? "Your plan includes 1 custom dashboard and it's already in use."
|
||||
: `You've used all ${limits.limit} of your custom dashboards.`}{" "}
|
||||
Upgrade your plan to create more.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{limits.limit === 1
|
||||
? "Your plan includes 1 custom dashboard and it's already in use."
|
||||
: `You've used all ${limits.limit} of your custom dashboards.`}{" "}
|
||||
To create more, request a limit increase or visit the{" "}
|
||||
<TextLink to={v3BillingPath(organization)}>billing page</TextLink> for pricing
|
||||
details.
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogFooter className="flex justify-between">
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
{canUpgrade ? (
|
||||
<LinkButton variant="primary/medium" to={v3BillingPath(organization)}>
|
||||
Upgrade plan
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Feedback
|
||||
button={<Button variant="primary/medium">Request more…</Button>}
|
||||
defaultValue="help"
|
||||
/>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateDashboardDialog({
|
||||
formAction,
|
||||
limits,
|
||||
}: {
|
||||
formAction: string;
|
||||
limits: { used: number; limit: number };
|
||||
}) {
|
||||
const navigation = useNavigation();
|
||||
const [title, setTitle] = useState("");
|
||||
|
||||
const isLoading = navigation.formAction === formAction;
|
||||
|
||||
return (
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>Create dashboard</DialogHeader>
|
||||
<Form method="post" action={formAction} className="space-y-4 pt-3">
|
||||
<InputGroup>
|
||||
<Label>Title</Label>
|
||||
<Input
|
||||
name="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="My Dashboard"
|
||||
required
|
||||
/>
|
||||
</InputGroup>
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
{limits.used}/{limits.limit} dashboards used
|
||||
</Paragraph>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button type="submit" variant="primary/medium" disabled={isLoading || !title.trim()}>
|
||||
{isLoading ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { IconChartHistogram } from "@tabler/icons-react";
|
||||
import { GripVerticalIcon, LineChartIcon } from "lucide-react";
|
||||
import ReactGridLayout from "react-grid-layout";
|
||||
import { type MatchedOrganization, useCustomDashboards } from "~/hooks/useOrganizations";
|
||||
import { type UserWithDashboardPreferences } from "~/models/user.server";
|
||||
import { v3CustomDashboardPath } from "~/utils/pathBuilder";
|
||||
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
import { TreeConnectorBranch, TreeConnectorEnd } from "./TreeConnectors";
|
||||
import { useReorderableList } from "./useReorderableList";
|
||||
|
||||
type SideMenuUser = Pick<UserWithDashboardPreferences, "dashboardPreferences"> & {
|
||||
isImpersonating: boolean;
|
||||
};
|
||||
|
||||
export function DashboardList({
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
isCollapsed,
|
||||
user,
|
||||
}: {
|
||||
organization: MatchedOrganization;
|
||||
project: SideMenuProject;
|
||||
environment: SideMenuEnvironment;
|
||||
isCollapsed: boolean;
|
||||
user: SideMenuUser;
|
||||
}) {
|
||||
const customDashboards = useCustomDashboards();
|
||||
const initialOrder =
|
||||
user.dashboardPreferences.sideMenu?.organizations?.[organization.id]?.orderedItems?.[
|
||||
"customDashboards"
|
||||
];
|
||||
|
||||
const {
|
||||
orderedItems: orderedDashboards,
|
||||
layout,
|
||||
containerRef,
|
||||
gridWidth,
|
||||
canReorder,
|
||||
handleDrag,
|
||||
handleDragStop,
|
||||
getIsLast,
|
||||
} = useReorderableList({
|
||||
organizationId: organization.id,
|
||||
listId: "customDashboards",
|
||||
items: customDashboards,
|
||||
itemKey: (d) => d.friendlyId,
|
||||
initialOrder,
|
||||
isImpersonating: user.isImpersonating,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
{canReorder ? (
|
||||
<ReactGridLayout
|
||||
layout={layout}
|
||||
width={gridWidth}
|
||||
gridConfig={{
|
||||
cols: 1,
|
||||
rowHeight: 32,
|
||||
margin: [0, 0] as const,
|
||||
containerPadding: [0, 0] as const,
|
||||
}}
|
||||
resizeConfig={{ enabled: false }}
|
||||
dragConfig={{ enabled: !isCollapsed, handle: ".sidebar-drag-handle" }}
|
||||
onDrag={handleDrag}
|
||||
onDragStop={handleDragStop}
|
||||
className="sidebar-reorder-grid"
|
||||
autoSize
|
||||
>
|
||||
{orderedDashboards.map((dashboard, index) => {
|
||||
const isLast = getIsLast(dashboard.friendlyId, index);
|
||||
return (
|
||||
<div key={dashboard.friendlyId}>
|
||||
<SideMenuItem
|
||||
name={dashboard.title}
|
||||
icon={
|
||||
isCollapsed
|
||||
? IconChartHistogram
|
||||
: isLast
|
||||
? TreeConnectorEnd
|
||||
: TreeConnectorBranch
|
||||
}
|
||||
activeIconColor={isCollapsed ? "text-customDashboards" : undefined}
|
||||
inactiveIconColor={isCollapsed ? "text-customDashboards" : undefined}
|
||||
to={v3CustomDashboardPath(organization, project, environment, dashboard)}
|
||||
isCollapsed={isCollapsed}
|
||||
action={
|
||||
<div className="sidebar-drag-handle flex h-full w-full cursor-grab items-center justify-center rounded text-text-dimmed opacity-0 transition group-hover/menuitem:opacity-100 hover:text-text-bright active:cursor-grabbing">
|
||||
<GripVerticalIcon className="size-3.5" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ReactGridLayout>
|
||||
) : (
|
||||
orderedDashboards.map((dashboard, index) => {
|
||||
const isLast = index === orderedDashboards.length - 1;
|
||||
return (
|
||||
<SideMenuItem
|
||||
key={dashboard.friendlyId}
|
||||
name={dashboard.title}
|
||||
icon={
|
||||
isCollapsed
|
||||
? LineChartIcon
|
||||
: isLast
|
||||
? TreeConnectorEnd
|
||||
: TreeConnectorBranch
|
||||
}
|
||||
activeIconColor={isCollapsed ? "text-customDashboards" : "text-charcoal-700"}
|
||||
inactiveIconColor={isCollapsed ? "text-customDashboards" : "text-charcoal-700"}
|
||||
to={v3CustomDashboardPath(organization, project, environment, dashboard)}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChevronRightIcon, Cog8ToothIcon } from "@heroicons/react/20/solid";
|
||||
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
|
||||
@@ -9,19 +10,19 @@ import { useOrganization, type MatchedOrganization } from "~/hooks/useOrganizati
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { branchesPath, docsPath, v3BillingPath } from "~/utils/pathBuilder";
|
||||
import { EnvironmentCombo } from "../environments/EnvironmentLabel";
|
||||
import { EnvironmentCombo, EnvironmentIcon, EnvironmentLabel, environmentFullTitle } from "../environments/EnvironmentLabel";
|
||||
import { ButtonContent } from "../primitives/Buttons";
|
||||
import { Header2 } from "../primitives/Headers";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import {
|
||||
Popover,
|
||||
PopoverArrowTrigger,
|
||||
PopoverContent,
|
||||
PopoverMenuItem,
|
||||
PopoverSectionHeader,
|
||||
PopoverTrigger,
|
||||
} from "../primitives/Popover";
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
import { SimpleTooltip } from "../primitives/Tooltip";
|
||||
import { V4Badge } from "../V4Badge";
|
||||
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
|
||||
import { Badge } from "../primitives/Badge";
|
||||
@@ -31,11 +32,13 @@ export function EnvironmentSelector({
|
||||
project,
|
||||
environment,
|
||||
className,
|
||||
isCollapsed = false,
|
||||
}: {
|
||||
organization: MatchedOrganization;
|
||||
project: SideMenuProject;
|
||||
environment: SideMenuEnvironment;
|
||||
className?: string;
|
||||
isCollapsed?: boolean;
|
||||
}) {
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
@@ -50,16 +53,48 @@ export function EnvironmentSelector({
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={(open) => setIsMenuOpen(open)} open={isMenuOpen}>
|
||||
<PopoverArrowTrigger
|
||||
isOpen={isMenuOpen}
|
||||
overflowHidden
|
||||
fullWidth
|
||||
className={cn("h-7 overflow-hidden py-1 pl-1.5", className)}
|
||||
>
|
||||
<EnvironmentCombo environment={environment} className="w-full text-2sm" />
|
||||
</PopoverArrowTrigger>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
"group flex h-8 items-center rounded pl-[0.4375rem] transition-colors hover:bg-charcoal-750",
|
||||
isCollapsed ? "justify-center pr-0.5" : "justify-between pr-1",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
|
||||
<EnvironmentIcon environment={environment} className="size-5 shrink-0" />
|
||||
<span
|
||||
className={cn(
|
||||
"flex min-w-0 items-center overflow-hidden transition-all duration-200",
|
||||
isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"
|
||||
)}
|
||||
>
|
||||
<EnvironmentLabel environment={environment} className="text-2sm" disableTooltip />
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"overflow-hidden transition-all duration-200",
|
||||
isCollapsed ? "max-w-0 opacity-0" : "max-w-[16px] opacity-100"
|
||||
)}
|
||||
>
|
||||
<DropdownIcon className="size-4 min-w-4 text-text-dimmed transition group-hover:text-text-bright" />
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
}
|
||||
content={environmentFullTitle(environment)}
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
hidden={!isCollapsed}
|
||||
buttonClassName="!h-8"
|
||||
asChild
|
||||
disableHoverableContent
|
||||
/>
|
||||
<PopoverContent
|
||||
className="min-w-[14rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
side={isCollapsed ? "right" : "bottom"}
|
||||
sideOffset={isCollapsed ? 8 : 4}
|
||||
align="start"
|
||||
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
|
||||
>
|
||||
|
||||
@@ -8,9 +8,12 @@ import {
|
||||
SignalIcon,
|
||||
StarIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { DiscordIcon, SlackIcon } from "@trigger.dev/companyicons";
|
||||
import { Fragment, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { Feedback } from "../Feedback";
|
||||
import { Shortcuts } from "../Shortcuts";
|
||||
import { StepContentContainer } from "../StepContentContainer";
|
||||
@@ -19,30 +22,85 @@ import { ClipboardField } from "../primitives/ClipboardField";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog";
|
||||
import { Icon } from "../primitives/Icon";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Popover, PopoverContent, PopoverSideMenuTrigger } from "../primitives/Popover";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
|
||||
import { SimpleTooltip } from "../primitives/Tooltip";
|
||||
import { ShortcutKey } from "../primitives/ShortcutKey";
|
||||
import { StepNumber } from "../primitives/StepNumber";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
import { Badge } from "../primitives/Badge";
|
||||
|
||||
export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?: boolean }) {
|
||||
export function HelpAndFeedback({
|
||||
disableShortcut = false,
|
||||
isCollapsed = false,
|
||||
}: {
|
||||
disableShortcut?: boolean;
|
||||
isCollapsed?: boolean;
|
||||
}) {
|
||||
const [isHelpMenuOpen, setHelpMenuOpen] = useState(false);
|
||||
const currentPlan = useCurrentPlan();
|
||||
|
||||
useShortcutKeys({
|
||||
shortcut: disableShortcut ? undefined : { key: "h", enabledOnInputElements: false },
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setHelpMenuOpen(true);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={(open) => setHelpMenuOpen(open)}>
|
||||
<PopoverSideMenuTrigger
|
||||
isOpen={isHelpMenuOpen}
|
||||
shortcut={{ key: "h", enabledOnInputElements: false }}
|
||||
className="grow pr-2"
|
||||
disabled={disableShortcut}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<QuestionMarkCircleIcon className="size-4 text-success" />
|
||||
Help & Feedback
|
||||
</div>
|
||||
</PopoverSideMenuTrigger>
|
||||
<motion.div
|
||||
layout="position"
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
className={isCollapsed ? undefined : "flex-1"}
|
||||
>
|
||||
<Popover open={isHelpMenuOpen} onOpenChange={setHelpMenuOpen}>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
"group flex h-8 items-center gap-1.5 rounded pl-[0.4375rem] pr-2 transition-colors hover:bg-charcoal-750 focus-custom",
|
||||
isCollapsed ? "w-full" : "w-full justify-between"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 overflow-hidden">
|
||||
<QuestionMarkCircleIcon className="size-5 min-w-5 shrink-0 text-success" />
|
||||
<span
|
||||
className={cn(
|
||||
"overflow-hidden whitespace-nowrap text-2sm text-text-bright transition-all duration-150",
|
||||
isCollapsed ? "max-w-0 opacity-0" : "max-w-[150px] opacity-100"
|
||||
)}
|
||||
>
|
||||
Help & Feedback
|
||||
</span>
|
||||
</span>
|
||||
<ShortcutKey
|
||||
className={cn(
|
||||
"size-4 flex-none transition-all duration-150",
|
||||
isCollapsed ? "hidden" : ""
|
||||
)}
|
||||
shortcut={{ key: "h" }}
|
||||
variant="medium/bright"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
}
|
||||
content={
|
||||
<span className="flex items-center gap-1">
|
||||
Help & Feedback
|
||||
<ShortcutKey shortcut={{ key: "h" }} variant="medium/bright" />
|
||||
</span>
|
||||
}
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
hidden={!isCollapsed}
|
||||
buttonClassName="!h-8 w-full"
|
||||
asChild
|
||||
disableHoverableContent
|
||||
/>
|
||||
<PopoverContent
|
||||
className="min-w-[14rem] divide-y divide-grid-bright overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
side={isCollapsed ? "right" : "top"}
|
||||
sideOffset={isCollapsed ? 8 : 4}
|
||||
align="start"
|
||||
>
|
||||
<Fragment>
|
||||
@@ -176,8 +234,9 @@ export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?:
|
||||
button={
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
className="pl-2"
|
||||
LeadingIcon={EnvelopeIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
leadingIconClassName="text-blue-500 pr-1"
|
||||
data-action="contact-us"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
@@ -189,6 +248,7 @@ export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?:
|
||||
</div>
|
||||
</Fragment>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Popover>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ChartBarIcon,
|
||||
Cog8ToothIcon,
|
||||
CreditCardIcon,
|
||||
PuzzlePieceIcon,
|
||||
UserGroupIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
|
||||
@@ -12,6 +13,7 @@ import { cn } from "~/utils/cn";
|
||||
import {
|
||||
organizationSettingsPath,
|
||||
organizationTeamPath,
|
||||
organizationVercelIntegrationPath,
|
||||
rootPath,
|
||||
v3BillingAlertsPath,
|
||||
v3BillingPath,
|
||||
@@ -25,6 +27,7 @@ import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Badge } from "../primitives/Badge";
|
||||
import { useHasAdminAccess } from "~/hooks/useUser";
|
||||
import { AskAI } from "../AskAI";
|
||||
|
||||
export type BuildInfo = {
|
||||
appVersion: string | undefined;
|
||||
@@ -112,6 +115,13 @@ export function OrganizationSettingsSideMenu({
|
||||
to={organizationSettingsPath(organization)}
|
||||
data-action="settings"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Integrations"
|
||||
icon={PuzzlePieceIcon}
|
||||
activeIconColor="text-blue-500"
|
||||
to={organizationVercelIntegrationPath(organization)}
|
||||
data-action="integrations"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<SideMenuHeader title="App version" />
|
||||
@@ -131,7 +141,14 @@ export function OrganizationSettingsSideMenu({
|
||||
<div className="flex flex-col gap-1">
|
||||
<SideMenuHeader title="Git ref" />
|
||||
<Paragraph variant="extra-small" className="px-2 text-text-dimmed">
|
||||
{buildInfo.gitRefName}
|
||||
<a
|
||||
href={`https://github.com/triggerdotdev/trigger.dev/tree/${buildInfo.gitRefName}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="transition hover:text-text-bright"
|
||||
>
|
||||
{buildInfo.gitRefName}
|
||||
</a>
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
@@ -139,13 +156,21 @@ export function OrganizationSettingsSideMenu({
|
||||
<div className="flex flex-col gap-1">
|
||||
<SideMenuHeader title="Git sha" />
|
||||
<Paragraph variant="extra-small" className="px-2 text-text-dimmed">
|
||||
{buildInfo.gitSha.slice(0, 9)}
|
||||
<a
|
||||
href={`https://github.com/triggerdotdev/trigger.dev/commit/${buildInfo.gitSha}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="transition hover:text-text-bright"
|
||||
>
|
||||
{buildInfo.gitSha.slice(0, 9)}
|
||||
</a>
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
|
||||
<div className="flex w-full items-center justify-between border-t border-grid-bright p-1">
|
||||
<HelpAndFeedback />
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,21 @@
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Popover, PopoverContent, PopoverCustomTrigger } from "../primitives/Popover";
|
||||
import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
export function SideMenuHeader({ title, children }: { title: string; children?: React.ReactNode }) {
|
||||
export function SideMenuHeader({
|
||||
title,
|
||||
children,
|
||||
isCollapsed = false,
|
||||
collapsedTitle,
|
||||
}: {
|
||||
title: string;
|
||||
children?: React.ReactNode;
|
||||
isCollapsed?: boolean;
|
||||
/** When provided, this text stays visible when collapsed and the rest fades out */
|
||||
collapsedTitle?: string;
|
||||
}) {
|
||||
const [isHeaderMenuOpen, setHeaderMenuOpen] = useState(false);
|
||||
const navigation = useNavigation();
|
||||
|
||||
@@ -11,9 +23,34 @@ export function SideMenuHeader({ title, children }: { title: string; children?:
|
||||
setHeaderMenuOpen(false);
|
||||
}, [navigation.location?.pathname]);
|
||||
|
||||
// If collapsedTitle is provided and title starts with it, split the title
|
||||
const hasCollapsedTitle = collapsedTitle && title.startsWith(collapsedTitle);
|
||||
const visiblePart = hasCollapsedTitle ? collapsedTitle : title;
|
||||
const fadingPart = hasCollapsedTitle ? title.slice(collapsedTitle.length) : "";
|
||||
|
||||
return (
|
||||
<div className="group flex items-center justify-between pl-1.5">
|
||||
<h2 className="text-xs">{title}</h2>
|
||||
<motion.div
|
||||
className="group flex h-4 items-center justify-between overflow-hidden pl-1.5"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: hasCollapsedTitle ? 1 : isCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
<h2 className="text-xs whitespace-nowrap">
|
||||
{visiblePart}
|
||||
{fadingPart && (
|
||||
<motion.span
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: isCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
{fadingPart}
|
||||
</motion.span>
|
||||
)}
|
||||
</h2>
|
||||
{children !== undefined ? (
|
||||
<Popover onOpenChange={(open) => setHeaderMenuOpen(open)} open={isHeaderMenuOpen}>
|
||||
<PopoverCustomTrigger className="p-1">
|
||||
@@ -27,6 +64,6 @@ export function SideMenuHeader({ title, children }: { title: string; children?:
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { type AnchorHTMLAttributes, type ReactNode } from "react";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { usePathName } from "~/hooks/usePathName";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
import { type RenderIcon } from "../primitives/Icon";
|
||||
import { type RenderIcon, Icon } from "../primitives/Icon";
|
||||
import { SimpleTooltip } from "../primitives/Tooltip";
|
||||
|
||||
export function SideMenuItem({
|
||||
icon,
|
||||
@@ -14,6 +16,8 @@ export function SideMenuItem({
|
||||
to,
|
||||
badge,
|
||||
target,
|
||||
isCollapsed = false,
|
||||
action,
|
||||
}: {
|
||||
icon?: RenderIcon;
|
||||
activeIconColor?: string;
|
||||
@@ -24,30 +28,92 @@ export function SideMenuItem({
|
||||
to: string;
|
||||
badge?: ReactNode;
|
||||
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
|
||||
isCollapsed?: boolean;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
const pathName = usePathName();
|
||||
const isActive = pathName === to;
|
||||
|
||||
return (
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
LeadingIcon={icon}
|
||||
leadingIconClassName={isActive ? activeIconColor : inactiveIconColor ?? "text-text-dimmed"}
|
||||
TrailingIcon={trailingIcon}
|
||||
trailingIconClassName={trailingIconClassName}
|
||||
const link = (
|
||||
<Link
|
||||
to={to}
|
||||
target={target}
|
||||
className={cn(
|
||||
"text-text-bright group-hover:bg-charcoal-750 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
|
||||
isActive ? "bg-tertiary text-text-bright" : "group-hover:text-text-bright"
|
||||
"flex h-8 w-full items-center gap-2 overflow-hidden rounded pr-2 pl-[0.4375rem] text-text-bright transition-colors hover:bg-charcoal-750 group-hover/menuitem:bg-charcoal-750",
|
||||
isActive ? "bg-tertiary" : ""
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{name}
|
||||
<div className="flex items-center gap-1">{badge !== undefined && badge}</div>
|
||||
<Icon
|
||||
icon={icon}
|
||||
className={cn(
|
||||
"size-5 shrink-0",
|
||||
isActive ? activeIconColor : inactiveIconColor ?? "text-text-dimmed"
|
||||
)}
|
||||
/>
|
||||
<motion.div
|
||||
className="flex min-w-0 flex-1 items-center justify-between overflow-hidden"
|
||||
initial={false}
|
||||
animate={{
|
||||
width: isCollapsed ? 0 : "auto",
|
||||
opacity: isCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<span className="truncate select-none text-2sm">{name}</span>
|
||||
{badge && !isCollapsed && (
|
||||
<motion.div
|
||||
className="ml-1 flex shrink-0 items-center gap-1"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
{badge}
|
||||
</motion.div>
|
||||
)}
|
||||
{trailingIcon && !isCollapsed && (
|
||||
<Icon
|
||||
icon={trailingIcon}
|
||||
className={cn("ml-1 size-4 shrink-0", trailingIconClassName)}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (action) {
|
||||
return (
|
||||
<div className="group/menuitem relative h-8 w-full">
|
||||
<SimpleTooltip
|
||||
button={link}
|
||||
content={name}
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
buttonClassName="!h-8 block w-full"
|
||||
hidden={!isCollapsed}
|
||||
asChild
|
||||
disableHoverableContent
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<div className="absolute top-1 right-1 bottom-1 flex aspect-square items-center justify-center rounded group-hover/menuitem:bg-charcoal-750">
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={link}
|
||||
content={name}
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
buttonClassName="!h-8 block w-full"
|
||||
hidden={!isCollapsed}
|
||||
asChild
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@ type Props = {
|
||||
initialCollapsed?: boolean;
|
||||
onCollapseToggle?: (isCollapsed: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
/** When true, hides the section header and shows only children */
|
||||
isSideMenuCollapsed?: boolean;
|
||||
itemSpacingClassName?: string;
|
||||
/** Optional action element (e.g., + button) to render on the right side of the header */
|
||||
headerAction?: React.ReactNode;
|
||||
};
|
||||
|
||||
/** A collapsible section for the side menu
|
||||
@@ -17,6 +22,9 @@ export function SideMenuSection({
|
||||
initialCollapsed = false,
|
||||
onCollapseToggle,
|
||||
children,
|
||||
isSideMenuCollapsed = false,
|
||||
itemSpacingClassName = "space-y-px",
|
||||
headerAction,
|
||||
}: Props) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(initialCollapsed);
|
||||
|
||||
@@ -27,22 +35,45 @@ export function SideMenuSection({
|
||||
}, [isCollapsed, onCollapseToggle]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-1 rounded-sm py-1 pl-1.5 text-text-dimmed transition hover:bg-charcoal-750 hover:text-text-bright"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<h2 className="text-xs">{title}</h2>
|
||||
<div className="w-full overflow-hidden">
|
||||
{/* Header container - stays in DOM to preserve height */}
|
||||
<div className="relative w-full">
|
||||
{/* Header - fades out when sidebar is collapsed */}
|
||||
<motion.div
|
||||
initial={isCollapsed}
|
||||
animate={{ rotate: isCollapsed ? -90 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="group/section flex cursor-pointer items-center justify-between overflow-hidden rounded-sm py-1 pl-1.5 pr-1 transition hover:bg-charcoal-750"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: isSideMenuCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
onClick={isSideMenuCollapsed ? undefined : handleToggle}
|
||||
style={{ cursor: isSideMenuCollapsed ? "default" : "pointer" }}
|
||||
>
|
||||
<ToggleArrowIcon className="size-2" />
|
||||
<div className="flex items-center gap-1 text-text-dimmed transition group-hover/section:text-text-bright">
|
||||
<h2 className="whitespace-nowrap text-xs">{title}</h2>
|
||||
<motion.div
|
||||
initial={isCollapsed}
|
||||
animate={{ rotate: isCollapsed ? -90 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<ToggleArrowIcon className="size-2" />
|
||||
</motion.div>
|
||||
</div>
|
||||
{headerAction && <div className="flex items-center">{headerAction}</div>}
|
||||
</motion.div>
|
||||
{/* Divider - absolutely positioned, visible when sidebar is collapsed but section is expanded */}
|
||||
<motion.div
|
||||
className="absolute left-2 right-2 top-1 h-px bg-charcoal-600"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: isSideMenuCollapsed && !isCollapsed ? 1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
<motion.div
|
||||
className="w-full"
|
||||
initial={isCollapsed ? "collapsed" : "expanded"}
|
||||
animate={isCollapsed ? "collapsed" : "expanded"}
|
||||
exit="collapsed"
|
||||
@@ -63,6 +94,7 @@ export function SideMenuSection({
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<motion.div
|
||||
className={`w-full ${itemSpacingClassName}`}
|
||||
variants={{
|
||||
expanded: {
|
||||
translateY: 0,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
// Tree connector icons for sub-items. The SVG viewBox is 20x20 matching the size-5 icon area.
|
||||
// Lines extend to y=-6 and y=26 to fill the full 32px row height (6px gap above/below the 20px icon).
|
||||
export function TreeConnectorBranch({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={cn("overflow-visible", className, "text-charcoal-600")}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
>
|
||||
<line x1="10" y1="-6" x2="10" y2="26" stroke="currentColor" strokeWidth="1" />
|
||||
<line x1="10" y1="10" x2="20" y2="10" stroke="currentColor" strokeWidth="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TreeConnectorEnd({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={cn("overflow-visible", className, "text-charcoal-600")}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
>
|
||||
<line x1="10" y1="-6" x2="10" y2="10" stroke="currentColor" strokeWidth="1" />
|
||||
<line x1="10" y1="10" x2="20" y2="10" stroke="currentColor" strokeWidth="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Valid section IDs that can have their collapsed state toggled
|
||||
export const SideMenuSectionIdSchema = z.enum(["manage", "metrics"]);
|
||||
|
||||
// Inferred type from the schema
|
||||
export type SideMenuSectionId = z.infer<typeof SideMenuSectionIdSchema>;
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { type Ref, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { type Layout, useContainerWidth } from "react-grid-layout";
|
||||
|
||||
/**
|
||||
* Generic hook for managing a reorderable list in the side menu.
|
||||
*
|
||||
* Handles order state, sorting, grid layout, drag callbacks, and persistence
|
||||
* via the `/resources/preferences/sidemenu` resource route.
|
||||
*
|
||||
* @param organizationId - Organization ID for scoping the persisted order
|
||||
* @param listId - Identifier for this list (e.g. "customDashboards")
|
||||
* @param items - The items to reorder
|
||||
* @param itemKey - Extract a stable string key from each item
|
||||
* @param initialOrder - Initial order from stored preferences (if any)
|
||||
* @param isImpersonating - Skip persistence when impersonating
|
||||
*/
|
||||
export function useReorderableList<T>({
|
||||
organizationId,
|
||||
listId,
|
||||
items,
|
||||
itemKey,
|
||||
initialOrder,
|
||||
isImpersonating,
|
||||
}: {
|
||||
organizationId: string;
|
||||
listId: string;
|
||||
items: T[];
|
||||
itemKey: (item: T) => string;
|
||||
initialOrder: string[] | undefined;
|
||||
isImpersonating: boolean;
|
||||
}) {
|
||||
const orderFetcher = useFetcher();
|
||||
|
||||
const [order, setOrder] = useState<string[]>(
|
||||
() => initialOrder ?? items.map(itemKey)
|
||||
);
|
||||
|
||||
// Sync order when organizationId changes (component may not remount)
|
||||
useEffect(() => {
|
||||
setOrder(initialOrder ?? items.map(itemKey));
|
||||
}, [organizationId]);
|
||||
|
||||
// Sort items by stored order, new items go to end
|
||||
const orderedItems = useMemo(() => {
|
||||
const orderMap = new Map(order.map((id, i) => [id, i]));
|
||||
return [...items].sort((a, b) => {
|
||||
const aIdx = orderMap.get(itemKey(a)) ?? Infinity;
|
||||
const bIdx = orderMap.get(itemKey(b)) ?? Infinity;
|
||||
return aIdx - bIdx;
|
||||
});
|
||||
}, [items, order, itemKey]);
|
||||
|
||||
// Layout for ReactGridLayout (1-column vertical list, each item h=1 row)
|
||||
const layout = useMemo(
|
||||
() =>
|
||||
orderedItems.map((item, i) => ({
|
||||
i: itemKey(item),
|
||||
x: 0,
|
||||
y: i,
|
||||
w: 1,
|
||||
h: 1,
|
||||
})),
|
||||
[orderedItems, itemKey]
|
||||
);
|
||||
|
||||
// Width measurement for ReactGridLayout
|
||||
const {
|
||||
width: gridWidth,
|
||||
containerRef,
|
||||
mounted: gridMounted,
|
||||
} = useContainerWidth({ initialWidth: 216 });
|
||||
|
||||
const canReorder = orderedItems.length >= 2;
|
||||
|
||||
// Track layout during drag for real-time visual updates
|
||||
const [dragLayout, setDragLayout] = useState<Layout | null>(null);
|
||||
|
||||
const handleDrag = useCallback((layout: Layout) => {
|
||||
setDragLayout(layout);
|
||||
}, []);
|
||||
|
||||
// Handle drag stop - extract new order from layout y-positions
|
||||
const handleDragStop = useCallback(
|
||||
(layout: Layout) => {
|
||||
setDragLayout(null);
|
||||
const sorted = [...layout].sort((a, b) => a.y - b.y);
|
||||
const newOrder = sorted.map((item) => item.i);
|
||||
if (JSON.stringify(newOrder) === JSON.stringify(order)) return;
|
||||
setOrder(newOrder);
|
||||
// Persist immediately
|
||||
if (!isImpersonating) {
|
||||
const formData = new FormData();
|
||||
formData.append("organizationId", organizationId);
|
||||
formData.append("listId", listId);
|
||||
formData.append("itemOrder", JSON.stringify(newOrder));
|
||||
orderFetcher.submit(formData, {
|
||||
method: "POST",
|
||||
action: "/resources/preferences/sidemenu",
|
||||
});
|
||||
}
|
||||
},
|
||||
[order, organizationId, listId, isImpersonating, orderFetcher]
|
||||
);
|
||||
|
||||
// Compute which item is visually last (during drag or at rest)
|
||||
const getIsLast = useCallback(
|
||||
(key: string, index: number) => {
|
||||
if (dragLayout) {
|
||||
const maxY = Math.max(...dragLayout.map((l) => l.y));
|
||||
return dragLayout.find((l) => l.i === key)?.y === maxY;
|
||||
}
|
||||
return index === orderedItems.length - 1;
|
||||
},
|
||||
[dragLayout, orderedItems.length]
|
||||
);
|
||||
|
||||
return {
|
||||
orderedItems,
|
||||
layout,
|
||||
containerRef: containerRef as Ref<HTMLDivElement>,
|
||||
gridWidth,
|
||||
gridMounted,
|
||||
canReorder,
|
||||
handleDrag,
|
||||
handleDragStop,
|
||||
getIsLast,
|
||||
};
|
||||
}
|
||||
@@ -1,16 +1,71 @@
|
||||
import { animate, motion, useMotionValue, useTransform } from "framer-motion";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
|
||||
export function AnimatedNumber({ value }: { value: number }) {
|
||||
/**
|
||||
* Determines the number of decimal places to display based on the value.
|
||||
* - For integers or large numbers (>=100), no decimals
|
||||
* - For numbers >= 10, 1 decimal place
|
||||
* - For numbers >= 1, 2 decimal places
|
||||
* - For smaller numbers, up to 4 decimal places
|
||||
*/
|
||||
function getDecimalPlaces(value: number): number {
|
||||
if (Number.isInteger(value)) return 0;
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
if (absValue >= 100) return 0;
|
||||
if (absValue >= 10) return 1;
|
||||
if (absValue >= 1) return 2;
|
||||
if (absValue >= 0.1) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a decimal places value to ensure it's valid for toLocaleString.
|
||||
* - Coerces to a finite number (handles NaN, Infinity, -Infinity)
|
||||
* - Rounds to an integer
|
||||
* - Clamps to the valid 0-20 range for toLocaleString options
|
||||
*/
|
||||
function sanitizeDecimals(decimals: number): number {
|
||||
if (!Number.isFinite(decimals)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(20, Math.max(0, Math.round(decimals)));
|
||||
}
|
||||
|
||||
export function AnimatedNumber({
|
||||
value,
|
||||
duration = 0.5,
|
||||
decimalPlaces,
|
||||
}: {
|
||||
value: number;
|
||||
duration?: number;
|
||||
/** Number of decimal places to display. If not provided, auto-detects based on value. */
|
||||
decimalPlaces?: number;
|
||||
}) {
|
||||
const motionValue = useMotionValue(value);
|
||||
let display = useTransform(motionValue, (current) => Math.round(current).toLocaleString());
|
||||
|
||||
// Determine decimal places - use provided value or auto-detect, then sanitize
|
||||
const safeDecimals = useMemo(() => {
|
||||
const rawDecimals = decimalPlaces !== undefined ? decimalPlaces : getDecimalPlaces(value);
|
||||
return sanitizeDecimals(rawDecimals);
|
||||
}, [decimalPlaces, value]);
|
||||
|
||||
const display = useTransform(motionValue, (current) => {
|
||||
if (safeDecimals === 0) {
|
||||
return Math.round(current).toLocaleString();
|
||||
}
|
||||
return current.toLocaleString(undefined, {
|
||||
minimumFractionDigits: safeDecimals,
|
||||
maximumFractionDigits: safeDecimals,
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
animate(motionValue, value, {
|
||||
duration: 0.5,
|
||||
duration,
|
||||
ease: "easeInOut",
|
||||
});
|
||||
}, [value]);
|
||||
}, [value, duration]);
|
||||
|
||||
return <motion.span>{display}</motion.span>;
|
||||
}
|
||||
|
||||
@@ -21,12 +21,13 @@ type Variant = keyof typeof variants;
|
||||
|
||||
type AppliedFilterProps = {
|
||||
icon?: ReactNode;
|
||||
label: ReactNode;
|
||||
label?: ReactNode;
|
||||
value: ReactNode;
|
||||
removable?: boolean;
|
||||
onRemove?: () => void;
|
||||
variant?: Variant;
|
||||
className?: string;
|
||||
valueClassName?: string;
|
||||
};
|
||||
|
||||
export function AppliedFilter({
|
||||
@@ -37,6 +38,7 @@ export function AppliedFilter({
|
||||
onRemove,
|
||||
variant = "secondary/small",
|
||||
className,
|
||||
valueClassName,
|
||||
}: AppliedFilterProps) {
|
||||
const variantClassName = variants[variant];
|
||||
return (
|
||||
@@ -48,14 +50,18 @@ 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">
|
||||
<span>{label}</span>:
|
||||
</div>
|
||||
{label && (
|
||||
<div className="text-text-bright">
|
||||
<span>{label}</span>:
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-text-dimmed">
|
||||
<div className={cn("text-text-dimmed", valueClassName)}>
|
||||
<div>{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
import {
|
||||
CreditCardIcon,
|
||||
ExclamationCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
@@ -60,10 +61,10 @@ export const variantClasses = {
|
||||
linkClassName: "transition hover:bg-blue-400/20",
|
||||
},
|
||||
pricing: {
|
||||
className: "border-charcoal-700 bg-charcoal-800",
|
||||
icon: <ChartBarIcon className="h-5 w-5 shrink-0 text-text-dimmed" />,
|
||||
textColor: "text-text-bright",
|
||||
linkClassName: "transition hover:bg-charcoal-750",
|
||||
className: "border-indigo-400/20 bg-indigo-800/30",
|
||||
icon: <CreditCardIcon className="h-5 w-5 shrink-0 text-indigo-400" />,
|
||||
textColor: "text-indigo-300",
|
||||
linkClassName: "transition hover:bg-indigo-400/20",
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ const ClientTabs = React.forwardRef<
|
||||
<ClientTabsContext.Provider value={contextValue}>
|
||||
<TabsPrimitive.Root
|
||||
ref={ref}
|
||||
activationMode="manual"
|
||||
onValueChange={handleValueChange}
|
||||
{...controlledProps}
|
||||
{...props}
|
||||
@@ -96,6 +97,7 @@ const ClientTabsTrigger = React.forwardRef<
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
tabIndex={0}
|
||||
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",
|
||||
@@ -134,6 +136,7 @@ const ClientTabsTrigger = React.forwardRef<
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"group flex flex-col items-center pt-1 focus-custom disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
@@ -143,7 +146,7 @@ const ClientTabsTrigger = React.forwardRef<
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive ? "text-text-bright" : "text-text-dimmed hover:text-text-bright"
|
||||
isActive ? "text-text-bright" : "text-text-dimmed group-hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -170,8 +173,9 @@ const ClientTabsTrigger = React.forwardRef<
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
tabIndex={0}
|
||||
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",
|
||||
"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 focus-custom data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -188,9 +192,11 @@ const ClientTabsContent = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring mt-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||
className
|
||||
"mt-1 outline-none",
|
||||
className,
|
||||
"data-[state=inactive]:hidden"
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
import { GlobeAltIcon, GlobeAmericasIcon } from "@heroicons/react/20/solid";
|
||||
import { useRouteLoaderData } from "@remix-run/react";
|
||||
import { Laptop } from "lucide-react";
|
||||
import { Fragment, memo, type ReactNode, useMemo, useSyncExternalStore } from "react";
|
||||
import { memo, type ReactNode, useMemo, useSyncExternalStore } from "react";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { useLocales } from "./LocaleProvider";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
@@ -39,11 +40,24 @@ export function useLocalTimeZone(): string {
|
||||
return useSyncExternalStore(subscribeToTimeZone, getTimeZoneSnapshot, getServerTimeZoneSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the user's preferred timezone.
|
||||
* Returns the timezone stored in the user's preferences cookie (from root loader),
|
||||
* falling back to the browser's local timezone if not set.
|
||||
*/
|
||||
export function useUserTimeZone(): string {
|
||||
const rootData = useRouteLoaderData("root") as { timezone?: string } | undefined;
|
||||
const localTimeZone = useLocalTimeZone();
|
||||
// Use stored timezone from cookie, or fall back to browser's local timezone
|
||||
return rootData?.timezone && rootData.timezone !== "UTC" ? rootData.timezone : localTimeZone;
|
||||
}
|
||||
|
||||
type DateTimeProps = {
|
||||
date: Date | string;
|
||||
timeZone?: string;
|
||||
includeSeconds?: boolean;
|
||||
includeTime?: boolean;
|
||||
includeDate?: boolean;
|
||||
showTimezone?: boolean;
|
||||
showTooltip?: boolean;
|
||||
hideDate?: boolean;
|
||||
@@ -56,27 +70,29 @@ export const DateTime = ({
|
||||
timeZone,
|
||||
includeSeconds = true,
|
||||
includeTime = true,
|
||||
includeDate = true,
|
||||
showTimezone = false,
|
||||
showTooltip = true,
|
||||
hour12 = true,
|
||||
}: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
const localTimeZone = useLocalTimeZone();
|
||||
const userTimeZone = useUserTimeZone();
|
||||
|
||||
const realDate = useMemo(() => (typeof date === "string" ? new Date(date) : date), [date]);
|
||||
|
||||
const formattedDateTime = (
|
||||
<Fragment>
|
||||
<span suppressHydrationWarning>
|
||||
{formatDateTime(
|
||||
realDate,
|
||||
timeZone ?? localTimeZone,
|
||||
timeZone ?? userTimeZone,
|
||||
locales,
|
||||
includeSeconds,
|
||||
includeTime,
|
||||
includeDate,
|
||||
hour12
|
||||
).replace(/\s/g, String.fromCharCode(32))}
|
||||
{showTimezone ? ` (${timeZone ?? "UTC"})` : null}
|
||||
</Fragment>
|
||||
</span>
|
||||
);
|
||||
|
||||
if (!showTooltip) return formattedDateTime;
|
||||
@@ -88,11 +104,12 @@ export const DateTime = ({
|
||||
<TooltipContent
|
||||
realDate={realDate}
|
||||
timeZone={timeZone}
|
||||
localTimeZone={localTimeZone}
|
||||
localTimeZone={userTimeZone}
|
||||
locales={locales}
|
||||
/>
|
||||
}
|
||||
side="right"
|
||||
asChild={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -103,12 +120,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,
|
||||
@@ -162,7 +180,7 @@ export function formatDateTimeISO(date: Date, timeZone: string): string {
|
||||
// New component that only shows date when it changes
|
||||
export const SmartDateTime = ({ date, previousDate = null, hour12 = true }: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
const localTimeZone = useLocalTimeZone();
|
||||
const userTimeZone = useUserTimeZone();
|
||||
const realDate = typeof date === "string" ? new Date(date) : date;
|
||||
const realPrevDate = previousDate
|
||||
? typeof previousDate === "string"
|
||||
@@ -175,10 +193,14 @@ export const SmartDateTime = ({ date, previousDate = null, hour12 = true }: Date
|
||||
|
||||
// Format with appropriate function
|
||||
const formattedDateTime = showDatePart
|
||||
? formatSmartDateTime(realDate, localTimeZone, locales, hour12)
|
||||
: formatTimeOnly(realDate, localTimeZone, locales, hour12);
|
||||
? formatSmartDateTime(realDate, userTimeZone, locales, hour12)
|
||||
: formatTimeOnly(realDate, userTimeZone, locales, 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
|
||||
@@ -230,14 +252,16 @@ function formatTimeOnly(
|
||||
|
||||
const DateTimeAccurateInner = ({
|
||||
date,
|
||||
timeZone = "UTC",
|
||||
timeZone,
|
||||
previousDate = null,
|
||||
showTooltip = true,
|
||||
hideDate = false,
|
||||
hour12 = true,
|
||||
}: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
const localTimeZone = useLocalTimeZone();
|
||||
const userTimeZone = useUserTimeZone();
|
||||
// Use provided timeZone prop if available, otherwise fall back to user's preferred timezone
|
||||
const displayTimeZone = timeZone ?? userTimeZone;
|
||||
const realDate = typeof date === "string" ? new Date(date) : date;
|
||||
const realPrevDate = previousDate
|
||||
? typeof previousDate === "string"
|
||||
@@ -248,31 +272,40 @@ const DateTimeAccurateInner = ({
|
||||
// Smart formatting based on whether date changed
|
||||
const formattedDateTime = useMemo(() => {
|
||||
return hideDate
|
||||
? formatTimeOnly(realDate, localTimeZone, locales, hour12)
|
||||
? formatTimeOnly(realDate, displayTimeZone, 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]);
|
||||
? formatTimeOnly(realDate, displayTimeZone, locales, hour12)
|
||||
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12)
|
||||
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12);
|
||||
}, [realDate, displayTimeZone, 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
|
||||
realDate={realDate}
|
||||
timeZone={timeZone}
|
||||
localTimeZone={localTimeZone}
|
||||
localTimeZone={userTimeZone}
|
||||
locales={locales}
|
||||
/>
|
||||
);
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -305,9 +338,13 @@ function formatDateTimeAccurate(
|
||||
locales: string[],
|
||||
hour12: boolean = true
|
||||
): string {
|
||||
const formattedDateTime = new Intl.DateTimeFormat(locales, {
|
||||
const datePart = new Intl.DateTimeFormat(locales, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone,
|
||||
}).format(date);
|
||||
|
||||
const timePart = new Intl.DateTimeFormat(locales, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
@@ -317,16 +354,20 @@ function formatDateTimeAccurate(
|
||||
hour12,
|
||||
}).format(date);
|
||||
|
||||
return formattedDateTime;
|
||||
return `${datePart} ${timePart}`;
|
||||
}
|
||||
|
||||
export const DateTimeShort = ({ date, hour12 = true }: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
const localTimeZone = useLocalTimeZone();
|
||||
const userTimeZone = useUserTimeZone();
|
||||
const realDate = typeof date === "string" ? new Date(date) : date;
|
||||
const formattedDateTime = formatDateTimeShort(realDate, localTimeZone, locales, hour12);
|
||||
const formattedDateTime = formatDateTimeShort(realDate, userTimeZone, locales, hour12);
|
||||
|
||||
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
|
||||
return (
|
||||
<span suppressHydrationWarning>
|
||||
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
function formatDateTimeShort(
|
||||
@@ -407,20 +448,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
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ export function FormButtons({
|
||||
className,
|
||||
}: {
|
||||
cancelButton?: React.ReactNode;
|
||||
confirmButton: React.ReactNode;
|
||||
confirmButton?: React.ReactNode;
|
||||
defaultAction?: { name: string; value: string; disabled?: boolean };
|
||||
className?: string;
|
||||
}) {
|
||||
@@ -29,7 +29,7 @@ export function FormButtons({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{cancelButton ? cancelButton : <div />} {confirmButton}
|
||||
{cancelButton ? cancelButton : <div />} {confirmButton ?? null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { AnimatePresence, useAnimate, usePresence } from "framer-motion";
|
||||
import { useEffect } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type LoadingBarDividerProps = {
|
||||
isLoading: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function LoadingBarDivider({ isLoading }: LoadingBarDividerProps) {
|
||||
export function LoadingBarDivider({ isLoading, className }: LoadingBarDividerProps) {
|
||||
return (
|
||||
<div className="relative h-px w-full overflow-hidden bg-grid-bright">
|
||||
<div className={cn("relative h-px w-full overflow-hidden bg-grid-bright", className)}>
|
||||
<AnimationDivider isLoading={isLoading} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useRef, useState, useLayoutEffect, useCallback } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { SimpleTooltip } from "./Tooltip";
|
||||
|
||||
type MiddleTruncateProps = {
|
||||
text: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A component that truncates text in the middle, showing the beginning and end.
|
||||
* Shows the full text in a tooltip on hover when truncated.
|
||||
*
|
||||
* Example: "namespace:category:subcategory:task-name" becomes "namespace:cat…task-name"
|
||||
*/
|
||||
export function MiddleTruncate({ text, className }: MiddleTruncateProps) {
|
||||
const containerRef = useRef<HTMLSpanElement>(null);
|
||||
const measureRef = useRef<HTMLSpanElement>(null);
|
||||
const [displayText, setDisplayText] = useState(text);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
|
||||
const calculateTruncation = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
const measure = measureRef.current;
|
||||
if (!container || !measure) return;
|
||||
|
||||
const parent = container.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
// Get the available width from the parent container
|
||||
const parentStyle = getComputedStyle(parent);
|
||||
const availableWidth =
|
||||
parent.clientWidth -
|
||||
parseFloat(parentStyle.paddingLeft) -
|
||||
parseFloat(parentStyle.paddingRight);
|
||||
|
||||
// Measure full text width
|
||||
measure.textContent = text;
|
||||
const fullTextWidth = measure.offsetWidth;
|
||||
|
||||
// If text fits, no truncation needed
|
||||
if (fullTextWidth <= availableWidth) {
|
||||
setDisplayText(text);
|
||||
setIsTruncated(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Text needs truncation - find optimal split
|
||||
const ellipsis = "…";
|
||||
measure.textContent = ellipsis;
|
||||
const ellipsisWidth = measure.offsetWidth;
|
||||
|
||||
const targetWidth = availableWidth - ellipsisWidth - 4; // small buffer
|
||||
|
||||
if (targetWidth <= 0) {
|
||||
setDisplayText(ellipsis);
|
||||
setIsTruncated(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Incrementally find the optimal character counts
|
||||
let startChars = 0;
|
||||
let endChars = 0;
|
||||
|
||||
// Alternate adding characters from start and end
|
||||
while (startChars + endChars < text.length) {
|
||||
// Try adding to start
|
||||
const testStart = text.slice(0, startChars + 1);
|
||||
const testEnd = endChars > 0 ? text.slice(-endChars) : "";
|
||||
measure.textContent = testStart + ellipsis + testEnd;
|
||||
|
||||
if (measure.offsetWidth > targetWidth) break;
|
||||
startChars++;
|
||||
|
||||
if (startChars + endChars >= text.length) break;
|
||||
|
||||
// Try adding to end
|
||||
const newTestEnd = text.slice(-(endChars + 1));
|
||||
measure.textContent = text.slice(0, startChars) + ellipsis + newTestEnd;
|
||||
|
||||
if (measure.offsetWidth > targetWidth) break;
|
||||
endChars++;
|
||||
}
|
||||
|
||||
// Ensure minimum characters on each side for readability
|
||||
const minChars = 4;
|
||||
const prevStartChars = startChars;
|
||||
const prevEndChars = endChars;
|
||||
|
||||
if (startChars < minChars && text.length > minChars * 2 + 1) {
|
||||
startChars = minChars;
|
||||
}
|
||||
if (endChars < minChars && text.length > minChars * 2 + 1) {
|
||||
endChars = minChars;
|
||||
}
|
||||
|
||||
// Re-measure after enforcing minChars to prevent overflow
|
||||
if (startChars !== prevStartChars || endChars !== prevEndChars) {
|
||||
measure.textContent = text.slice(0, startChars) + ellipsis + text.slice(-endChars);
|
||||
if (measure.offsetWidth > targetWidth) {
|
||||
// Revert to previous values if minChars enforcement causes overflow
|
||||
startChars = prevStartChars;
|
||||
endChars = prevEndChars;
|
||||
}
|
||||
}
|
||||
|
||||
// If combined chars would exceed text length, show full text
|
||||
if (startChars + endChars >= text.length) {
|
||||
setDisplayText(text);
|
||||
setIsTruncated(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = text.slice(0, startChars) + ellipsis + text.slice(-endChars);
|
||||
setDisplayText(result);
|
||||
setIsTruncated(true);
|
||||
}, [text]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
calculateTruncation();
|
||||
|
||||
// Recalculate on resize (guard for jsdom/older browsers)
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
calculateTruncation();
|
||||
});
|
||||
|
||||
const container = containerRef.current;
|
||||
if (container?.parentElement) {
|
||||
resizeObserver.observe(container.parentElement);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [calculateTruncation]);
|
||||
|
||||
const content = (
|
||||
<span
|
||||
ref={containerRef}
|
||||
className={cn("block", isTruncated && "min-w-[360px]", className)}
|
||||
>
|
||||
{/* Hidden span for measuring text width */}
|
||||
<span
|
||||
ref={measureRef}
|
||||
className="invisible absolute whitespace-nowrap"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{displayText}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (isTruncated) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={content}
|
||||
content={<span className="max-w-xs break-all font-mono text-xs">{text}</span>}
|
||||
side="top"
|
||||
asChild
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -150,10 +154,12 @@ function PopoverSideMenuTrigger({
|
||||
children,
|
||||
className,
|
||||
shortcut,
|
||||
hideShortcutKey = false,
|
||||
...props
|
||||
}: {
|
||||
isOpen?: boolean;
|
||||
shortcut?: useShortcutKeys.ShortcutDefinition;
|
||||
hideShortcutKey?: boolean;
|
||||
} & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
useShortcutKeys.useShortcutKeys({
|
||||
@@ -172,68 +178,106 @@ function PopoverSideMenuTrigger({
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-[1.8rem] shrink-0 select-none items-center gap-x-1.5 rounded-sm bg-transparent px-[0.4rem] text-center font-sans text-2sm font-normal text-text-bright transition duration-150 focus-custom hover:bg-charcoal-750",
|
||||
shortcut ? "justify-between" : "",
|
||||
"flex h-[1.8rem] shrink-0 select-none items-center rounded-sm bg-transparent pl-[0.4rem] pr-2.5 text-center font-sans text-2sm font-normal text-text-bright transition duration-150 focus-custom hover:bg-charcoal-750",
|
||||
shortcut && !hideShortcutKey ? "justify-between gap-x-1.5" : "",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{shortcut && (
|
||||
<ShortcutKey className={cn("size-4 flex-none")} shortcut={shortcut} variant={"small"} />
|
||||
{shortcut && !hideShortcutKey && (
|
||||
<ShortcutKey className="size-4 flex-none" shortcut={shortcut} variant={"small"} />
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const popoverVerticalEllipseVariants = {
|
||||
minimal: {
|
||||
trigger:
|
||||
"size-6 rounded-[3px] text-text-dimmed hover:bg-tertiary hover:text-text-bright",
|
||||
icon: "size-5",
|
||||
},
|
||||
secondary: {
|
||||
trigger:
|
||||
"size-6 rounded border border-charcoal-600 bg-secondary text-text-bright hover:bg-charcoal-600 hover:border-charcoal-550",
|
||||
icon: "size-4",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type PopoverVerticalEllipseVariant = keyof typeof popoverVerticalEllipseVariants;
|
||||
|
||||
function PopoverVerticalEllipseTrigger({
|
||||
isOpen,
|
||||
variant = "minimal",
|
||||
className,
|
||||
...props
|
||||
}: { isOpen?: boolean } & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
|
||||
}: {
|
||||
isOpen?: boolean;
|
||||
variant?: PopoverVerticalEllipseVariant;
|
||||
} & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
|
||||
const styles = popoverVerticalEllipseVariants[variant];
|
||||
return (
|
||||
<PopoverTrigger
|
||||
{...props}
|
||||
className={cn(
|
||||
"group flex items-center justify-end gap-1 rounded-[3px] p-0.5 text-text-dimmed transition focus-custom hover:bg-tertiary hover:text-text-bright",
|
||||
"group flex items-center justify-center transition focus-custom",
|
||||
styles.trigger,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<EllipsisVerticalIcon className={cn("size-5 transition group-hover:text-text-bright")} />
|
||||
<EllipsisVerticalIcon className={cn(styles.icon, "transition")} />
|
||||
</PopoverTrigger>
|
||||
);
|
||||
}
|
||||
@@ -249,3 +293,5 @@ export {
|
||||
PopoverTrigger,
|
||||
PopoverVerticalEllipseTrigger,
|
||||
};
|
||||
|
||||
export type { PopoverArrowTriggerVariant };
|
||||
|
||||
@@ -26,19 +26,40 @@ const ResizableHandle = ({
|
||||
}) => (
|
||||
<PanelResizer
|
||||
className={cn(
|
||||
"group relative flex w-0.75 items-center justify-center focus-custom after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 hover:w-0.75 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
// Base styles
|
||||
"group relative flex items-center justify-center focus-custom",
|
||||
// Horizontal orientation (default)
|
||||
"w-0.75 after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2",
|
||||
// Vertical orientation
|
||||
"data-[handle-orientation=vertical]:h-0.75 data-[handle-orientation=vertical]:w-full",
|
||||
"data-[handle-orientation=vertical]:after:inset-x-0 data-[handle-orientation=vertical]:after:inset-y-auto",
|
||||
"data-[handle-orientation=vertical]:after:left-0 data-[handle-orientation=vertical]:after:top-1/2",
|
||||
"data-[handle-orientation=vertical]:after:h-1 data-[handle-orientation=vertical]:after:w-full",
|
||||
"data-[handle-orientation=vertical]:after:-translate-y-1/2 data-[handle-orientation=vertical]:after:translate-x-0",
|
||||
className
|
||||
)}
|
||||
size="3px"
|
||||
{...props}
|
||||
>
|
||||
<div className="absolute left-[0.0625rem] top-0 h-full w-px bg-grid-bright transition group-hover:left-0 group-hover:w-0.75 group-hover:bg-lavender-500" />
|
||||
{/* Horizontal orientation line indicator */}
|
||||
<div className="absolute left-[0.0625rem] top-0 z-20 h-full w-px bg-grid-bright transition group-hover:left-0 group-hover:w-0.75 group-hover:bg-indigo-500 group-data-[handle-orientation=vertical]:hidden" />
|
||||
{/* Vertical orientation line indicator */}
|
||||
<div className="absolute left-0 top-[0.0625rem] z-20 hidden h-px w-full bg-grid-bright transition group-hover:top-0 group-hover:h-0.75 group-hover:bg-indigo-500 group-data-[handle-orientation=vertical]:block" />
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-5 w-3 flex-col items-center justify-center gap-[0.1875rem] bg-background-dimmed group-hover:hidden">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="h-[0.1875rem] w-0.75 rounded-full bg-charcoal-600" />
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
{/* Horizontal orientation dots (vertical arrangement) */}
|
||||
<div className="z-10 flex h-5 w-0.75 flex-col items-center justify-center gap-[0.1875rem] bg-background-dimmed group-hover:hidden group-data-[handle-orientation=vertical]:hidden">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="h-[0.1875rem] w-0.75 rounded-full bg-charcoal-600" />
|
||||
))}
|
||||
</div>
|
||||
{/* Vertical orientation dots (horizontal arrangement) */}
|
||||
<div className="z-10 hidden h-0.75 w-5 flex-row items-center justify-center gap-[0.1875rem] bg-background-dimmed group-hover:hidden group-data-[handle-orientation=vertical]:flex">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="h-0.75 w-[0.1875rem] rounded-full bg-charcoal-600" />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PanelResizer>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user